206 lines
9.3 KiB
Go
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])
|
|
}
|