Files
browser/platform/service/game_client_bridge_sessions.go
T

206 lines
9.3 KiB
Go

package service
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const gameClientBridgeLogStreamCapability = "logs.stream"
const gameClientBridgeCapability = "game-client.bridge"
func (svc *CoreService) ClaimGameClientBridgeCommands(request domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error) {
if err := validator.ValidateGameClientBridgeClaimRequest(request); err != nil {
return nil, err
}
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
if err != nil {
return nil, err
}
return svc.claimGameClientBridgeCommands(component, request.Limit)
}
func (svc *CoreService) AckGameClientBridgeCommand(request domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) {
if err := validator.ValidateGameClientBridgeAckRequest(request); err != nil {
return domain.GameClientBridgeCommand{}, err
}
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
return svc.ackGameClientBridgeCommand(component, request)
}
func (svc *CoreService) CompleteGameClientBridgeCommand(request domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) {
if err := validator.ValidateGameClientBridgeResultRequest(request); err != nil {
return domain.GameClientBridgeCommand{}, err
}
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
if err != nil {
return domain.GameClientBridgeCommand{}, err
}
return svc.completeGameClientBridgeCommand(component, request)
}
func (svc *CoreService) UploadGameClientBridgeSnapshot(request domain.GameClientBridgeSnapshotIngestRequest) (domain.GameClientBridgeSnapshot, error) {
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
if err := validator.ValidateGameClientBridgeSnapshotIngestRequest(request); err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
if err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
stamp := svc.now()
if request.ObservedAt.After(stamp.Add(5 * time.Minute)) {
return domain.GameClientBridgeSnapshot{}, validationError("snapshot observedAt is too far in the future")
}
plugin, err := svc.store.GamePlugins().Get(component.Installation.PluginID)
if err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
declaration, declared := gameClientBridgeSnapshotDeclaration(plugin, request.Type, request.SchemaVersion)
if !declared {
return domain.GameClientBridgeSnapshot{}, validationError("bridge snapshot type and schema version are not declared")
}
if request.Retention != declaration.Retention {
return domain.GameClientBridgeSnapshot{}, validationError("bridge snapshot retention does not match declaration")
}
svc.bridgeMu.Lock()
defer svc.bridgeMu.Unlock()
streamID := gameClientBridgeStreamID(component.Session.ServerInstanceID, component.Installation.PluginID, component.Session.ProfileKey, request.Type, request.StreamKey)
latestSequence := uint64(0)
stream, streamErr := svc.store.GameClientBridgeSnapshotStreams().Get(streamID)
if streamErr == nil {
latestSequence = stream.LatestSequence
} else if streamErr != repo.ErrNotFound {
return domain.GameClientBridgeSnapshot{}, streamErr
}
existing, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: component.Session.ServerInstanceID, PluginID: component.Installation.PluginID, ProfileKey: component.Session.ProfileKey, Type: request.Type, StreamKey: request.StreamKey})
if err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
for _, snapshot := range existing {
if snapshot.Sequence > latestSequence {
latestSequence = snapshot.Sequence
}
}
if request.Sequence <= latestSequence {
return domain.GameClientBridgeSnapshot{}, validationError("snapshot sequence is stale")
}
svc.bridgeSeq++
snapshot := domain.GameClientBridgeSnapshot{
ID: fmt.Sprintf("bridge-snapshot-%d-%d", stamp.UnixNano(), svc.bridgeSeq),
ServerInstanceID: component.Session.ServerInstanceID,
PluginID: component.Installation.PluginID,
ProfileKey: component.Session.ProfileKey,
Type: request.Type,
SchemaVersion: request.SchemaVersion,
StreamKey: request.StreamKey,
Sequence: request.Sequence,
SourceSessionID: component.Session.ID,
ObservedAt: request.ObservedAt,
Payload: domain.CopyGameClientBridgePayload(request.Payload),
Retention: request.Retention,
CreatedAt: stamp,
ExpiresAt: stamp.Add(time.Duration(request.Retention.KeepForSeconds) * time.Second),
}
if err := svc.store.GameClientBridgeSnapshots().Create(snapshot); err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
stream = domain.GameClientBridgeSnapshotStream{ID: streamID, ServerInstanceID: snapshot.ServerInstanceID, PluginID: snapshot.PluginID, ProfileKey: snapshot.ProfileKey, Type: snapshot.Type, StreamKey: snapshot.StreamKey, LatestSequence: snapshot.Sequence, UpdatedAt: stamp}
if streamErr == repo.ErrNotFound {
if err := svc.store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
} else {
if err := svc.store.GameClientBridgeSnapshotStreams().Update(stream); err != nil {
return domain.GameClientBridgeSnapshot{}, err
}
}
return domain.CopyGameClientBridgeSnapshot(snapshot), nil
}
// AuthorizeGameClientBridgeLogStream authenticates a component session and
// returns only its bound server identity. Log entries are forwarded verbatim;
// this method intentionally performs no content inspection or transformation.
func (svc *CoreService) AuthorizeGameClientBridgeLogStream(request domain.GameClientBridgeLogStreamRequest) (domain.ServerInstance, error) {
if err := validator.ValidateGameClientBridgeLogStreamRequest(request); err != nil {
return domain.ServerInstance{}, err
}
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
if err != nil {
return domain.ServerInstance{}, err
}
if !containsString(component.Session.Capabilities, gameClientBridgeLogStreamCapability) {
return domain.ServerInstance{}, ErrForbidden
}
instance, err := svc.store.ServerInstances().Get(component.Session.ServerInstanceID)
if err != nil {
return domain.ServerInstance{}, err
}
if instance.PluginID != component.Installation.PluginID {
return domain.ServerInstance{}, ErrUnauthorized
}
return domain.CopyServerInstance(instance), nil
}
func gameClientBridgeSnapshotDeclaration(plugin domain.GamePlugin, snapshotType, schemaVersion string) (domain.GameClientBridgeSnapshotDeclaration, bool) {
for _, declaration := range plugin.GameClientBridge.Snapshots {
if declaration.Type == snapshotType && declaration.SchemaVersion == schemaVersion {
return declaration, true
}
}
return domain.GameClientBridgeSnapshotDeclaration{}, false
}
func (svc *CoreService) authorizeGameClientBridgeSession(sessionToken string) (gameClientBridgeComponentSession, error) {
presentedHash := tokenHash(sessionToken)
sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{Status: domain.ClientManagerSessionActive})
if err != nil {
return gameClientBridgeComponentSession{}, err
}
var session domain.ClientManagerSession
for _, candidate := range sessions {
if subtle.ConstantTimeCompare([]byte(candidate.TokenHash), []byte(presentedHash)) == 1 {
session = candidate
break
}
}
stamp := svc.now()
if session.ID == "" || !stamp.Before(session.ExpiresAt) {
return gameClientBridgeComponentSession{}, ErrUnauthorized
}
if !containsString(session.Capabilities, gameClientBridgeCapability) {
return gameClientBridgeComponentSession{}, ErrForbidden
}
installation, err := svc.store.ClientManagerInstallations().Get(session.InstallationID)
if err != nil {
return gameClientBridgeComponentSession{}, ErrUnauthorized
}
if installation.Status != domain.ClientManagerLifecycleOnline && installation.Status != domain.ClientManagerLifecycleDegraded {
return gameClientBridgeComponentSession{}, ErrUnauthorized
}
if installation.ServerInstanceID != session.ServerInstanceID || installation.ProfileKey != session.ProfileKey || installation.RunEndpointID != session.RunEndpointID || installation.ActiveArtifactID != session.ArtifactID || installation.KeyGeneration != session.KeyGeneration || installation.DeploymentGeneration != session.DeploymentGeneration || installation.RequiresRedeploy {
return gameClientBridgeComponentSession{}, ErrUnauthorized
}
key, err := svc.activeComponentKey(session.ServerInstanceID, domain.DistributionComponentClientManager, session.ProfileKey)
if err != nil || key.Generation != session.KeyGeneration {
return gameClientBridgeComponentSession{}, ErrUnauthorized
}
return gameClientBridgeComponentSession{Session: domain.CopyClientManagerSession(session), Installation: domain.CopyClientManagerInstallation(installation)}, nil
}
func gameClientBridgeStreamID(serverInstanceID, pluginID, profileKey, snapshotType, streamKey string) string {
digest := sha256.Sum256([]byte(serverInstanceID + "\x00" + pluginID + "\x00" + profileKey + "\x00" + snapshotType + "\x00" + streamKey))
return "bridge-stream-" + hex.EncodeToString(digest[:16])
}