2793 lines
120 KiB
Go
2793 lines
120 KiB
Go
package validator
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"browser.local/platform/domain"
|
|
)
|
|
|
|
const (
|
|
maxAuditSummaryLength = 512
|
|
maxContactNoteLength = 160
|
|
maxMarketplaceKeywordSize = 80
|
|
maxMarketplaceListSize = 500
|
|
maxPluginDescriptionLength = 240
|
|
maxPluginPageTitleLength = 40
|
|
maxPluginBridgePayloadKeys = 16
|
|
maxPluginBridgePayloadSize = 4096
|
|
maxProgressMessageLength = 256
|
|
maxServerConfigContentSize = 64 * 1024
|
|
maxJobExecutionContentSize = 64 * 1024
|
|
maxLogicalFileKeyLength = 160
|
|
maxProductionMessageLength = 320
|
|
)
|
|
|
|
var (
|
|
gameClientBridgeCollectionPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,119}$`)
|
|
gameClientBridgeFieldPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,79}$`)
|
|
gameClientBridgeCaptureNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,79}$`)
|
|
)
|
|
|
|
type ValidationError struct {
|
|
Violations []string
|
|
}
|
|
|
|
func (err ValidationError) Error() string {
|
|
return "validation failed: " + strings.Join(err.Violations, "; ")
|
|
}
|
|
|
|
func (err ValidationError) IsEmpty() bool {
|
|
return len(err.Violations) == 0
|
|
}
|
|
|
|
func appendRequired(violations []string, field string, value string) []string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return append(violations, field+" is required")
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func finish(violations []string) error {
|
|
if len(violations) == 0 {
|
|
return nil
|
|
}
|
|
return ValidationError{Violations: violations}
|
|
}
|
|
|
|
func ValidateUser(user domain.User) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "id", user.ID)
|
|
violations = appendRequired(violations, "displayName", user.DisplayName)
|
|
if !validUserStatus(user.Status) {
|
|
violations = append(violations, "status is invalid")
|
|
}
|
|
if strings.TrimSpace(user.Email) != "" && !strings.Contains(user.Email, "@") {
|
|
violations = append(violations, "email is invalid")
|
|
}
|
|
if len(user.Profile.ContactNote) > maxContactNoteLength {
|
|
violations = append(violations, "profile.contactNote is too long")
|
|
}
|
|
for i, role := range user.Roles {
|
|
if strings.TrimSpace(role) == "" {
|
|
violations = append(violations, fmt.Sprintf("roles[%d] is required", i))
|
|
}
|
|
if !validUserRole(role) {
|
|
violations = append(violations, fmt.Sprintf("roles[%d] is invalid", i))
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateAIProvider(provider domain.AIProvider) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "id", provider.ID)
|
|
violations = appendRequired(violations, "name", provider.Name)
|
|
violations = appendRequired(violations, "baseUrl", provider.BaseURL)
|
|
if !validAIProviderKind(provider.Kind) {
|
|
violations = append(violations, "kind is invalid")
|
|
}
|
|
if !validAIRelayMode(provider.RelayMode) {
|
|
violations = append(violations, "relayMode is invalid")
|
|
}
|
|
if !validAIProviderStatus(provider.Status) {
|
|
violations = append(violations, "status is invalid")
|
|
}
|
|
if provider.RelayMode == domain.AIRelayModeDirect || provider.RelayMode == domain.AIRelayModeRelay {
|
|
violations = appendRequired(violations, "apiKeyRef", provider.APIKeyRef)
|
|
}
|
|
if looksLikeRawSecret(provider.APIKeyRef) {
|
|
violations = append(violations, "apiKeyRef must reference secret storage, not raw key material")
|
|
}
|
|
violations = appendRequired(violations, "redactionPolicy", provider.RedactionPolicy)
|
|
if provider.TimeoutMS <= 0 {
|
|
violations = append(violations, "timeoutMs must be positive")
|
|
}
|
|
if len(provider.Models) == 0 {
|
|
violations = append(violations, "models must not be empty")
|
|
}
|
|
modelSet := map[string]struct{}{}
|
|
for i, model := range provider.Models {
|
|
model = strings.TrimSpace(model)
|
|
if model == "" {
|
|
violations = append(violations, fmt.Sprintf("models[%d] is required", i))
|
|
continue
|
|
}
|
|
if _, exists := modelSet[model]; exists {
|
|
violations = append(violations, fmt.Sprintf("models[%d] duplicates %q", i, model))
|
|
}
|
|
modelSet[model] = struct{}{}
|
|
}
|
|
if provider.DefaultModel != "" {
|
|
if _, exists := modelSet[provider.DefaultModel]; !exists {
|
|
violations = append(violations, "defaultModel must be included in models")
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateGamePlugin(plugin domain.GamePlugin) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "id", plugin.ID)
|
|
violations = appendRequired(violations, "name", plugin.Name)
|
|
if len(plugin.Description) > maxPluginDescriptionLength {
|
|
violations = append(violations, "description is too long")
|
|
}
|
|
violations = appendRequired(violations, "version", plugin.Version)
|
|
violations = appendRequired(violations, "serverType", plugin.ServerType)
|
|
violations = appendRequired(violations, "manifestRef", plugin.ManifestRef)
|
|
violations = appendRequired(violations, "createFormSchemaRef", plugin.CreateFormSchemaRef)
|
|
if !validGamePluginStatus(plugin.Status) {
|
|
violations = append(violations, "status is invalid")
|
|
}
|
|
for i, capability := range plugin.RequiredRunCapabilities {
|
|
if strings.TrimSpace(capability) == "" {
|
|
violations = append(violations, fmt.Sprintf("requiredRunCapabilities[%d] is required", i))
|
|
} else if !validPluginRunCapability(capability) {
|
|
violations = append(violations, fmt.Sprintf("requiredRunCapabilities[%d] is not allowed", i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations("requiredRunCapabilities", plugin.RequiredRunCapabilities)...)
|
|
violations = append(violations, validateDeclaredPluginPermissions(plugin.DeclaredPermissions)...)
|
|
violations = append(violations, validateBridgeActions("bridgeActions", plugin.BridgeActions)...)
|
|
violations = append(violations, validatePluginPages(plugin.Pages)...)
|
|
violations = append(violations, validatePluginFileWorkspace("fileWorkspace", plugin.FileWorkspace)...)
|
|
violations = append(violations, duplicateViolations("tags", plugin.Tags)...)
|
|
violations = append(violations, validateAIPurposes(plugin.AIPurposes)...)
|
|
violations = append(violations, validateProductionLifecycle("productionLifecycle", plugin.ProductionLifecycle, true)...)
|
|
violations = append(violations, validateRemoteAccess("remoteAccess", plugin.RemoteAccess, plugin.RequiredRunCapabilities)...)
|
|
if err := ValidateGamePluginRuntimeProfiles(plugin.RuntimeProfiles); err != nil {
|
|
violations = append(violations, err.Error())
|
|
}
|
|
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...)
|
|
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("runtimeProfiles.logEvents", plugin.RuntimeProfiles, plugin.DeclaredPermissions)...)
|
|
violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.Pages, plugin.RuntimeProfiles)...)
|
|
violations = append(violations, validatePluginCreateFields("createFields", plugin.CreateFields)...)
|
|
violations = append(violations, validatePluginAssetFiles("lifecycleAssets", plugin.LifecycleAssets)...)
|
|
violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...)
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateGamePluginManifestRegistration(registration domain.GamePluginManifestRegistration) error {
|
|
registration = domain.CopyGamePluginManifestRegistration(registration)
|
|
manifest := registration.Manifest
|
|
var violations []string
|
|
violations = appendRequired(violations, "manifestRef", registration.ManifestRef)
|
|
if !safeRef(registration.ManifestRef) {
|
|
violations = append(violations, "manifestRef is unsafe")
|
|
}
|
|
violations = appendRequired(violations, "manifest.id", manifest.ID)
|
|
violations = appendRequired(violations, "manifest.name", manifest.Name)
|
|
if len(manifest.Description) > maxPluginDescriptionLength {
|
|
violations = append(violations, "manifest.description is too long")
|
|
}
|
|
violations = appendRequired(violations, "manifest.version", manifest.Version)
|
|
if manifest.Kind != "game-plugin" {
|
|
violations = append(violations, "manifest.kind must be game-plugin")
|
|
}
|
|
violations = appendRequired(violations, "manifest.server.type", manifest.Server.Type)
|
|
violations = appendRequired(violations, "manifest.server.displayName", manifest.Server.DisplayName)
|
|
violations = appendRequired(violations, "manifest.server.createFormSchema", manifest.Server.CreateFormSchema)
|
|
if !safeRelativeJSONRef(manifest.Server.CreateFormSchema) {
|
|
violations = append(violations, "manifest.server.createFormSchema must be a safe relative JSON reference")
|
|
}
|
|
violations = append(violations, validatePluginCreateFields("manifest.server.createFields", manifest.Server.CreateFields)...)
|
|
for i, osName := range manifest.Server.SupportedOS {
|
|
if !validPluginSupportedOS(osName) {
|
|
violations = append(violations, fmt.Sprintf("manifest.server.supportedOs[%d] is not allowed", i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations("manifest.server.supportedOs", manifest.Server.SupportedOS)...)
|
|
for i, capability := range manifest.Capabilities {
|
|
if !validPluginRunCapability(capability) {
|
|
violations = append(violations, fmt.Sprintf("manifest.capabilities[%d] is not allowed", i))
|
|
}
|
|
}
|
|
if len(manifest.Capabilities) == 0 {
|
|
violations = append(violations, "manifest.capabilities must not be empty")
|
|
}
|
|
violations = append(violations, duplicateViolations("manifest.capabilities", manifest.Capabilities)...)
|
|
if len(manifest.Permissions) == 0 {
|
|
violations = append(violations, "manifest.permissions must not be empty")
|
|
}
|
|
violations = append(violations, validateDeclaredPluginPermissions(manifest.Permissions)...)
|
|
violations = append(violations, validateBridgeActions("manifest.bridge.actions", manifest.Bridge.Actions)...)
|
|
violations = append(violations, validateLifecycleActions(manifest.Actions)...)
|
|
violations = append(violations, validatePluginPages(manifest.Pages)...)
|
|
violations = append(violations, validatePluginFileWorkspace("manifest.fileWorkspace", manifest.FileWorkspace)...)
|
|
violations = append(violations, duplicateViolations("manifest.tags", manifest.Tags)...)
|
|
violations = append(violations, validateAIPurposes(manifest.AI.Purposes)...)
|
|
violations = append(violations, validateProductionLifecycle("manifest.productionLifecycle", manifest.ProductionLifecycle, true)...)
|
|
if containsString(manifest.Permissions, "ai.invoke") || len(manifest.AI.Purposes) > 0 {
|
|
if manifest.AI.Mediation != "platform" {
|
|
violations = append(violations, "manifest.ai.mediation must be platform")
|
|
}
|
|
if manifest.AI.ConfigWritePolicy != "review-required" {
|
|
violations = append(violations, "manifest.ai.configWritePolicy must be review-required")
|
|
}
|
|
}
|
|
violations = append(violations, validateRemoteAccess("manifest.remoteAccess", manifest.RemoteAccess, manifest.Capabilities)...)
|
|
if err := ValidateGamePluginRuntimeProfiles(manifest.RuntimeProfiles); err != nil {
|
|
violations = append(violations, err.Error())
|
|
}
|
|
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(manifest.RuntimeProfiles, manifest.Capabilities)...)
|
|
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("manifest.runtimeProfiles.logEvents", manifest.RuntimeProfiles, manifest.Permissions)...)
|
|
violations = append(violations, validateGameClientBridgeManifest("manifest.gameClientBridge", manifest.GameClientBridge, manifest.Permissions, manifest.Pages, manifest.RuntimeProfiles)...)
|
|
violations = append(violations, validatePluginAssetFileDeclarations("manifest.assetFiles", manifest.AssetFiles)...)
|
|
violations = append(violations, validatePluginAssetFiles("assetFiles", registration.AssetFiles)...)
|
|
violations = append(violations, validateRegistrationAssetCoverage(registration.Manifest.AssetFiles, registration.AssetFiles)...)
|
|
violations = append(violations, validateSafePluginStrings("manifest", manifestSafeStrings(registration))...)
|
|
return finish(violations)
|
|
}
|
|
|
|
func validatePluginAssetFileDeclarations(prefix string, files []domain.PluginAssetFile) []string {
|
|
if len(files) > 64 {
|
|
return []string{prefix + " has too many files"}
|
|
}
|
|
var violations []string
|
|
seen := map[string]struct{}{}
|
|
for i, file := range files {
|
|
field := fmt.Sprintf("%s[%d]", prefix, i)
|
|
if !validLogicalFileKey(file.Path) {
|
|
violations = append(violations, field+".path is unsafe")
|
|
}
|
|
if _, exists := seen[file.Path]; exists {
|
|
violations = append(violations, field+".path is duplicated")
|
|
}
|
|
seen[file.Path] = struct{}{}
|
|
if file.Content != "" {
|
|
violations = append(violations, field+".content must be supplied only in registration assetFiles")
|
|
}
|
|
if file.Mode != 0 && file.Mode != 0o600 && file.Mode != 0o700 {
|
|
violations = append(violations, field+".mode is unsafe")
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validatePluginAssetFiles(prefix string, files []domain.PluginAssetFile) []string {
|
|
if len(files) > 64 {
|
|
return []string{prefix + " has too many files"}
|
|
}
|
|
var violations []string
|
|
seen := map[string]struct{}{}
|
|
for i, file := range files {
|
|
field := fmt.Sprintf("%s[%d]", prefix, i)
|
|
if !validLogicalFileKey(file.Path) {
|
|
violations = append(violations, field+".path is unsafe")
|
|
}
|
|
if _, exists := seen[file.Path]; exists {
|
|
violations = append(violations, field+".path is duplicated")
|
|
}
|
|
seen[file.Path] = struct{}{}
|
|
if len([]byte(file.Content)) > 64*1024 || strings.ContainsRune(file.Content, '\x00') || containsUnsafeRuntimeSecret(file.Content) {
|
|
violations = append(violations, field+".content is unsafe")
|
|
}
|
|
if file.Mode != 0 && (file.Mode < 0o400 || file.Mode > 0o700 || file.Mode&0o022 != 0) {
|
|
violations = append(violations, field+".mode is unsafe")
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validateRegistrationAssetCoverage(declared []domain.PluginAssetFile, payload []domain.PluginAssetFile) []string {
|
|
if len(declared) == 0 {
|
|
return nil
|
|
}
|
|
allowed := map[string]struct{}{}
|
|
for _, file := range declared {
|
|
allowed[file.Path] = struct{}{}
|
|
}
|
|
provided := map[string]struct{}{}
|
|
for _, file := range payload {
|
|
provided[file.Path] = struct{}{}
|
|
if _, ok := allowed[file.Path]; !ok {
|
|
return []string{"assetFiles contains undeclared plugin asset " + file.Path}
|
|
}
|
|
}
|
|
var violations []string
|
|
for _, file := range declared {
|
|
if _, ok := provided[file.Path]; !ok {
|
|
violations = append(violations, "assetFiles is missing declared plugin asset "+file.Path)
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validatePluginCreateFields(prefix string, fields []domain.PluginCreateField) []string {
|
|
if len(fields) > 32 {
|
|
return []string{prefix + " must contain at most 32 fields"}
|
|
}
|
|
var violations []string
|
|
keys := map[string]struct{}{}
|
|
for i, field := range fields {
|
|
item := fmt.Sprintf("%s[%d]", prefix, i)
|
|
if !validDistributionLogicalKey(field.Key) {
|
|
violations = append(violations, item+".key is invalid")
|
|
}
|
|
if _, found := keys[field.Key]; found {
|
|
violations = append(violations, item+".key duplicates another field")
|
|
}
|
|
keys[field.Key] = struct{}{}
|
|
if strings.TrimSpace(field.Label) == "" || len(field.Label) > 60 || strings.TrimSpace(field.Label) != field.Label {
|
|
violations = append(violations, item+".label is invalid")
|
|
}
|
|
if !validPluginCreateFieldType(field.Type) {
|
|
violations = append(violations, item+".type is invalid")
|
|
}
|
|
if len(field.DefaultValue) > 256 || strings.TrimSpace(field.DefaultValue) != field.DefaultValue || containsUnsafeRuntimeSecret(field.DefaultValue) || looksLikeRawHostPath(field.DefaultValue) {
|
|
violations = append(violations, item+".defaultValue is unsafe")
|
|
}
|
|
if field.ConfigKey != "" && !validDistributionLogicalKey(field.ConfigKey) {
|
|
violations = append(violations, item+".configKey is invalid")
|
|
}
|
|
if len(field.Options) > 32 {
|
|
violations = append(violations, item+".options has too many values")
|
|
}
|
|
options := map[string]struct{}{}
|
|
for _, option := range field.Options {
|
|
if strings.TrimSpace(option) == "" || len(option) > 120 || strings.TrimSpace(option) != option || containsUnsafeRuntimeSecret(option) || looksLikeRawHostPath(option) {
|
|
violations = append(violations, item+".options contains an unsafe value")
|
|
break
|
|
}
|
|
if _, found := options[option]; found {
|
|
violations = append(violations, item+".options contains duplicates")
|
|
break
|
|
}
|
|
options[option] = struct{}{}
|
|
}
|
|
if field.Type == "select" && len(field.Options) == 0 {
|
|
violations = append(violations, item+".options is required for select")
|
|
}
|
|
if field.Type != "select" && len(field.Options) > 0 {
|
|
violations = append(violations, item+".options is only valid for select")
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validPluginCreateFieldType(value string) bool {
|
|
switch value {
|
|
case "text", "number", "boolean", "select", "port":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// ValidatePluginCreateInputs keeps game settings distinct from runtime
|
|
// bindings while enforcing the plugin's published, non-executable field set.
|
|
func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[string]string) error {
|
|
if len(fields) == 0 {
|
|
return nil
|
|
}
|
|
declared := make(map[string]domain.PluginCreateField, len(fields))
|
|
for _, field := range fields {
|
|
declared[field.Key] = field
|
|
}
|
|
var violations []string
|
|
for key, value := range inputs {
|
|
field, found := declared[key]
|
|
if !found {
|
|
violations = append(violations, "createInputs."+key+" is not declared by plugin")
|
|
continue
|
|
}
|
|
if len(value) > 1024 || strings.TrimSpace(value) != value || containsUnsafeRuntimeSecret(value) {
|
|
violations = append(violations, "createInputs."+key+" is invalid")
|
|
continue
|
|
}
|
|
if value == "" {
|
|
if field.Required && field.DefaultValue == "" {
|
|
violations = append(violations, "createInputs."+key+" is required")
|
|
}
|
|
continue
|
|
}
|
|
switch field.Type {
|
|
case "port":
|
|
port, err := strconv.Atoi(value)
|
|
if err != nil || port < 1 || port > 65535 {
|
|
violations = append(violations, "createInputs."+key+" must be a port between 1 and 65535")
|
|
}
|
|
case "number":
|
|
if _, err := strconv.ParseFloat(value, 64); err != nil {
|
|
violations = append(violations, "createInputs."+key+" must be numeric")
|
|
}
|
|
case "boolean":
|
|
if value != "true" && value != "false" {
|
|
violations = append(violations, "createInputs."+key+" must be true or false")
|
|
}
|
|
case "select":
|
|
if !containsString(field.Options, value) {
|
|
violations = append(violations, "createInputs."+key+" is not an allowed option")
|
|
}
|
|
}
|
|
}
|
|
for _, field := range fields {
|
|
if field.Required && field.DefaultValue == "" && strings.TrimSpace(inputs[field.Key]) == "" {
|
|
violations = append(violations, "createInputs."+field.Key+" is required")
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
|
|
companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{})
|
|
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.LogProjections) == 0 && len(bridge.DataPacks) == 0 && len(bridge.OperationTemplates) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
|
|
return nil
|
|
}
|
|
var violations []string
|
|
if bridge.Retention.KeepForSeconds <= 0 || bridge.Retention.KeepForSeconds > 365*24*60*60 {
|
|
violations = append(violations, field+".commandRetentionSeconds is invalid")
|
|
}
|
|
if bridge.Retention.MaxRecords <= 0 || bridge.Retention.MaxRecords > 100000 {
|
|
violations = append(violations, field+".maxCommands is invalid")
|
|
}
|
|
if companionPresent {
|
|
prefix := field + ".companion"
|
|
companion := bridge.Companion
|
|
if !clientManagerIdentifierPattern.MatchString(companion.ProfileKey) || !clientManagerIdentifierPattern.MatchString(companion.ConfigTemplateKey) {
|
|
violations = append(violations, prefix+" profile or config template key is invalid")
|
|
}
|
|
if !safeRelativeJSONRef(companion.ConfigSchemaRef) {
|
|
violations = append(violations, prefix+".configSchemaRef must be a safe relative JSON reference")
|
|
}
|
|
if companion.ConfigFormat != "yaml" || companion.PlatformBaseURLSource != "run-control" || companion.RegistrationProof != "hmac-sha256" || companion.ProofMaterialSource != "component-package" || companion.SessionMode != "component-session" || companion.TLSPolicy != "verify-system-roots" {
|
|
violations = append(violations, prefix+" bootstrap security policy is invalid")
|
|
}
|
|
if !validCompanionProofEnvironment(companion.ProofMaterialEnv) {
|
|
violations = append(violations, prefix+".proofMaterialEnv is invalid")
|
|
}
|
|
if companion.HeartbeatIntervalSeconds < 5 || companion.HeartbeatIntervalSeconds > 300 || companion.CommandPollIntervalSeconds < 1 || companion.CommandPollIntervalSeconds > 60 || companion.RequestTimeoutSeconds < 1 || companion.RequestTimeoutSeconds > 60 {
|
|
violations = append(violations, prefix+" timing policy is invalid")
|
|
}
|
|
managerFound := false
|
|
for _, manager := range runtimeProfiles.ClientManagers {
|
|
if manager.Key != companion.ProfileKey {
|
|
continue
|
|
}
|
|
managerFound = true
|
|
if manager.Health.IntervalSeconds != companion.HeartbeatIntervalSeconds {
|
|
violations = append(violations, prefix+".heartbeatIntervalSeconds must match the Client Manager health interval")
|
|
}
|
|
for _, capability := range []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"} {
|
|
if !containsString(manager.Health.RequiredCapabilities, capability) {
|
|
violations = append(violations, prefix+" requires Client Manager capability "+capability)
|
|
}
|
|
}
|
|
templateFound := false
|
|
for _, template := range manager.ConfigTemplates {
|
|
if template.Key == companion.ConfigTemplateKey {
|
|
templateFound = true
|
|
if template.OutputRef != "config.yaml" {
|
|
violations = append(violations, prefix+" config template must materialize config.yaml")
|
|
}
|
|
}
|
|
}
|
|
if !templateFound {
|
|
violations = append(violations, prefix+".configTemplateKey must reference the Client Manager profile")
|
|
}
|
|
}
|
|
if !managerFound {
|
|
violations = append(violations, prefix+".profileKey must reference a declared Client Manager profile")
|
|
}
|
|
}
|
|
transports := map[string]domain.RuntimeTransportProfile{}
|
|
for _, transport := range runtimeProfiles.TransportProfiles {
|
|
transports[transport.Key] = transport
|
|
}
|
|
commandTypes := map[string]struct{}{}
|
|
for index, command := range bridge.Commands {
|
|
prefix := fmt.Sprintf("%s.commands[%d]", field, index)
|
|
if !clientManagerIdentifierPattern.MatchString(command.Type) || command.ProtectedRequest == nil && unsafeGameClientBridgeCommandType(command.Type) {
|
|
violations = append(violations, prefix+".type is invalid or unsafe")
|
|
}
|
|
if _, exists := commandTypes[command.Type]; exists {
|
|
violations = append(violations, prefix+".type is duplicated")
|
|
}
|
|
commandTypes[command.Type] = struct{}{}
|
|
if strings.TrimSpace(command.Title) == "" || len([]rune(command.Title)) > 80 {
|
|
violations = append(violations, prefix+".title is invalid")
|
|
}
|
|
if !containsString(permissions, command.Permission) {
|
|
violations = append(violations, prefix+".permission must be declared by the plugin")
|
|
}
|
|
if command.ApprovalLevel != domain.GameClientBridgeApprovalLevelNone && command.ApprovalLevel != domain.GameClientBridgeApprovalLevelOperator && command.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin {
|
|
violations = append(violations, prefix+".approvalLevel is invalid")
|
|
}
|
|
if !safeRelativeJSONRef(command.PayloadSchemaRef) || command.ResultSchemaRef != "" && !safeRelativeJSONRef(command.ResultSchemaRef) {
|
|
violations = append(violations, prefix+" schema references must be safe relative JSON references")
|
|
}
|
|
if command.TimeoutSeconds <= 0 || command.TimeoutSeconds > 3600 {
|
|
violations = append(violations, prefix+".timeoutSeconds is invalid")
|
|
}
|
|
if command.MaxPayloadBytes <= 0 || command.MaxPayloadBytes > maxGameClientBridgePayloadSize {
|
|
violations = append(violations, prefix+".maxPayloadBytes is invalid")
|
|
}
|
|
violations = append(violations, validateGameClientBridgeProtectedRequest(prefix+".protectedRequest", command.ProtectedRequest, transports)...)
|
|
}
|
|
snapshotTypes := map[string]struct{}{}
|
|
for index, snapshot := range bridge.Snapshots {
|
|
prefix := fmt.Sprintf("%s.snapshots[%d]", field, index)
|
|
key := snapshot.Type + "\x00" + snapshot.SchemaVersion
|
|
if !clientManagerIdentifierPattern.MatchString(snapshot.Type) || !clientManagerIdentifierPattern.MatchString(snapshot.SchemaVersion) {
|
|
violations = append(violations, prefix+" type or schemaVersion is invalid")
|
|
}
|
|
if _, exists := snapshotTypes[key]; exists {
|
|
violations = append(violations, prefix+" type and schemaVersion are duplicated")
|
|
}
|
|
snapshotTypes[key] = struct{}{}
|
|
if !safeRelativeJSONRef(snapshot.SchemaRef) {
|
|
violations = append(violations, prefix+".schemaRef must be a safe relative JSON reference")
|
|
}
|
|
if snapshot.Retention.KeepForSeconds <= 0 || snapshot.Retention.KeepForSeconds > 31*24*60*60 || snapshot.Retention.MaxRecords <= 0 || snapshot.Retention.MaxRecords > 10000 {
|
|
violations = append(violations, prefix+" retention is invalid")
|
|
}
|
|
}
|
|
queryTemplates := map[string]domain.GameClientBridgeQueryTemplateDeclaration{}
|
|
for index, template := range bridge.QueryTemplates {
|
|
prefix := fmt.Sprintf("%s.queryTemplates[%d]", field, index)
|
|
if !clientManagerIdentifierPattern.MatchString(template.Key) {
|
|
violations = append(violations, prefix+".key is invalid")
|
|
}
|
|
if _, exists := queryTemplates[template.Key]; exists {
|
|
violations = append(violations, prefix+".key is duplicated")
|
|
}
|
|
queryTemplates[template.Key] = template
|
|
if strings.TrimSpace(template.Title) == "" || len([]rune(template.Title)) > 80 {
|
|
violations = append(violations, prefix+".title is invalid")
|
|
}
|
|
if !containsString(permissions, template.Permission) {
|
|
violations = append(violations, prefix+".permission must be declared by the plugin")
|
|
}
|
|
if template.Engine != "sqlite" {
|
|
violations = append(violations, prefix+".engine must be sqlite")
|
|
}
|
|
if !safeRelativeJSONRef(template.ParameterSchemaRef) || !safeRelativeJSONRef(template.ResultSchemaRef) {
|
|
violations = append(violations, prefix+" schema references must be safe relative JSON references")
|
|
}
|
|
if template.MaxRows < 1 || template.MaxRows > 500 {
|
|
violations = append(violations, prefix+".maxRows is invalid")
|
|
}
|
|
if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 60 {
|
|
violations = append(violations, prefix+".timeoutSeconds is invalid")
|
|
}
|
|
if template.PollIntervalSeconds < 0 || template.PollIntervalSeconds > 86400 {
|
|
violations = append(violations, prefix+".pollIntervalSeconds is invalid")
|
|
}
|
|
projectsRows := template.SQLRef != "" || template.RowTarget != nil
|
|
if projectsRows {
|
|
if !safeRelativeSQLRef(template.SQLRef) {
|
|
violations = append(violations, prefix+".sqlRef must reference a package-relative SQL asset")
|
|
}
|
|
if template.RowTarget == nil {
|
|
violations = append(violations, prefix+".rowTarget is required for projected queries")
|
|
} else {
|
|
target := template.RowTarget
|
|
if !clientManagerIdentifierPattern.MatchString(target.Collection) || len(target.UpsertKeys) == 0 || len(target.ColumnMappings) == 0 {
|
|
violations = append(violations, prefix+".rowTarget must declare a collection, upsert keys, and column mappings")
|
|
}
|
|
if target.WriteMode != "" && target.WriteMode != domain.PluginDataRowWriteModeMerge && target.WriteMode != domain.PluginDataRowWriteModeReplace {
|
|
violations = append(violations, prefix+".rowTarget.writeMode must be merge or replace")
|
|
}
|
|
for _, key := range target.UpsertKeys {
|
|
if !clientManagerIdentifierPattern.MatchString(key) {
|
|
violations = append(violations, prefix+".rowTarget upsert key is invalid")
|
|
}
|
|
}
|
|
for destination, source := range target.ColumnMappings {
|
|
if !clientManagerIdentifierPattern.MatchString(destination) || !clientManagerIdentifierPattern.MatchString(source) {
|
|
violations = append(violations, prefix+".rowTarget column mapping is invalid")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
transport, exists := transports[template.TransportKey]
|
|
if !exists {
|
|
violations = append(violations, prefix+".transportKey must reference a declared runtime transport profile")
|
|
continue
|
|
}
|
|
if transport.TargetKey != template.TargetKey || strings.TrimSpace(template.TargetKey) == "" {
|
|
violations = append(violations, prefix+".targetKey must match the declared runtime transport profile")
|
|
}
|
|
if transport.Kind != "sqlite" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
|
violations = append(violations, prefix+" transport must be sqlite with remote.run.db.sqlite.query capability")
|
|
}
|
|
}
|
|
logProjectionKeys := map[string]struct{}{}
|
|
for index, projection := range bridge.LogProjections {
|
|
prefix := fmt.Sprintf("%s.logProjections[%d]", field, index)
|
|
if !clientManagerIdentifierPattern.MatchString(projection.Key) {
|
|
violations = append(violations, prefix+".key is invalid")
|
|
}
|
|
if _, exists := logProjectionKeys[projection.Key]; exists {
|
|
violations = append(violations, prefix+".key is duplicated")
|
|
}
|
|
logProjectionKeys[projection.Key] = struct{}{}
|
|
violations = append(violations, validateGameClientBridgeLogProjection(prefix, projection, bridge.Commands, runtimeProfiles.ClientManagers)...)
|
|
}
|
|
dataPackKeys := map[string]struct{}{}
|
|
for index, dataPack := range bridge.DataPacks {
|
|
prefix := fmt.Sprintf("%s.dataPacks[%d]", field, index)
|
|
if !validDistributionLogicalKey(dataPack.Key) {
|
|
violations = append(violations, prefix+".key is invalid")
|
|
}
|
|
if _, exists := dataPackKeys[dataPack.Key]; exists {
|
|
violations = append(violations, prefix+".key is duplicated")
|
|
}
|
|
dataPackKeys[dataPack.Key] = struct{}{}
|
|
if dataPack.DatabaseUserVersion < 1 || len(dataPack.LogParserRefs) == 0 || len(dataPack.ConfigMapRefs) == 0 {
|
|
violations = append(violations, prefix+" must declare a database version and parser/config assets")
|
|
}
|
|
refs := append(domain.CopyStringSlice(dataPack.LogParserRefs), dataPack.ConfigMapRefs...)
|
|
refs = append(refs, dataPack.DataRefs...)
|
|
for _, ref := range refs {
|
|
if !safeRelativeJSONRef(ref) {
|
|
violations = append(violations, prefix+" asset reference is invalid")
|
|
}
|
|
}
|
|
}
|
|
operationTemplates := map[string]domain.GameClientBridgeOperationTemplateDeclaration{}
|
|
for index, template := range bridge.OperationTemplates {
|
|
prefix := fmt.Sprintf("%s.operationTemplates[%d]", field, index)
|
|
if !clientManagerIdentifierPattern.MatchString(template.Key) || unsafeGameClientBridgeCommandType(template.Key) {
|
|
violations = append(violations, prefix+".key is invalid or unsafe")
|
|
}
|
|
if _, exists := operationTemplates[template.Key]; exists {
|
|
violations = append(violations, prefix+".key is duplicated")
|
|
}
|
|
operationTemplates[template.Key] = template
|
|
if strings.TrimSpace(template.Title) == "" || len([]rune(template.Title)) > 80 {
|
|
violations = append(violations, prefix+".title is invalid")
|
|
}
|
|
if !containsString(permissions, template.Permission) {
|
|
violations = append(violations, prefix+".permission must be declared by the plugin")
|
|
}
|
|
if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelOperator && template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin {
|
|
violations = append(violations, prefix+".approvalLevel must require operator or platform-admin approval")
|
|
}
|
|
if template.Kind != domain.GameClientBridgeOperationKindRCON && template.Kind != domain.GameClientBridgeOperationKindSQLiteMutation {
|
|
violations = append(violations, prefix+".kind is invalid")
|
|
}
|
|
if !safeRelativeJSONRef(template.PayloadSchemaRef) || template.ResultSchemaRef != "" && !safeRelativeJSONRef(template.ResultSchemaRef) || template.ConfirmationSchemaRef != "" && !safeRelativeJSONRef(template.ConfirmationSchemaRef) {
|
|
violations = append(violations, prefix+" schema references must be safe relative JSON references")
|
|
}
|
|
if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 3600 {
|
|
violations = append(violations, prefix+".timeoutSeconds is invalid")
|
|
}
|
|
if template.MaxPayloadBytes < 1 || template.MaxPayloadBytes > maxGameClientBridgePayloadSize {
|
|
violations = append(violations, prefix+".maxPayloadBytes is invalid")
|
|
}
|
|
transport, exists := transports[template.TransportKey]
|
|
if !exists {
|
|
violations = append(violations, prefix+".transportKey must reference a declared runtime transport profile")
|
|
continue
|
|
}
|
|
if transport.TargetKey != template.TargetKey || strings.TrimSpace(template.TargetKey) == "" {
|
|
violations = append(violations, prefix+".targetKey must match the declared runtime transport profile")
|
|
}
|
|
switch template.Kind {
|
|
case domain.GameClientBridgeOperationKindRCON:
|
|
if transport.Kind != "rcon" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunProtectedRCON) {
|
|
violations = append(violations, prefix+" transport must be rcon with remote.run.protected.rcon capability")
|
|
}
|
|
if template.MaxRowsAffected != 0 {
|
|
violations = append(violations, prefix+".maxRowsAffected is only valid for sqlite-mutation")
|
|
}
|
|
if !emptyGameClientBridgeOperationMutation(template.Mutation) {
|
|
violations = append(violations, prefix+".mutation is only valid for sqlite-mutation")
|
|
}
|
|
case domain.GameClientBridgeOperationKindSQLiteMutation:
|
|
if transport.Kind != "sqlite" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunProtectedSQL) {
|
|
violations = append(violations, prefix+" transport must be sqlite with remote.run.protected.sql capability")
|
|
}
|
|
if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin {
|
|
violations = append(violations, prefix+".approvalLevel must require platform-admin approval for sqlite-mutation")
|
|
}
|
|
if template.MaxRowsAffected < 1 || template.MaxRowsAffected > 10 {
|
|
violations = append(violations, prefix+".maxRowsAffected is invalid")
|
|
}
|
|
if !template.Safety.RequiresBeforeValue || !template.Safety.RequiresConfirmation || (!template.Safety.RequiresOfflinePlayer && !template.Safety.RequiresMaintenanceWindow) {
|
|
violations = append(violations, prefix+".safety must require before value, confirmation, and offline or maintenance protection")
|
|
}
|
|
violations = append(violations, validateGameClientBridgeOperationMutation(prefix+".mutation", template.Mutation, queryTemplates)...)
|
|
}
|
|
}
|
|
pageDeclarations := map[string]domain.GamePluginPage{}
|
|
for _, page := range pages {
|
|
pageDeclarations[page.Key] = page
|
|
}
|
|
features := map[string]domain.GameClientBridgeFeatureDeclaration{}
|
|
for index, feature := range bridge.Features {
|
|
prefix := fmt.Sprintf("%s.features[%d]", field, index)
|
|
if !clientManagerIdentifierPattern.MatchString(feature.Key) || unsafeGameClientBridgeCommandType(feature.Key) {
|
|
violations = append(violations, prefix+".key is invalid or unsafe")
|
|
}
|
|
if _, exists := features[feature.Key]; exists {
|
|
violations = append(violations, prefix+".key is duplicated")
|
|
}
|
|
features[feature.Key] = feature
|
|
if strings.TrimSpace(feature.Title) == "" || len([]rune(feature.Title)) > 80 {
|
|
violations = append(violations, prefix+".title is invalid")
|
|
}
|
|
if !containsString(permissions, feature.Permission) {
|
|
violations = append(violations, prefix+".permission must be declared by the plugin")
|
|
}
|
|
if len(feature.RequiredHandlers) == 0 && len(feature.RequiredEventProducers) == 0 {
|
|
violations = append(violations, prefix+" must require a handler or event producer")
|
|
}
|
|
for _, handler := range feature.RequiredHandlers {
|
|
if !clientManagerIdentifierPattern.MatchString(handler) {
|
|
violations = append(violations, prefix+".requiredHandlers contains an invalid handler")
|
|
}
|
|
}
|
|
for _, producer := range feature.RequiredEventProducers {
|
|
if !clientManagerIdentifierPattern.MatchString(producer) {
|
|
violations = append(violations, prefix+".requiredEventProducers contains an invalid producer")
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(prefix+".requiredHandlers", feature.RequiredHandlers)...)
|
|
violations = append(violations, duplicateViolations(prefix+".requiredEventProducers", feature.RequiredEventProducers)...)
|
|
}
|
|
for _, page := range pages {
|
|
for _, featureKey := range page.FeatureKeys {
|
|
feature, exists := features[featureKey]
|
|
if !exists {
|
|
violations = append(violations, field+" page "+page.Key+" references undeclared feature "+featureKey)
|
|
continue
|
|
}
|
|
if !containsString(page.Permissions, feature.Permission) {
|
|
violations = append(violations, field+" page "+page.Key+" must declare feature permission "+feature.Permission)
|
|
}
|
|
}
|
|
}
|
|
for index, page := range bridge.Pages {
|
|
prefix := fmt.Sprintf("%s.pages[%d]", field, index)
|
|
pageDeclaration, pageExists := pageDeclarations[page.PageKey]
|
|
if !pageExists {
|
|
violations = append(violations, prefix+".pageKey must reference a declared plugin page")
|
|
}
|
|
for _, commandType := range page.CommandTypes {
|
|
if _, exists := commandTypes[commandType]; !exists {
|
|
violations = append(violations, prefix+" references undeclared command "+commandType)
|
|
}
|
|
}
|
|
for _, snapshotType := range page.SnapshotTypes {
|
|
found := false
|
|
for key := range snapshotTypes {
|
|
if strings.HasPrefix(key, snapshotType+"\x00") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
violations = append(violations, prefix+" references undeclared snapshot "+snapshotType)
|
|
}
|
|
}
|
|
for _, templateKey := range page.QueryTemplateKeys {
|
|
template, exists := queryTemplates[templateKey]
|
|
if !exists {
|
|
violations = append(violations, prefix+" references undeclared query template "+templateKey)
|
|
continue
|
|
}
|
|
if !containsString(pageDeclaration.Permissions, template.Permission) {
|
|
violations = append(violations, prefix+" must declare query template permission "+template.Permission)
|
|
}
|
|
if !containsString(pageDeclaration.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest)) {
|
|
violations = append(violations, prefix+" must declare remote.access.request for query templates")
|
|
}
|
|
}
|
|
for _, operationKey := range page.OperationKeys {
|
|
operation, exists := operationTemplates[operationKey]
|
|
if !exists {
|
|
violations = append(violations, prefix+" references undeclared operation template "+operationKey)
|
|
continue
|
|
}
|
|
if !containsString(pageDeclaration.Permissions, operation.Permission) {
|
|
violations = append(violations, prefix+" must declare operation template permission "+operation.Permission)
|
|
}
|
|
}
|
|
for _, featureKey := range page.FeatureKeys {
|
|
feature, exists := features[featureKey]
|
|
if !exists {
|
|
violations = append(violations, prefix+" references undeclared feature "+featureKey)
|
|
continue
|
|
}
|
|
if !containsString(pageDeclaration.Permissions, feature.Permission) {
|
|
violations = append(violations, prefix+" must declare feature permission "+feature.Permission)
|
|
}
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validateGameClientBridgeLogProjection(prefix string, projection domain.GameClientBridgeLogProjectionDeclaration, commands []domain.GameClientBridgeCommandDeclaration, clientManagers []domain.RuntimeClientManagerProfile) []string {
|
|
var violations []string
|
|
if len(projection.StreamKeys) < 1 || len(projection.StreamKeys) > 64 {
|
|
violations = append(violations, prefix+".streamKeys must contain between 1 and 64 streams")
|
|
}
|
|
for _, streamKey := range projection.StreamKeys {
|
|
if !clientManagerIdentifierPattern.MatchString(streamKey) {
|
|
violations = append(violations, prefix+".streamKeys contains an invalid stream key")
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(prefix+".streamKeys", projection.StreamKeys)...)
|
|
|
|
captures := map[string]struct{}{}
|
|
if len(projection.Steps) < 1 || len(projection.Steps) > 64 {
|
|
violations = append(violations, prefix+".steps must contain between 1 and 64 patterns")
|
|
}
|
|
for index, step := range projection.Steps {
|
|
stepPrefix := fmt.Sprintf("%s.steps[%d].pattern", prefix, index)
|
|
if strings.TrimSpace(step.Pattern) == "" || len([]rune(step.Pattern)) > 16384 {
|
|
violations = append(violations, stepPrefix+" is empty or too large")
|
|
continue
|
|
}
|
|
compiled, err := regexp.Compile(step.Pattern)
|
|
if err != nil {
|
|
violations = append(violations, stepPrefix+" must be a valid regular expression")
|
|
continue
|
|
}
|
|
for _, capture := range compiled.SubexpNames() {
|
|
if capture != "" {
|
|
captures[capture] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(projection.CorrelationFields) < 1 || len(projection.CorrelationFields) > 64 {
|
|
violations = append(violations, prefix+".correlationFields must contain between 1 and 64 captures")
|
|
}
|
|
for _, field := range projection.CorrelationFields {
|
|
if !gameClientBridgeCaptureNamePattern.MatchString(field) {
|
|
violations = append(violations, prefix+".correlationFields contains an invalid capture name")
|
|
continue
|
|
}
|
|
if _, exists := captures[field]; !exists {
|
|
violations = append(violations, prefix+".correlationFields references undeclared capture "+field)
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(prefix+".correlationFields", projection.CorrelationFields)...)
|
|
if projection.MaxInterveningLines < 0 || projection.MaxInterveningLines > 100000 {
|
|
violations = append(violations, prefix+".maxInterveningLines is invalid")
|
|
}
|
|
violations = append(violations, validateGameClientBridgeLogProjectionTarget(prefix+".target", projection.Target, captures)...)
|
|
|
|
if projection.Presence == nil {
|
|
return violations
|
|
}
|
|
presence := projection.Presence
|
|
if !gameClientBridgeFieldPattern.MatchString(presence.TimestampField) || !gameClientBridgeLogProjectionTargetDeclaresField(projection.Target, presence.TimestampField) {
|
|
violations = append(violations, prefix+".presence.timestampField must reference a projected target field")
|
|
}
|
|
if presence.ActiveWindowSeconds < 1 || presence.ActiveWindowSeconds > 31536000 {
|
|
violations = append(violations, prefix+".presence.activeWindowSeconds is invalid")
|
|
}
|
|
if presence.ActivityTarget != nil {
|
|
violations = append(violations, validateGameClientBridgeLogProjectionTarget(prefix+".presence.activityTarget", *presence.ActivityTarget, captures)...)
|
|
}
|
|
|
|
announcement := presence.Announcement
|
|
if !clientManagerIdentifierPattern.MatchString(announcement.ProfileKey) {
|
|
violations = append(violations, prefix+".presence.announcement.profileKey is invalid")
|
|
} else {
|
|
profileFound := false
|
|
for _, profile := range clientManagers {
|
|
if profile.Key == announcement.ProfileKey && containsString(profile.Health.RequiredCapabilities, "game-client.bridge") {
|
|
profileFound = true
|
|
break
|
|
}
|
|
}
|
|
if !profileFound {
|
|
violations = append(violations, prefix+".presence.announcement.profileKey must reference a declared game-client bridge profile")
|
|
}
|
|
}
|
|
var command *domain.GameClientBridgeCommandDeclaration
|
|
for index := range commands {
|
|
if commands[index].Type == announcement.CommandType {
|
|
command = &commands[index]
|
|
break
|
|
}
|
|
}
|
|
if command == nil {
|
|
violations = append(violations, prefix+".presence.announcement.commandType must reference a declared command")
|
|
}
|
|
if !gameClientBridgeFieldPattern.MatchString(announcement.TextField) {
|
|
violations = append(violations, prefix+".presence.announcement.textField is invalid")
|
|
} else if command != nil && command.ProtectedRequest != nil && command.ProtectedRequest.TextField != announcement.TextField {
|
|
violations = append(violations, prefix+".presence.announcement.textField must match the command protected request")
|
|
}
|
|
if strings.TrimSpace(announcement.NewTextTemplate) == "" || len([]rune(announcement.NewTextTemplate)) > 4096 {
|
|
violations = append(violations, prefix+".presence.announcement.newTextTemplate is empty or too large")
|
|
}
|
|
if strings.TrimSpace(announcement.ReturningTextTemplate) == "" || len([]rune(announcement.ReturningTextTemplate)) > 4096 {
|
|
violations = append(violations, prefix+".presence.announcement.returningTextTemplate is empty or too large")
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validateGameClientBridgeLogProjectionTarget(prefix string, target domain.GameClientBridgeLogProjectionTargetDeclaration, captures map[string]struct{}) []string {
|
|
var violations []string
|
|
if !gameClientBridgeCollectionPattern.MatchString(target.Collection) {
|
|
violations = append(violations, prefix+".collection is invalid")
|
|
}
|
|
if len(target.UpsertKeys) < 1 || len(target.UpsertKeys) > 8 {
|
|
violations = append(violations, prefix+".upsertKeys must contain between 1 and 8 fields")
|
|
}
|
|
for _, key := range target.UpsertKeys {
|
|
if !gameClientBridgeFieldPattern.MatchString(key) {
|
|
violations = append(violations, prefix+".upsertKeys contains an invalid field")
|
|
}
|
|
if !gameClientBridgeLogProjectionTargetDeclaresField(target, key) {
|
|
violations = append(violations, prefix+".upsertKeys field "+key+" is not projected")
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(prefix+".upsertKeys", target.UpsertKeys)...)
|
|
|
|
if len(target.CaptureMappings) < 1 || len(target.CaptureMappings) > 64 {
|
|
violations = append(violations, prefix+".captureMappings must contain between 1 and 64 mappings")
|
|
}
|
|
projectedFields := map[string]struct{}{}
|
|
for destination, capture := range target.CaptureMappings {
|
|
if !gameClientBridgeFieldPattern.MatchString(destination) || !gameClientBridgeCaptureNamePattern.MatchString(capture) {
|
|
violations = append(violations, prefix+".captureMappings contains an invalid field or capture")
|
|
}
|
|
if _, exists := captures[capture]; !exists {
|
|
violations = append(violations, prefix+".captureMappings references undeclared capture "+capture)
|
|
}
|
|
projectedFields[destination] = struct{}{}
|
|
}
|
|
if len(target.FixedValues) > 64 {
|
|
violations = append(violations, prefix+".fixedValues contains too many fields")
|
|
}
|
|
for destination, value := range target.FixedValues {
|
|
if !gameClientBridgeFieldPattern.MatchString(destination) || len([]rune(value)) > 4096 {
|
|
violations = append(violations, prefix+".fixedValues contains an invalid field or oversized value")
|
|
}
|
|
if _, exists := projectedFields[destination]; exists {
|
|
violations = append(violations, prefix+" declares field "+destination+" more than once")
|
|
}
|
|
projectedFields[destination] = struct{}{}
|
|
}
|
|
if target.ObservedAtField != "" {
|
|
if !gameClientBridgeFieldPattern.MatchString(target.ObservedAtField) {
|
|
violations = append(violations, prefix+".observedAtField is invalid")
|
|
}
|
|
if _, exists := projectedFields[target.ObservedAtField]; exists {
|
|
violations = append(violations, prefix+" declares field "+target.ObservedAtField+" more than once")
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func gameClientBridgeLogProjectionTargetDeclaresField(target domain.GameClientBridgeLogProjectionTargetDeclaration, field string) bool {
|
|
if target.ObservedAtField == field {
|
|
return true
|
|
}
|
|
if _, exists := target.CaptureMappings[field]; exists {
|
|
return true
|
|
}
|
|
_, exists := target.FixedValues[field]
|
|
return exists
|
|
}
|
|
|
|
func validCompanionProofEnvironment(value string) bool {
|
|
if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' {
|
|
return false
|
|
}
|
|
for _, character := range value[1:] {
|
|
if character != '_' && (character < 'A' || character > 'Z') && (character < '0' || character > '9') {
|
|
return false
|
|
}
|
|
}
|
|
reserved := map[string]struct{}{
|
|
"COMSPEC": {}, "DYLD_INSERT_LIBRARIES": {}, "DYLD_LIBRARY_PATH": {}, "HOME": {}, "LD_LIBRARY_PATH": {}, "LD_PRELOAD": {},
|
|
"PATH": {}, "PATHEXT": {}, "SHELL": {}, "SYSTEMROOT": {}, "TEMP": {}, "TMP": {}, "USERPROFILE": {}, "WINDIR": {},
|
|
}
|
|
if _, exists := reserved[value]; exists {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func unsafeGameClientBridgeCommandType(value string) bool {
|
|
tokens := gameClientBridgePayloadKeyTokens(value)
|
|
tokenSet := make(map[string]struct{}, len(tokens))
|
|
for _, token := range tokens {
|
|
tokenSet[token] = struct{}{}
|
|
}
|
|
has := func(values ...string) bool {
|
|
for _, candidate := range values {
|
|
if _, ok := tokenSet[candidate]; ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
if has("sql") || has("database", "db") && has("execute", "exec", "eval", "run", "query", "statement") || has("query") && has("execute", "exec", "eval", "raw", "statement") {
|
|
return true
|
|
}
|
|
return has("shell", "powershell", "script", "terminal", "execute", "exec", "eval") || has("command", "cmd", "process", "system", "os", "executor") && has("run")
|
|
}
|
|
|
|
func validateGameClientBridgeProtectedRequest(prefix string, request *domain.GameClientBridgeProtectedRequestDeclaration, transports map[string]domain.RuntimeTransportProfile) []string {
|
|
if request == nil {
|
|
return nil
|
|
}
|
|
var violations []string
|
|
if !oneOf(request.Kind, "sql", "rcon", "program") {
|
|
violations = append(violations, prefix+".kind is invalid")
|
|
}
|
|
for field, value := range map[string]string{"transportKey": request.TransportKey, "targetKey": request.TargetKey, "textField": request.TextField} {
|
|
if !validDistributionLogicalKey(value) || unsafeGameClientBridgePayloadKey(value) {
|
|
violations = append(violations, prefix+"."+field+" is invalid")
|
|
}
|
|
}
|
|
if request.MaxTextBytes < 1 || request.MaxTextBytes > maxGameClientBridgePayloadString {
|
|
violations = append(violations, prefix+".maxTextBytes is invalid")
|
|
}
|
|
transport, exists := transports[request.TransportKey]
|
|
if !exists {
|
|
return append(violations, prefix+".transportKey must reference a declared runtime transport profile")
|
|
}
|
|
if transport.TargetKey != request.TargetKey {
|
|
violations = append(violations, prefix+".targetKey must match the declared runtime transport profile")
|
|
}
|
|
wantKind, wantCapability := "", ""
|
|
switch request.Kind {
|
|
case "sql":
|
|
wantCapability = domain.JobCapabilityRemoteRunProtectedSQL
|
|
case "rcon":
|
|
wantKind, wantCapability = "rcon", domain.JobCapabilityRemoteRunProtectedRCON
|
|
case "program":
|
|
wantKind, wantCapability = "program", domain.JobCapabilityRemoteRunProgram
|
|
}
|
|
if request.Kind == "sql" && transport.Kind != "mysql" && transport.Kind != "sqlite" {
|
|
violations = append(violations, prefix+".transportKey must use mysql or sqlite for sql requests")
|
|
}
|
|
if wantKind != "" && transport.Kind != wantKind {
|
|
violations = append(violations, prefix+".transportKey does not match protected request kind")
|
|
}
|
|
if wantCapability != "" && !containsString(transport.Capabilities, wantCapability) {
|
|
violations = append(violations, prefix+".transportKey is missing required protected transport capability")
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func emptyGameClientBridgeOperationMutation(value domain.GameClientBridgeOperationMutationDeclaration) bool {
|
|
return value.FieldKey == "" && value.TableKey == "" && value.IdentityKey == "" && value.ValueKey == "" && value.ConfirmationQueryKey == "" && value.AllowedValueType == "" && value.MinValue == 0 && value.MaxValue == 0
|
|
}
|
|
|
|
func validateGameClientBridgeOperationMutation(prefix string, value domain.GameClientBridgeOperationMutationDeclaration, queryTemplates map[string]domain.GameClientBridgeQueryTemplateDeclaration) []string {
|
|
var violations []string
|
|
for field, item := range map[string]string{"fieldKey": value.FieldKey, "tableKey": value.TableKey, "identityKey": value.IdentityKey, "valueKey": value.ValueKey, "confirmationQueryKey": value.ConfirmationQueryKey} {
|
|
if !validDistributionLogicalKey(item) || unsafeGameClientBridgePayloadKey(item) {
|
|
violations = append(violations, prefix+"."+field+" must be a safe logical key")
|
|
}
|
|
}
|
|
if !oneOf(value.AllowedValueType, "integer", "number", "string", "boolean") {
|
|
violations = append(violations, prefix+".allowedValueType is invalid")
|
|
}
|
|
if value.MaxValue != 0 && value.MinValue > value.MaxValue {
|
|
violations = append(violations, prefix+".minValue must not exceed maxValue")
|
|
}
|
|
if value.ConfirmationQueryKey != "" {
|
|
if _, exists := queryTemplates[value.ConfirmationQueryKey]; !exists {
|
|
violations = append(violations, prefix+".confirmationQueryKey must reference a declared query template")
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func ValidatePluginBridgeAuthorizeRequest(request domain.PluginBridgeAuthorizeRequest) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "pluginId", request.PluginID)
|
|
violations = appendRequired(violations, "routeKey", request.RouteKey)
|
|
violations = appendRequired(violations, "action", string(request.Action))
|
|
if !validPluginBridgeAction(request.Action) {
|
|
violations = append(violations, "action is not supported")
|
|
}
|
|
if request.Action == domain.PluginBridgeActionAIInvoke {
|
|
violations = appendRequired(violations, "aiPurpose", request.AIPurpose)
|
|
if strings.TrimSpace(request.AIPurpose) != "" && !validAIPurpose(request.AIPurpose) {
|
|
violations = append(violations, "aiPurpose is not allowed")
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidatePluginBridgeExecuteRequest(request domain.PluginBridgeExecuteRequest) error {
|
|
request = domain.CopyPluginBridgeExecuteRequest(request)
|
|
var violations []string
|
|
violations = appendRequired(violations, "requestId", request.RequestID)
|
|
violations = appendRequired(violations, "pluginId", request.PluginID)
|
|
violations = appendRequired(violations, "routeKey", request.RouteKey)
|
|
violations = appendRequired(violations, "action", string(request.Action))
|
|
if !validPluginBridgeAction(request.Action) {
|
|
violations = append(violations, "action is not supported")
|
|
}
|
|
if request.Action != domain.PluginBridgeActionAIInvoke {
|
|
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
|
|
}
|
|
if request.Action == domain.PluginBridgeActionAIInvoke {
|
|
violations = appendRequired(violations, "aiPurpose", request.AIPurpose)
|
|
if strings.TrimSpace(request.AIPurpose) != "" && !validAIPurpose(request.AIPurpose) {
|
|
violations = append(violations, "aiPurpose is not allowed")
|
|
}
|
|
}
|
|
if len(request.Payload) > maxPluginBridgePayloadKeys {
|
|
violations = append(violations, "payload has too many keys")
|
|
}
|
|
payloadSize := 0
|
|
for key, value := range request.Payload {
|
|
payloadSize += len(key) + len(value)
|
|
if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || len([]rune(key)) > 80 {
|
|
violations = append(violations, "payload key is invalid")
|
|
}
|
|
if len([]rune(value)) > 1024 {
|
|
violations = append(violations, "payload value is too long")
|
|
}
|
|
for _, reason := range unsafePluginStringReasons(key) {
|
|
violations = append(violations, "payload key: "+reason)
|
|
}
|
|
for _, reason := range unsafePluginStringReasons(value) {
|
|
violations = append(violations, "payload."+key+": "+reason)
|
|
}
|
|
if containsUnsafeRuntimeSecret(value) || strings.Contains(strings.ToLower(value), "unix://") || strings.Contains(strings.ToLower(value), "tcp://") {
|
|
violations = append(violations, "payload contains unsafe content")
|
|
}
|
|
}
|
|
if payloadSize > maxPluginBridgePayloadSize {
|
|
violations = append(violations, "payload is too large")
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidatePluginMarketplaceFilter(filter domain.PluginMarketplaceFilter) error {
|
|
var violations []string
|
|
if filter.Status != "" && !validGamePluginStatus(filter.Status) {
|
|
violations = append(violations, "status is invalid")
|
|
}
|
|
if strings.TrimSpace(filter.ServerType) != filter.ServerType {
|
|
violations = append(violations, "serverType must not have surrounding whitespace")
|
|
}
|
|
if strings.TrimSpace(filter.Capability) != filter.Capability {
|
|
violations = append(violations, "capability must not have surrounding whitespace")
|
|
}
|
|
if len([]rune(filter.Keyword)) > maxMarketplaceKeywordSize {
|
|
violations = append(violations, "keyword is too long")
|
|
}
|
|
for _, value := range []fieldString{
|
|
{field: "serverType", value: filter.ServerType},
|
|
{field: "capability", value: filter.Capability},
|
|
{field: "keyword", value: filter.Keyword},
|
|
} {
|
|
for _, reason := range unsafePluginStringReasons(value.value) {
|
|
violations = append(violations, "filter."+value.field+": "+reason)
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidatePluginMarketplaceStateAction(action domain.PluginMarketplaceStateAction) error {
|
|
if validPluginMarketplaceStateAction(action) {
|
|
return nil
|
|
}
|
|
return ValidationError{Violations: []string{"action is not supported"}}
|
|
}
|
|
|
|
func ValidatePluginMarketplacePlugins(plugins []domain.PluginMarketplacePlugin) error {
|
|
var violations []string
|
|
if len(plugins) > maxMarketplaceListSize {
|
|
violations = append(violations, "items is too long")
|
|
}
|
|
for i, plugin := range plugins {
|
|
prefix := fmt.Sprintf("items[%d]", i)
|
|
violations = append(violations, validatePluginMarketplacePlugin(prefix, plugin)...)
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidatePluginMarketplacePlugin(plugin domain.PluginMarketplacePlugin) error {
|
|
return finish(validatePluginMarketplacePlugin("plugin", plugin))
|
|
}
|
|
|
|
func validatePluginMarketplacePlugin(prefix string, plugin domain.PluginMarketplacePlugin) []string {
|
|
var violations []string
|
|
violations = appendRequired(violations, prefix+".id", plugin.ID)
|
|
violations = appendRequired(violations, prefix+".name", plugin.Name)
|
|
violations = appendRequired(violations, prefix+".version", plugin.Version)
|
|
violations = appendRequired(violations, prefix+".serverType", plugin.ServerType)
|
|
violations = appendRequired(violations, prefix+".manifestRef", plugin.ManifestRef)
|
|
violations = appendRequired(violations, prefix+".createFormSchemaRef", plugin.CreateFormSchemaRef)
|
|
violations = appendRequired(violations, prefix+".source", plugin.Source)
|
|
if !validGamePluginStatus(plugin.Status) {
|
|
violations = append(violations, prefix+".status is invalid")
|
|
}
|
|
if len(plugin.Description) > maxPluginDescriptionLength {
|
|
violations = append(violations, prefix+".description is too long")
|
|
}
|
|
if len(plugin.Capabilities) == 0 {
|
|
violations = append(violations, prefix+".capabilities must not be empty")
|
|
}
|
|
for i, capability := range plugin.Capabilities {
|
|
if !validPluginRunCapability(capability) {
|
|
violations = append(violations, fmt.Sprintf("%s.capabilities[%d] is not allowed", prefix, i))
|
|
}
|
|
}
|
|
for i, osName := range plugin.SupportedOS {
|
|
if !validPluginSupportedOS(osName) {
|
|
violations = append(violations, fmt.Sprintf("%s.supportedOs[%d] is not allowed", prefix, i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(prefix+".supportedOs", plugin.SupportedOS)...)
|
|
violations = append(violations, duplicateViolations(prefix+".capabilities", plugin.Capabilities)...)
|
|
violations = append(violations, validateDeclaredPluginPermissions(plugin.DeclaredPermissions)...)
|
|
violations = append(violations, validateBridgeActions(prefix+".bridgeActions", plugin.BridgeActions)...)
|
|
violations = append(violations, validatePluginPages(plugin.Pages)...)
|
|
violations = append(violations, duplicateViolations(prefix+".tags", plugin.Tags)...)
|
|
violations = append(violations, validateAIPurposes(plugin.AIPurposes)...)
|
|
violations = append(violations, validateRemoteAccess(prefix+".remoteAccess", plugin.RemoteAccess, plugin.Capabilities)...)
|
|
violations = append(violations, validateSafePluginStrings(prefix, marketplacePluginSafeStrings(plugin))...)
|
|
return violations
|
|
}
|
|
|
|
func AuthorizePluginBridgeAction(plugin domain.GamePlugin, request domain.PluginBridgeAuthorizeRequest) (domain.PluginBridgeAuthorization, error) {
|
|
result := domain.PluginBridgeAuthorization{
|
|
PluginID: request.PluginID,
|
|
RouteKey: request.RouteKey,
|
|
ServerInstanceID: request.ServerInstanceID,
|
|
Action: request.Action,
|
|
RequiredPermissions: requiredBridgePermissions(request.Action),
|
|
EffectivePermissions: effectivePagePermissions(plugin, request.RouteKey),
|
|
}
|
|
if err := ValidateGamePlugin(plugin); err != nil {
|
|
return result, err
|
|
}
|
|
if err := ValidatePluginBridgeAuthorizeRequest(request); err != nil {
|
|
return result, err
|
|
}
|
|
if plugin.ID != request.PluginID {
|
|
result.Reason = "pluginId must match installed plugin"
|
|
return result, nil
|
|
}
|
|
if plugin.Status != domain.GamePluginStatusInstalled {
|
|
result.Reason = "plugin must be installed"
|
|
return result, nil
|
|
}
|
|
if !containsString(plugin.BridgeActions, string(request.Action)) {
|
|
result.Reason = "bridge action is not declared by plugin"
|
|
return result, nil
|
|
}
|
|
page, pageFound := findPluginPage(plugin.Pages, request.RouteKey)
|
|
if !pageFound {
|
|
result.Reason = "routeKey is not declared by plugin"
|
|
return result, nil
|
|
}
|
|
if len(page.BridgeActions) > 0 && !containsString(page.BridgeActions, string(request.Action)) {
|
|
result.Reason = "bridge action is not declared by page"
|
|
return result, nil
|
|
}
|
|
if !containsAll(result.EffectivePermissions, result.RequiredPermissions) {
|
|
result.Reason = "required permission is missing"
|
|
return result, nil
|
|
}
|
|
if request.Action == domain.PluginBridgeActionAIInvoke && !containsString(plugin.AIPurposes, request.AIPurpose) {
|
|
result.Reason = "ai purpose is not declared by plugin"
|
|
return result, nil
|
|
}
|
|
result.Allowed = true
|
|
return result, nil
|
|
}
|
|
|
|
func ValidateServerInstance(instance domain.ServerInstance) error {
|
|
return validateServerInstance(instance, false)
|
|
}
|
|
|
|
func ValidateStoredServerInstance(instance domain.ServerInstance) error {
|
|
return validateServerInstance(instance, true)
|
|
}
|
|
|
|
func validateServerInstance(instance domain.ServerInstance, allowDeleted bool) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "id", instance.ID)
|
|
violations = appendRequired(violations, "pluginId", instance.PluginID)
|
|
violations = appendRequired(violations, "pluginVersion", instance.PluginVersion)
|
|
if instance.State != domain.ServerInstanceStateDraft && instance.State != domain.ServerInstanceStateDeleted {
|
|
violations = appendRequired(violations, "runEndpointId", instance.RunEndpointID)
|
|
}
|
|
violations = appendRequired(violations, "name", instance.Name)
|
|
if strings.TrimSpace(instance.OwnerUserID) != instance.OwnerUserID {
|
|
violations = append(violations, "ownerUserId must not have surrounding whitespace")
|
|
}
|
|
for i, userID := range instance.AdminUserIDs {
|
|
if strings.TrimSpace(userID) == "" {
|
|
violations = append(violations, fmt.Sprintf("adminUserIds[%d] is required", i))
|
|
}
|
|
if strings.TrimSpace(userID) != userID {
|
|
violations = append(violations, fmt.Sprintf("adminUserIds[%d] must not have surrounding whitespace", i))
|
|
}
|
|
if userID == instance.OwnerUserID {
|
|
violations = append(violations, fmt.Sprintf("adminUserIds[%d] must not duplicate ownerUserId", i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations("adminUserIds", instance.AdminUserIDs)...)
|
|
if !validServerInstanceState(instance.State) {
|
|
violations = append(violations, "state is invalid")
|
|
}
|
|
if instance.State == domain.ServerInstanceStateDeleted && !allowDeleted {
|
|
violations = append(violations, "state must not be deleted on create")
|
|
}
|
|
if instance.ConfigVersion < 0 {
|
|
violations = append(violations, "configVersion must not be negative")
|
|
}
|
|
if err := ValidateServerDeploymentDefinition(instance.Deployment); err != nil {
|
|
violations = append(violations, err.Error())
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateServerDeploymentDefinition(definition domain.ServerDeploymentDefinition) error {
|
|
if definition.Mode == "" {
|
|
return nil
|
|
}
|
|
var violations []string
|
|
if definition.Mode != domain.ServerDeploymentModeGuided && definition.Mode != domain.ServerDeploymentModeExisting && definition.Mode != domain.ServerDeploymentModeCustom {
|
|
violations = append(violations, "deployment mode is invalid")
|
|
}
|
|
if definition.Shell != domain.ServerCommandShellNone && definition.Shell != domain.ServerCommandShellPosix && definition.Shell != domain.ServerCommandShellPowerShell && definition.Shell != domain.ServerCommandShellCmd {
|
|
violations = append(violations, "deployment shell is invalid")
|
|
}
|
|
if definition.Revision < 0 {
|
|
violations = append(violations, "deployment revision must not be negative")
|
|
}
|
|
for key, value := range definition.CreateInputs {
|
|
if !validDistributionLogicalKey(key) || len(value) > 1024 || strings.TrimSpace(value) != value || containsUnsafeRuntimeSecret(value) {
|
|
violations = append(violations, "deployment createInputs are invalid")
|
|
break
|
|
}
|
|
}
|
|
for key, value := range definition.RuntimeBindings {
|
|
if !validDistributionLogicalKey(key) || strings.TrimSpace(value) != value || looksLikeRawHostPath(value) || containsUnsafeRuntimeSecret(value) {
|
|
violations = append(violations, "deployment runtimeBindings are invalid")
|
|
break
|
|
}
|
|
}
|
|
for _, value := range []string{definition.ServerRoot, definition.WorkingDirectory} {
|
|
if value != "" && (len(value) > 1024 || strings.TrimSpace(value) != value || !looksLikeAbsoluteHostPath(value)) {
|
|
violations = append(violations, "deployment path must be an absolute host path")
|
|
break
|
|
}
|
|
}
|
|
for _, value := range []string{definition.InstallCommand, definition.StartCommand, definition.StopCommand, definition.StatusCommand} {
|
|
if value != "" && (len(value) > 4096 || strings.TrimSpace(value) != value || strings.ContainsAny(value, "\x00\r\n") || containsUnsafeRuntimeSecret(value)) {
|
|
violations = append(violations, "deployment command is invalid or contains a secret")
|
|
break
|
|
}
|
|
}
|
|
if definition.Mode == domain.ServerDeploymentModeCustom && strings.TrimSpace(definition.StartCommand) == "" {
|
|
violations = append(violations, "custom deployment requires a start command")
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func looksLikeAbsoluteHostPath(value string) bool {
|
|
trimmed := strings.TrimSpace(value)
|
|
return strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, `\\`) || (len(trimmed) >= 3 && trimmed[1] == ':' && (trimmed[2] == '\\' || trimmed[2] == '/'))
|
|
}
|
|
|
|
func ValidateServerInstanceUpdate(update domain.ServerInstanceUpdate) error {
|
|
var violations []string
|
|
if update.Name != nil {
|
|
name := strings.TrimSpace(*update.Name)
|
|
if name == "" {
|
|
violations = append(violations, "name is required")
|
|
}
|
|
if name != *update.Name {
|
|
violations = append(violations, "name must not have surrounding whitespace")
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateServerInstanceDependencies(instance domain.ServerInstance, plugin domain.GamePlugin, endpoint domain.RunEndpoint) error {
|
|
return ValidateServerInstanceDependenciesForCapabilities(instance, plugin, endpoint, plugin.RequiredRunCapabilities)
|
|
}
|
|
|
|
func ValidateServerInstanceDependenciesForCapabilities(instance domain.ServerInstance, plugin domain.GamePlugin, endpoint domain.RunEndpoint, requiredRunCapabilities []string) error {
|
|
var violations []string
|
|
if plugin.ID == "" {
|
|
violations = append(violations, "plugin is required")
|
|
} else {
|
|
if plugin.ID != instance.PluginID {
|
|
violations = append(violations, "pluginId must match plugin")
|
|
}
|
|
if plugin.Status != domain.GamePluginStatusInstalled {
|
|
violations = append(violations, "plugin must be installed")
|
|
}
|
|
if plugin.Version != instance.PluginVersion {
|
|
violations = append(violations, "pluginVersion must match plugin")
|
|
}
|
|
}
|
|
if endpoint.ID == "" {
|
|
violations = append(violations, "run endpoint is required")
|
|
} else {
|
|
if endpoint.ID != instance.RunEndpointID {
|
|
violations = append(violations, "runEndpointId must match run endpoint")
|
|
}
|
|
if endpoint.Status != domain.RunEndpointStatusOnline && endpoint.Status != domain.RunEndpointStatusDegraded {
|
|
violations = append(violations, "run endpoint must be online or degraded")
|
|
}
|
|
missing := MissingCapabilities(endpoint.Capabilities, requiredRunCapabilities)
|
|
if len(missing) > 0 {
|
|
violations = append(violations, "run endpoint missing required capabilities: "+strings.Join(missing, ", "))
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidatePlatformResourceUsage(usage domain.PlatformResourceUsage) error {
|
|
var violations []string
|
|
violations = appendPercentViolation(violations, "cpuPercent", usage.CPUPercent)
|
|
violations = appendPercentViolation(violations, "memoryPercent", usage.MemoryPercent)
|
|
violations = appendPercentViolation(violations, "diskPercent", usage.DiskPercent)
|
|
violations = appendRequired(violations, "source", usage.Source)
|
|
if usage.CollectedAt.IsZero() {
|
|
violations = append(violations, "collectedAt is required")
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateServerMetricsList(items []domain.ServerMetrics) error {
|
|
var violations []string
|
|
if len(items) > 1000 {
|
|
violations = append(violations, "items is too long")
|
|
}
|
|
for i, item := range items {
|
|
field := fmt.Sprintf("items[%d]", i)
|
|
if strings.TrimSpace(item.ServerInstanceID) == "" {
|
|
violations = append(violations, field+".serverInstanceId is required")
|
|
}
|
|
if item.CollectedAt.IsZero() {
|
|
violations = append(violations, field+".collectedAt is required")
|
|
}
|
|
if strings.TrimSpace(item.Source) == "" {
|
|
violations = append(violations, field+".source is required")
|
|
}
|
|
violations = appendOptionalPercentViolation(violations, field+".cpuPercent", item.CPUPercent)
|
|
violations = appendOptionalPercentViolation(violations, field+".memoryPercent", item.MemoryPercent)
|
|
violations = appendOptionalPercentViolation(violations, field+".diskPercent", item.DiskPercent)
|
|
if item.PlayerCount != nil && *item.PlayerCount < 0 {
|
|
violations = append(violations, field+".playerCount must not be negative")
|
|
}
|
|
if item.MaxPlayers != nil && *item.MaxPlayers < 0 {
|
|
violations = append(violations, field+".maxPlayers must not be negative")
|
|
}
|
|
if item.TPS != nil && (*item.TPS < 0 || *item.TPS > 100) {
|
|
violations = append(violations, field+".tps must be between 0 and 100")
|
|
}
|
|
if item.LatencyMS != nil && *item.LatencyMS < 0 {
|
|
violations = append(violations, field+".latencyMs must not be negative")
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateServerConfig(config domain.ServerConfig) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "serverInstanceId", config.ServerInstanceID)
|
|
violations = appendRequired(violations, "format", config.Format)
|
|
violations = appendRequired(violations, "key", config.Key)
|
|
if config.ConfigVersion <= 0 {
|
|
violations = append(violations, "configVersion must be positive")
|
|
}
|
|
if len([]byte(config.Content)) > maxServerConfigContentSize {
|
|
violations = append(violations, "content is too large")
|
|
}
|
|
if config.Checksum != "" && !validSHA256Checksum(config.Checksum) {
|
|
violations = append(violations, "checksum must be sha256:<hex>")
|
|
}
|
|
if config.UpdatedAt.IsZero() {
|
|
violations = append(violations, "updatedAt is required")
|
|
}
|
|
if containsUnsafeRuntimeSecret(config.Content) {
|
|
violations = append(violations, "content must not expose raw secrets, host paths, or direct sockets")
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateServerConfigDiffRequest(request domain.ServerConfigDiffRequest) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
|
|
violations = appendRequired(violations, "key", request.Key)
|
|
if request.ExpectedConfigVersion <= 0 {
|
|
violations = append(violations, "expectedConfigVersion must be positive")
|
|
}
|
|
if !validLogicalFileKey(request.Key) || !validConfigFileKey(request.Key) {
|
|
violations = append(violations, "key is not allowed")
|
|
}
|
|
if len([]byte(request.ProposedContent)) > maxServerConfigContentSize {
|
|
violations = append(violations, "proposedContent is too large")
|
|
}
|
|
if containsUnsafeRuntimeSecret(request.ProposedContent) {
|
|
violations = append(violations, "proposedContent must not expose raw secrets, host paths, or direct sockets")
|
|
}
|
|
if request.ProposedContentInputRef != "" && !validScopedInputRef(request.ProposedContentInputRef) {
|
|
violations = append(violations, "proposedContentInputRef is not allowed")
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateServerConfigWriteApproval(approval domain.ServerConfigWriteApproval) error {
|
|
request := domain.ServerConfigDiffRequest{
|
|
ServerInstanceID: approval.ServerInstanceID,
|
|
ExpectedConfigVersion: approval.ExpectedConfigVersion,
|
|
Key: approval.Key,
|
|
ProposedContent: approval.ProposedContent,
|
|
ProposedContentInputRef: approval.ProposedContentInputRef,
|
|
}
|
|
var violations []string
|
|
if err := ValidateServerConfigDiffRequest(request); err != nil {
|
|
if validationErr, ok := err.(ValidationError); ok {
|
|
violations = append(violations, validationErr.Violations...)
|
|
} else {
|
|
violations = append(violations, err.Error())
|
|
}
|
|
}
|
|
violations = appendRequired(violations, "idempotencyKey", approval.IdempotencyKey)
|
|
if containsUnsafeRuntimeSecret(approval.IdempotencyKey) || looksLikeRawHostPath(approval.IdempotencyKey) {
|
|
violations = append(violations, "idempotencyKey is not allowed")
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateFileOperationDispatchRequest(request domain.FileOperationDispatchRequest) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
|
|
violations = appendRequired(violations, "key", request.Key)
|
|
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
|
if !validFileOperationKind(request.Operation) {
|
|
violations = append(violations, "operation is invalid")
|
|
}
|
|
if !validLogicalFileKey(request.Key) {
|
|
violations = append(violations, "key is not allowed")
|
|
}
|
|
if request.Operation == domain.FileOperationWrite && request.InputRef == "" {
|
|
violations = append(violations, "inputRef is required for writes")
|
|
}
|
|
if request.InputRef != "" && !validScopedInputRef(request.InputRef) {
|
|
violations = append(violations, "inputRef is not allowed")
|
|
}
|
|
if len([]byte(request.Content)) > maxJobExecutionContentSize {
|
|
violations = append(violations, "content is too large")
|
|
}
|
|
if containsUnsafeRuntimeSecret(request.Content) {
|
|
violations = append(violations, "content must not expose raw secrets, host paths, or direct sockets")
|
|
}
|
|
if request.ExpectedConfigVersion < 0 {
|
|
violations = append(violations, "expectedConfigVersion must not be negative")
|
|
}
|
|
for _, value := range []string{request.PluginID, request.IdempotencyKey} {
|
|
if containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(strings.ToLower(value), "unix://") {
|
|
violations = append(violations, "request contains unsafe content")
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func appendPercentViolation(violations []string, field string, value float64) []string {
|
|
if value < 0 || value > 100 {
|
|
return append(violations, field+" must be between 0 and 100")
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func appendOptionalPercentViolation(violations []string, field string, value *float64) []string {
|
|
if value == nil {
|
|
return violations
|
|
}
|
|
return appendPercentViolation(violations, field, *value)
|
|
}
|
|
|
|
func containsUnsafeRuntimeSecret(value string) bool {
|
|
lower := strings.ToLower(value)
|
|
unsafeFragments := []string{
|
|
"/users/",
|
|
"/var/run/",
|
|
"unix://",
|
|
"tcp://",
|
|
"bearer ",
|
|
"api_key=",
|
|
"apikey=",
|
|
"password=",
|
|
"secret=",
|
|
"sk-",
|
|
}
|
|
for _, fragment := range unsafeFragments {
|
|
if strings.Contains(lower, fragment) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func ValidateRunEndpoint(endpoint domain.RunEndpoint) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "id", endpoint.ID)
|
|
violations = appendRequired(violations, "displayName", endpoint.DisplayName)
|
|
violations = appendRequired(violations, "version", endpoint.Version)
|
|
if endpoint.Architecture != "" {
|
|
if !validDistributionTargetOS(endpoint.Platform) {
|
|
violations = append(violations, "platform is invalid")
|
|
}
|
|
if !validDistributionTargetArch(endpoint.Architecture) {
|
|
violations = append(violations, "architecture is invalid")
|
|
}
|
|
}
|
|
if !validRunEndpointStatus(endpoint.Status) {
|
|
violations = append(violations, "status is invalid")
|
|
}
|
|
violations = appendCapacityViolations(violations, endpoint.Capacity)
|
|
for i, capability := range endpoint.Capabilities {
|
|
if strings.TrimSpace(capability) == "" {
|
|
violations = append(violations, fmt.Sprintf("capabilities[%d] is required", i))
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateJob(job domain.Job) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "id", job.ID)
|
|
violations = appendRequired(violations, "runEndpointId", job.RunEndpointID)
|
|
violations = appendRequired(violations, "capability", job.Capability)
|
|
violations = appendRequired(violations, "idempotencyKey", job.IdempotencyKey)
|
|
if !validJobState(job.State) {
|
|
violations = append(violations, "state is invalid")
|
|
}
|
|
if job.Progress.Percent < 0 || job.Progress.Percent > 100 {
|
|
violations = append(violations, "progress.percent must be between 0 and 100")
|
|
}
|
|
if job.Progress.Phase != "" && !validDeploymentProgressPhase(job.Progress.Phase) {
|
|
violations = append(violations, "progress.phase is invalid")
|
|
}
|
|
if len(job.Progress.Message) > maxProgressMessageLength {
|
|
violations = append(violations, "progress.message is too long")
|
|
}
|
|
if job.Attempt < 0 {
|
|
violations = append(violations, "attempt must not be negative")
|
|
}
|
|
if job.RetryPolicy.MaxAttempts <= 0 {
|
|
violations = append(violations, "retryPolicy.maxAttempts must be positive")
|
|
}
|
|
if job.RetryPolicy.InitialBackoffSeconds <= 0 || job.RetryPolicy.MaxBackoffSeconds < job.RetryPolicy.InitialBackoffSeconds {
|
|
violations = append(violations, "retryPolicy backoff must be positive and bounded")
|
|
}
|
|
if job.Attempt > job.RetryPolicy.MaxAttempts {
|
|
violations = append(violations, "attempt must not exceed retryPolicy.maxAttempts")
|
|
}
|
|
if job.LeaseTokenHash != "" && len(job.LeaseTokenHash) != 64 {
|
|
violations = append(violations, "leaseTokenHash must be a SHA-256 hash")
|
|
}
|
|
if len(job.CancelReason) > maxProgressMessageLength {
|
|
violations = append(violations, "cancelReason is too long")
|
|
}
|
|
if len(job.ReconcileOutcome) > maxProgressMessageLength {
|
|
violations = append(violations, "reconcileOutcome is too long")
|
|
}
|
|
if job.TargetKey != "" && !validLogicalFileKey(job.TargetKey) {
|
|
violations = append(violations, "targetKey is not allowed")
|
|
}
|
|
if job.InputRef != "" && !validScopedInputRef(job.InputRef) {
|
|
violations = append(violations, "inputRef is not allowed")
|
|
}
|
|
if len([]byte(job.ExecutionInput.Content)) > maxJobExecutionContentSize {
|
|
violations = append(violations, "executionInput.content is too large")
|
|
}
|
|
if job.ExecutionInput.MaxReadBytes < 0 || job.ExecutionInput.MaxReadBytes > maxJobExecutionContentSize {
|
|
violations = append(violations, "executionInput.maxReadBytes is out of bounds")
|
|
}
|
|
if job.ExecutionInput.ExpectedChecksum != "" && !validSHA256Checksum(job.ExecutionInput.ExpectedChecksum) {
|
|
violations = append(violations, "executionInput.expectedChecksum must be sha256:<hex>")
|
|
}
|
|
if len(job.ExecutionInput.DLLExtensions) > 16 {
|
|
violations = append(violations, "executionInput.dllExtensions must not exceed 16")
|
|
}
|
|
if len(job.ExecutionInput.DLLExtensions) > 0 && (job.Capability != domain.LifecycleCapabilityStart || job.ExecutionInput.LifecycleOperation != "start" || job.ServerInstanceID == "") {
|
|
violations = append(violations, "executionInput.dllExtensions are allowed only for scoped process.start jobs")
|
|
}
|
|
for i, plan := range job.ExecutionInput.DLLExtensions {
|
|
violations = append(violations, validateRuntimeDLLExtensionPlan(fmt.Sprintf("executionInput.dllExtensions[%d]", i), plan)...)
|
|
}
|
|
if job.ExecutionInput.LogSource != nil {
|
|
violations = append(violations, validateRuntimeLogSourcePlanForJob("executionInput.logSource", job.ExecutionInput.LogSource)...)
|
|
if job.Capability != domain.JobCapabilityLogsBackfill || job.ServerInstanceID == "" {
|
|
violations = append(violations, "executionInput.logSource is allowed only for logs.backfill jobs")
|
|
}
|
|
}
|
|
if len(job.ExecutionInput.LogSources) > 0 {
|
|
if job.Capability != domain.LifecycleCapabilityStart || job.ExecutionInput.LifecycleOperation != "start" || job.ServerInstanceID == "" {
|
|
violations = append(violations, "executionInput.logSources are allowed only for scoped process.start jobs")
|
|
}
|
|
seenKinds := map[string]struct{}{}
|
|
for i, source := range job.ExecutionInput.LogSources {
|
|
prefix := fmt.Sprintf("executionInput.logSources[%d]", i)
|
|
violations = append(violations, validateRuntimeProcessLogSourcePlanForJob(prefix, source)...)
|
|
if _, exists := seenKinds[source.Kind]; exists {
|
|
violations = append(violations, "executionInput.logSources kind is duplicated")
|
|
}
|
|
seenKinds[source.Kind] = struct{}{}
|
|
}
|
|
}
|
|
if job.ExecutionInput.SourceRCON != nil {
|
|
violations = append(violations, validateRuntimeSourceRCONPlan("executionInput.sourceRcon", job.ExecutionInput.SourceRCON)...)
|
|
isSourceCommand := job.Capability == domain.JobCapabilityRemoteRunRCONCommand
|
|
isProtectedRCON := job.Capability == domain.JobCapabilityRemoteRunProtectedRCON
|
|
wantAdapterKind := "rcon"
|
|
if isProtectedRCON {
|
|
wantAdapterKind = "protected-rcon"
|
|
}
|
|
if (!isSourceCommand && !isProtectedRCON) || job.ExecutionInput.RemoteAdapterKind != wantAdapterKind {
|
|
violations = append(violations, "executionInput.sourceRcon is allowed only for rcon jobs")
|
|
}
|
|
if job.RetryPolicy.MaxAttempts != 1 {
|
|
violations = append(violations, "executionInput.sourceRcon jobs must have one attempt")
|
|
}
|
|
if len(job.ExecutionInput.Inputs) != 0 {
|
|
violations = append(violations, "executionInput.sourceRcon must not persist adapter inputs")
|
|
}
|
|
}
|
|
violations = append(violations, validateRemoteAdapterInputs("executionInput.inputs", job.ExecutionInput.Inputs)...)
|
|
if job.ExecutionResult.Checksum != "" && !validSHA256Checksum(job.ExecutionResult.Checksum) {
|
|
violations = append(violations, "executionResult.checksum must be sha256:<hex>")
|
|
}
|
|
if len([]byte(job.ExecutionResult.Content)) > maxJobExecutionContentSize {
|
|
violations = append(violations, "executionResult.content is too large")
|
|
}
|
|
if len(job.ExecutionResult.AuditSummary) > maxAuditSummaryLength {
|
|
violations = append(violations, "executionResult.auditSummary is too long")
|
|
}
|
|
if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite {
|
|
if job.ServerInstanceID == "" {
|
|
violations = append(violations, "serverInstanceId is required for scoped file jobs")
|
|
}
|
|
if job.TargetKey == "" {
|
|
violations = append(violations, "targetKey is required for scoped file jobs")
|
|
}
|
|
}
|
|
if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesWrite {
|
|
if job.InputRef == "" {
|
|
violations = append(violations, "inputRef is required for scoped write jobs")
|
|
}
|
|
}
|
|
if isRemoteRunCapability(job.Capability) {
|
|
if job.ServerInstanceID == "" {
|
|
violations = append(violations, "serverInstanceId is required for remote access jobs")
|
|
}
|
|
if remoteCapabilityRequiresTargetKey(job.Capability) && job.TargetKey == "" {
|
|
violations = append(violations, "targetKey is required for remote access jobs")
|
|
}
|
|
if remoteCapabilityRequiresInputRef(job.Capability) && job.InputRef == "" {
|
|
violations = append(violations, "inputRef is required for remote access jobs")
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func validateRuntimeLogSourcePlanForJob(prefix string, source *domain.RuntimeLogSource) []string {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
var violations []string
|
|
violations = append(violations, validateProfileKey(prefix+".key", source.Key)...)
|
|
violations = append(violations, validateProfileKey(prefix+".targetKey", source.TargetKey)...)
|
|
violations = append(violations, validateProfileKey(prefix+".streamKey", source.StreamKey)...)
|
|
if source.Kind != "file.tail" {
|
|
violations = append(violations, prefix+".kind must be file.tail")
|
|
}
|
|
if source.CursorKind != "" && !oneOf(source.CursorKind, "offset", "fingerprint") {
|
|
violations = append(violations, prefix+".cursorKind is invalid")
|
|
}
|
|
if source.RetentionDays < 0 || source.RetentionDays > 365 {
|
|
violations = append(violations, prefix+".retentionDays is invalid")
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validateRuntimeProcessLogSourcePlanForJob(prefix string, source domain.RuntimeLogSource) []string {
|
|
var violations []string
|
|
violations = append(violations, validateProfileKey(prefix+".key", source.Key)...)
|
|
if source.TargetKey != "" {
|
|
violations = append(violations, validateProfileKey(prefix+".targetKey", source.TargetKey)...)
|
|
}
|
|
violations = append(violations, validateProfileKey(prefix+".streamKey", source.StreamKey)...)
|
|
if source.Kind != "process.stdout" && source.Kind != "process.stderr" {
|
|
violations = append(violations, prefix+".kind must be process.stdout or process.stderr")
|
|
}
|
|
if source.CursorKind != "" && source.CursorKind != "sequence" {
|
|
violations = append(violations, prefix+".cursorKind is invalid")
|
|
}
|
|
if source.RetentionDays < 0 || source.RetentionDays > 365 {
|
|
violations = append(violations, prefix+".retentionDays is invalid")
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func ValidateArtifact(artifact domain.Artifact) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "id", artifact.ID)
|
|
violations = appendRequired(violations, "ownerId", artifact.OwnerID)
|
|
violations = appendRequired(violations, "checksum", artifact.Checksum)
|
|
if !validArtifactOwnerKind(artifact.OwnerKind) {
|
|
violations = append(violations, "ownerKind is invalid")
|
|
}
|
|
if !validArtifactState(artifact.State) {
|
|
violations = append(violations, "state is invalid")
|
|
}
|
|
if artifact.SizeBytes < 0 {
|
|
violations = append(violations, "sizeBytes must not be negative")
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateLogStream(stream domain.LogStream) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "id", stream.ID)
|
|
violations = appendRequired(violations, "serverInstanceId", stream.ServerInstanceID)
|
|
violations = appendRequired(violations, "streamKey", stream.StreamKey)
|
|
violations = appendRequired(violations, "retentionPolicy", stream.RetentionPolicy)
|
|
if !validLogStreamSource(stream.Source) {
|
|
violations = append(violations, "source is invalid")
|
|
}
|
|
if !validLogStorageBackend(stream.StorageBackend) {
|
|
violations = append(violations, "storageBackend is invalid")
|
|
}
|
|
hasSessionID := strings.TrimSpace(stream.LogSessionID) != ""
|
|
hasSessionStart := !stream.SessionStartedAt.IsZero()
|
|
if hasSessionID != hasSessionStart {
|
|
violations = append(violations, "logSessionId and sessionStartedAt must be provided together")
|
|
}
|
|
if (hasSessionID || hasSessionStart) && stream.Source != domain.LogStreamSourceProcess {
|
|
violations = append(violations, "log session metadata is only valid for process streams")
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateAuditEvent(event domain.AuditEvent) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "id", event.ID)
|
|
violations = appendRequired(violations, "actorId", event.ActorID)
|
|
violations = appendRequired(violations, "action", event.Action)
|
|
violations = appendRequired(violations, "resourceKind", event.ResourceKind)
|
|
violations = appendRequired(violations, "resourceId", event.ResourceID)
|
|
violations = appendRequired(violations, "summary", event.Summary)
|
|
if !validAuditResult(event.Result) {
|
|
violations = append(violations, "result is invalid")
|
|
}
|
|
if len(event.Summary) > maxAuditSummaryLength {
|
|
violations = append(violations, "summary is too long")
|
|
}
|
|
if looksLikeRawSecret(event.Summary) {
|
|
violations = append(violations, "summary must be redacted")
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func MissingCapabilities(actual []string, required []string) []string {
|
|
actualSet := make(map[string]struct{}, len(actual))
|
|
for _, capability := range actual {
|
|
actualSet[capability] = struct{}{}
|
|
}
|
|
var missing []string
|
|
for _, capability := range required {
|
|
if _, exists := actualSet[capability]; !exists {
|
|
missing = append(missing, capability)
|
|
}
|
|
}
|
|
return missing
|
|
}
|
|
|
|
type fieldString struct {
|
|
field string
|
|
value string
|
|
}
|
|
|
|
func duplicateViolations(field string, values []string) []string {
|
|
seen := map[string]struct{}{}
|
|
var violations []string
|
|
for i, value := range values {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
violations = append(violations, fmt.Sprintf("%s[%d] is required", field, i))
|
|
continue
|
|
}
|
|
if _, exists := seen[trimmed]; exists {
|
|
violations = append(violations, fmt.Sprintf("%s[%d] duplicates an earlier value", field, i))
|
|
}
|
|
seen[trimmed] = struct{}{}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validateDeclaredPluginPermissions(permissions []string) []string {
|
|
var violations []string
|
|
for i, permission := range permissions {
|
|
if !validPluginPermission(permission) {
|
|
violations = append(violations, fmt.Sprintf("permissions[%d] is not allowed", i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations("permissions", permissions)...)
|
|
return violations
|
|
}
|
|
|
|
func validateLifecycleActions(actions domain.PluginLifecycleActions) []string {
|
|
var violations []string
|
|
required := []fieldString{
|
|
{field: "actions.install", value: actions.Install},
|
|
{field: "actions.start", value: actions.Start},
|
|
{field: "actions.stop", value: actions.Stop},
|
|
}
|
|
for _, action := range required {
|
|
violations = appendRequired(violations, action.field, action.value)
|
|
}
|
|
for _, action := range []fieldString{
|
|
{field: "actions.install", value: actions.Install},
|
|
{field: "actions.start", value: actions.Start},
|
|
{field: "actions.stop", value: actions.Stop},
|
|
{field: "actions.restart", value: actions.Restart},
|
|
{field: "actions.status", value: actions.Status},
|
|
} {
|
|
if strings.TrimSpace(action.value) != "" && !safeRelativeJSONRef(action.value) {
|
|
violations = append(violations, action.field+" must be a safe relative JSON reference")
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validatePluginPages(pages []domain.GamePluginPage) []string {
|
|
var violations []string
|
|
seenKeys := map[string]struct{}{}
|
|
for i, page := range pages {
|
|
prefix := fmt.Sprintf("pages[%d]", i)
|
|
violations = appendRequired(violations, prefix+".key", page.Key)
|
|
violations = appendRequired(violations, prefix+".title", page.Title)
|
|
violations = appendRequired(violations, prefix+".path", page.Path)
|
|
if len(page.Title) > maxPluginPageTitleLength {
|
|
violations = append(violations, prefix+".title is too long")
|
|
}
|
|
if !validPluginPagePath(page.Path) {
|
|
violations = append(violations, prefix+".path is invalid")
|
|
}
|
|
if page.Bundle.Key != "" || page.Bundle.Version != "" || page.Bundle.IntegritySHA256 != "" {
|
|
violations = appendRequired(violations, prefix+".bundle.key", page.Bundle.Key)
|
|
violations = appendRequired(violations, prefix+".bundle.version", page.Bundle.Version)
|
|
violations = appendRequired(violations, prefix+".bundle.integritySha256", page.Bundle.IntegritySHA256)
|
|
if !validDistributionLogicalKey(page.Bundle.Key) || !validPluginPageBundleVersion(page.Bundle.Version) || !validPluginPageBundleIntegrity(page.Bundle.IntegritySHA256) {
|
|
violations = append(violations, prefix+".bundle is invalid")
|
|
}
|
|
}
|
|
if page.Key != "" {
|
|
if _, exists := seenKeys[page.Key]; exists {
|
|
violations = append(violations, prefix+".key duplicates another page")
|
|
}
|
|
seenKeys[page.Key] = struct{}{}
|
|
}
|
|
for permissionIndex, permission := range page.Permissions {
|
|
if !validPluginPermission(permission) {
|
|
violations = append(violations, fmt.Sprintf("%s.permissions[%d] is not allowed", prefix, permissionIndex))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(prefix+".permissions", page.Permissions)...)
|
|
violations = append(violations, validateBridgeActions(prefix+".bridgeActions", page.BridgeActions)...)
|
|
for _, key := range page.FeatureKeys {
|
|
if !clientManagerIdentifierPattern.MatchString(key) {
|
|
violations = append(violations, prefix+".featureKeys contains an invalid feature key")
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(prefix+".featureKeys", page.FeatureKeys)...)
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validPluginPageBundleVersion(value string) bool {
|
|
if len(value) == 0 || len(value) > 80 {
|
|
return false
|
|
}
|
|
for _, item := range value {
|
|
if !(item >= 'a' && item <= 'z' || item >= 'A' && item <= 'Z' || item >= '0' && item <= '9' || item == '.' || item == '_' || item == '-') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validPluginPageBundleIntegrity(value string) bool {
|
|
if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") {
|
|
return false
|
|
}
|
|
for _, item := range value[len("sha256:"):] {
|
|
if !(item >= 'a' && item <= 'f' || item >= '0' && item <= '9') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validatePluginFileWorkspace(prefix string, workspace domain.PluginFileWorkspace) []string {
|
|
if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) == 0 && len(workspace.Files) == 0 && len(workspace.ConfigFields) == 0 {
|
|
return nil
|
|
}
|
|
var violations []string
|
|
directories := map[string]bool{}
|
|
files := map[string]domain.PluginLogicalFile{}
|
|
for i, item := range workspace.Directories {
|
|
field := fmt.Sprintf("%s.directories[%d]", prefix, i)
|
|
if !validDistributionLogicalKey(item.Key) || item.Label == "" || !oneOf(item.Scope, "config", "logs") {
|
|
violations = append(violations, field+" is invalid")
|
|
}
|
|
if directories[item.Key] {
|
|
violations = append(violations, field+".key duplicates another directory")
|
|
}
|
|
directories[item.Key] = true
|
|
}
|
|
if !directories[workspace.DefaultDirectoryKey] {
|
|
violations = append(violations, prefix+".defaultDirectoryKey must reference a declared directory")
|
|
}
|
|
for i, item := range workspace.Files {
|
|
field := fmt.Sprintf("%s.files[%d]", prefix, i)
|
|
if !validDistributionLogicalKey(item.Key) || !directories[item.DirectoryKey] || item.Label == "" || !oneOf(item.Kind, "config", "log") || (item.Kind == "log" && item.StreamKey == "") {
|
|
violations = append(violations, field+" is invalid")
|
|
}
|
|
if _, exists := files[item.Key]; exists {
|
|
violations = append(violations, field+".key duplicates another file")
|
|
}
|
|
files[item.Key] = item
|
|
}
|
|
for i, item := range workspace.ConfigFields {
|
|
field := fmt.Sprintf("%s.configFields[%d]", prefix, i)
|
|
file, exists := files[item.FileKey]
|
|
if !validDistributionLogicalKey(item.Key) || !exists || file.Kind != "config" || !file.Editable || item.ConfigKey == "" || item.Label == "" || item.Description == "" || !oneOf(item.Control, "text", "number", "boolean", "port") || !oneOf(item.RestartImpact, "none", "restart-required") || (item.Minimum > 0 && item.Maximum > 0 && item.Minimum > item.Maximum) {
|
|
violations = append(violations, field+" is invalid")
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validateBridgeActions(field string, actions []string) []string {
|
|
var violations []string
|
|
for i, action := range actions {
|
|
if !validPluginBridgeAction(domain.PluginBridgeAction(action)) {
|
|
violations = append(violations, fmt.Sprintf("%s[%d] is not supported", field, i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(field, actions)...)
|
|
return violations
|
|
}
|
|
|
|
func validateAIPurposes(purposes []string) []string {
|
|
var violations []string
|
|
for i, purpose := range purposes {
|
|
if !validAIPurpose(purpose) {
|
|
violations = append(violations, fmt.Sprintf("aiPurposes[%d] is not allowed", i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations("aiPurposes", purposes)...)
|
|
return violations
|
|
}
|
|
|
|
func validateRemoteAccess(field string, remote domain.GamePluginRemoteAccess, declaredCapabilities []string) []string {
|
|
var violations []string
|
|
if len(remote.Methods) == 0 && len(remote.RunCapabilities) == 0 && len(remote.DatabaseEngines) == 0 && !remote.RCON && !remote.LogTransfer {
|
|
return violations
|
|
}
|
|
if len(remote.Methods) == 0 {
|
|
violations = append(violations, field+".methods must not be empty when remote access is declared")
|
|
}
|
|
for i, method := range remote.Methods {
|
|
if !validRemoteAccessMethod(method) {
|
|
violations = append(violations, fmt.Sprintf("%s.methods[%d] is not allowed", field, i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(field+".methods", remote.Methods)...)
|
|
for i, capability := range remote.RunCapabilities {
|
|
if !validPluginRunCapability(capability) || !isRemoteRunCapability(capability) {
|
|
violations = append(violations, fmt.Sprintf("%s.runCapabilities[%d] is not allowed", field, i))
|
|
continue
|
|
}
|
|
if !containsString(declaredCapabilities, capability) {
|
|
violations = append(violations, fmt.Sprintf("%s.runCapabilities[%d] must also be declared in capabilities", field, i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(field+".runCapabilities", remote.RunCapabilities)...)
|
|
for i, engine := range remote.DatabaseEngines {
|
|
if !validRemoteDatabaseEngine(engine) {
|
|
violations = append(violations, fmt.Sprintf("%s.databaseEngines[%d] is not allowed", field, i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(field+".databaseEngines", remote.DatabaseEngines)...)
|
|
if containsString(remote.Methods, "run") && len(remote.RunCapabilities) == 0 {
|
|
violations = append(violations, field+".runCapabilities must not be empty when run access is declared")
|
|
}
|
|
if containsString(remote.Methods, "ftp") && !containsAny(declaredCapabilities, []string{domain.JobCapabilityRemoteFTPRead, domain.JobCapabilityRemoteFTPWrite}) {
|
|
violations = append(violations, field+" requires remote.ftp.read or remote.ftp.write when ftp is declared")
|
|
}
|
|
if containsString(remote.Methods, "rsync") && !containsAny(declaredCapabilities, []string{domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite}) {
|
|
violations = append(violations, field+" requires remote.rsync.read or remote.rsync.write when rsync is declared")
|
|
}
|
|
if remote.RCON && !containsString(remote.RunCapabilities, domain.JobCapabilityRemoteRunRCONCommand) {
|
|
violations = append(violations, field+".rcon requires remote.run.rcon.command")
|
|
}
|
|
if remote.LogTransfer && !containsString(remote.RunCapabilities, domain.JobCapabilityRemoteRunLogsTransfer) {
|
|
violations = append(violations, field+".logTransfer requires remote.run.logs.transfer")
|
|
}
|
|
for _, engine := range remote.DatabaseEngines {
|
|
required := domain.JobCapabilityRemoteRunDBMySQLQuery
|
|
if engine == "sqlite" {
|
|
required = domain.JobCapabilityRemoteRunDBSQLiteQuery
|
|
}
|
|
if !containsString(remote.RunCapabilities, required) {
|
|
violations = append(violations, fmt.Sprintf("%s.databaseEngines requires %s", field, required))
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validateSafePluginStrings(prefix string, values []fieldString) []string {
|
|
var violations []string
|
|
for _, value := range values {
|
|
for _, reason := range unsafePluginStringReasons(value.value) {
|
|
violations = append(violations, prefix+"."+value.field+": "+reason)
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func pluginSafeStrings(plugin domain.GamePlugin) []fieldString {
|
|
values := []fieldString{
|
|
{field: "id", value: plugin.ID},
|
|
{field: "name", value: plugin.Name},
|
|
{field: "description", value: plugin.Description},
|
|
{field: "serverType", value: plugin.ServerType},
|
|
{field: "serverDisplayName", value: plugin.ServerDisplayName},
|
|
{field: "manifestRef", value: plugin.ManifestRef},
|
|
{field: "createFormSchemaRef", value: plugin.CreateFormSchemaRef},
|
|
{field: "actions.install", value: plugin.LifecycleActions.Install},
|
|
{field: "actions.start", value: plugin.LifecycleActions.Start},
|
|
{field: "actions.stop", value: plugin.LifecycleActions.Stop},
|
|
{field: "actions.restart", value: plugin.LifecycleActions.Restart},
|
|
{field: "actions.status", value: plugin.LifecycleActions.Status},
|
|
}
|
|
values = appendStringSliceFields(values, "supportedOs", plugin.SupportedOS)
|
|
values = appendStringSliceFields(values, "requiredRunCapabilities", plugin.RequiredRunCapabilities)
|
|
values = appendStringSliceFields(values, "declaredPermissions", plugin.DeclaredPermissions)
|
|
values = appendStringSliceFields(values, "tags", plugin.Tags)
|
|
values = appendStringSliceFields(values, "aiPurposes", plugin.AIPurposes)
|
|
values = appendStringSliceFields(values, "productionLifecycle.operations", plugin.ProductionLifecycle.Operations)
|
|
values = appendStringSliceFields(values, "productionLifecycle.approvalRequired", plugin.ProductionLifecycle.ApprovalRequired)
|
|
values = append(values, fieldString{field: "productionLifecycle.dependencyPolicy", value: plugin.ProductionLifecycle.DependencyPolicy})
|
|
values = appendStringSliceFields(values, "bridgeActions", plugin.BridgeActions)
|
|
values = appendStringSliceFields(values, "remoteAccess.methods", plugin.RemoteAccess.Methods)
|
|
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", plugin.RemoteAccess.RunCapabilities)
|
|
values = appendStringSliceFields(values, "remoteAccess.databaseEngines", plugin.RemoteAccess.DatabaseEngines)
|
|
for i, file := range plugin.LifecycleAssets {
|
|
values = append(values, fieldString{field: fmt.Sprintf("lifecycleAssets[%d].path", i), value: file.Path})
|
|
}
|
|
for i, page := range plugin.Pages {
|
|
prefix := fmt.Sprintf("pages[%d]", i)
|
|
values = append(values,
|
|
fieldString{field: prefix + ".key", value: page.Key},
|
|
fieldString{field: prefix + ".title", value: page.Title},
|
|
fieldString{field: prefix + ".path", value: page.Path},
|
|
)
|
|
values = appendStringSliceFields(values, prefix+".permissions", page.Permissions)
|
|
values = appendStringSliceFields(values, prefix+".bridgeActions", page.BridgeActions)
|
|
}
|
|
return values
|
|
}
|
|
|
|
func manifestSafeStrings(registration domain.GamePluginManifestRegistration) []fieldString {
|
|
manifest := registration.Manifest
|
|
values := []fieldString{
|
|
{field: "manifestRef", value: registration.ManifestRef},
|
|
{field: "id", value: manifest.ID},
|
|
{field: "name", value: manifest.Name},
|
|
{field: "description", value: manifest.Description},
|
|
{field: "kind", value: manifest.Kind},
|
|
{field: "server.type", value: manifest.Server.Type},
|
|
{field: "server.displayName", value: manifest.Server.DisplayName},
|
|
{field: "server.createFormSchema", value: manifest.Server.CreateFormSchema},
|
|
{field: "actions.install", value: manifest.Actions.Install},
|
|
{field: "actions.start", value: manifest.Actions.Start},
|
|
{field: "actions.stop", value: manifest.Actions.Stop},
|
|
{field: "actions.restart", value: manifest.Actions.Restart},
|
|
{field: "actions.status", value: manifest.Actions.Status},
|
|
}
|
|
values = appendStringSliceFields(values, "tags", manifest.Tags)
|
|
values = appendStringSliceFields(values, "server.supportedOs", manifest.Server.SupportedOS)
|
|
values = appendStringSliceFields(values, "bridge.actions", manifest.Bridge.Actions)
|
|
values = appendStringSliceFields(values, "capabilities", manifest.Capabilities)
|
|
values = appendStringSliceFields(values, "permissions", manifest.Permissions)
|
|
values = appendStringSliceFields(values, "ai.purposes", manifest.AI.Purposes)
|
|
values = append(values, fieldString{field: "ai.mediation", value: manifest.AI.Mediation}, fieldString{field: "ai.configWritePolicy", value: manifest.AI.ConfigWritePolicy}, fieldString{field: "productionLifecycle.dependencyPolicy", value: manifest.ProductionLifecycle.DependencyPolicy})
|
|
values = appendStringSliceFields(values, "productionLifecycle.operations", manifest.ProductionLifecycle.Operations)
|
|
values = appendStringSliceFields(values, "productionLifecycle.approvalRequired", manifest.ProductionLifecycle.ApprovalRequired)
|
|
values = appendStringSliceFields(values, "remoteAccess.methods", manifest.RemoteAccess.Methods)
|
|
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", manifest.RemoteAccess.RunCapabilities)
|
|
values = appendStringSliceFields(values, "remoteAccess.databaseEngines", manifest.RemoteAccess.DatabaseEngines)
|
|
for i, file := range manifest.AssetFiles {
|
|
values = append(values, fieldString{field: fmt.Sprintf("assetFiles[%d].path", i), value: file.Path})
|
|
}
|
|
for i, page := range manifest.Pages {
|
|
prefix := fmt.Sprintf("pages[%d]", i)
|
|
values = append(values,
|
|
fieldString{field: prefix + ".key", value: page.Key},
|
|
fieldString{field: prefix + ".title", value: page.Title},
|
|
fieldString{field: prefix + ".path", value: page.Path},
|
|
)
|
|
values = appendStringSliceFields(values, prefix+".permissions", page.Permissions)
|
|
values = appendStringSliceFields(values, prefix+".bridgeActions", page.BridgeActions)
|
|
}
|
|
return values
|
|
}
|
|
|
|
func marketplacePluginSafeStrings(plugin domain.PluginMarketplacePlugin) []fieldString {
|
|
values := []fieldString{
|
|
{field: "id", value: plugin.ID},
|
|
{field: "name", value: plugin.Name},
|
|
{field: "description", value: plugin.Description},
|
|
{field: "serverType", value: plugin.ServerType},
|
|
{field: "serverDisplayName", value: plugin.ServerDisplayName},
|
|
{field: "manifestRef", value: plugin.ManifestRef},
|
|
{field: "createFormSchemaRef", value: plugin.CreateFormSchemaRef},
|
|
{field: "actions.install", value: plugin.LifecycleActions.Install},
|
|
{field: "actions.start", value: plugin.LifecycleActions.Start},
|
|
{field: "actions.stop", value: plugin.LifecycleActions.Stop},
|
|
{field: "actions.restart", value: plugin.LifecycleActions.Restart},
|
|
{field: "actions.status", value: plugin.LifecycleActions.Status},
|
|
{field: "source", value: plugin.Source},
|
|
}
|
|
values = appendStringSliceFields(values, "supportedOs", plugin.SupportedOS)
|
|
values = appendStringSliceFields(values, "capabilities", plugin.Capabilities)
|
|
values = appendStringSliceFields(values, "declaredPermissions", plugin.DeclaredPermissions)
|
|
values = appendStringSliceFields(values, "tags", plugin.Tags)
|
|
values = appendStringSliceFields(values, "aiPurposes", plugin.AIPurposes)
|
|
values = appendStringSliceFields(values, "bridgeActions", plugin.BridgeActions)
|
|
values = appendStringSliceFields(values, "remoteAccess.methods", plugin.RemoteAccess.Methods)
|
|
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", plugin.RemoteAccess.RunCapabilities)
|
|
values = appendStringSliceFields(values, "remoteAccess.databaseEngines", plugin.RemoteAccess.DatabaseEngines)
|
|
for i, page := range plugin.Pages {
|
|
prefix := fmt.Sprintf("pages[%d]", i)
|
|
values = append(values,
|
|
fieldString{field: prefix + ".key", value: page.Key},
|
|
fieldString{field: prefix + ".title", value: page.Title},
|
|
fieldString{field: prefix + ".path", value: page.Path},
|
|
)
|
|
values = appendStringSliceFields(values, prefix+".permissions", page.Permissions)
|
|
values = appendStringSliceFields(values, prefix+".bridgeActions", page.BridgeActions)
|
|
}
|
|
return values
|
|
}
|
|
|
|
func appendStringSliceFields(values []fieldString, field string, items []string) []fieldString {
|
|
for i, item := range items {
|
|
values = append(values, fieldString{field: fmt.Sprintf("%s[%d]", field, i), value: item})
|
|
}
|
|
return values
|
|
}
|
|
|
|
func unsafePluginStringReasons(value string) []string {
|
|
trimmed := strings.TrimSpace(value)
|
|
lowered := strings.ToLower(trimmed)
|
|
if trimmed == "" {
|
|
return nil
|
|
}
|
|
var reasons []string
|
|
if looksLikeRawSecret(trimmed) || strings.Contains(lowered, "raw api key") || strings.Contains(lowered, "provider key") || strings.Contains(lowered, "ai key") || strings.Contains(lowered, "raw credential") {
|
|
reasons = append(reasons, "raw credential or AI/provider key content is not allowed")
|
|
}
|
|
if strings.Contains(lowered, "direct run") || strings.Contains(lowered, "run socket") || strings.Contains(lowered, "run credential") || strings.Contains(lowered, "run token") || strings.Contains(lowered, "direct socket") {
|
|
reasons = append(reasons, "direct run access request is not allowed")
|
|
}
|
|
if strings.HasPrefix(lowered, "file://") || strings.HasPrefix(trimmed, `\\`) || looksLikeRawHostPath(trimmed) || strings.Contains(lowered, "host path") || strings.Contains(lowered, "raw host path") {
|
|
reasons = append(reasons, "raw host path access is not allowed")
|
|
}
|
|
return reasons
|
|
}
|
|
|
|
func looksLikeRawHostPath(value string) bool {
|
|
if len(value) >= 3 && ((value[1] == ':' && value[2] == '\\') || (value[1] == ':' && value[2] == '/')) {
|
|
return true
|
|
}
|
|
lowered := strings.ToLower(value)
|
|
for _, prefix := range []string{"/users/", "/etc/", "/var/", "/tmp/", "/home/", "/root/", "/private/", "/volumes/", "/opt/"} {
|
|
if strings.HasPrefix(lowered, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func safeRef(value string) bool {
|
|
trimmed := strings.TrimSpace(value)
|
|
return strings.HasPrefix(trimmed, "artifact://") || strings.HasPrefix(trimmed, "manifest://") || safeRelativeJSONRef(trimmed)
|
|
}
|
|
|
|
func safeRelativeJSONRef(value string) bool {
|
|
trimmed := strings.TrimSpace(value)
|
|
lowered := strings.ToLower(trimmed)
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "/") || strings.Contains(trimmed, "..") || strings.Contains(trimmed, "://") || strings.Contains(trimmed, `\`) || !strings.HasSuffix(lowered, ".json") {
|
|
return false
|
|
}
|
|
if len(trimmed) >= 2 && trimmed[1] == ':' {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func safeRelativeSQLRef(value string) bool {
|
|
trimmed := strings.TrimSpace(value)
|
|
lowered := strings.ToLower(trimmed)
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "/") || strings.Contains(trimmed, "..") || strings.Contains(trimmed, "://") || strings.Contains(trimmed, `\`) || !strings.HasSuffix(lowered, ".sql") {
|
|
return false
|
|
}
|
|
return len(trimmed) < 2 || trimmed[1] != ':'
|
|
}
|
|
|
|
func looksLikeRawSecret(value string) bool {
|
|
trimmed := strings.TrimSpace(strings.ToLower(value))
|
|
if trimmed == "" {
|
|
return false
|
|
}
|
|
if strings.HasPrefix(trimmed, "secret://") || strings.HasPrefix(trimmed, "vault://") || strings.HasPrefix(trimmed, "env://") {
|
|
return false
|
|
}
|
|
return strings.HasPrefix(trimmed, "sk-") ||
|
|
strings.HasPrefix(trimmed, "sk_") ||
|
|
strings.Contains(trimmed, "api_key=") ||
|
|
strings.Contains(trimmed, "apikey=") ||
|
|
strings.Contains(trimmed, "bearer ")
|
|
}
|
|
|
|
func validUserStatus(status domain.UserStatus) bool {
|
|
switch status {
|
|
case domain.UserStatusActive, domain.UserStatusDisabled, domain.UserStatusPending:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validUserRole(role string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(role)) {
|
|
case "admin", "platform-admin", "platformadmin", "server-owner", "owner", "server-admin", "operator":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validAIProviderKind(kind domain.AIProviderKind) bool {
|
|
switch kind {
|
|
case domain.AIProviderKindOpenAICompatible, domain.AIProviderKindOpenAI, domain.AIProviderKindClaude, domain.AIProviderKindGemini, domain.AIProviderKindOllama, domain.AIProviderKindCustom:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validAIRelayMode(mode domain.AIRelayMode) bool {
|
|
switch mode {
|
|
case domain.AIRelayModeDirect, domain.AIRelayModeRelay, domain.AIRelayModeLocal:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validAIProviderStatus(status domain.AIProviderStatus) bool {
|
|
switch status {
|
|
case domain.AIProviderStatusActive, domain.AIProviderStatusDisabled, domain.AIProviderStatusError:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validGamePluginStatus(status domain.GamePluginStatus) bool {
|
|
switch status {
|
|
case domain.GamePluginStatusInstalled, domain.GamePluginStatusDisabled, domain.GamePluginStatusInvalid, domain.GamePluginStatusUpdating:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validPluginMarketplaceStateAction(action domain.PluginMarketplaceStateAction) bool {
|
|
switch action {
|
|
case domain.PluginMarketplaceStateActionInstall, domain.PluginMarketplaceStateActionEnable, domain.PluginMarketplaceStateActionDisable:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validPluginRunCapability(capability string) bool {
|
|
switch capability {
|
|
case "process.install", "process.start", "process.stop", "process.restart", "process.status",
|
|
"config.write",
|
|
"files.list", "files.read", "files.write", "files.patch",
|
|
"file.list", "file.read", "file.write", "file.patch",
|
|
"logs.read", "log.query", domain.JobCapabilityLogsBackfill,
|
|
domain.JobCapabilityRemoteFTPRead, domain.JobCapabilityRemoteFTPWrite,
|
|
domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite,
|
|
domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite,
|
|
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
|
|
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
|
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
|
|
domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram,
|
|
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
|
|
domain.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd,
|
|
domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate,
|
|
domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall,
|
|
"artifacts.read", "artifacts.write", "artifact.read", "artifact.write",
|
|
"ai.invoke":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isRemoteRunCapability(capability string) bool {
|
|
return strings.HasPrefix(capability, "remote.")
|
|
}
|
|
|
|
func remoteCapabilityRequiresTargetKey(capability string) bool {
|
|
switch capability {
|
|
case domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop:
|
|
return false
|
|
default:
|
|
return isRemoteRunCapability(capability)
|
|
}
|
|
}
|
|
|
|
func remoteCapabilityRequiresInputRef(capability string) bool {
|
|
switch capability {
|
|
case domain.JobCapabilityRemoteFTPWrite,
|
|
domain.JobCapabilityRemoteRsyncWrite,
|
|
domain.JobCapabilityRemoteRunFilesWrite,
|
|
domain.JobCapabilityRemoteRunDBMySQLQuery,
|
|
domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
|
domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL,
|
|
domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validRemoteAccessMethod(method string) bool {
|
|
switch method {
|
|
case "ftp", "rsync", "run":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validRemoteDatabaseEngine(engine string) bool {
|
|
switch engine {
|
|
case "mysql", "sqlite":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validFileOperationKind(operation domain.FileOperationKind) bool {
|
|
switch operation {
|
|
case domain.FileOperationRead, domain.FileOperationWrite:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validConfigFileKey(key string) bool {
|
|
switch key {
|
|
case "server.properties", "config/server.properties":
|
|
return true
|
|
default:
|
|
return strings.HasPrefix(key, "config/") && (strings.HasSuffix(key, ".properties") || strings.HasSuffix(key, ".json"))
|
|
}
|
|
}
|
|
|
|
func validLogicalFileKey(key string) bool {
|
|
trimmed := strings.TrimSpace(key)
|
|
if trimmed == "" || trimmed != key || len([]rune(key)) > maxLogicalFileKeyLength {
|
|
return false
|
|
}
|
|
if strings.HasPrefix(key, "/") || strings.Contains(key, "..") || strings.Contains(key, `\`) || strings.Contains(key, "://") || looksLikeRawHostPath(key) || containsUnsafeRuntimeSecret(key) {
|
|
return false
|
|
}
|
|
for _, char := range key {
|
|
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == '/' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validScopedInputRef(ref string) bool {
|
|
trimmed := strings.TrimSpace(ref)
|
|
if trimmed == "" || trimmed != ref || containsUnsafeRuntimeSecret(ref) || looksLikeRawHostPath(ref) {
|
|
return false
|
|
}
|
|
return strings.HasPrefix(ref, "input://") || strings.HasPrefix(ref, "artifact://")
|
|
}
|
|
|
|
func validPluginPermission(permission string) bool {
|
|
switch permission {
|
|
case "server.create", "server.read", "server.lifecycle", "server.files.read", "server.files.write", "server.logs.read", "server.artifacts.read", "server.artifacts.write", "server.remote.access", "server.run.distribution", "server.dependencies.manage", "server.client-manager.manage", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "ai.invoke":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validPluginBridgeAction(action domain.PluginBridgeAction) bool {
|
|
switch action {
|
|
case domain.PluginBridgeActionServerInstancesRead,
|
|
domain.PluginBridgeActionJobsDispatch,
|
|
domain.PluginBridgeActionLogsQuery,
|
|
domain.PluginBridgeActionArtifactsOpen,
|
|
domain.PluginBridgeActionFilesRequest,
|
|
domain.PluginBridgeActionRemoteAccessRequest,
|
|
domain.PluginBridgeActionRunDistribution,
|
|
domain.PluginBridgeActionDependenciesRequest,
|
|
domain.PluginBridgeActionLogsBackfillRequest,
|
|
domain.PluginBridgeActionClientManager,
|
|
domain.PluginBridgeActionPluginLifecycle,
|
|
domain.PluginBridgeActionAIInvoke:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func requiredBridgePermissions(action domain.PluginBridgeAction) []string {
|
|
switch action {
|
|
case domain.PluginBridgeActionServerInstancesRead:
|
|
return []string{"server.read"}
|
|
case domain.PluginBridgeActionJobsDispatch:
|
|
return []string{"server.lifecycle"}
|
|
case domain.PluginBridgeActionLogsQuery:
|
|
return []string{"server.logs.read"}
|
|
case domain.PluginBridgeActionArtifactsOpen:
|
|
return []string{"server.artifacts.read"}
|
|
case domain.PluginBridgeActionFilesRequest:
|
|
return []string{"server.files.read"}
|
|
case domain.PluginBridgeActionRemoteAccessRequest:
|
|
return []string{"server.remote.access"}
|
|
case domain.PluginBridgeActionRunDistribution:
|
|
return []string{"server.run.distribution"}
|
|
case domain.PluginBridgeActionDependenciesRequest:
|
|
return []string{"server.dependencies.manage"}
|
|
case domain.PluginBridgeActionLogsBackfillRequest:
|
|
return []string{"server.logs.read"}
|
|
case domain.PluginBridgeActionClientManager:
|
|
return []string{"server.client-manager.manage"}
|
|
case domain.PluginBridgeActionPluginLifecycle:
|
|
return []string{"server.lifecycle"}
|
|
case domain.PluginBridgeActionAIInvoke:
|
|
return []string{"ai.invoke"}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func validateProductionLifecycle(field string, lifecycle domain.GamePluginProductionLifecycle, required bool) []string {
|
|
var violations []string
|
|
if required && len(lifecycle.Operations) == 0 {
|
|
violations = append(violations, field+".operations must not be empty")
|
|
}
|
|
for i, operation := range lifecycle.Operations {
|
|
switch domain.PluginLifecycleOperation(operation) {
|
|
case domain.PluginLifecycleOperationInstall, domain.PluginLifecycleOperationEnable, domain.PluginLifecycleOperationDisable, domain.PluginLifecycleOperationUpgrade, domain.PluginLifecycleOperationRollback, domain.PluginLifecycleOperationRetire, domain.PluginLifecycleOperationDependencyCheck:
|
|
default:
|
|
violations = append(violations, fmt.Sprintf("%s.operations[%d] is invalid", field, i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(field+".operations", lifecycle.Operations)...)
|
|
if lifecycle.DependencyPolicy != "required" && lifecycle.DependencyPolicy != "optional" {
|
|
violations = append(violations, field+".dependencyPolicy must be required or optional")
|
|
}
|
|
for i, operation := range lifecycle.ApprovalRequired {
|
|
if operation != string(domain.PluginLifecycleOperationDisable) && operation != string(domain.PluginLifecycleOperationRollback) && operation != string(domain.PluginLifecycleOperationRetire) {
|
|
violations = append(violations, fmt.Sprintf("%s.approvalRequired[%d] is invalid", field, i))
|
|
}
|
|
}
|
|
for _, operation := range []string{string(domain.PluginLifecycleOperationDisable), string(domain.PluginLifecycleOperationRollback), string(domain.PluginLifecycleOperationRetire)} {
|
|
if containsString(lifecycle.Operations, operation) && !containsString(lifecycle.ApprovalRequired, operation) {
|
|
violations = append(violations, field+".approvalRequired must include "+operation)
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func effectivePagePermissions(plugin domain.GamePlugin, routeKey string) []string {
|
|
declared := plugin.DeclaredPermissions
|
|
page, found := findPluginPage(plugin.Pages, routeKey)
|
|
if !found || len(page.Permissions) == 0 {
|
|
return domain.CopyStringSlice(declared)
|
|
}
|
|
var effective []string
|
|
for _, permission := range page.Permissions {
|
|
if containsString(declared, permission) {
|
|
effective = append(effective, permission)
|
|
}
|
|
}
|
|
return effective
|
|
}
|
|
|
|
func findPluginPage(pages []domain.GamePluginPage, routeKey string) (domain.GamePluginPage, bool) {
|
|
for _, page := range pages {
|
|
if page.Key == routeKey {
|
|
return page, true
|
|
}
|
|
}
|
|
return domain.GamePluginPage{}, false
|
|
}
|
|
|
|
func containsAll(values []string, required []string) bool {
|
|
for _, value := range required {
|
|
if !containsString(values, value) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func containsAny(values []string, candidates []string) bool {
|
|
for _, candidate := range candidates {
|
|
if containsString(values, candidate) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsString(values []string, target string) bool {
|
|
for _, value := range values {
|
|
if value == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validPluginSupportedOS(osName string) bool {
|
|
switch osName {
|
|
case "windows", "linux", "darwin":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validPluginPagePath(path string) bool {
|
|
if !strings.HasPrefix(path, "/") || strings.Contains(path, "..") || strings.Contains(path, `\`) || strings.Contains(path, "://") {
|
|
return false
|
|
}
|
|
for _, char := range path[1:] {
|
|
if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '_' || char == '.' || char == '/' || char == '-' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validAIPurpose(purpose string) bool {
|
|
switch purpose {
|
|
case "config.read", "config.generate", "config.suggest", "logs.diagnose", "files.suggest":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validServerInstanceState(state domain.ServerInstanceState) bool {
|
|
switch state {
|
|
case domain.ServerInstanceStateDraft, domain.ServerInstanceStateInstalling, domain.ServerInstanceStateReady, domain.ServerInstanceStateRunning, domain.ServerInstanceStateStopped, domain.ServerInstanceStateFailed, domain.ServerInstanceStateDeleted:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validRunEndpointStatus(status domain.RunEndpointStatus) bool {
|
|
switch status {
|
|
case domain.RunEndpointStatusOnline, domain.RunEndpointStatusOffline, domain.RunEndpointStatusDegraded, domain.RunEndpointStatusDisabled:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validJobState(state domain.JobState) bool {
|
|
switch state {
|
|
case domain.JobStateQueued, domain.JobStateAccepted, domain.JobStateRunning, domain.JobStateRetrying, domain.JobStateSucceeded, domain.JobStateFailed, domain.JobStateCancelled:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validArtifactOwnerKind(kind domain.ArtifactOwnerKind) bool {
|
|
switch kind {
|
|
case domain.ArtifactOwnerKindPlatform, domain.ArtifactOwnerKindPlugin, domain.ArtifactOwnerKindServerInstance, domain.ArtifactOwnerKindJob:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validArtifactState(state domain.ArtifactState) bool {
|
|
switch state {
|
|
case domain.ArtifactStateUploading, domain.ArtifactStateAvailable, domain.ArtifactStateExpired, domain.ArtifactStateFailed:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validLogStreamSource(source domain.LogStreamSource) bool {
|
|
switch source {
|
|
case domain.LogStreamSourceProcess, domain.LogStreamSourceFile, domain.LogStreamSourcePlugin, domain.LogStreamSourceManagementProgram:
|
|
return true
|
|
default:
|
|
return strings.TrimSpace(string(source)) != ""
|
|
}
|
|
}
|
|
|
|
func validLogStorageBackend(backend domain.LogStorageBackend) bool {
|
|
switch backend {
|
|
case domain.LogStorageBackendLocalSegments, domain.LogStorageBackendLoki, domain.LogStorageBackendClickHouse, domain.LogStorageBackendOpenSearch, domain.LogStorageBackendElasticsearch:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validAuditResult(result domain.AuditResult) bool {
|
|
switch result {
|
|
case domain.AuditResultSuccess, domain.AuditResultDenied, domain.AuditResultFailed, domain.AuditResultQueued:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|