package service import ( "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/hex" "errors" "sort" "strconv" "strings" "time" "browser.local/platform/domain" "browser.local/platform/repo" "browser.local/platform/validator" ) const ( clientManagerRegistrationWindow = 5 * time.Minute clientManagerSessionTTL = 15 * time.Minute ) func (svc *CoreService) DeployClientManagerForSession(sessionID string, request domain.ClientManagerDeployRequest) (domain.ClientManagerLifecycleView, error) { if strings.TrimSpace(request.IdempotencyKey) == "" { request.IdempotencyKey = "client-manager-deploy-" + request.ServerInstanceID + "-" + request.ProfileKey + "-" + request.DistributionID } if err := validator.ValidateClientManagerDeployRequest(request); err != nil { return domain.ClientManagerLifecycleView{}, err } _, instance, plugin, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerDeploy, "client-manager.deploy.denied") if err != nil { return domain.ClientManagerLifecycleView{}, err } distribution, err := svc.authorizedClientManagerDistribution(instance, profile, request.DistributionID) if err != nil { return domain.ClientManagerLifecycleView{}, err } installation, err := svc.ensureClientManagerInstallationFromDistribution(instance, plugin, distribution) if err != nil { return domain.ClientManagerLifecycleView{}, err } if request.ExpectedDeploymentGeneration > 0 && request.ExpectedDeploymentGeneration != installation.DeploymentGeneration { return domain.ClientManagerLifecycleView{}, validationError("client-manager deployment generation is stale") } if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil { if existing.ServerInstanceID != instance.ID || existing.Capability != domain.JobCapabilityClientManagerDeploy || existing.TargetKey != "client-manager/"+profile.Key || installation.CurrentJobID != existing.ID { return domain.ClientManagerLifecycleView{}, validationError("client-manager deploy idempotency key conflicts with another job") } return svc.clientManagerLifecycleView(installation) } else if !errors.Is(err, repo.ErrNotFound) { return domain.ClientManagerLifecycleView{}, err } stamp := svc.now() installation.RunEndpointID = endpoint.ID installation.TargetOS = distribution.TargetOS installation.TargetArch = distribution.TargetArch installation.DesiredVersion = clientManagerDistributionVersion(distribution, profile) installation.DesiredRevision = distribution.SourceRevision installation.DesiredArtifactID = distribution.ArtifactID installation.Checksum = distribution.Checksum installation.KeyGeneration = distribution.KeyGeneration installation.DeploymentGeneration++ installation.LastOperation = domain.ClientManagerOperationDeploy installation.CurrentJobID = clientManagerJobID(domain.ClientManagerOperationDeploy, installation.ID, request.IdempotencyKey) installation.Phase = "deployment queued" installation.Health = domain.ClientManagerHealthUnknown installation.HealthReason = "awaiting verified deployment" installation.Retryable = false installation.RequiresRedeploy = false installation.UpdatedAt = stamp if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleDeploying); err != nil { return domain.ClientManagerLifecycleView{}, err } if err := validator.ValidateClientManagerInstallation(installation); err != nil { return domain.ClientManagerLifecycleView{}, err } if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { return domain.ClientManagerLifecycleView{}, err } job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, domain.JobCapabilityClientManagerDeploy, "deployment queued")) if err != nil { installation.Status = domain.ClientManagerLifecycleFailed installation.Phase = "deployment dispatch failed" installation.HealthReason = "Run endpoint rejected deployment" installation.Retryable = true installation.UpdatedAt = svc.now() _ = svc.store.ClientManagerInstallations().Update(installation) return domain.ClientManagerLifecycleView{}, err } if job.ID != installation.CurrentJobID { return domain.ClientManagerLifecycleView{}, validationError("client-manager deploy job fence is invalid") } return svc.clientManagerLifecycleView(installation) } func (svc *CoreService) ControlClientManagerForSession(sessionID string, request domain.ClientManagerControlRequest) (domain.ClientManagerLifecycleView, error) { if err := validator.ValidateClientManagerControlRequest(request); err != nil { return domain.ClientManagerLifecycleView{}, err } capability := domain.JobCapabilityClientManagerControl if request.Operation == domain.ClientManagerOperationRollback { capability = domain.JobCapabilityClientManagerRollback } _, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, capability, "client-manager."+string(request.Operation)+".denied") if err != nil { return domain.ClientManagerLifecycleView{}, err } if !containsString(profile.Lifecycle.Actions, string(request.Operation)) { return domain.ClientManagerLifecycleView{}, ErrForbidden } installation, err := svc.getClientManagerInstallation(instance.ID, profile.Key) if err != nil { return domain.ClientManagerLifecycleView{}, err } if request.ExpectedDeploymentGeneration != installation.DeploymentGeneration || installation.RequiresRedeploy { return domain.ClientManagerLifecycleView{}, validationError("client-manager deployment generation is stale or requires redeploy") } if installation.ActiveArtifactID == "" || installation.Status == domain.ClientManagerLifecycleUninstalled || installation.Status == domain.ClientManagerLifecycleDeploying || installation.Status == domain.ClientManagerLifecycleUpdating || installation.Status == domain.ClientManagerLifecycleRollingBack || installation.Status == domain.ClientManagerLifecycleStopping { return domain.ClientManagerLifecycleView{}, validationError("client-manager state does not allow control") } if request.Operation == domain.ClientManagerOperationRollback && installation.PreviousArtifactID == "" { return domain.ClientManagerLifecycleView{}, validationError("client-manager previous deployment is unavailable") } if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil { if installation.CurrentJobID != existing.ID || existing.Capability != capability { return domain.ClientManagerLifecycleView{}, validationError("client-manager control idempotency key conflicts with another job") } return svc.clientManagerLifecycleView(installation) } else if !errors.Is(err, repo.ErrNotFound) { return domain.ClientManagerLifecycleView{}, err } installation.LastOperation = request.Operation installation.CurrentJobID = clientManagerJobID(request.Operation, installation.ID, request.IdempotencyKey) installation.Phase = string(request.Operation) + " queued" installation.Retryable = false installation.UpdatedAt = svc.now() switch request.Operation { case domain.ClientManagerOperationStop: if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleStopping); err != nil { return domain.ClientManagerLifecycleView{}, err } case domain.ClientManagerOperationRollback: installation.DesiredArtifactID = installation.PreviousArtifactID installation.DesiredVersion = installation.PreviousVersion installation.DesiredRevision = installation.PreviousRevision installation.DeploymentGeneration++ if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleRollingBack); err != nil { return domain.ClientManagerLifecycleView{}, err } case domain.ClientManagerOperationStart, domain.ClientManagerOperationRestart: if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleRegistering); err != nil { return domain.ClientManagerLifecycleView{}, err } } if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { return domain.ClientManagerLifecycleView{}, err } job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, capability, installation.Phase)) if err != nil { installation.Status = domain.ClientManagerLifecycleFailed installation.Phase = string(request.Operation) + " dispatch failed" installation.Retryable = true installation.UpdatedAt = svc.now() _ = svc.store.ClientManagerInstallations().Update(installation) return domain.ClientManagerLifecycleView{}, err } if job.ID != installation.CurrentJobID { return domain.ClientManagerLifecycleView{}, validationError("client-manager control job fence is invalid") } return svc.clientManagerLifecycleView(installation) } func (svc *CoreService) UpdateClientManagerForSession(sessionID string, request domain.ClientManagerUpdateRequest) (domain.ClientManagerLifecycleView, error) { if err := validator.ValidateClientManagerUpdateRequest(request); err != nil { return domain.ClientManagerLifecycleView{}, err } _, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerUpdate, "client-manager.update.denied") if err != nil { return domain.ClientManagerLifecycleView{}, err } if !containsString(profile.Lifecycle.Actions, "update") || profile.UpdatePolicy.Strategy != "manual-staged" || !profile.UpdatePolicy.RequireApproval { return domain.ClientManagerLifecycleView{}, ErrForbidden } installation, err := svc.getClientManagerInstallation(instance.ID, profile.Key) if err != nil { return domain.ClientManagerLifecycleView{}, err } if request.ExpectedDeploymentGeneration != installation.DeploymentGeneration || installation.ActiveArtifactID == "" || installation.RequiresRedeploy { return domain.ClientManagerLifecycleView{}, validationError("client-manager update fence is stale or no healthy baseline is installed") } distribution, err := svc.authorizedClientManagerDistribution(instance, profile, request.DistributionID) if err != nil { return domain.ClientManagerLifecycleView{}, err } if distribution.ArtifactID == installation.ActiveArtifactID || !clientManagerVersionAllowed(profile, installation.ActiveVersion, clientManagerDistributionVersion(distribution, profile)) { return domain.ClientManagerLifecycleView{}, validationError("client-manager update artifact is incompatible") } if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil { if installation.CurrentJobID != existing.ID || existing.Capability != domain.JobCapabilityClientManagerUpdate { return domain.ClientManagerLifecycleView{}, validationError("client-manager update idempotency key conflicts with another job") } return svc.clientManagerLifecycleView(installation) } else if !errors.Is(err, repo.ErrNotFound) { return domain.ClientManagerLifecycleView{}, err } installation.DesiredArtifactID = distribution.ArtifactID installation.DesiredVersion = clientManagerDistributionVersion(distribution, profile) installation.DesiredRevision = distribution.SourceRevision installation.Checksum = distribution.Checksum installation.KeyGeneration = distribution.KeyGeneration installation.DeploymentGeneration++ installation.LastOperation = domain.ClientManagerOperationUpdate installation.CurrentJobID = clientManagerJobID(domain.ClientManagerOperationUpdate, installation.ID, request.IdempotencyKey) installation.Phase = "staged update queued" installation.Retryable = false installation.UpdatedAt = svc.now() if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleUpdating); err != nil { return domain.ClientManagerLifecycleView{}, err } if err := svc.revokeClientManagerSessions(installation.ID, "update activation requires a new component session"); err != nil { return domain.ClientManagerLifecycleView{}, err } if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { return domain.ClientManagerLifecycleView{}, err } job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, domain.JobCapabilityClientManagerUpdate, installation.Phase)) if err != nil { installation.Status = domain.ClientManagerLifecycleFailed installation.Phase = "update dispatch failed" installation.Retryable = true installation.UpdatedAt = svc.now() _ = svc.store.ClientManagerInstallations().Update(installation) return domain.ClientManagerLifecycleView{}, err } if job.ID != installation.CurrentJobID { return domain.ClientManagerLifecycleView{}, validationError("client-manager update job fence is invalid") } return svc.clientManagerLifecycleView(installation) } func (svc *CoreService) UninstallClientManagerForSession(sessionID string, request domain.ClientManagerUninstallRequest) (domain.ClientManagerLifecycleView, error) { if err := validator.ValidateClientManagerUninstallRequest(request); err != nil { return domain.ClientManagerLifecycleView{}, err } _, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerUninstall, "client-manager.uninstall.denied") if err != nil { return domain.ClientManagerLifecycleView{}, err } if !containsString(profile.Lifecycle.Actions, "uninstall") { return domain.ClientManagerLifecycleView{}, ErrForbidden } installation, err := svc.getClientManagerInstallation(instance.ID, profile.Key) if err != nil { return domain.ClientManagerLifecycleView{}, err } if request.ExpectedDeploymentGeneration != installation.DeploymentGeneration { return domain.ClientManagerLifecycleView{}, validationError("client-manager uninstall generation is stale") } if installation.Status == domain.ClientManagerLifecycleUninstalled { return svc.clientManagerLifecycleView(installation) } if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil { if installation.CurrentJobID != existing.ID || existing.Capability != domain.JobCapabilityClientManagerUninstall { return domain.ClientManagerLifecycleView{}, validationError("client-manager uninstall idempotency key conflicts with another job") } return svc.clientManagerLifecycleView(installation) } else if !errors.Is(err, repo.ErrNotFound) { return domain.ClientManagerLifecycleView{}, err } installation.LastOperation = domain.ClientManagerOperationUninstall installation.CurrentJobID = clientManagerJobID(domain.ClientManagerOperationUninstall, installation.ID, request.IdempotencyKey) installation.Phase = "safe uninstall queued" installation.UpdatedAt = svc.now() if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleStopping); err != nil { return domain.ClientManagerLifecycleView{}, err } if err := svc.revokeClientManagerSessions(installation.ID, "uninstall requested"); err != nil { return domain.ClientManagerLifecycleView{}, err } if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { return domain.ClientManagerLifecycleView{}, err } job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, domain.JobCapabilityClientManagerUninstall, installation.Phase)) if err != nil { installation.Status = domain.ClientManagerLifecycleFailed installation.Phase = "uninstall dispatch failed" installation.Retryable = true installation.UpdatedAt = svc.now() _ = svc.store.ClientManagerInstallations().Update(installation) return domain.ClientManagerLifecycleView{}, err } if job.ID != installation.CurrentJobID { return domain.ClientManagerLifecycleView{}, validationError("client-manager uninstall job fence is invalid") } return svc.clientManagerLifecycleView(installation) } func (svc *CoreService) RevokeClientManagerSessionForSession(sessionID string, request domain.ClientManagerRevokeSessionRequest) (domain.ClientManagerLifecycleView, error) { if _, err := svc.GetCurrentUser(sessionID); err != nil { return domain.ClientManagerLifecycleView{}, err } instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID) if err != nil { return domain.ClientManagerLifecycleView{}, err } installation, err := svc.getClientManagerInstallation(instance.ID, request.ProfileKey) if err != nil { return domain.ClientManagerLifecycleView{}, err } if err := svc.revokeClientManagerSessions(installation.ID, "operator revoked component session"); err != nil { return domain.ClientManagerLifecycleView{}, err } if installation.Status != domain.ClientManagerLifecycleUninstalled { installation.Status = domain.ClientManagerLifecycleOffline installation.Health = domain.ClientManagerHealthOffline installation.HealthReason = "component session revoked" installation.Phase = "registration required" installation.UpdatedAt = svc.now() if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { return domain.ClientManagerLifecycleView{}, err } } return svc.clientManagerLifecycleView(installation) } func (svc *CoreService) RetryClientManagerLifecycleForSession(sessionID string, request domain.ClientManagerRetryRequest) (domain.ClientManagerLifecycleView, error) { if err := validator.ValidateClientManagerRetryRequest(request); err != nil { return domain.ClientManagerLifecycleView{}, err } installation, err := svc.getClientManagerInstallation(request.ServerInstanceID, request.ProfileKey) if err != nil { return domain.ClientManagerLifecycleView{}, err } if request.ExpectedDeploymentGeneration != installation.DeploymentGeneration || !installation.Retryable || installation.LastOperation == "" { return domain.ClientManagerLifecycleView{}, validationError("client-manager lifecycle operation is not retryable or generation is stale") } capability := domain.JobCapabilityClientManagerControl switch installation.LastOperation { case domain.ClientManagerOperationDeploy: capability = domain.JobCapabilityClientManagerDeploy case domain.ClientManagerOperationUpdate: capability = domain.JobCapabilityClientManagerUpdate case domain.ClientManagerOperationRollback: capability = domain.JobCapabilityClientManagerRollback case domain.ClientManagerOperationUninstall: capability = domain.JobCapabilityClientManagerUninstall } _, _, _, _, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, capability, "client-manager.retry.denied") if err != nil { return domain.ClientManagerLifecycleView{}, err } if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil { if installation.CurrentJobID != existing.ID || existing.Capability != capability { return domain.ClientManagerLifecycleView{}, validationError("client-manager retry idempotency key conflicts with another job") } return svc.clientManagerLifecycleView(installation) } else if !errors.Is(err, repo.ErrNotFound) { return domain.ClientManagerLifecycleView{}, err } installation.CurrentJobID = clientManagerJobID(installation.LastOperation, installation.ID, request.IdempotencyKey) installation.Phase = string(installation.LastOperation) + " retry queued" installation.Retryable = false switch installation.LastOperation { case domain.ClientManagerOperationDeploy: installation.Status = domain.ClientManagerLifecycleDeploying case domain.ClientManagerOperationUpdate: installation.Status = domain.ClientManagerLifecycleUpdating case domain.ClientManagerOperationRollback: installation.Status = domain.ClientManagerLifecycleRollingBack case domain.ClientManagerOperationStop, domain.ClientManagerOperationUninstall: installation.Status = domain.ClientManagerLifecycleStopping default: installation.Status = domain.ClientManagerLifecycleRegistering } installation.UpdatedAt = svc.now() if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { return domain.ClientManagerLifecycleView{}, err } job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, capability, installation.Phase)) if err != nil { installation.Status = domain.ClientManagerLifecycleFailed installation.Retryable = true installation.UpdatedAt = svc.now() _ = svc.store.ClientManagerInstallations().Update(installation) return domain.ClientManagerLifecycleView{}, err } if job.ID != installation.CurrentJobID { return domain.ClientManagerLifecycleView{}, validationError("client-manager retry job fence is invalid") } return svc.clientManagerLifecycleView(installation) } func (svc *CoreService) GetClientManagerLifecycleForSession(sessionID, serverInstanceID, profileKey string) (domain.ClientManagerLifecycleView, error) { if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil { return domain.ClientManagerLifecycleView{}, err } if err := svc.ReconcileClientManagerLifecycle(); err != nil { return domain.ClientManagerLifecycleView{}, err } installation, err := svc.getClientManagerInstallation(serverInstanceID, profileKey) if err != nil { return domain.ClientManagerLifecycleView{}, err } return svc.clientManagerLifecycleView(installation) } func (svc *CoreService) ListClientManagerLifecyclesForSession(sessionID, serverInstanceID string) ([]domain.ClientManagerLifecycleView, error) { if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil { return nil, err } if err := svc.ReconcileClientManagerLifecycle(); err != nil { return nil, err } installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: serverInstanceID}) if err != nil { return nil, err } views := make([]domain.ClientManagerLifecycleView, 0, len(installations)) for _, installation := range installations { view, err := svc.clientManagerLifecycleView(installation) if err != nil { return nil, err } views = append(views, view) } sort.Slice(views, func(i, j int) bool { return views[i].Installation.ProfileKey < views[j].Installation.ProfileKey }) return views, nil } func clientManagerLifecycleJob(installation domain.ClientManagerInstallation, idempotencyKey, capability, message string) domain.Job { return domain.Job{ID: installation.CurrentJobID, ServerInstanceID: installation.ServerInstanceID, RunEndpointID: installation.RunEndpointID, Capability: capability, TargetKey: "client-manager/" + installation.ProfileKey, InputRef: "input://client-manager-lifecycle/" + installation.ID + "/" + strconv.Itoa(installation.DeploymentGeneration), IdempotencyKey: idempotencyKey, Progress: domain.JobProgress{Percent: 0, Message: message}} } func clientManagerJobID(operation domain.ClientManagerLifecycleOperation, installationID, idempotencyKey string) string { return jobIDFromParts("job-client-manager-"+string(operation), installationID, idempotencyKey) } func (svc *CoreService) authorizeClientManagerLifecycle(sessionID, serverInstanceID, profileKey, capability, deniedAction string) (domain.User, domain.ServerInstance, domain.GamePlugin, domain.RuntimeClientManagerProfile, domain.RunEndpoint, error) { user, err := svc.GetCurrentUser(sessionID) if err != nil { return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err } instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID) if err != nil { return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err } plugin, err := svc.store.GamePlugins().Get(instance.PluginID) if err != nil { return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err } if err := svc.validateDistributionPluginPermission(user.ID, plugin, instance.ID, "server.client-manager.manage", deniedAction); err != nil { return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err } if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, deniedAction); err != nil { return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err } profile, err := findRuntimeClientManagerProfile(plugin, profileKey) if err != nil || profile.Deployment.Mode != "run-supervised" || !containsString(profile.Deployment.RequiredRunCapabilities, capability) { return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, ErrForbidden } endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) if err != nil { return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err } if err := svc.validateRunnableEndpoint(endpoint, capability); err != nil { return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err } return user, instance, plugin, profile, endpoint, nil } func (svc *CoreService) authorizedClientManagerDistribution(instance domain.ServerInstance, profile domain.RuntimeClientManagerProfile, distributionID string) (domain.ClientManagerDistribution, error) { distribution, err := svc.store.ClientManagerDistributions().Get(distributionID) if err != nil { return domain.ClientManagerDistribution{}, err } if distribution.ServerInstanceID != instance.ID || distribution.PluginID != instance.PluginID || distribution.ProfileKey != profile.Key || distribution.Status != domain.DistributionStatusAvailable || !clientManagerProfileSupportsTarget(profile, distribution.TargetOS, distribution.TargetArch) || !clientManagerProfileAllowsRevision(profile, distribution.SourceRevision) { return domain.ClientManagerDistribution{}, validationError("client-manager distribution is outside the lifecycle scope") } endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) if err != nil { return domain.ClientManagerDistribution{}, err } if distribution.TargetOS != endpoint.Platform || distribution.TargetArch != endpoint.Architecture { return domain.ClientManagerDistribution{}, validationError("client-manager distribution target does not match assigned Run endpoint") } key, err := svc.activeComponentKey(instance.ID, domain.DistributionComponentClientManager, profile.Key) if err != nil || key.Generation != distribution.KeyGeneration { return domain.ClientManagerDistribution{}, validationError("client-manager distribution key generation is no longer current") } artifact, err := svc.store.Artifacts().Get(distribution.ArtifactID) if err != nil { return domain.ClientManagerDistribution{}, err } if artifact.State != domain.ArtifactStateAvailable || artifact.Checksum != distribution.Checksum || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != distribution.BuildJobID { return domain.ClientManagerDistribution{}, validationError("client-manager distribution artifact is unavailable or outside its build job") } return domain.CopyClientManagerDistribution(distribution), nil } func findRuntimeClientManagerProfile(plugin domain.GamePlugin, profileKey string) (domain.RuntimeClientManagerProfile, error) { plugin = domain.CopyGamePlugin(plugin) for _, profile := range plugin.RuntimeProfiles.ClientManagers { if profile.Key == strings.TrimSpace(profileKey) { return profile, nil } } return domain.RuntimeClientManagerProfile{}, repo.ErrNotFound } func clientManagerCompanionConfigInput(plugin domain.GamePlugin, profile domain.RuntimeClientManagerProfile, installation domain.ClientManagerInstallation, artifactID, version, revision string) (*domain.ClientManagerCompanionConfigInput, error) { companion := plugin.GameClientBridge.Companion if companion == (domain.GameClientBridgeCompanionDeclaration{}) { return nil, nil } if companion.ProfileKey == "" { return nil, validationError("client-manager companion declaration is incomplete") } if companion.ProfileKey != installation.ProfileKey { return nil, nil } var configTemplate domain.RuntimeConfigTemplate found := false for _, candidate := range profile.ConfigTemplates { if candidate.Key == companion.ConfigTemplateKey { configTemplate = candidate found = true break } } if !found || strings.TrimSpace(configTemplate.TemplateRef) == "" || configTemplate.OutputRef != "config.yaml" { return nil, validationError("client-manager companion config template is unavailable") } config := domain.ClientManagerCompanionConfigInput{ SchemaVersion: domain.ClientManagerCompanionConfigSchemaVersion, ConfigTemplateKey: companion.ConfigTemplateKey, ConfigTemplateRef: configTemplate.TemplateRef, ConfigOutputRef: configTemplate.OutputRef, ConfigSchemaRef: companion.ConfigSchemaRef, ConfigFormat: companion.ConfigFormat, PlatformBaseURLSource: companion.PlatformBaseURLSource, InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, PluginID: plugin.ID, ProfileKey: installation.ProfileKey, ArtifactID: artifactID, Version: version, SourceRevision: revision, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, Capabilities: domain.CopyStringSlice(profile.Health.RequiredCapabilities), RegistrationProof: companion.RegistrationProof, ProofMaterialSource: companion.ProofMaterialSource, ProofMaterialEnv: companion.ProofMaterialEnv, SessionMode: companion.SessionMode, TLSPolicy: companion.TLSPolicy, HeartbeatIntervalSeconds: companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: companion.RequestTimeoutSeconds, } if err := validator.ValidateClientManagerCompanionConfigInput(config); err != nil { return nil, err } return &config, nil } func clientManagerProfileSupportsTarget(profile domain.RuntimeClientManagerProfile, targetOS, targetArch string) bool { for _, target := range profile.SupportedTargets { if target.OS == targetOS && target.Arch == targetArch { return true } } return false } func clientManagerProfileRevision(profile domain.RuntimeClientManagerProfile) string { switch profile.RevisionPolicy { case "pinned": return profile.Revision case "tag": return profile.Tag default: return profile.Branch } } func clientManagerProfileAllowsRevision(profile domain.RuntimeClientManagerProfile, revision string) bool { revision = strings.TrimSpace(revision) if revision == "" { return false } switch profile.RevisionPolicy { case "pinned": return revision == profile.Revision case "tag": return revision == profile.Tag || revision == profile.Revision case "branch": return revision == profile.Branch || profile.Revision != "" && revision == profile.Revision default: return false } } func clientManagerDistributionVersion(distribution domain.ClientManagerDistribution, profile domain.RuntimeClientManagerProfile) string { if strings.TrimSpace(distribution.Version) != "" { return distribution.Version } return profile.Version } func (svc *CoreService) ensureClientManagerInstallationFromDistribution(instance domain.ServerInstance, plugin domain.GamePlugin, distribution domain.ClientManagerDistribution) (domain.ClientManagerInstallation, error) { installationID := distributionID("client-manager-installation", instance.ID, distribution.ProfileKey) installation, err := svc.store.ClientManagerInstallations().Get(installationID) if err == nil { return installation, nil } if !errors.Is(err, repo.ErrNotFound) { return domain.ClientManagerInstallation{}, err } status := domain.ClientManagerLifecycleBuilding phase := "build in progress" if distribution.Status == domain.DistributionStatusAvailable { status = domain.ClientManagerLifecycleAvailable phase = "artifact available" } else if distribution.Status == domain.DistributionStatusFailed || distribution.Status == domain.DistributionStatusRevoked { status = domain.ClientManagerLifecycleFailed phase = "build unavailable" } stamp := svc.now() installation = domain.ClientManagerInstallation{ID: installationID, ServerInstanceID: instance.ID, PluginID: plugin.ID, ProfileKey: distribution.ProfileKey, RunEndpointID: instance.RunEndpointID, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, Status: status, Phase: phase, DesiredVersion: distribution.Version, DesiredRevision: distribution.SourceRevision, DesiredArtifactID: distribution.ArtifactID, Checksum: distribution.Checksum, KeyGeneration: distribution.KeyGeneration, Health: domain.ClientManagerHealthUnknown, HealthReason: "component is not installed", CreatedAt: stamp, UpdatedAt: stamp} if err := validator.ValidateClientManagerInstallation(installation); err != nil { return domain.ClientManagerInstallation{}, err } if err := svc.store.ClientManagerInstallations().Create(installation); err != nil { if errors.Is(err, repo.ErrDuplicate) { return svc.store.ClientManagerInstallations().Get(installation.ID) } return domain.ClientManagerInstallation{}, err } return installation, nil } func (svc *CoreService) ProjectClientManagerDistribution(distribution domain.ClientManagerDistribution) error { instance, err := svc.store.ServerInstances().Get(distribution.ServerInstanceID) if err != nil { return err } plugin, err := svc.store.GamePlugins().Get(distribution.PluginID) if err != nil { return err } installation, err := svc.ensureClientManagerInstallationFromDistribution(instance, plugin, distribution) if err != nil { return err } if installation.ActiveArtifactID != "" || installation.Status == domain.ClientManagerLifecycleDeploying || installation.Status == domain.ClientManagerLifecycleUpdating || installation.Status == domain.ClientManagerLifecycleRollingBack || installation.Status == domain.ClientManagerLifecycleStopping { return nil } installation.DesiredVersion = distribution.Version installation.DesiredRevision = distribution.SourceRevision installation.DesiredArtifactID = distribution.ArtifactID installation.TargetOS = distribution.TargetOS installation.TargetArch = distribution.TargetArch installation.Checksum = distribution.Checksum installation.KeyGeneration = distribution.KeyGeneration switch distribution.Status { case domain.DistributionStatusBuilding: installation.Status = domain.ClientManagerLifecycleBuilding installation.Phase = "build in progress" case domain.DistributionStatusAvailable: installation.Status = domain.ClientManagerLifecycleAvailable installation.Phase = "artifact available" case domain.DistributionStatusFailed, domain.DistributionStatusRevoked: installation.Status = domain.ClientManagerLifecycleFailed installation.Phase = "build unavailable" installation.RequiresRedeploy = distribution.Status == domain.DistributionStatusRevoked } installation.UpdatedAt = svc.now() return svc.store.ClientManagerInstallations().Update(installation) } func (svc *CoreService) getClientManagerInstallation(serverInstanceID, profileKey string) (domain.ClientManagerInstallation, error) { items, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: serverInstanceID, ProfileKey: profileKey}) if err != nil { return domain.ClientManagerInstallation{}, err } if len(items) == 0 { return domain.ClientManagerInstallation{}, repo.ErrNotFound } sort.Slice(items, func(i, j int) bool { return items[i].CreatedAt.Before(items[j].CreatedAt) }) return items[0], nil } func setClientManagerLifecycleStatus(installation *domain.ClientManagerInstallation, status domain.ClientManagerLifecycleStatus) error { if err := validator.ValidateClientManagerLifecycleTransition(installation.Status, status); err != nil { return err } installation.Status = status return nil } func clientManagerVersionAllowed(profile domain.RuntimeClientManagerProfile, current, desired string) bool { currentParts, currentOK := parseClientManagerVersion(current) desiredParts, desiredOK := parseClientManagerVersion(desired) if !desiredOK { return false } if currentOK && !profile.Compatibility.AllowDowngrade && compareClientManagerVersion(desiredParts, currentParts) < 0 { return false } if minimum, ok := parseClientManagerVersion(profile.Compatibility.MinimumVersion); ok && compareClientManagerVersion(desiredParts, minimum) < 0 { return false } if maximum, ok := parseClientManagerVersion(profile.Compatibility.MaximumVersion); ok && compareClientManagerVersion(desiredParts, maximum) > 0 { return false } return true } func parseClientManagerVersion(value string) ([3]int, bool) { var result [3]int parts := strings.SplitN(strings.SplitN(value, "-", 2)[0], ".", 4) if len(parts) != 3 { return result, false } for i, part := range parts { parsed, err := strconv.Atoi(part) if err != nil || parsed < 0 { return [3]int{}, false } result[i] = parsed } return result, true } func compareClientManagerVersion(left, right [3]int) int { for i := 0; i < 3; i++ { if left[i] < right[i] { return -1 } if left[i] > right[i] { return 1 } } return 0 } func (svc *CoreService) GetClientManagerLifecycleInput(request domain.ClientManagerLifecycleInputRequest) (domain.ClientManagerLifecycleInput, error) { if err := validator.ValidateClientManagerLifecycleInputRequest(request); err != nil { return domain.ClientManagerLifecycleInput{}, err } session, err := svc.validatedRunSession(request.RunEndpointID, request.SessionToken) if err != nil { return domain.ClientManagerLifecycleInput{}, err } job, err := svc.fencedJob(session, request.JobID, request.LeaseToken, request.Attempt) if err != nil { return domain.ClientManagerLifecycleInput{}, err } if !isClientManagerJobCapability(job.Capability) || job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning { return domain.ClientManagerLifecycleInput{}, validationError("job is not an active client-manager lifecycle job") } installation, err := svc.getClientManagerInstallation(job.ServerInstanceID, strings.TrimPrefix(job.TargetKey, "client-manager/")) if err != nil { return domain.ClientManagerLifecycleInput{}, err } if installation.CurrentJobID != job.ID || installation.RunEndpointID != request.RunEndpointID || installation.DeploymentGeneration <= 0 || job.InputRef != "input://client-manager-lifecycle/"+installation.ID+"/"+strconv.Itoa(installation.DeploymentGeneration) { return domain.ClientManagerLifecycleInput{}, validationError("client-manager lifecycle input fence is stale") } instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID) if err != nil || instance.RunEndpointID != request.RunEndpointID { return domain.ClientManagerLifecycleInput{}, validationError("client-manager endpoint ownership fence is stale") } plugin, err := svc.store.GamePlugins().Get(installation.PluginID) if err != nil { return domain.ClientManagerLifecycleInput{}, err } profile, err := findRuntimeClientManagerProfile(plugin, installation.ProfileKey) if err != nil { return domain.ClientManagerLifecycleInput{}, err } key, err := svc.activeComponentKey(installation.ServerInstanceID, domain.DistributionComponentClientManager, installation.ProfileKey) if err != nil || key.Generation != installation.KeyGeneration || installation.RequiresRedeploy { return domain.ClientManagerLifecycleInput{}, validationError("client-manager component key generation is stale") } operation := installation.LastOperation artifactID := installation.DesiredArtifactID checksum := installation.Checksum version := installation.DesiredVersion revision := installation.DesiredRevision if operation == domain.ClientManagerOperationStart || operation == domain.ClientManagerOperationStop || operation == domain.ClientManagerOperationRestart || operation == domain.ClientManagerOperationStatus || operation == domain.ClientManagerOperationUninstall { artifactID = installation.ActiveArtifactID version = installation.ActiveVersion revision = installation.ActiveRevision checksum = "" } companionConfig, err := clientManagerCompanionConfigInput(plugin, profile, installation, artifactID, version, revision) if err != nil { return domain.ClientManagerLifecycleInput{}, err } return domain.CopyClientManagerLifecycleInput(domain.ClientManagerLifecycleInput{InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, Operation: operation, ArtifactID: artifactID, Checksum: checksum, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, Version: version, SourceRevision: revision, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, ExecutableRef: profile.Deployment.ExecutableRef, Arguments: profile.Deployment.Arguments, AutoStart: profile.Deployment.AutoStart, StartupTimeoutSeconds: profile.Lifecycle.StartupTimeoutSeconds, StopTimeoutSeconds: profile.Lifecycle.StopTimeoutSeconds, HealthConfirmationSeconds: profile.UpdatePolicy.HealthConfirmationSeconds, IdempotencyKey: job.IdempotencyKey, CompanionConfig: companionConfig}), nil } func (svc *CoreService) ReadClientManagerLifecycleChunk(request domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) { if err := validator.ValidateRunUpdateChunkRequest(request); err != nil { return domain.RunUpdateChunk{}, err } job, err := svc.activeFencedInputJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt) if err != nil { return domain.RunUpdateChunk{}, err } if !isClientManagerJobCapability(job.Capability) || job.Capability == domain.JobCapabilityClientManagerControl || job.Capability == domain.JobCapabilityClientManagerUninstall { return domain.RunUpdateChunk{}, validationError("job does not carry a client-manager artifact") } installation, err := svc.getClientManagerInstallation(job.ServerInstanceID, strings.TrimPrefix(job.TargetKey, "client-manager/")) if err != nil || installation.CurrentJobID != job.ID || installation.DesiredArtifactID == "" { return domain.RunUpdateChunk{}, validationError("client-manager artifact fence is stale") } artifact, err := svc.store.Artifacts().Get(installation.DesiredArtifactID) if err != nil { return domain.RunUpdateChunk{}, err } if artifact.State != domain.ArtifactStateAvailable || artifact.Checksum != installation.Checksum { return domain.RunUpdateChunk{}, validationError("client-manager artifact is unavailable or checksum changed") } payload, err := svc.artifactPayload(artifact.ID) if err != nil { return domain.RunUpdateChunk{}, err } if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum || request.Offset >= artifact.SizeBytes { return domain.RunUpdateChunk{}, validationError("client-manager artifact content or offset is invalid") } length := request.Length if remaining := artifact.SizeBytes - request.Offset; int64(length) > remaining { length = int(remaining) } end := request.Offset + int64(length) return domain.CopyRunUpdateChunk(domain.RunUpdateChunk{JobID: job.ID, ArtifactID: artifact.ID, Offset: request.Offset, TotalBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Payload: payload[int(request.Offset):int(end)], Complete: end == artifact.SizeBytes}), nil } func isClientManagerJobCapability(capability string) bool { switch capability { case domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall: return true default: return false } } func (svc *CoreService) projectClientManagerLifecycleProgress(job domain.Job, stamp time.Time) error { if !isClientManagerJobCapability(job.Capability) { return nil } installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: job.ServerInstanceID}) if err != nil { return err } for _, installation := range installations { if installation.CurrentJobID != job.ID { continue } installation.Phase = safeClientManagerLifecycleMessage(job.Progress.Message, "lifecycle job running") installation.UpdatedAt = stamp return svc.store.ClientManagerInstallations().Update(installation) } return repo.ErrNotFound } func (svc *CoreService) projectClientManagerLifecycleResult(job domain.Job, stamp time.Time) error { if !isClientManagerJobCapability(job.Capability) { return nil } installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: job.ServerInstanceID}) if err != nil { return err } for _, installation := range installations { if installation.CurrentJobID != job.ID { continue } installation.Phase = safeClientManagerLifecycleMessage(job.Progress.Message, "lifecycle job finished") installation.UpdatedAt = stamp installation.Retryable = false success := job.State == domain.JobStateSucceeded if !success { installation.Retryable = job.State == domain.JobStateFailed installation.HealthReason = "client-manager " + string(installation.LastOperation) + " did not complete" if installation.LastOperation == domain.ClientManagerOperationUpdate && job.ExecutionResult.Kind == "client-manager.rollback.restored" { installation.Status = domain.ClientManagerLifecycleDegraded installation.Phase = "update failed; previous deployment restored" installation.Health = domain.ClientManagerHealthDegraded } else if job.State == domain.JobStateCancelled && installation.ActiveArtifactID != "" { installation.Status = domain.ClientManagerLifecycleOffline installation.Health = domain.ClientManagerHealthOffline } else { installation.Status = domain.ClientManagerLifecycleFailed installation.Health = domain.ClientManagerHealthUnhealthy } } else { switch installation.LastOperation { case domain.ClientManagerOperationDeploy: installation.ActiveArtifactID = installation.DesiredArtifactID installation.ActiveVersion = installation.DesiredVersion installation.ActiveRevision = installation.DesiredRevision installation.Status = domain.ClientManagerLifecycleRegistering installation.Phase = "deployed; component registration pending" installation.InstalledAt = stamp installation.Health = domain.ClientManagerHealthUnknown installation.HealthReason = "awaiting component heartbeat" installation.LastSeenAt = time.Time{} installation.LastHeartbeatSequence = 0 case domain.ClientManagerOperationStart, domain.ClientManagerOperationRestart: installation.Status = domain.ClientManagerLifecycleRegistering installation.Phase = "process started; component registration pending" installation.Health = domain.ClientManagerHealthUnknown installation.HealthReason = "awaiting component heartbeat" installation.LastSeenAt = time.Time{} installation.LastHeartbeatSequence = 0 case domain.ClientManagerOperationStop: installation.Status = domain.ClientManagerLifecycleOffline installation.Phase = "process stopped" installation.Health = domain.ClientManagerHealthOffline installation.HealthReason = "stopped by operator" case domain.ClientManagerOperationStatus: if job.ExecutionResult.ProcessState == "running" { installation.Status = domain.ClientManagerLifecycleRegistering installation.Phase = "process running; heartbeat pending" } else { installation.Status = domain.ClientManagerLifecycleOffline installation.Health = domain.ClientManagerHealthOffline installation.Phase = "process is not running" } case domain.ClientManagerOperationUpdate: installation.PreviousArtifactID = installation.ActiveArtifactID installation.PreviousVersion = installation.ActiveVersion installation.PreviousRevision = installation.ActiveRevision installation.ActiveArtifactID = installation.DesiredArtifactID installation.ActiveVersion = installation.DesiredVersion installation.ActiveRevision = installation.DesiredRevision installation.Status = domain.ClientManagerLifecycleRegistering installation.Phase = "update activated; health registration pending" installation.Health = domain.ClientManagerHealthUnknown installation.HealthReason = "awaiting updated component heartbeat" installation.LastSeenAt = time.Time{} installation.LastHeartbeatSequence = 0 case domain.ClientManagerOperationRollback: oldArtifact, oldVersion, oldRevision := installation.ActiveArtifactID, installation.ActiveVersion, installation.ActiveRevision installation.ActiveArtifactID, installation.ActiveVersion, installation.ActiveRevision = installation.DesiredArtifactID, installation.DesiredVersion, installation.DesiredRevision installation.PreviousArtifactID, installation.PreviousVersion, installation.PreviousRevision = oldArtifact, oldVersion, oldRevision installation.Status = domain.ClientManagerLifecycleRegistering installation.Phase = "rollback activated; health registration pending" installation.Health = domain.ClientManagerHealthUnknown installation.HealthReason = "awaiting rolled-back component heartbeat" installation.LastSeenAt = time.Time{} installation.LastHeartbeatSequence = 0 case domain.ClientManagerOperationUninstall: installation.Status = domain.ClientManagerLifecycleUninstalled installation.Phase = "managed workspace removed" installation.Health = domain.ClientManagerHealthOffline installation.HealthReason = "uninstalled" installation.ActiveArtifactID = "" installation.ActiveVersion = "" installation.ActiveRevision = "" installation.PreviousArtifactID = "" installation.PreviousVersion = "" installation.PreviousRevision = "" installation.UninstalledAt = stamp } installation.LastSuccessfulJobID = job.ID } installation.CurrentJobID = job.ID if err := validator.ValidateClientManagerInstallation(installation); err != nil { return err } if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { return err } return nil } return repo.ErrNotFound } func safeClientManagerLifecycleMessage(value, fallback string) string { value = strings.TrimSpace(value) if value == "" { return fallback } if len(value) > 160 || strings.Contains(strings.ToLower(value), "secret://") || strings.Contains(strings.ToLower(value), "bearer ") || strings.Contains(strings.ToLower(value), "password=") || strings.Contains(strings.ToLower(value), "token=") || strings.Contains(value, "tcp://") || strings.Contains(value, "unix://") || strings.Contains(value, "/Users/") { return fallback } return value } func (svc *CoreService) clientManagerLifecycleView(installation domain.ClientManagerInstallation) (domain.ClientManagerLifecycleView, error) { view := domain.ClientManagerLifecycleView{Installation: domain.CopyClientManagerInstallation(installation)} if installation.DesiredArtifactID != "" { distributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey}) if err != nil { return domain.ClientManagerLifecycleView{}, err } for _, distribution := range distributions { if distribution.ArtifactID == installation.DesiredArtifactID { view.Distribution = distribution break } } } if installation.CurrentJobID != "" { job, err := svc.store.Jobs().Get(installation.CurrentJobID) if err == nil { view.Job = job } else if !errors.Is(err, repo.ErrNotFound) { return domain.ClientManagerLifecycleView{}, err } } instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID) if err != nil { return domain.ClientManagerLifecycleView{}, err } plugin, err := svc.store.GamePlugins().Get(installation.PluginID) if err != nil { return domain.ClientManagerLifecycleView{}, err } profile, _ := findRuntimeClientManagerProfile(plugin, installation.ProfileKey) endpoint, endpointErr := svc.store.RunEndpoints().Get(instance.RunEndpointID) view.Actions = clientManagerLifecycleActions(installation, view.Distribution, profile, endpoint, endpointErr) return domain.CopyClientManagerLifecycleView(view), nil } func clientManagerLifecycleActions(installation domain.ClientManagerInstallation, distribution domain.ClientManagerDistribution, profile domain.RuntimeClientManagerProfile, endpoint domain.RunEndpoint, endpointErr error) []domain.ClientManagerLifecycleActionAvailability { operations := []domain.ClientManagerLifecycleOperation{domain.ClientManagerOperationDeploy, domain.ClientManagerOperationStart, domain.ClientManagerOperationStop, domain.ClientManagerOperationRestart, domain.ClientManagerOperationStatus, domain.ClientManagerOperationUpdate, domain.ClientManagerOperationRollback, domain.ClientManagerOperationUninstall} items := make([]domain.ClientManagerLifecycleActionAvailability, 0, len(operations)) for _, operation := range operations { capability := domain.JobCapabilityClientManagerControl switch operation { case domain.ClientManagerOperationDeploy: capability = domain.JobCapabilityClientManagerDeploy case domain.ClientManagerOperationUpdate: capability = domain.JobCapabilityClientManagerUpdate case domain.ClientManagerOperationRollback: capability = domain.JobCapabilityClientManagerRollback case domain.ClientManagerOperationUninstall: capability = domain.JobCapabilityClientManagerUninstall } available := endpointErr == nil && endpoint.Status == domain.RunEndpointStatusOnline && containsString(endpoint.Capabilities, capability) && containsString(profile.Deployment.RequiredRunCapabilities, capability) reason := "" if !available { reason = "assigned Run endpoint is offline or lacks the declared capability" } if operation != domain.ClientManagerOperationDeploy && !containsString(profile.Lifecycle.Actions, string(operation)) { available = false reason = "client-manager profile does not declare this action" } if installation.RequiresRedeploy && operation != domain.ClientManagerOperationDeploy && operation != domain.ClientManagerOperationUninstall { available = false reason = "component key changed; rebuild and redeploy is required" } if operation == domain.ClientManagerOperationDeploy && (installation.DesiredArtifactID == "" || distribution.Status != domain.DistributionStatusAvailable) { available = false reason = "no available client-manager build artifact is ready" } if operation == domain.ClientManagerOperationRollback && installation.PreviousArtifactID == "" { available = false reason = "no previous deployment is retained" } if operation != domain.ClientManagerOperationDeploy && operation != domain.ClientManagerOperationUninstall && installation.ActiveArtifactID == "" { available = false reason = "client manager is not installed" } if installation.Status == domain.ClientManagerLifecycleDeploying || installation.Status == domain.ClientManagerLifecycleUpdating || installation.Status == domain.ClientManagerLifecycleRollingBack || installation.Status == domain.ClientManagerLifecycleStopping { available = false reason = "another lifecycle operation is in progress" } items = append(items, domain.ClientManagerLifecycleActionAvailability{Operation: operation, Available: available, Reason: reason}) } return items } func (svc *CoreService) clientManagerRuntimeActionProjection(instance domain.ServerInstance, plugin domain.GamePlugin, endpoint domain.RunEndpoint, bindingsComplete bool, bindingReason string) []domain.ServerRuntimeAction { profiles := plugin.RuntimeProfiles.ClientManagers hasLifecycle := false for _, profile := range profiles { if profile.Deployment.Mode == "run-supervised" { hasLifecycle = true break } } installations, _ := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: instance.ID}) hasInstalled := false hasPrevious := false hasUpdateCandidate := false for _, installation := range installations { hasInstalled = hasInstalled || installation.ActiveArtifactID != "" && installation.Status != domain.ClientManagerLifecycleUninstalled hasPrevious = hasPrevious || installation.PreviousArtifactID != "" hasUpdateCandidate = hasUpdateCandidate || installation.DesiredArtifactID != "" && installation.DesiredArtifactID != installation.ActiveArtifactID } baseReason := fallbackReason(!hasLifecycle || !bindingsComplete, "plugin does not declare a complete Client Manager lifecycle", bindingReason) return []domain.ServerRuntimeAction{ runtimeAction("deploy-client-manager", "Deploy client manager", hasLifecycle && bindingsComplete && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerDeploy), fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerDeploy), "run endpoint cannot deploy client managers", baseReason)), runtimeAction("control-client-manager", "Control client manager", hasLifecycle && hasInstalled && bindingsComplete && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerControl), fallbackReason(!hasInstalled || !svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerControl), "client manager is not installed or endpoint cannot control it", baseReason)), runtimeAction("update-client-manager", "Update client manager", hasLifecycle && hasInstalled && hasUpdateCandidate && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerUpdate), "no compatible update candidate is available"), runtimeAction("rollback-client-manager", "Rollback client manager", hasLifecycle && hasPrevious && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerRollback), "no retained previous deployment is available"), runtimeAction("uninstall-client-manager", "Uninstall client manager", hasLifecycle && hasInstalled && svc.endpointSupports(endpoint, domain.JobCapabilityClientManagerUninstall), "client manager is not installed or endpoint cannot uninstall it"), } } func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegisterRequest) (domain.ClientManagerRegisterResult, error) { request = domain.CopyClientManagerRegisterRequest(request) if err := validator.ValidateClientManagerRegisterRequest(request); err != nil { return domain.ClientManagerRegisterResult{}, err } stamp := svc.now() if request.Timestamp.Before(stamp.Add(-clientManagerRegistrationWindow)) || request.Timestamp.After(stamp.Add(clientManagerRegistrationWindow)) { return domain.ClientManagerRegisterResult{}, ErrUnauthorized } installation, err := svc.store.ClientManagerInstallations().Get(request.InstallationID) if err != nil { return domain.ClientManagerRegisterResult{}, ErrUnauthorized } if installation.ServerInstanceID != request.ServerInstanceID || installation.ProfileKey != request.ProfileKey || installation.ActiveArtifactID != request.ArtifactID || installation.ActiveVersion != request.Version || installation.ActiveRevision != request.SourceRevision || installation.TargetOS != request.TargetOS || installation.TargetArch != request.TargetArch || installation.KeyGeneration != request.KeyGeneration || installation.DeploymentGeneration != request.DeploymentGeneration || installation.RequiresRedeploy || installation.Status == domain.ClientManagerLifecycleUninstalled { return domain.ClientManagerRegisterResult{}, ErrUnauthorized } instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID) if err != nil || instance.RunEndpointID != installation.RunEndpointID { return domain.ClientManagerRegisterResult{}, ErrUnauthorized } plugin, err := svc.store.GamePlugins().Get(installation.PluginID) if err != nil { return domain.ClientManagerRegisterResult{}, ErrUnauthorized } profile, err := findRuntimeClientManagerProfile(plugin, installation.ProfileKey) if err != nil || !clientManagerCapabilitiesMatch(profile.Health.RequiredCapabilities, request.Capabilities) { return domain.ClientManagerRegisterResult{}, ErrUnauthorized } key, err := svc.activeComponentKey(installation.ServerInstanceID, domain.DistributionComponentClientManager, installation.ProfileKey) if err != nil || key.Generation != request.KeyGeneration { return domain.ClientManagerRegisterResult{}, ErrUnauthorized } plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey) if err != nil { return domain.ClientManagerRegisterResult{}, err } expected := clientManagerRegistrationSignature(plainKey, request) if subtle.ConstantTimeCompare([]byte(expected), []byte(request.Signature)) != 1 { return domain.ClientManagerRegisterResult{}, ErrUnauthorized } nonceID := clientManagerNonceID(request.InstallationID, request.Nonce) if _, err := svc.store.ClientManagerNonces().Get(nonceID); err == nil { return domain.ClientManagerRegisterResult{}, ErrUnauthorized } else if !errors.Is(err, repo.ErrNotFound) { return domain.ClientManagerRegisterResult{}, err } nonce := domain.ClientManagerRegistrationNonce{ID: nonceID, InstallationID: installation.ID, ExpiresAt: stamp.Add(clientManagerRegistrationWindow), CreatedAt: stamp} if err := validator.ValidateClientManagerNonce(nonce); err != nil { return domain.ClientManagerRegisterResult{}, err } if err := svc.store.ClientManagerNonces().Create(nonce); err != nil { return domain.ClientManagerRegisterResult{}, ErrUnauthorized } if err := svc.revokeClientManagerSessions(installation.ID, "component registered a replacement session"); err != nil { return domain.ClientManagerRegisterResult{}, err } token, err := randomToken() if err != nil { return domain.ClientManagerRegisterResult{}, err } session := domain.ClientManagerSession{ID: clientManagerSessionID(installation.ID, installation.DeploymentGeneration, token), InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, TokenHash: tokenHash(token), Capabilities: domain.CopyStringSlice(request.Capabilities), Status: domain.ClientManagerSessionActive, LastSeenAt: stamp, ExpiresAt: stamp.Add(clientManagerSessionTTL), CreatedAt: stamp, UpdatedAt: stamp} if err := validator.ValidateClientManagerSession(session); err != nil { return domain.ClientManagerRegisterResult{}, err } if err := svc.store.ClientManagerSessions().Create(session); err != nil { return domain.ClientManagerRegisterResult{}, err } installation.Status = domain.ClientManagerLifecycleOnline installation.Phase = "component registered" installation.Health = domain.ClientManagerHealthHealthy installation.HealthReason = "authenticated component session active" installation.LastSeenAt = stamp installation.LastHeartbeatSequence = 0 installation.UpdatedAt = stamp if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { return domain.ClientManagerRegisterResult{}, err } return domain.ClientManagerRegisterResult{Accepted: true, InstallationID: installation.ID, SessionToken: token, ExpiresAt: session.ExpiresAt, HeartbeatEvery: profile.Health.IntervalSeconds, ServerTime: stamp}, nil } func (svc *CoreService) AcceptClientManagerHeartbeat(heartbeat domain.ClientManagerHeartbeat) (domain.ClientManagerHeartbeatResult, error) { heartbeat = domain.CopyClientManagerHeartbeat(heartbeat) if err := validator.ValidateClientManagerHeartbeat(heartbeat); err != nil { return domain.ClientManagerHeartbeatResult{}, err } stamp := svc.now() installation, err := svc.store.ClientManagerInstallations().Get(heartbeat.InstallationID) if err != nil { return domain.ClientManagerHeartbeatResult{}, ErrUnauthorized } sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installation.ID, Status: domain.ClientManagerSessionActive}) if err != nil { return domain.ClientManagerHeartbeatResult{}, err } var session domain.ClientManagerSession for _, candidate := range sessions { if subtle.ConstantTimeCompare([]byte(candidate.TokenHash), []byte(tokenHash(heartbeat.SessionToken))) == 1 { session = candidate break } } if session.ID == "" || !stamp.Before(session.ExpiresAt) || session.KeyGeneration != installation.KeyGeneration || session.DeploymentGeneration != installation.DeploymentGeneration || session.ArtifactID != installation.ActiveArtifactID || session.RunEndpointID != installation.RunEndpointID || heartbeat.Sequence <= session.LastHeartbeatSequence { return domain.ClientManagerHeartbeatResult{}, ErrUnauthorized } plugin, err := svc.store.GamePlugins().Get(installation.PluginID) if err != nil { return domain.ClientManagerHeartbeatResult{}, err } profile, err := findRuntimeClientManagerProfile(plugin, installation.ProfileKey) if err != nil || !clientManagerCapabilitiesMatch(profile.Health.RequiredCapabilities, heartbeat.Capabilities) || !clientManagerCapabilitiesMatch(session.Capabilities, heartbeat.Capabilities) { return domain.ClientManagerHeartbeatResult{}, ErrUnauthorized } session.LastHeartbeatSequence = heartbeat.Sequence session.LastSeenAt = stamp session.UpdatedAt = stamp if err := svc.store.ClientManagerSessions().Update(session); err != nil { return domain.ClientManagerHeartbeatResult{}, err } installation.LastHeartbeatSequence = heartbeat.Sequence installation.LastSeenAt = stamp installation.Health = heartbeat.Health installation.HealthReason = safeClientManagerLifecycleMessage(heartbeat.HealthReason, "component health reported") installation.Status = domain.ClientManagerLifecycleOnline if heartbeat.Health == domain.ClientManagerHealthDegraded || heartbeat.Health == domain.ClientManagerHealthUnhealthy { installation.Status = domain.ClientManagerLifecycleDegraded } installation.Phase = "component heartbeat accepted" installation.UpdatedAt = stamp if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { return domain.ClientManagerHeartbeatResult{}, err } return domain.ClientManagerHeartbeatResult{Accepted: true, InstallationID: installation.ID, Status: installation.Status, Health: installation.Health, NextHeartbeat: profile.Health.IntervalSeconds, SessionExpiresAt: session.ExpiresAt, ServerTime: stamp}, nil } func clientManagerRegistrationSignature(key string, request domain.ClientManagerRegisterRequest) string { capabilities := domain.CopyStringSlice(request.Capabilities) sort.Strings(capabilities) canonical := strings.Join([]string{request.InstallationID, request.ServerInstanceID, request.ProfileKey, request.ArtifactID, request.Version, request.SourceRevision, request.TargetOS, request.TargetArch, strconv.Itoa(request.KeyGeneration), strconv.Itoa(request.DeploymentGeneration), request.Timestamp.UTC().Format(time.RFC3339Nano), request.Nonce, strings.Join(capabilities, ",")}, "\n") mac := hmac.New(sha256.New, []byte(key)) _, _ = mac.Write([]byte(canonical)) return "sha256:" + hex.EncodeToString(mac.Sum(nil)) } func clientManagerNonceID(installationID, nonce string) string { sum := sha256.Sum256([]byte(installationID + "\x00" + nonce)) return hex.EncodeToString(sum[:]) } func clientManagerSessionID(installationID string, generation int, token string) string { sum := sha256.Sum256([]byte(installationID + "\x00" + strconv.Itoa(generation) + "\x00" + token)) return "cm-session-" + hex.EncodeToString(sum[:12]) } func clientManagerCapabilitiesMatch(required, actual []string) bool { actualSet := map[string]struct{}{} for _, capability := range actual { switch capability { case "component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream": actualSet[capability] = struct{}{} default: return false } } for _, capability := range required { if _, ok := actualSet[capability]; !ok { return false } } return true } func (svc *CoreService) revokeClientManagerSessions(installationID, reason string) error { sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installationID, Status: domain.ClientManagerSessionActive}) if err != nil { return err } stamp := svc.now() for _, session := range sessions { session.Status = domain.ClientManagerSessionRevoked session.RevokedAt = stamp session.UpdatedAt = stamp if err := svc.store.ClientManagerSessions().Update(session); err != nil { return err } } _ = reason return nil } func (svc *CoreService) ReconcileClientManagerLifecycle() error { stamp := svc.now() nonces, err := svc.store.ClientManagerNonces().List(domain.ClientManagerNonceFilter{ExpiresBefore: stamp}) if err != nil { return err } for _, nonce := range nonces { if err := svc.store.ClientManagerNonces().Delete(nonce.ID); err != nil && !errors.Is(err, repo.ErrNotFound) { return err } } sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{}) if err != nil { return err } for _, session := range sessions { if session.Status == domain.ClientManagerSessionActive && !stamp.Before(session.ExpiresAt) { session.Status = domain.ClientManagerSessionExpired session.UpdatedAt = stamp session.RevokedAt = stamp if err := svc.store.ClientManagerSessions().Update(session); err != nil { return err } } } installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{}) if err != nil { return err } for _, installation := range installations { changed := false if installation.CurrentJobID != "" { job, jobErr := svc.store.Jobs().Get(installation.CurrentJobID) if jobErr == nil && isTerminalJobState(job.State) && installation.UpdatedAt.Before(job.UpdatedAt) { if err := svc.projectClientManagerLifecycleResult(job, job.UpdatedAt); err != nil { return err } continue } } instance, instanceErr := svc.store.ServerInstances().Get(installation.ServerInstanceID) if instanceErr == nil && installation.RunEndpointID != instance.RunEndpointID && installation.Status != domain.ClientManagerLifecycleUninstalled { if err := svc.revokeClientManagerSessions(installation.ID, "Run endpoint assignment changed"); err != nil { return err } installation.Status = domain.ClientManagerLifecycleFailed installation.Health = domain.ClientManagerHealthOffline installation.HealthReason = "assigned Run endpoint changed; redeploy required" installation.RequiresRedeploy = true changed = true } if key, keyErr := svc.activeComponentKey(installation.ServerInstanceID, domain.DistributionComponentClientManager, installation.ProfileKey); keyErr == nil && installation.KeyGeneration > 0 && key.Generation != installation.KeyGeneration && installation.Status != domain.ClientManagerLifecycleUninstalled { if err := svc.revokeClientManagerSessions(installation.ID, "component key generation changed"); err != nil { return err } installation.Status = domain.ClientManagerLifecycleFailed installation.Health = domain.ClientManagerHealthOffline installation.HealthReason = "component key changed; rebuild and redeploy required" installation.RequiresRedeploy = true changed = true } if installation.ActiveArtifactID != "" && !installation.LastSeenAt.IsZero() { plugin, pluginErr := svc.store.GamePlugins().Get(installation.PluginID) profile, profileErr := findRuntimeClientManagerProfile(plugin, installation.ProfileKey) if pluginErr == nil && profileErr == nil { age := stamp.Sub(installation.LastSeenAt) if age >= time.Duration(profile.Health.OfflineAfterSeconds)*time.Second && installation.Status != domain.ClientManagerLifecycleOffline && installation.Status != domain.ClientManagerLifecycleUninstalled { installation.Status = domain.ClientManagerLifecycleOffline installation.Health = domain.ClientManagerHealthOffline installation.HealthReason = "component heartbeat timed out" changed = true } else if age >= time.Duration(profile.Health.DegradedAfterSeconds)*time.Second && installation.Status == domain.ClientManagerLifecycleOnline { installation.Status = domain.ClientManagerLifecycleDegraded installation.Health = domain.ClientManagerHealthDegraded installation.HealthReason = "component heartbeat is late" changed = true } } } if changed { installation.UpdatedAt = stamp if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { return err } } } return nil } func (svc *CoreService) fenceClientManagerAfterKeyReset(serverInstanceID, profileKey string, generation int) error { installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: serverInstanceID, ProfileKey: profileKey}) if err != nil { return err } for _, installation := range installations { if err := svc.revokeClientManagerSessions(installation.ID, "component key reset"); err != nil { return err } installation.Status = domain.ClientManagerLifecycleFailed installation.Phase = "key reset; rebuild and redeploy required" installation.Health = domain.ClientManagerHealthOffline installation.HealthReason = "component key generation changed" installation.RequiresRedeploy = true installation.KeyGeneration = generation installation.UpdatedAt = svc.now() if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { return err } } return nil } func (svc *CoreService) resolveClientManagerDistributionID(serverInstanceID, profileKey, artifactOrDistributionID string) (string, error) { if distribution, err := svc.store.ClientManagerDistributions().Get(artifactOrDistributionID); err == nil { if distribution.ServerInstanceID == serverInstanceID && distribution.ProfileKey == profileKey { return distribution.ID, nil } return "", ErrForbidden } distributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: serverInstanceID, ProfileKey: profileKey}) if err != nil { return "", err } for _, distribution := range distributions { if distribution.ArtifactID == artifactOrDistributionID { return distribution.ID, nil } } return "", repo.ErrNotFound } func safeClientManagerBridgeResult(view domain.ClientManagerLifecycleView) map[string]string { installation := view.Installation actions := make([]string, 0, len(view.Actions)) for _, action := range view.Actions { if action.Available { actions = append(actions, string(action.Operation)) } } sort.Strings(actions) result := map[string]string{ "installationId": installation.ID, "profileKey": installation.ProfileKey, "status": string(installation.Status), "phase": installation.Phase, "targetOS": installation.TargetOS, "targetArch": installation.TargetArch, "version": installation.ActiveVersion, "previousVersion": installation.PreviousVersion, "artifactId": installation.ActiveArtifactID, "currentJobId": installation.CurrentJobID, "deploymentGeneration": strconv.Itoa(installation.DeploymentGeneration), "health": string(installation.Health), "healthReason": installation.HealthReason, "actions": strings.Join(actions, ","), } if !installation.LastSeenAt.IsZero() { result["lastSeenAt"] = installation.LastSeenAt.UTC().Format(time.RFC3339) } return result }