1345 lines
54 KiB
Go
1345 lines
54 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"browser.local/platform/domain"
|
|
"browser.local/platform/repo"
|
|
"browser.local/platform/validator"
|
|
)
|
|
|
|
type generatedPackageConfig struct {
|
|
Kind string `json:"kind"`
|
|
ServerInstanceID string `json:"serverInstanceId"`
|
|
PluginID string `json:"pluginId"`
|
|
RunEndpointID string `json:"runEndpointId,omitempty"`
|
|
ProfileKey string `json:"profileKey,omitempty"`
|
|
TargetOS string `json:"targetOs"`
|
|
TargetArch string `json:"targetArch"`
|
|
SecretRef string `json:"secretRef"`
|
|
KeyGeneration int `json:"keyGeneration"`
|
|
AuthKey string `json:"authKey"`
|
|
}
|
|
|
|
type generatedClientManagerPackage struct {
|
|
Kind string `json:"kind"`
|
|
Checkout clientManagerCheckoutPlan `json:"checkout"`
|
|
Config generatedPackageConfig `json:"config"`
|
|
OutputArtifacts []string `json:"outputArtifacts"`
|
|
BuildLogRef string `json:"buildLogRef"`
|
|
KeyFingerprint string `json:"keyFingerprint"`
|
|
}
|
|
|
|
type clientManagerCheckoutPlan struct {
|
|
RepositoryURL string `json:"repositoryUrl"`
|
|
SourceRevision string `json:"sourceRevision"`
|
|
CheckoutRef string `json:"checkoutRef"`
|
|
TargetOS string `json:"targetOs"`
|
|
TargetArch string `json:"targetArch"`
|
|
}
|
|
|
|
func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, request domain.RunDistributionGenerateRequest) (domain.RunDistribution, error) {
|
|
request = domain.CopyRunDistributionGenerateRequest(request)
|
|
if strings.TrimSpace(request.IdempotencyKey) == "" {
|
|
request.IdempotencyKey = "run-" + request.ServerInstanceID + "-" + request.TargetOS + "-" + request.TargetArch
|
|
}
|
|
if err := validator.ValidateRunDistributionGenerateRequest(request); err != nil {
|
|
return domain.RunDistribution{}, err
|
|
}
|
|
user, err := svc.GetCurrentUser(sessionID)
|
|
if err != nil {
|
|
return domain.RunDistribution{}, err
|
|
}
|
|
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
|
|
if err != nil {
|
|
return domain.RunDistribution{}, err
|
|
}
|
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
if err != nil {
|
|
return domain.RunDistribution{}, err
|
|
}
|
|
if err := svc.validateDistributionPluginPermission(user.ID, plugin, instance.ID, "server.run.distribution", "run.generate.denied"); err != nil {
|
|
return domain.RunDistribution{}, err
|
|
}
|
|
if err := validatePluginTarget(plugin, request.TargetOS); err != nil {
|
|
_ = svc.recordAuditEvent(user.ID, "run.generate.denied", "server-instance", instance.ID, domain.AuditResultDenied, "run generation denied: unsupported target")
|
|
return domain.RunDistribution{}, err
|
|
}
|
|
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "run.generate.denied"); err != nil {
|
|
return domain.RunDistribution{}, err
|
|
}
|
|
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
|
if err != nil {
|
|
return domain.RunDistribution{}, err
|
|
}
|
|
if err := validateRunnableEndpoint(endpoint, domain.JobCapabilityDistributionBuild); err != nil {
|
|
return domain.RunDistribution{}, err
|
|
}
|
|
|
|
key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentRun, "")
|
|
if err != nil {
|
|
return domain.RunDistribution{}, err
|
|
}
|
|
distributionID := distributionID("run-dist", instance.ID, request.TargetOS, request.TargetArch, key.Generation, request.IdempotencyKey)
|
|
if existing, err := svc.store.RunDistributions().Get(distributionID); err == nil {
|
|
return domain.CopyRunDistribution(existing), nil
|
|
} else if !errors.Is(err, repo.ErrNotFound) {
|
|
return domain.RunDistribution{}, err
|
|
}
|
|
|
|
_ = plainKey
|
|
artifactID := artifactIDForDistribution(distributionID + "-binary")
|
|
buildJobID := jobIDFromParts("job-distribution-build", instance.ID, distributionID)
|
|
stamp := svc.now()
|
|
distribution := domain.RunDistribution{
|
|
ID: distributionID,
|
|
ServerInstanceID: instance.ID,
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: instance.RunEndpointID,
|
|
TargetOS: request.TargetOS,
|
|
TargetArch: request.TargetArch,
|
|
PackageFormat: packageFormatForTarget(request.TargetOS),
|
|
BuildJobID: buildJobID,
|
|
ArtifactID: artifactID,
|
|
KeyGeneration: key.Generation,
|
|
SecretRef: key.SecretRef,
|
|
Status: domain.DistributionStatusBuilding,
|
|
CreatedAt: stamp,
|
|
UpdatedAt: stamp,
|
|
}
|
|
if err := validator.ValidateRunDistribution(distribution); err != nil {
|
|
return domain.RunDistribution{}, err
|
|
}
|
|
if err := svc.store.RunDistributions().Create(distribution); err != nil {
|
|
if errors.Is(err, repo.ErrDuplicate) {
|
|
existing, getErr := svc.store.RunDistributions().Get(distribution.ID)
|
|
if getErr != nil {
|
|
return domain.RunDistribution{}, getErr
|
|
}
|
|
return domain.CopyRunDistribution(existing), nil
|
|
}
|
|
return domain.RunDistribution{}, err
|
|
}
|
|
job, err := svc.CreateJob(domain.Job{
|
|
ID: buildJobID,
|
|
ServerInstanceID: instance.ID,
|
|
RunEndpointID: instance.RunEndpointID,
|
|
Capability: domain.JobCapabilityDistributionBuild,
|
|
TargetKey: "distribution/run",
|
|
InputRef: "input://distribution-build/" + distribution.ID,
|
|
IdempotencyKey: "distribution-build:" + distribution.ID,
|
|
Progress: domain.JobProgress{Percent: 0, Message: "build queued"},
|
|
})
|
|
if err != nil {
|
|
distribution.Status = domain.DistributionStatusFailed
|
|
distribution.UpdatedAt = svc.now()
|
|
_ = svc.store.RunDistributions().Update(distribution)
|
|
return domain.RunDistribution{}, err
|
|
}
|
|
if job.ID != buildJobID || job.Capability != domain.JobCapabilityDistributionBuild {
|
|
return domain.RunDistribution{}, validationError("distribution build idempotency key conflicts with another job")
|
|
}
|
|
if err := svc.recordAuditEvent(user.ID, "run.generate", "server-instance", instance.ID, domain.AuditResultQueued, "queued run binary build job with redacted runtime key ref"); err != nil {
|
|
return domain.RunDistribution{}, err
|
|
}
|
|
return domain.CopyRunDistribution(distribution), nil
|
|
}
|
|
|
|
func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID string, request domain.ClientManagerBuildRequest) (domain.ClientManagerDistribution, error) {
|
|
request = domain.CopyClientManagerBuildRequest(request)
|
|
if strings.TrimSpace(request.IdempotencyKey) == "" {
|
|
request.IdempotencyKey = "client-manager-" + request.ServerInstanceID + "-" + request.ProfileKey + "-" + request.TargetOS + "-" + request.TargetArch
|
|
}
|
|
if strings.TrimSpace(request.SourceRevision) == "" {
|
|
request.SourceRevision = "main"
|
|
}
|
|
if err := validator.ValidateClientManagerBuildRequest(request); err != nil {
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
user, err := svc.GetCurrentUser(sessionID)
|
|
if err != nil {
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
|
|
if err != nil {
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
if err != nil {
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
if err := svc.validateDistributionPluginPermission(user.ID, plugin, instance.ID, "server.client-manager.manage", "client-manager.build.denied"); err != nil {
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
if err := validatePluginTarget(plugin, request.TargetOS); err != nil {
|
|
_ = svc.recordAuditEvent(user.ID, "client-manager.build.denied", "server-instance", instance.ID, domain.AuditResultDenied, "client-manager build denied: unsupported target")
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "client-manager.build.denied"); err != nil {
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
|
if err != nil {
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
if err := validateRunnableEndpoint(endpoint, domain.JobCapabilityDistributionBuild); err != nil {
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
|
|
key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentClientManager, request.ProfileKey)
|
|
if err != nil {
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
clientDistributionID := distributionID("client-manager-dist", instance.ID, request.ProfileKey, request.TargetOS, request.TargetArch, key.Generation, request.IdempotencyKey)
|
|
if existing, err := svc.store.ClientManagerDistributions().Get(clientDistributionID); err == nil {
|
|
return domain.CopyClientManagerDistribution(existing), nil
|
|
} else if !errors.Is(err, repo.ErrNotFound) {
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
|
|
_ = plainKey
|
|
artifactID := artifactIDForDistribution(clientDistributionID + "-binary")
|
|
stamp := svc.now()
|
|
buildJobID := jobIDFromParts("job-distribution-build", instance.ID, clientDistributionID)
|
|
buildJob := domain.ClientManagerBuildJob{
|
|
ID: buildJobID,
|
|
ServerInstanceID: instance.ID,
|
|
PluginID: plugin.ID,
|
|
ProfileKey: request.ProfileKey,
|
|
TargetOS: request.TargetOS,
|
|
TargetArch: request.TargetArch,
|
|
RepositoryURL: request.RepositoryURL,
|
|
SourceRevision: request.SourceRevision,
|
|
ArtifactID: artifactID,
|
|
KeyGeneration: key.Generation,
|
|
Status: domain.DistributionJobStatusQueued,
|
|
CreatedAt: stamp,
|
|
UpdatedAt: stamp,
|
|
}
|
|
if err := validator.ValidateClientManagerBuildJob(buildJob); err != nil {
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
if err := svc.store.ClientManagerBuildJobs().Create(buildJob); err != nil {
|
|
if !errors.Is(err, repo.ErrDuplicate) {
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
existing, getErr := svc.store.ClientManagerBuildJobs().Get(buildJob.ID)
|
|
if getErr != nil {
|
|
return domain.ClientManagerDistribution{}, getErr
|
|
}
|
|
if !sameClientManagerBuildJobArtifacts(existing, buildJob) {
|
|
return domain.ClientManagerDistribution{}, validationError("client-manager build job already exists with different artifacts")
|
|
}
|
|
buildJob = existing
|
|
}
|
|
distribution := domain.ClientManagerDistribution{
|
|
ID: clientDistributionID,
|
|
ServerInstanceID: instance.ID,
|
|
PluginID: plugin.ID,
|
|
ProfileKey: request.ProfileKey,
|
|
TargetOS: request.TargetOS,
|
|
TargetArch: request.TargetArch,
|
|
RepositoryURL: request.RepositoryURL,
|
|
SourceRevision: request.SourceRevision,
|
|
BuildJobID: buildJob.ID,
|
|
ArtifactID: artifactID,
|
|
KeyGeneration: key.Generation,
|
|
SecretRef: key.SecretRef,
|
|
Status: domain.DistributionStatusBuilding,
|
|
CreatedAt: stamp,
|
|
UpdatedAt: stamp,
|
|
}
|
|
if err := validator.ValidateClientManagerDistribution(distribution); err != nil {
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
if err := svc.store.ClientManagerDistributions().Create(distribution); err != nil {
|
|
if errors.Is(err, repo.ErrDuplicate) {
|
|
existing, getErr := svc.store.ClientManagerDistributions().Get(distribution.ID)
|
|
if getErr != nil {
|
|
return domain.ClientManagerDistribution{}, getErr
|
|
}
|
|
return domain.CopyClientManagerDistribution(existing), nil
|
|
}
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
job, err := svc.CreateJob(domain.Job{
|
|
ID: buildJobID,
|
|
ServerInstanceID: instance.ID,
|
|
RunEndpointID: instance.RunEndpointID,
|
|
Capability: domain.JobCapabilityDistributionBuild,
|
|
TargetKey: "distribution/client-manager/" + request.ProfileKey,
|
|
InputRef: "input://distribution-build/" + distribution.ID,
|
|
IdempotencyKey: "distribution-build:" + distribution.ID,
|
|
Progress: domain.JobProgress{Percent: 0, Message: "build queued"},
|
|
})
|
|
if err != nil {
|
|
buildJob.Status = domain.DistributionJobStatusFailed
|
|
buildJob.UpdatedAt = svc.now()
|
|
distribution.Status = domain.DistributionStatusFailed
|
|
distribution.UpdatedAt = buildJob.UpdatedAt
|
|
_ = svc.store.ClientManagerBuildJobs().Update(buildJob)
|
|
_ = svc.store.ClientManagerDistributions().Update(distribution)
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
if job.ID != buildJobID || job.Capability != domain.JobCapabilityDistributionBuild {
|
|
return domain.ClientManagerDistribution{}, validationError("distribution build idempotency key conflicts with another job")
|
|
}
|
|
if err := svc.recordAuditEvent(user.ID, "client-manager.build", "server-instance", instance.ID, domain.AuditResultQueued, "queued client-manager source build with redacted runtime key ref"); err != nil {
|
|
return domain.ClientManagerDistribution{}, err
|
|
}
|
|
return domain.CopyClientManagerDistribution(distribution), nil
|
|
}
|
|
|
|
func (svc *CoreService) OpenLatestRunDistributionDownloadForSession(sessionID string, serverInstanceID string) (domain.ArtifactDownloadReference, error) {
|
|
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
|
|
if err != nil {
|
|
return domain.ArtifactDownloadReference{}, err
|
|
}
|
|
distributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: instance.ID, Status: domain.DistributionStatusAvailable})
|
|
if err != nil {
|
|
return domain.ArtifactDownloadReference{}, err
|
|
}
|
|
var latest domain.RunDistribution
|
|
for _, distribution := range distributions {
|
|
if latest.ID == "" || distribution.KeyGeneration > latest.KeyGeneration || (distribution.KeyGeneration == latest.KeyGeneration && distribution.UpdatedAt.After(latest.UpdatedAt)) {
|
|
latest = distribution
|
|
}
|
|
}
|
|
if latest.ID == "" {
|
|
return domain.ArtifactDownloadReference{}, repo.ErrNotFound
|
|
}
|
|
return svc.OpenArtifactDownloadForSession(sessionID, domain.ArtifactDownloadReferenceRequest{ArtifactID: latest.ArtifactID})
|
|
}
|
|
|
|
func (svc *CoreService) OpenLatestClientManagerDistributionDownloadForSession(sessionID string, serverInstanceID string, profileKey string) (domain.ArtifactDownloadReference, error) {
|
|
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
|
|
if err != nil {
|
|
return domain.ArtifactDownloadReference{}, err
|
|
}
|
|
filter := domain.ClientManagerDistributionFilter{ServerInstanceID: instance.ID, Status: domain.DistributionStatusAvailable}
|
|
if strings.TrimSpace(profileKey) != "" {
|
|
filter.ProfileKey = strings.TrimSpace(profileKey)
|
|
}
|
|
distributions, err := svc.store.ClientManagerDistributions().List(filter)
|
|
if err != nil {
|
|
return domain.ArtifactDownloadReference{}, err
|
|
}
|
|
var latest domain.ClientManagerDistribution
|
|
for _, distribution := range distributions {
|
|
if latest.ID == "" || distribution.KeyGeneration > latest.KeyGeneration || (distribution.KeyGeneration == latest.KeyGeneration && distribution.UpdatedAt.After(latest.UpdatedAt)) {
|
|
latest = distribution
|
|
}
|
|
}
|
|
if latest.ID == "" {
|
|
return domain.ArtifactDownloadReference{}, repo.ErrNotFound
|
|
}
|
|
return svc.OpenArtifactDownloadForSession(sessionID, domain.ArtifactDownloadReferenceRequest{ArtifactID: latest.ArtifactID})
|
|
}
|
|
|
|
func (svc *CoreService) ResetComponentKeyForSession(sessionID string, request domain.ComponentKeyResetRequest) (domain.EncryptedComponentKey, error) {
|
|
request = domain.CopyComponentKeyResetRequest(request)
|
|
if err := validator.ValidateComponentKeyResetRequest(request); err != nil {
|
|
return domain.EncryptedComponentKey{}, err
|
|
}
|
|
user, instance, err := svc.requireServerOwner(sessionID, request.ServerInstanceID)
|
|
if err != nil {
|
|
return domain.EncryptedComponentKey{}, err
|
|
}
|
|
_ = user
|
|
filter := domain.EncryptedComponentKeyFilter{
|
|
ServerInstanceID: request.ServerInstanceID,
|
|
ComponentKind: request.ComponentKind,
|
|
ComponentKey: normalizedComponentKey(request.ComponentKind, request.ComponentKey),
|
|
}
|
|
keys, err := svc.store.EncryptedComponentKeys().List(filter)
|
|
if err != nil {
|
|
return domain.EncryptedComponentKey{}, err
|
|
}
|
|
nextGeneration := 1
|
|
for _, key := range keys {
|
|
if key.Generation >= nextGeneration {
|
|
nextGeneration = key.Generation + 1
|
|
}
|
|
if key.Status == domain.ComponentKeyStatusActive {
|
|
key.Status = domain.ComponentKeyStatusRevoked
|
|
key.UpdatedAt = svc.now()
|
|
key.ResetAt = svc.now()
|
|
if err := validator.ValidateEncryptedComponentKey(key); err != nil {
|
|
return domain.EncryptedComponentKey{}, err
|
|
}
|
|
if err := svc.store.EncryptedComponentKeys().Update(key); err != nil {
|
|
return domain.EncryptedComponentKey{}, err
|
|
}
|
|
}
|
|
}
|
|
newKey, _, err := svc.createEncryptedComponentKey(instance.ID, request.ComponentKind, request.ComponentKey, nextGeneration)
|
|
if err != nil {
|
|
return domain.EncryptedComponentKey{}, err
|
|
}
|
|
if err := svc.revokeComponentDistributions(instance.ID, request.ComponentKind, normalizedComponentKey(request.ComponentKind, request.ComponentKey), nextGeneration); err != nil {
|
|
return domain.EncryptedComponentKey{}, err
|
|
}
|
|
if err := svc.recordAuditEvent(user.ID, "runtime-key.reset", "server-instance", instance.ID, domain.AuditResultSuccess, "reset "+string(request.ComponentKind)+" key; previous packages revoked"); err != nil {
|
|
return domain.EncryptedComponentKey{}, err
|
|
}
|
|
return domain.CopyEncryptedComponentKey(newKey), nil
|
|
}
|
|
|
|
func (svc *CoreService) AuthenticateComponent(request domain.ComponentAuthenticationRequest) (domain.ComponentAuthenticationResult, error) {
|
|
request = domain.CopyComponentAuthenticationRequest(request)
|
|
if err := validator.ValidateComponentAuthenticationRequest(request); err != nil {
|
|
return domain.ComponentAuthenticationResult{}, err
|
|
}
|
|
componentKey := normalizedComponentKey(request.ComponentKind, request.ComponentKey)
|
|
result := domain.ComponentAuthenticationResult{
|
|
ServerInstanceID: request.ServerInstanceID,
|
|
ComponentKind: request.ComponentKind,
|
|
ComponentKey: componentKey,
|
|
Generation: request.Generation,
|
|
}
|
|
key, err := svc.activeComponentKey(request.ServerInstanceID, request.ComponentKind, componentKey)
|
|
if err != nil {
|
|
if errors.Is(err, repo.ErrNotFound) {
|
|
result.Reason = "active key not found"
|
|
return domain.CopyComponentAuthenticationResult(result), nil
|
|
}
|
|
return domain.ComponentAuthenticationResult{}, err
|
|
}
|
|
if key.Generation != request.Generation {
|
|
result.Reason = "key generation is no longer current"
|
|
_ = svc.recordAuditEvent("runtime", "runtime-key.auth", "server-instance", request.ServerInstanceID, domain.AuditResultDenied, "component authentication denied: stale generation")
|
|
return domain.CopyComponentAuthenticationResult(result), nil
|
|
}
|
|
plainKey, err := decryptRuntimeKey(key.EncryptedKey)
|
|
if err != nil {
|
|
return domain.ComponentAuthenticationResult{}, err
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(plainKey), []byte(request.Key)) != 1 {
|
|
result.Reason = "key is not current"
|
|
_ = svc.recordAuditEvent("runtime", "runtime-key.auth", "server-instance", request.ServerInstanceID, domain.AuditResultDenied, "component authentication denied: key mismatch")
|
|
return domain.CopyComponentAuthenticationResult(result), nil
|
|
}
|
|
result.Allowed = true
|
|
result.Reason = "current key accepted"
|
|
return domain.CopyComponentAuthenticationResult(result), nil
|
|
}
|
|
|
|
func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serverInstanceID string) (domain.ServerRuntimeActions, error) {
|
|
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
|
|
if err != nil {
|
|
return domain.ServerRuntimeActions{}, err
|
|
}
|
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
if err != nil {
|
|
return domain.ServerRuntimeActions{}, err
|
|
}
|
|
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
|
if err != nil {
|
|
return domain.ServerRuntimeActions{}, err
|
|
}
|
|
hasAvailableRunPackage := false
|
|
runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: instance.ID, Status: domain.DistributionStatusAvailable})
|
|
if err != nil {
|
|
return domain.ServerRuntimeActions{}, err
|
|
}
|
|
for _, distribution := range runDistributions {
|
|
if distribution.ArtifactID != "" {
|
|
hasAvailableRunPackage = true
|
|
break
|
|
}
|
|
}
|
|
hasAvailableClientPackage := false
|
|
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: instance.ID, Status: domain.DistributionStatusAvailable})
|
|
if err != nil {
|
|
return domain.ServerRuntimeActions{}, err
|
|
}
|
|
for _, distribution := range clientDistributions {
|
|
if distribution.ArtifactID != "" {
|
|
hasAvailableClientPackage = true
|
|
break
|
|
}
|
|
}
|
|
bindingsComplete := svc.runtimeBindingsComplete(instance.ID)
|
|
bindingReason := "runtime binding is incomplete"
|
|
actions := domain.ServerRuntimeActions{
|
|
ServerInstanceID: instance.ID,
|
|
PluginID: plugin.ID,
|
|
RunEndpointID: endpoint.ID,
|
|
RunStatus: endpoint.Status,
|
|
Actions: []domain.ServerRuntimeAction{
|
|
runtimeAction("generate-run", "Generate run", pluginDeclares(plugin, "server.run.distribution") && endpointSupports(endpoint, domain.JobCapabilityDistributionBuild) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.run.distribution") || !endpointSupports(endpoint, domain.JobCapabilityDistributionBuild), "run endpoint cannot build distributions", bindingReason)),
|
|
runtimeAction("download-run", "Download run", hasAvailableRunPackage, "run package has not been generated"),
|
|
runtimeAction("push-run-update", "Push run update", pluginDeclares(plugin, "server.run.distribution") && endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.run.distribution") || !endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate), "run endpoint cannot self-update", bindingReason)),
|
|
runtimeAction("reset-run-key", "Reset run key", pluginDeclares(plugin, "server.run.distribution"), "plugin permission is not declared"),
|
|
runtimeAction("generate-client-manager", "Generate client manager", pluginDeclares(plugin, "server.client-manager.manage") && endpointSupports(endpoint, domain.JobCapabilityDistributionBuild) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.client-manager.manage") || !endpointSupports(endpoint, domain.JobCapabilityDistributionBuild), "run endpoint cannot build distributions", bindingReason)),
|
|
runtimeAction("download-client-manager", "Download client manager", hasAvailableClientPackage, "client-manager package has not been generated"),
|
|
runtimeAction("reset-client-manager-key", "Reset client-manager key", pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared"),
|
|
runtimeAction("dependencies-check", "Check dependencies", endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason)),
|
|
runtimeAction("dependencies-install", "Install dependencies", endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall), "run endpoint cannot install dependencies", bindingReason)),
|
|
runtimeAction("live-logs", "Live logs", pluginSupports(plugin, "logs.read"), "plugin does not declare live logs"),
|
|
runtimeAction("historical-logs", "Historical logs", endpointSupports(endpoint, domain.JobCapabilityLogsBackfill) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityLogsBackfill), "run endpoint cannot backfill logs", bindingReason)),
|
|
},
|
|
}
|
|
return domain.CopyServerRuntimeActions(actions), nil
|
|
}
|
|
|
|
func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain.RunUpdateRequest) (domain.RunUpdateJob, error) {
|
|
request = domain.CopyRunUpdateRequest(request)
|
|
if strings.TrimSpace(request.IdempotencyKey) == "" {
|
|
request.IdempotencyKey = "run-update-" + request.ServerInstanceID + "-" + request.ArtifactID
|
|
}
|
|
if err := validateRunUpdateRequest(request); err != nil {
|
|
return domain.RunUpdateJob{}, err
|
|
}
|
|
user, err := svc.GetCurrentUser(sessionID)
|
|
if err != nil {
|
|
return domain.RunUpdateJob{}, err
|
|
}
|
|
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
|
|
if err != nil {
|
|
return domain.RunUpdateJob{}, err
|
|
}
|
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
if err != nil {
|
|
return domain.RunUpdateJob{}, err
|
|
}
|
|
if err := svc.validateDistributionPluginPermission(user.ID, plugin, instance.ID, "server.run.distribution", "run.update.denied"); err != nil {
|
|
return domain.RunUpdateJob{}, err
|
|
}
|
|
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "run.update.denied"); err != nil {
|
|
return domain.RunUpdateJob{}, err
|
|
}
|
|
artifact, err := svc.GetArtifactForSession(sessionID, request.ArtifactID)
|
|
if err != nil {
|
|
return domain.RunUpdateJob{}, err
|
|
}
|
|
if artifact.State != domain.ArtifactStateAvailable {
|
|
_ = svc.recordAuditEvent(user.ID, "run.update.denied", "server-instance", instance.ID, domain.AuditResultDenied, "run update denied: artifact is unavailable")
|
|
return domain.RunUpdateJob{}, validationError("artifact must be available")
|
|
}
|
|
if request.Checksum == "" {
|
|
request.Checksum = artifact.Checksum
|
|
}
|
|
if request.Checksum != artifact.Checksum {
|
|
_ = svc.recordAuditEvent(user.ID, "run.update.denied", "server-instance", instance.ID, domain.AuditResultDenied, "run update denied: checksum mismatch")
|
|
return domain.RunUpdateJob{}, validationError("checksum must match artifact")
|
|
}
|
|
job, err := svc.CreateJob(domain.Job{
|
|
ID: jobIDFromParts("job-run-update", request.ServerInstanceID, request.IdempotencyKey),
|
|
ServerInstanceID: instance.ID,
|
|
RunEndpointID: instance.RunEndpointID,
|
|
Capability: domain.JobCapabilityRunSelfUpdate,
|
|
TargetKey: "run/update",
|
|
InputRef: "artifact://" + artifact.ID,
|
|
IdempotencyKey: request.IdempotencyKey,
|
|
Progress: domain.JobProgress{Percent: 0, Message: "run self-update queued"},
|
|
})
|
|
if err != nil {
|
|
_ = svc.recordAuditEvent(user.ID, "run.update.denied", "server-instance", instance.ID, domain.AuditResultDenied, "run update denied: endpoint unsupported or offline")
|
|
return domain.RunUpdateJob{}, err
|
|
}
|
|
stamp := svc.now()
|
|
updateJob := domain.RunUpdateJob{
|
|
ID: distributionID("run-update", instance.ID, artifact.ID, request.IdempotencyKey),
|
|
ServerInstanceID: instance.ID,
|
|
RunEndpointID: instance.RunEndpointID,
|
|
ArtifactID: artifact.ID,
|
|
Checksum: artifact.Checksum,
|
|
JobID: job.ID,
|
|
IdempotencyKey: request.IdempotencyKey,
|
|
Status: domain.DistributionJobStatusQueued,
|
|
CreatedAt: stamp,
|
|
UpdatedAt: stamp,
|
|
}
|
|
if err := validator.ValidateRunUpdateJob(updateJob); err != nil {
|
|
return domain.RunUpdateJob{}, err
|
|
}
|
|
if err := svc.store.RunUpdateJobs().Create(updateJob); err != nil {
|
|
if errors.Is(err, repo.ErrDuplicate) {
|
|
existing, getErr := svc.store.RunUpdateJobs().Get(updateJob.ID)
|
|
if getErr != nil {
|
|
return domain.RunUpdateJob{}, getErr
|
|
}
|
|
if !sameRunUpdateJob(existing, updateJob) {
|
|
return domain.RunUpdateJob{}, validationError("run update job already exists with different target")
|
|
}
|
|
return domain.CopyRunUpdateJob(existing), nil
|
|
}
|
|
return domain.RunUpdateJob{}, err
|
|
}
|
|
if err := svc.recordAuditEvent(user.ID, "run.update", "server-instance", instance.ID, domain.AuditResultQueued, "queued run self-update job with artifact checksum"); err != nil {
|
|
return domain.RunUpdateJob{}, err
|
|
}
|
|
return domain.CopyRunUpdateJob(updateJob), nil
|
|
}
|
|
|
|
func (svc *CoreService) QueueDependencyJobForSession(sessionID string, request domain.DependencyJobRequest) (domain.Job, error) {
|
|
request = domain.CopyDependencyJobRequest(request)
|
|
if strings.TrimSpace(request.IdempotencyKey) == "" {
|
|
request.IdempotencyKey = "dependencies-" + request.ServerInstanceID + "-" + request.ProbeKey
|
|
}
|
|
if err := validateDependencyJobRequest(request); err != nil {
|
|
return domain.Job{}, err
|
|
}
|
|
user, err := svc.GetCurrentUser(sessionID)
|
|
if err != nil {
|
|
return domain.Job{}, err
|
|
}
|
|
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
|
|
if err != nil {
|
|
return domain.Job{}, err
|
|
}
|
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
if err != nil {
|
|
return domain.Job{}, err
|
|
}
|
|
if !pluginDeclares(plugin, "server.dependencies.manage") {
|
|
_ = svc.recordAuditEvent(user.ID, "dependency.install.denied", "server-instance", instance.ID, domain.AuditResultDenied, "dependency operation denied: plugin permission is not declared")
|
|
return domain.Job{}, ErrForbidden
|
|
}
|
|
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "dependency.install.denied"); err != nil {
|
|
return domain.Job{}, err
|
|
}
|
|
capability := domain.JobCapabilityDependenciesCheck
|
|
targetKey := "dependencies/" + request.ProbeKey
|
|
message := "dependency check queued"
|
|
auditAction := "dependency.check"
|
|
state := domain.DependencyStateUnknown
|
|
if request.Install {
|
|
capability = domain.JobCapabilityDependenciesInstall
|
|
targetKey = "dependencies/install/" + request.InstallPlanKey
|
|
message = "dependency install queued"
|
|
auditAction = "dependency.install"
|
|
state = domain.DependencyStateInstalling
|
|
}
|
|
job, err := svc.CreateJob(domain.Job{
|
|
ID: jobIDFromParts("job-dependencies", request.ServerInstanceID, request.IdempotencyKey),
|
|
ServerInstanceID: instance.ID,
|
|
RunEndpointID: instance.RunEndpointID,
|
|
Capability: capability,
|
|
TargetKey: targetKey,
|
|
IdempotencyKey: request.IdempotencyKey,
|
|
Progress: domain.JobProgress{Percent: 0, Message: message},
|
|
})
|
|
if err != nil {
|
|
_ = svc.recordAuditEvent(user.ID, auditAction+".denied", "server-instance", instance.ID, domain.AuditResultDenied, "dependency operation denied: endpoint unsupported or offline")
|
|
return domain.Job{}, err
|
|
}
|
|
if err := svc.upsertDependencyStatus(instance, request, state, "queued through platform job"); err != nil {
|
|
return domain.Job{}, err
|
|
}
|
|
if err := svc.recordAuditEvent(user.ID, auditAction, "server-instance", instance.ID, domain.AuditResultQueued, message); err != nil {
|
|
return domain.Job{}, err
|
|
}
|
|
return domain.CopyJob(job), nil
|
|
}
|
|
|
|
func (svc *CoreService) QueueLogBackfillForSession(sessionID string, request domain.LogBackfillRequest) (domain.Job, error) {
|
|
request = domain.CopyLogBackfillRequest(request)
|
|
if strings.TrimSpace(request.IdempotencyKey) == "" {
|
|
request.IdempotencyKey = "logs-backfill-" + request.ServerInstanceID + "-" + request.SourceKey
|
|
}
|
|
if err := validateLogBackfillRequest(request); err != nil {
|
|
return domain.Job{}, err
|
|
}
|
|
user, err := svc.GetCurrentUser(sessionID)
|
|
if err != nil {
|
|
return domain.Job{}, err
|
|
}
|
|
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
|
|
if err != nil {
|
|
return domain.Job{}, err
|
|
}
|
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
if err != nil {
|
|
return domain.Job{}, err
|
|
}
|
|
if !pluginSupports(plugin, domain.JobCapabilityLogsBackfill) {
|
|
_ = svc.recordAuditEvent(user.ID, "logs.backfill.denied", "server-instance", instance.ID, domain.AuditResultDenied, "log backfill denied: plugin capability is not declared")
|
|
return domain.Job{}, ErrForbidden
|
|
}
|
|
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "logs.backfill.denied"); err != nil {
|
|
return domain.Job{}, err
|
|
}
|
|
job, err := svc.CreateJob(domain.Job{
|
|
ID: jobIDFromParts("job-logs-backfill", request.ServerInstanceID, request.IdempotencyKey),
|
|
ServerInstanceID: instance.ID,
|
|
RunEndpointID: instance.RunEndpointID,
|
|
Capability: domain.JobCapabilityLogsBackfill,
|
|
TargetKey: "logs/" + request.SourceKey,
|
|
InputRef: request.CheckpointRef,
|
|
IdempotencyKey: request.IdempotencyKey,
|
|
Progress: domain.JobProgress{Percent: 0, Message: "historical log backfill queued"},
|
|
})
|
|
if err != nil {
|
|
_ = svc.recordAuditEvent(user.ID, "logs.backfill.denied", "server-instance", instance.ID, domain.AuditResultDenied, "log backfill denied: endpoint unsupported or offline")
|
|
return domain.Job{}, err
|
|
}
|
|
if err := svc.recordAuditEvent(user.ID, "logs.backfill", "server-instance", instance.ID, domain.AuditResultQueued, "queued historical log backfill without log bodies in job result"); err != nil {
|
|
return domain.Job{}, err
|
|
}
|
|
return domain.CopyJob(job), nil
|
|
}
|
|
|
|
func (svc *CoreService) validateDistributionPluginPermission(actorID string, plugin domain.GamePlugin, serverInstanceID string, permission string, deniedAction string) error {
|
|
if plugin.Status != domain.GamePluginStatusInstalled {
|
|
_ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "distribution operation denied: plugin is not installed")
|
|
return ErrForbidden
|
|
}
|
|
if !containsString(plugin.DeclaredPermissions, permission) {
|
|
_ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "distribution operation denied: plugin permission is not declared")
|
|
return ErrForbidden
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *CoreService) ensureActiveComponentKey(serverInstanceID string, kind domain.DistributionComponentKind, componentKey string) (domain.EncryptedComponentKey, string, error) {
|
|
normalized := normalizedComponentKey(kind, componentKey)
|
|
key, err := svc.activeComponentKey(serverInstanceID, kind, normalized)
|
|
if err == nil {
|
|
plainKey, err := decryptRuntimeKey(key.EncryptedKey)
|
|
return key, plainKey, err
|
|
}
|
|
if !errors.Is(err, repo.ErrNotFound) {
|
|
return domain.EncryptedComponentKey{}, "", err
|
|
}
|
|
return svc.createEncryptedComponentKey(serverInstanceID, kind, normalized, 1)
|
|
}
|
|
|
|
func (svc *CoreService) activeComponentKey(serverInstanceID string, kind domain.DistributionComponentKind, componentKey string) (domain.EncryptedComponentKey, error) {
|
|
keys, err := svc.store.EncryptedComponentKeys().List(domain.EncryptedComponentKeyFilter{
|
|
ServerInstanceID: serverInstanceID,
|
|
ComponentKind: kind,
|
|
ComponentKey: normalizedComponentKey(kind, componentKey),
|
|
Status: domain.ComponentKeyStatusActive,
|
|
})
|
|
if err != nil {
|
|
return domain.EncryptedComponentKey{}, err
|
|
}
|
|
if len(keys) == 0 {
|
|
return domain.EncryptedComponentKey{}, repo.ErrNotFound
|
|
}
|
|
active := keys[0]
|
|
for _, candidate := range keys[1:] {
|
|
if candidate.Generation > active.Generation {
|
|
active = candidate
|
|
}
|
|
}
|
|
return domain.CopyEncryptedComponentKey(active), nil
|
|
}
|
|
|
|
func (svc *CoreService) createEncryptedComponentKey(serverInstanceID string, kind domain.DistributionComponentKind, componentKey string, generation int) (domain.EncryptedComponentKey, string, error) {
|
|
plainKey, err := randomToken()
|
|
if err != nil {
|
|
return domain.EncryptedComponentKey{}, "", err
|
|
}
|
|
encryptedKey, err := encryptRuntimeKey(plainKey)
|
|
if err != nil {
|
|
return domain.EncryptedComponentKey{}, "", err
|
|
}
|
|
componentKey = normalizedComponentKey(kind, componentKey)
|
|
stamp := svc.now()
|
|
key := domain.EncryptedComponentKey{
|
|
ID: componentKeyID(serverInstanceID, kind, componentKey, generation),
|
|
ServerInstanceID: serverInstanceID,
|
|
ComponentKind: kind,
|
|
ComponentKey: componentKey,
|
|
EncryptedKey: encryptedKey,
|
|
KeyHash: checksumForString(plainKey),
|
|
Fingerprint: fingerprintForString(plainKey),
|
|
SecretRef: secretRefForComponent(serverInstanceID, kind, componentKey),
|
|
Generation: generation,
|
|
Status: domain.ComponentKeyStatusActive,
|
|
CreatedAt: stamp,
|
|
UpdatedAt: stamp,
|
|
}
|
|
if err := validator.ValidateEncryptedComponentKey(key); err != nil {
|
|
return domain.EncryptedComponentKey{}, "", err
|
|
}
|
|
if err := svc.store.EncryptedComponentKeys().Create(key); err != nil {
|
|
return domain.EncryptedComponentKey{}, "", err
|
|
}
|
|
return domain.CopyEncryptedComponentKey(key), plainKey, nil
|
|
}
|
|
|
|
func (svc *CoreService) revokeComponentDistributions(serverInstanceID string, kind domain.DistributionComponentKind, componentKey string, latestGeneration int) error {
|
|
switch kind {
|
|
case domain.DistributionComponentRun:
|
|
distributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: serverInstanceID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, distribution := range distributions {
|
|
if distribution.KeyGeneration >= latestGeneration || distribution.Status == domain.DistributionStatusRevoked {
|
|
continue
|
|
}
|
|
distribution.Status = domain.DistributionStatusRevoked
|
|
distribution.UpdatedAt = svc.now()
|
|
if err := validator.ValidateRunDistribution(distribution); err != nil {
|
|
return err
|
|
}
|
|
if err := svc.store.RunDistributions().Update(distribution); err != nil {
|
|
return err
|
|
}
|
|
if err := svc.expireDistributionArtifact(distribution.ArtifactID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case domain.DistributionComponentClientManager:
|
|
distributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: serverInstanceID, ProfileKey: componentKey})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, distribution := range distributions {
|
|
if distribution.KeyGeneration >= latestGeneration || distribution.Status == domain.DistributionStatusRevoked {
|
|
continue
|
|
}
|
|
distribution.Status = domain.DistributionStatusRevoked
|
|
distribution.UpdatedAt = svc.now()
|
|
if err := validator.ValidateClientManagerDistribution(distribution); err != nil {
|
|
return err
|
|
}
|
|
if err := svc.store.ClientManagerDistributions().Update(distribution); err != nil {
|
|
return err
|
|
}
|
|
if err := svc.expireDistributionArtifact(distribution.ArtifactID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *CoreService) expireDistributionArtifact(artifactID string) error {
|
|
artifact, err := svc.store.Artifacts().Get(artifactID)
|
|
if errors.Is(err, repo.ErrNotFound) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
artifact.State = domain.ArtifactStateExpired
|
|
artifact.UpdatedAt = svc.now()
|
|
if err := validator.ValidateArtifact(artifact); err != nil {
|
|
return err
|
|
}
|
|
return svc.store.Artifacts().Update(artifact)
|
|
}
|
|
|
|
func (svc *CoreService) createPlatformArtifactPayload(artifactID string, ownerKind domain.ArtifactOwnerKind, ownerID string, payload []byte) (domain.Artifact, error) {
|
|
artifact := domain.Artifact{
|
|
ID: artifactID,
|
|
OwnerKind: ownerKind,
|
|
OwnerID: ownerID,
|
|
SizeBytes: int64(len(payload)),
|
|
Checksum: validator.BytesChecksum(payload),
|
|
State: domain.ArtifactStateAvailable,
|
|
CreatedAt: svc.now(),
|
|
UpdatedAt: svc.now(),
|
|
}
|
|
if err := validator.ValidateArtifact(artifact); err != nil {
|
|
return domain.Artifact{}, err
|
|
}
|
|
if err := svc.store.Artifacts().Create(artifact); err != nil {
|
|
if !errors.Is(err, repo.ErrDuplicate) {
|
|
return domain.Artifact{}, err
|
|
}
|
|
existing, getErr := svc.store.Artifacts().Get(artifact.ID)
|
|
if getErr != nil {
|
|
return domain.Artifact{}, getErr
|
|
}
|
|
if err := validateReusablePlatformArtifact(existing, artifact); err != nil {
|
|
return domain.Artifact{}, err
|
|
}
|
|
if err := svc.ensureArtifactPayload(artifact.ID, payload, existing); err != nil {
|
|
return domain.Artifact{}, err
|
|
}
|
|
return domain.CopyArtifact(existing), nil
|
|
}
|
|
if err := svc.ensureArtifactPayload(artifact.ID, payload, artifact); err != nil {
|
|
return domain.Artifact{}, err
|
|
}
|
|
return domain.CopyArtifact(artifact), nil
|
|
}
|
|
|
|
func validateReusablePlatformArtifact(existing domain.Artifact, expected domain.Artifact) error {
|
|
if existing.OwnerKind != expected.OwnerKind || existing.OwnerID != expected.OwnerID {
|
|
return validationError("artifact already exists with different owner")
|
|
}
|
|
if existing.SizeBytes != expected.SizeBytes || existing.Checksum != expected.Checksum {
|
|
return validationError("artifact already exists with different content")
|
|
}
|
|
if existing.State != domain.ArtifactStateAvailable {
|
|
return validationError("artifact already exists but is not available")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *CoreService) ensureArtifactPayload(artifactID string, payload []byte, artifact domain.Artifact) error {
|
|
svc.artifactMu.Lock()
|
|
defer svc.artifactMu.Unlock()
|
|
|
|
if existingPayload, exists := svc.artifactPayloads[artifactID]; exists {
|
|
if int64(len(existingPayload)) != artifact.SizeBytes || validator.BytesChecksum(existingPayload) != artifact.Checksum {
|
|
return validationError("artifact payload does not match metadata")
|
|
}
|
|
return nil
|
|
}
|
|
if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum {
|
|
return validationError("artifact payload does not match metadata")
|
|
}
|
|
svc.artifactPayloads[artifactID] = domain.CopyBytes(payload)
|
|
return nil
|
|
}
|
|
|
|
func sameClientManagerBuildJobArtifacts(existing domain.ClientManagerBuildJob, expected domain.ClientManagerBuildJob) bool {
|
|
return existing.ServerInstanceID == expected.ServerInstanceID &&
|
|
existing.PluginID == expected.PluginID &&
|
|
existing.ProfileKey == expected.ProfileKey &&
|
|
existing.TargetOS == expected.TargetOS &&
|
|
existing.TargetArch == expected.TargetArch &&
|
|
existing.RepositoryURL == expected.RepositoryURL &&
|
|
existing.SourceRevision == expected.SourceRevision &&
|
|
existing.ArtifactID == expected.ArtifactID &&
|
|
existing.Checksum == expected.Checksum &&
|
|
existing.KeyGeneration == expected.KeyGeneration &&
|
|
existing.LogsRef == expected.LogsRef &&
|
|
existing.Status == expected.Status
|
|
}
|
|
|
|
func sameRunUpdateJob(existing domain.RunUpdateJob, expected domain.RunUpdateJob) bool {
|
|
return existing.ServerInstanceID == expected.ServerInstanceID &&
|
|
existing.RunEndpointID == expected.RunEndpointID &&
|
|
existing.ArtifactID == expected.ArtifactID &&
|
|
existing.Checksum == expected.Checksum &&
|
|
existing.JobID == expected.JobID &&
|
|
existing.IdempotencyKey == expected.IdempotencyKey &&
|
|
existing.Status == expected.Status
|
|
}
|
|
|
|
func (svc *CoreService) upsertDependencyStatus(instance domain.ServerInstance, request domain.DependencyJobRequest, state domain.DependencyState, message string) error {
|
|
statusID := distributionID("dependency-status", instance.ID, request.ProbeKey)
|
|
stamp := svc.now()
|
|
status := domain.DependencyStatus{
|
|
ID: statusID,
|
|
ServerInstanceID: instance.ID,
|
|
PluginID: instance.PluginID,
|
|
ProbeKey: request.ProbeKey,
|
|
TargetOS: request.TargetOS,
|
|
TargetArch: request.TargetArch,
|
|
State: state,
|
|
Required: true,
|
|
InstallPlanKey: request.InstallPlanKey,
|
|
Message: message,
|
|
CheckedAt: stamp,
|
|
UpdatedAt: stamp,
|
|
}
|
|
if err := validator.ValidateDependencyStatus(status); err != nil {
|
|
return err
|
|
}
|
|
if _, err := svc.store.DependencyStatuses().Get(status.ID); err == nil {
|
|
return svc.store.DependencyStatuses().Update(status)
|
|
} else if !errors.Is(err, repo.ErrNotFound) {
|
|
return err
|
|
}
|
|
return svc.store.DependencyStatuses().Create(status)
|
|
}
|
|
|
|
func (svc *CoreService) recordAuditEvent(actorID string, action string, resourceKind string, resourceID string, result domain.AuditResult, summary string) error {
|
|
svc.auditMu.Lock()
|
|
svc.auditSeq++
|
|
seq := svc.auditSeq
|
|
svc.auditMu.Unlock()
|
|
|
|
event := domain.AuditEvent{
|
|
ID: fmt.Sprintf("audit-%s-%d", strings.ReplaceAll(action, ".", "-"), seq),
|
|
ActorID: actorID,
|
|
Action: action,
|
|
ResourceKind: resourceKind,
|
|
ResourceID: resourceID,
|
|
Result: result,
|
|
Summary: safeBridgeReason(summary),
|
|
CreatedAt: svc.now(),
|
|
}
|
|
if err := validator.ValidateAuditEvent(event); err != nil {
|
|
return err
|
|
}
|
|
return svc.store.AuditEvents().Create(event)
|
|
}
|
|
|
|
func (svc *CoreService) auditArtifactDownload(sessionID string, artifact domain.Artifact) error {
|
|
user, err := svc.GetCurrentUser(sessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, distribution := range runDistributions {
|
|
if distribution.ArtifactID == artifact.ID {
|
|
return svc.recordAuditEvent(user.ID, "run.download", "server-instance", distribution.ServerInstanceID, domain.AuditResultSuccess, "downloaded run package artifact with redacted runtime key ref")
|
|
}
|
|
}
|
|
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, distribution := range clientDistributions {
|
|
if distribution.ArtifactID == artifact.ID {
|
|
return svc.recordAuditEvent(user.ID, "client-manager.download", "server-instance", distribution.ServerInstanceID, domain.AuditResultSuccess, "downloaded client-manager package artifact with redacted runtime key ref")
|
|
}
|
|
}
|
|
return svc.recordAuditEvent(user.ID, "artifact.download", string(artifact.OwnerKind), artifact.OwnerID, domain.AuditResultSuccess, "downloaded platform artifact")
|
|
}
|
|
|
|
func validatePluginTarget(plugin domain.GamePlugin, targetOS string) error {
|
|
if len(plugin.SupportedOS) == 0 || containsString(plugin.SupportedOS, targetOS) {
|
|
return nil
|
|
}
|
|
return validationError("targetOs is not declared by plugin")
|
|
}
|
|
|
|
func packageFormatForTarget(targetOS string) string {
|
|
if targetOS == "windows" {
|
|
return "zip"
|
|
}
|
|
return "tar.gz"
|
|
}
|
|
|
|
func distributionID(prefix string, parts ...interface{}) string {
|
|
values := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
values = append(values, fmt.Sprint(part))
|
|
}
|
|
raw := strings.Join(values, "-")
|
|
return prefix + "-" + sanitizeIDPart(raw) + "-" + fmt.Sprint(stableStringNumber(raw))
|
|
}
|
|
|
|
func artifactIDForDistribution(distributionID string) string {
|
|
sum := sha256.Sum256([]byte(distributionID))
|
|
return "artifact-" + sanitizeIDPart(distributionID)[:minInt(len(sanitizeIDPart(distributionID)), 48)] + "-" + hex.EncodeToString(sum[:])[:16]
|
|
}
|
|
|
|
func componentKeyID(serverInstanceID string, kind domain.DistributionComponentKind, componentKey string, generation int) string {
|
|
return distributionID("runtime-key", serverInstanceID, kind, normalizedComponentKey(kind, componentKey), generation)
|
|
}
|
|
|
|
func secretRefForComponent(serverInstanceID string, kind domain.DistributionComponentKind, componentKey string) string {
|
|
component := string(kind)
|
|
if normalized := normalizedComponentKey(kind, componentKey); normalized != "" {
|
|
component += "/" + normalized
|
|
}
|
|
return "secret://runtime-keys/" + sanitizeIDPart(serverInstanceID) + "/" + component + "/current"
|
|
}
|
|
|
|
func normalizedComponentKey(kind domain.DistributionComponentKind, componentKey string) string {
|
|
componentKey = strings.TrimSpace(componentKey)
|
|
if kind == domain.DistributionComponentRun {
|
|
return ""
|
|
}
|
|
return componentKey
|
|
}
|
|
|
|
func sanitizeIDPart(value string) string {
|
|
value = strings.TrimSpace(strings.ToLower(value))
|
|
var builder strings.Builder
|
|
for _, char := range value {
|
|
switch {
|
|
case char >= 'a' && char <= 'z':
|
|
builder.WriteRune(char)
|
|
case char >= '0' && char <= '9':
|
|
builder.WriteRune(char)
|
|
case char == '-' || char == '_' || char == '.':
|
|
builder.WriteRune(char)
|
|
default:
|
|
builder.WriteByte('-')
|
|
}
|
|
}
|
|
sanitized := strings.Trim(builder.String(), "-")
|
|
if sanitized == "" {
|
|
return "value"
|
|
}
|
|
if len(sanitized) > 80 {
|
|
return sanitized[:80]
|
|
}
|
|
return sanitized
|
|
}
|
|
|
|
func checksumForString(value string) string {
|
|
sum := sha256.Sum256([]byte(value))
|
|
return "sha256:" + hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func fingerprintForString(value string) string {
|
|
sum := sha256.Sum256([]byte(value))
|
|
return hex.EncodeToString(sum[:])[:12]
|
|
}
|
|
|
|
func clientManagerCheckoutRef(repositoryURL string, sourceRevision string) string {
|
|
sourceRevision = strings.TrimSpace(sourceRevision)
|
|
if sourceRevision == "" {
|
|
sourceRevision = "main"
|
|
}
|
|
if looksLikeCommitRevision(sourceRevision) {
|
|
return "commit/" + sourceRevision
|
|
}
|
|
return "branch/" + sanitizeIDPart(sourceRevision)
|
|
}
|
|
|
|
func clientManagerOutputName(profileKey string, targetOS string) string {
|
|
name := sanitizeIDPart(profileKey)
|
|
if targetOS == "windows" {
|
|
return name + ".exe"
|
|
}
|
|
return name
|
|
}
|
|
|
|
func clientManagerBuildLog(checkout clientManagerCheckoutPlan, config generatedPackageConfig, outputs []string) string {
|
|
lines := []string{
|
|
"client-manager checkout prepared",
|
|
"repository=" + checkout.RepositoryURL,
|
|
"sourceRevision=" + checkout.SourceRevision,
|
|
"checkoutRef=" + checkout.CheckoutRef,
|
|
"target=" + checkout.TargetOS + "/" + checkout.TargetArch,
|
|
"dependencyCheck=typed build profile accepted",
|
|
"configInjection=secret ref " + config.SecretRef + " generation " + fmt.Sprintf("%d", config.KeyGeneration),
|
|
"keyFingerprint=" + fingerprintForString(config.AuthKey),
|
|
"outputs=" + strings.Join(outputs, ","),
|
|
}
|
|
return redactDistributionLog(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
func looksLikeCommitRevision(value string) bool {
|
|
if len(value) < 7 || len(value) > 64 {
|
|
return false
|
|
}
|
|
for _, char := range value {
|
|
if (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F') || (char >= '0' && char <= '9') {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func redactDistributionLog(value string) string {
|
|
replacements := []string{
|
|
"/Users/", "[host]/",
|
|
"password=", "password=[redacted]",
|
|
"api_key=", "api_key=[redacted]",
|
|
"secret=", "secret=[redacted]",
|
|
"Bearer ", "Bearer [redacted] ",
|
|
"sk-", "sk-[redacted]",
|
|
"unix://", "socket://",
|
|
"tcp://", "endpoint://",
|
|
"mysql://", "db://",
|
|
"sqlite://", "db://",
|
|
}
|
|
redacted := value
|
|
for i := 0; i+1 < len(replacements); i += 2 {
|
|
redacted = strings.ReplaceAll(redacted, replacements[i], replacements[i+1])
|
|
}
|
|
if len(redacted) > 4096 {
|
|
return redacted[:4096]
|
|
}
|
|
return redacted
|
|
}
|
|
|
|
func minInt(a int, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func runtimeAction(key string, label string, available bool, reason string) domain.ServerRuntimeAction {
|
|
action := domain.ServerRuntimeAction{Key: key, Label: label, Available: available}
|
|
if !available {
|
|
action.Reason = reason
|
|
}
|
|
return action
|
|
}
|
|
|
|
func fallbackReason(primary bool, primaryReason string, fallback string) string {
|
|
if primary {
|
|
return primaryReason
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func (svc *CoreService) runtimeBindingsComplete(serverInstanceID string) bool {
|
|
bindings, err := svc.store.RuntimeBindings().List(domain.RuntimeBindingFilter{ServerInstanceID: serverInstanceID})
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, binding := range bindings {
|
|
if binding.Status == domain.RuntimeBindingStatusIncomplete {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (svc *CoreService) requireCompleteRuntimeBindings(actorID string, serverInstanceID string, deniedAction string) error {
|
|
if svc.runtimeBindingsComplete(serverInstanceID) {
|
|
return nil
|
|
}
|
|
_ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "operation denied: runtime binding is incomplete")
|
|
return ErrForbidden
|
|
}
|
|
|
|
func pluginDeclares(plugin domain.GamePlugin, permission string) bool {
|
|
return containsString(plugin.DeclaredPermissions, permission)
|
|
}
|
|
|
|
func pluginSupports(plugin domain.GamePlugin, capability string) bool {
|
|
return containsString(plugin.RequiredRunCapabilities, capability)
|
|
}
|
|
|
|
func endpointSupports(endpoint domain.RunEndpoint, capability string) bool {
|
|
if endpoint.Status != domain.RunEndpointStatusOnline && endpoint.Status != domain.RunEndpointStatusDegraded {
|
|
return false
|
|
}
|
|
return containsString(endpoint.Capabilities, capability)
|
|
}
|
|
|
|
func validateRunUpdateRequest(request domain.RunUpdateRequest) error {
|
|
if strings.TrimSpace(request.ServerInstanceID) == "" {
|
|
return validationError("serverInstanceId is required")
|
|
}
|
|
if strings.TrimSpace(request.ArtifactID) == "" {
|
|
return validationError("artifactId is required")
|
|
}
|
|
if request.Checksum != "" && !strings.HasPrefix(request.Checksum, "sha256:") {
|
|
return validationError("checksum must be sha256:<hex>")
|
|
}
|
|
if containsUnsafeRequestText(request.IdempotencyKey) {
|
|
return validationError("idempotencyKey is unsafe")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateDependencyJobRequest(request domain.DependencyJobRequest) error {
|
|
if strings.TrimSpace(request.ServerInstanceID) == "" {
|
|
return validationError("serverInstanceId is required")
|
|
}
|
|
if !safeDistributionKey(request.ProbeKey) {
|
|
return validationError("probeKey is invalid")
|
|
}
|
|
if request.Install && !safeDistributionKey(request.InstallPlanKey) {
|
|
return validationError("installPlanKey is invalid")
|
|
}
|
|
if containsUnsafeRequestText(request.IdempotencyKey) || containsUnsafeRequestText(request.TargetOS) || containsUnsafeRequestText(request.TargetArch) {
|
|
return validationError("dependency request contains unsafe content")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateLogBackfillRequest(request domain.LogBackfillRequest) error {
|
|
if strings.TrimSpace(request.ServerInstanceID) == "" {
|
|
return validationError("serverInstanceId is required")
|
|
}
|
|
if !safeDistributionKey(request.SourceKey) {
|
|
return validationError("sourceKey is invalid")
|
|
}
|
|
if request.CheckpointRef != "" && (!strings.HasPrefix(request.CheckpointRef, "input://") && !strings.HasPrefix(request.CheckpointRef, "artifact://")) {
|
|
return validationError("checkpointRef is not allowed")
|
|
}
|
|
if request.Limit < 0 || request.Limit > 10000 {
|
|
return validationError("limit must be between 0 and 10000")
|
|
}
|
|
if containsUnsafeRequestText(request.IdempotencyKey) || containsUnsafeRequestText(request.CheckpointRef) {
|
|
return validationError("log backfill request contains unsafe content")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func safeDistributionKey(value string) bool {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" || len(value) > 96 || strings.Contains(value, "..") || strings.Contains(value, "://") || strings.HasPrefix(value, "/") {
|
|
return false
|
|
}
|
|
for _, char := range value {
|
|
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == '/' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return !containsUnsafeRequestText(value)
|
|
}
|
|
|
|
func containsUnsafeRequestText(value string) bool {
|
|
lowered := strings.ToLower(value)
|
|
return strings.Contains(lowered, "bash -c") ||
|
|
strings.Contains(lowered, "powershell -") ||
|
|
strings.Contains(lowered, "cmd.exe") ||
|
|
strings.Contains(lowered, "curl |") ||
|
|
strings.Contains(lowered, "password=") ||
|
|
strings.Contains(lowered, "mysql://") ||
|
|
strings.Contains(lowered, "sqlite://") ||
|
|
strings.Contains(lowered, "unix://") ||
|
|
strings.Contains(lowered, "tcp://") ||
|
|
strings.Contains(lowered, "/users/")
|
|
}
|
|
|
|
func encryptRuntimeKey(plain string) (string, error) {
|
|
key := runtimeEncryptionKey()
|
|
block, err := aes.NewCipher(key[:])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return "", err
|
|
}
|
|
ciphertext := gcm.Seal(nil, nonce, []byte(plain), nil)
|
|
return "enc:v1:" + base64.RawURLEncoding.EncodeToString(nonce) + ":" + base64.RawURLEncoding.EncodeToString(ciphertext), nil
|
|
}
|
|
|
|
func decryptRuntimeKey(encrypted string) (string, error) {
|
|
parts := strings.Split(encrypted, ":")
|
|
if len(parts) != 4 || parts[0] != "enc" || parts[1] != "v1" {
|
|
return "", validationError("encrypted key format is invalid")
|
|
}
|
|
nonce, err := base64.RawURLEncoding.DecodeString(parts[2])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
ciphertext, err := base64.RawURLEncoding.DecodeString(parts[3])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
key := runtimeEncryptionKey()
|
|
block, err := aes.NewCipher(key[:])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(plain), nil
|
|
}
|
|
|
|
func runtimeEncryptionKey() [32]byte {
|
|
return sha256.Sum256([]byte("browser.local/platform/runtime-component-key/v1"))
|
|
}
|