Files
browser/platform/service/distributions.go
T

1039 lines
42 KiB
Go

package service
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"net"
"net/url"
"os"
"strings"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
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 {
return domain.RunDistribution{}, err
}
if deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment) {
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "run.generate.denied"); err != nil {
return domain.RunDistribution{}, err
}
}
if ready, reason := svc.distributionBuilderReadiness(); !ready {
return domain.RunDistribution{}, validationError(reason)
}
if _, err := runReleasePlatformURL(); err != nil {
return domain.RunDistribution{}, err
}
key, _, 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
}
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: runEndpointIDForDistribution(instance),
TargetOS: request.TargetOS,
TargetArch: request.TargetArch,
PackageFormat: runPackageFormatForTarget(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: platformDistributionBuilderEndpointID,
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")
}
svc.enqueueDistributionBuild(job)
return domain.CopyRunDistribution(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) 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 request.ComponentKind == domain.DistributionComponentRun {
if err := svc.revokeRunControlSessionForInstance(instance); 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"
return domain.CopyComponentAuthenticationResult(result), nil
}
plainKey, err := svc.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"
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, endpointErr := svc.GetRunEndpoint(instance.RunEndpointID)
runRegistered := endpointErr == nil
if endpointErr != nil && !errors.Is(endpointErr, repo.ErrNotFound) {
return domain.ServerRuntimeActions{}, endpointErr
}
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
}
}
bindingsComplete, bindingReason := svc.runtimeBindingReadiness(instance.ID)
runPackageInputsComplete, runPackageReason := bindingsComplete, bindingReason
if !deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment) {
runPackageInputsComplete, runPackageReason = true, ""
}
builderReady, builderReason := svc.distributionBuilderReadiness()
dependencyPermissionDeclared := pluginDeclares(plugin, "server.dependencies.manage")
actions := domain.ServerRuntimeActions{
ServerInstanceID: instance.ID,
PluginID: plugin.ID,
RunEndpointID: instance.RunEndpointID,
RunStatus: func() domain.RunEndpointStatus {
if runRegistered {
return endpoint.Status
}
return domain.RunEndpointStatusOffline
}(),
Actions: []domain.ServerRuntimeAction{
runtimeAction("generate-run", "Generate run", pluginDeclares(plugin, "server.run.distribution") && builderReady && runPackageInputsComplete, fallbackReason(!pluginDeclares(plugin, "server.run.distribution"), "plugin permission is not declared", fallbackReason(!builderReady, builderReason, runPackageReason))),
runtimeAction("download-run", "Download run", hasAvailableRunPackage, "run package has not been generated"),
runtimeAction("push-run-update", "Push run update", runRegistered && pluginDeclares(plugin, "server.run.distribution") && svc.endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate) && runPackageInputsComplete, fallbackReason(!runRegistered, "Run heartbeat has not been observed", fallbackReason(!pluginDeclares(plugin, "server.run.distribution") || !svc.endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate), "run endpoint cannot self-update", runPackageReason))),
runtimeAction("reset-run-key", "Reset run key", pluginDeclares(plugin, "server.run.distribution"), "plugin permission is not declared"),
runtimeAction("dependencies-check", "Check dependencies", runRegistered && dependencyPermissionDeclared && svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!runRegistered, "Run heartbeat has not been observed", fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason)))),
runtimeAction("dependencies-install", "Install dependencies", runRegistered && dependencyPermissionDeclared && svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall) && bindingsComplete, fallbackReason(!runRegistered, "Run heartbeat has not been observed", fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall), "run endpoint cannot install dependencies", bindingReason)))),
runtimeAction("live-logs", "Live logs", runRegistered && pluginSupports(plugin, "logs.read"), fallbackReason(!runRegistered, "Run heartbeat has not been observed", "plugin does not declare live logs")),
runtimeAction("historical-logs", "Historical logs", runRegistered && svc.endpointSupports(endpoint, domain.JobCapabilityLogsBackfill) && bindingsComplete, fallbackReason(!runRegistered, "Run heartbeat has not been observed", fallbackReason(!svc.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, instance, err := svc.requireServerOwner(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 deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment) {
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 {
return domain.RunUpdateJob{}, validationError("artifact must be available")
}
if request.Checksum == "" {
request.Checksum = artifact.Checksum
}
if request.Checksum != artifact.Checksum {
return domain.RunUpdateJob{}, validationError("checksum must match artifact")
}
endpoint, err := svc.GetRunEndpoint(instance.RunEndpointID)
if err != nil {
return domain.RunUpdateJob{}, err
}
if endpoint.Platform == "" || endpoint.Architecture == "" {
return domain.RunUpdateJob{}, validationError("Run endpoint target is not registered")
}
distributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: instance.ID, Status: domain.DistributionStatusAvailable})
if err != nil {
return domain.RunUpdateJob{}, err
}
distribution, err := findRunDistributionForArtifact(distributions, artifact.ID)
if err != nil || distribution.RunEndpointID != endpoint.ID || distribution.TargetOS != endpoint.Platform || distribution.TargetArch != endpoint.Architecture || distribution.Checksum != artifact.Checksum || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != distribution.BuildJobID {
return domain.RunUpdateJob{}, validationError("artifact must be an approved target-matched Run distribution")
}
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 {
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,
TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch,
TargetRelease: distribution.ID,
PreviousVersion: endpoint.Version,
JobID: job.ID,
IdempotencyKey: request.IdempotencyKey,
Status: domain.DistributionJobStatusQueued,
Phase: domain.RunUpdatePhaseQueued,
Message: "Run update queued",
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 !sameRunUpdateTarget(existing, updateJob) {
return domain.RunUpdateJob{}, validationError("run update job already exists with different target")
}
return domain.CopyRunUpdateJob(existing), 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) == "" {
operation := "check"
if request.Install {
operation = "install-" + request.InstallPlanKey
}
request.IdempotencyKey = "dependencies-" + operation + "-" + request.ServerInstanceID + "-" + request.ProbeKey
}
if err := validateDependencyJobRequest(request); err != nil {
return domain.Job{}, err
}
user, instance, err := svc.requireServerOwner(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") {
return domain.Job{}, forbiddenError("plugin does not declare required permission: server.dependencies.manage")
}
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "dependency.install.denied"); err != nil {
return domain.Job{}, err
}
resolution, err := svc.resolveDependencyContext(instance.ID)
if err != nil {
return domain.Job{}, err
}
if request.TargetOS != "" && request.TargetOS != resolution.endpoint.Platform || request.TargetArch != "" && request.TargetArch != resolution.endpoint.Architecture {
return domain.Job{}, validationError("dependency request target does not match Run endpoint")
}
request.TargetOS = resolution.endpoint.Platform
request.TargetArch = resolution.endpoint.Architecture
probe, err := declaredDependencyProbe(plugin, request.ProbeKey, request.TargetOS)
if err != nil {
return domain.Job{}, err
}
var plan domain.RuntimeInstallPlan
if request.Install {
plan, err = declaredInstallPlan(plugin, request.InstallPlanKey, request.TargetOS)
if err != nil {
return domain.Job{}, err
}
if !planTargetsProbe(plan, probe) {
return domain.Job{}, validationError("install plan does not target requested dependency probe")
}
}
expectedDigest := dependencyPlanDigest(resolution, probe, plan)
if request.Install && request.PlanDigest != expectedDigest {
return domain.Job{}, validationError("planDigest must match the current reviewed install plan")
}
request.PlanDigest = expectedDigest
capability := domain.JobCapabilityDependenciesCheck
targetKey := "dependencies/" + request.ProbeKey
message := "dependency check queued"
state := domain.DependencyStateUnknown
if request.Install {
capability = domain.JobCapabilityDependenciesInstall
targetKey = "dependencies/install/" + request.InstallPlanKey
message = "dependency install queued"
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 {
return domain.Job{}, err
}
if err := svc.upsertDependencyStatus(instance, request, job.ID, probe.Required, state, "queued through platform job"); 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) {
return domain.Job{}, ErrForbidden
}
source, err := declaredFileLogSource(plugin, request.SourceKey)
if err != nil {
return domain.Job{}, err
}
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/" + source.Key,
InputRef: request.CheckpointRef,
IdempotencyKey: request.IdempotencyKey,
Progress: domain.JobProgress{Percent: 0, Message: "historical log backfill queued"},
ExecutionInput: domain.JobExecutionInput{LogSource: &source},
})
if err != nil {
return domain.Job{}, err
}
return domain.CopyJob(job), nil
}
func declaredFileLogSource(plugin domain.GamePlugin, sourceKey string) (domain.RuntimeLogSource, error) {
for _, source := range plugin.RuntimeProfiles.LogSources {
if source.Key != sourceKey {
continue
}
if source.Kind != "file.tail" || strings.TrimSpace(source.TargetKey) == "" || strings.TrimSpace(source.StreamKey) == "" {
return domain.RuntimeLogSource{}, validationError("log source must be a file.tail source with target and stream keys")
}
copy := source
return copy, nil
}
return domain.RuntimeLogSource{}, validationError("log source is not declared by plugin")
}
func (svc *CoreService) validateDistributionPluginPermission(actorID string, plugin domain.GamePlugin, serverInstanceID string, permission string, deniedAction string) error {
if plugin.Status != domain.GamePluginStatusInstalled {
return forbiddenError("plugin is not installed")
}
if !containsString(plugin.DeclaredPermissions, permission) {
return forbiddenError("plugin does not declare required permission: " + permission)
}
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 := svc.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 kind == domain.DistributionComponentRun {
plainKey, err = randomRunComponentKey()
}
if err != nil {
return domain.EncryptedComponentKey{}, "", err
}
encryptedKey, err := svc.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
}
}
}
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 existingPayload, err := svc.artifactStore.GetPayload(artifactID); err == nil {
if int64(len(existingPayload)) != artifact.SizeBytes || validator.BytesChecksum(existingPayload) != artifact.Checksum {
return validationError("artifact payload does not match metadata")
}
svc.cacheArtifactPayload(artifactID, existingPayload)
return nil
} else if !errors.Is(err, repo.ErrNotFound) {
return err
}
if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum {
return validationError("artifact payload does not match metadata")
}
if err := svc.artifactStore.PutPayload(artifactID, payload); err != nil {
return err
}
svc.cacheArtifactPayload(artifactID, payload)
return nil
}
func (svc *CoreService) upsertDependencyStatus(instance domain.ServerInstance, request domain.DependencyJobRequest, jobID string, required bool, 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: required,
InstallPlanKey: request.InstallPlanKey,
PlanDigest: request.PlanDigest,
JobID: jobID,
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 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 runPackageFormatForTarget(targetOS string) string {
_ = targetOS
return "raw-executable"
}
func packageFormatForTarget(targetOS string) string {
if targetOS == "windows" {
return "zip"
}
return "tar.gz"
}
const defaultRunReleasePlatformURL = "https://scum.npc0.com/"
func runReleasePlatformURL() (string, error) {
value := strings.TrimSpace(os.Getenv("PLATFORM_RUN_RELEASE_URL"))
if value == "" {
value = defaultRunReleasePlatformURL
}
parsed, err := url.ParseRequestURI(value)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || strings.Contains(value, "#") {
return "", validationError("PLATFORM_RUN_RELEASE_URL must be an absolute http or https URL without credentials, query parameters, or fragments")
}
host := strings.TrimRight(strings.ToLower(parsed.Hostname()), ".")
if host == "" {
return "", validationError("PLATFORM_RUN_RELEASE_URL must be an absolute http or https URL with a host")
}
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
return "", validationError("PLATFORM_RUN_RELEASE_URL must use an address reachable from the target server; loopback or unspecified addresses are not valid for generated Run packages")
}
if ip := net.ParseIP(host); ip != nil && (ip.IsLoopback() || ip.IsUnspecified()) {
return "", validationError("PLATFORM_RUN_RELEASE_URL must use an address reachable from the target server; loopback or unspecified addresses are not valid for generated Run packages")
}
return value, nil
}
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 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 {
complete, _ := svc.runtimeBindingReadiness(serverInstanceID)
return complete
}
func (svc *CoreService) runtimeBindingReadiness(serverInstanceID string) (bool, string) {
binding, err := svc.runtimeBindingForServer(serverInstanceID)
if errors.Is(err, repo.ErrNotFound) {
return false, "runtime profile is not configured"
}
if err != nil {
return false, "runtime binding cannot be verified"
}
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
if err != nil || binding.PluginID != instance.PluginID || binding.PluginVersion != instance.PluginVersion {
return false, "runtime binding does not match the server plugin"
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return false, "runtime profile cannot be verified"
}
binding, err = normalizeRuntimeBinding(plugin, binding)
if err != nil {
return false, "runtime binding cannot be verified"
}
if binding.Status != domain.RuntimeBindingStatusComplete {
return false, "missing logical bindings: " + strings.Join(binding.MissingKeys, ", ")
}
return true, ""
}
func (svc *CoreService) requireCompleteRuntimeBindings(actorID string, serverInstanceID string, deniedAction string) error {
complete, reason := svc.runtimeBindingReadiness(serverInstanceID)
if complete {
return nil
}
return validationError(reason)
}
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 (svc *CoreService) endpointSupports(endpoint domain.RunEndpoint, capability string) bool {
if endpoint.Status != domain.RunEndpointStatusOnline && endpoint.Status != domain.RunEndpointStatusDegraded {
return false
}
if !svc.runEndpointHeartbeatCurrent(endpoint) {
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 request.Install && (request.PlanDigest == "" || !strings.HasPrefix(request.PlanDigest, "sha256:") || len(request.PlanDigest) != len("sha256:")+64) {
return validationError("planDigest must be a sha256 digest")
}
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/")
}