Ship SCUM user SQL management page

This commit is contained in:
npc0-hue
2026-08-24 16:43:00 +08:00
parent 3005e0510c
commit d4e3f68032
19 changed files with 351 additions and 115 deletions
+24 -22
View File
@@ -1199,28 +1199,30 @@ type RunCapacity struct {
}
const (
JobCapabilityConfigWrite = "config.write"
JobCapabilityFilesList = "files.list"
JobCapabilityFilesRead = "files.read"
JobCapabilityFilesWrite = "files.write"
JobCapabilityRemoteFTPRead = "remote.ftp.read"
JobCapabilityRemoteFTPWrite = "remote.ftp.write"
JobCapabilityRemoteRsyncRead = "remote.rsync.read"
JobCapabilityRemoteRsyncWrite = "remote.rsync.write"
JobCapabilityRemoteRunFilesRead = "remote.run.files.read"
JobCapabilityRemoteRunFilesWrite = "remote.run.files.write"
JobCapabilityRemoteRunProcessStart = "remote.run.process.start"
JobCapabilityRemoteRunProcessStop = "remote.run.process.stop"
JobCapabilityRemoteRunDBMySQLQuery = "remote.run.db.mysql.query"
JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query"
JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
JobCapabilityRemoteRunProgram = "remote.run.program.command"
JobCapabilityRunSelfUpdate = "run.self-update"
JobCapabilityDistributionBuild = "distribution.build"
JobCapabilityDependenciesCheck = "dependencies.check"
JobCapabilityDependenciesInstall = "dependencies.install"
JobCapabilityLogsBackfill = "logs.backfill"
JobCapabilityConfigWrite = "config.write"
JobCapabilityFilesList = "files.list"
JobCapabilityFilesRead = "files.read"
JobCapabilityFilesWrite = "files.write"
JobCapabilityRemoteFTPRead = "remote.ftp.read"
JobCapabilityRemoteFTPWrite = "remote.ftp.write"
JobCapabilityRemoteRsyncRead = "remote.rsync.read"
JobCapabilityRemoteRsyncWrite = "remote.rsync.write"
JobCapabilityRemoteRunFilesRead = "remote.run.files.read"
JobCapabilityRemoteRunFilesWrite = "remote.run.files.write"
JobCapabilityRemoteRunProcessStart = "remote.run.process.start"
JobCapabilityRemoteRunProcessStop = "remote.run.process.stop"
JobCapabilityRemoteRunDBMySQLQuery = "remote.run.db.mysql.query"
JobCapabilityRemoteRunDBMySQLExecute = "remote.run.db.mysql.execute"
JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query"
JobCapabilityRemoteRunDBSQLiteExecute = "remote.run.db.sqlite.execute"
JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
JobCapabilityRemoteRunProgram = "remote.run.program.command"
JobCapabilityRunSelfUpdate = "run.self-update"
JobCapabilityDistributionBuild = "distribution.build"
JobCapabilityDependenciesCheck = "dependencies.check"
JobCapabilityDependenciesInstall = "dependencies.install"
JobCapabilityLogsBackfill = "logs.backfill"
// JobCapabilityDeploymentPlan gates Run implementations that understand
// protected deployment definitions, absolute paths, and custom commands.
JobCapabilityDeploymentPlan = "deployment.plan.v1"
+2 -2
View File
@@ -119,7 +119,7 @@ func isRemoteAdapterCapability(capability string) bool {
domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite,
domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite,
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLExecute, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteExecute,
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand:
return true
default:
@@ -156,7 +156,7 @@ func remoteAdapterKindForCapability(capability string) domain.RemoteAdapterKind
return domain.RemoteAdapterRunFile
case domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop:
return domain.RemoteAdapterRunProcess
case domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery:
case domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLExecute, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteExecute:
return domain.RemoteAdapterDatabase
case domain.JobCapabilityRemoteRunRCONCommand:
return domain.RemoteAdapterRCON
+21 -20
View File
@@ -1624,36 +1624,36 @@ func TestFindBridgeQueryTemplateRequiresPagePermissionAndRemoteAction(t *testing
}
}
func TestCoreServiceRejectsArbitrarySQLBridgeInputBeforeJob(t *testing.T) {
func TestCoreServiceDispatchesSQLiteExecuteSQLText(t *testing.T) {
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
result, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
RequestID: "query-template-sql-rejected-1",
RequestID: "sqlite-execute-1",
PluginID: plugin.ID,
RouteKey: "remote",
ServerInstanceID: instance.ID,
Action: domain.PluginBridgeActionRemoteAccessRequest,
Payload: map[string]string{
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery,
"declarationKey": "scum-db-read",
"targetKey": "scum-db.player-lookup",
"idempotencyKey": "query-template-sql-rejected-1",
"input.templateKey": "players.by-id",
"input.sqlText": "SELECT * FROM users",
"capability": domain.JobCapabilityRemoteRunDBSQLiteExecute,
"declarationKey": "scum-db-read",
"targetKey": "scum-db.player-lookup",
"idempotencyKey": "sqlite-execute-1",
"input.mode": "execute",
"input.sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';",
},
})
if err != nil {
t.Fatalf("execute arbitrary SQL bridge input: %v", err)
t.Fatalf("execute sqlite SQL bridge input: %v", err)
}
if result.Status != "error" || result.Error == nil || !strings.Contains(strings.ToLower(result.Error.Message), "unsafe") {
t.Fatalf("expected arbitrary SQL input rejection, got %+v", result)
if result.Status != "queued" || result.Result["jobId"] == "" {
t.Fatalf("expected queued sqlite execute job, got %+v", result)
}
jobs, listErr := svc.ListJobs(domain.JobFilter{ServerInstanceID: instance.ID})
if listErr != nil {
t.Fatalf("list jobs after arbitrary SQL rejection: %v", listErr)
job, getErr := svc.store.Jobs().Get(result.Result["jobId"])
if getErr != nil {
t.Fatalf("get sqlite execute job: %v", getErr)
}
if len(jobs) != 0 {
t.Fatalf("arbitrary SQL rejection created jobs: %+v", jobs)
if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteExecute || job.ExecutionInput.Inputs["sqlText"] == "" || job.ExecutionInput.Inputs["mode"] != "execute" {
t.Fatalf("expected sqlite execute inputs, got %#v", job)
}
}
@@ -1898,7 +1898,8 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
capability := domain.JobCapabilityRemoteRunDBSQLiteQuery
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability)
executeCapability := domain.JobCapabilityRemoteRunDBSQLiteExecute
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability, executeCapability)
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.remote.access")
plugin.Permissions.RemoteAccess = true
plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest))
@@ -1911,14 +1912,14 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug
})
plugin.RemoteAccess = domain.GamePluginRemoteAccess{
Methods: []string{"run"},
RunCapabilities: []string{capability},
RunCapabilities: []string{capability, executeCapability},
DatabaseEngines: []string{"sqlite"},
}
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{
Key: "scum-db-read",
Kind: "sqlite",
TargetKey: "scum-db.player-lookup",
Capabilities: []string{capability},
Capabilities: []string{capability, executeCapability},
})
plugin.GameClientBridge = domain.GameClientBridgeManifest{
QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{
@@ -1944,7 +1945,7 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update sqlite query plugin fixture: %v", err)
}
endpoint.Capabilities = append(endpoint.Capabilities, capability)
endpoint.Capabilities = append(endpoint.Capabilities, capability, executeCapability)
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("update sqlite query endpoint fixture: %v", err)
}
+23 -2
View File
@@ -109,10 +109,14 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin
}
var violations []string
for key, value := range inputs {
if !clientManagerIdentifierPattern.MatchString(key) || unsafeGameClientBridgePayloadKey(key) {
if !clientManagerIdentifierPattern.MatchString(key) || unsafeRemoteAdapterInputKey(key) {
violations = append(violations, field+" key is invalid or unsafe")
}
if len([]rune(value)) > 2048 {
limit := 2048
if remoteAdapterSQLInputKey(key) {
limit = 16 * 1024
}
if len([]rune(value)) > limit {
violations = append(violations, field+"."+key+" is too long")
}
for _, reason := range unsafePluginStringReasons(value) {
@@ -121,3 +125,20 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin
}
return violations
}
func unsafeRemoteAdapterInputKey(key string) bool {
if remoteAdapterSQLInputKey(key) {
return false
}
return unsafeGameClientBridgePayloadKey(key)
}
func remoteAdapterSQLInputKey(key string) bool {
normalized := strings.ToLower(strings.NewReplacer(".", "", "_", "", "-", "", ":", "", "/", "").Replace(key))
switch normalized {
case "sql", "sqltext", "sqlstatement", "sqlquery", "rawsql", "rawquery", "statement":
return true
default:
return false
}
}
+3
View File
@@ -18,6 +18,9 @@ func TestObservabilityValidatorsBoundMetricsBackupsAndRemoteTargets(t *testing.T
if err := ValidateRemoteAdapterRequest(domain.RemoteAdapterRequest{ServerInstanceID: "server-1", DeclarationKey: "ftp", TargetKey: "tcp://host", Capability: "remote.ftp.read", IdempotencyKey: "request-1"}); err == nil {
t.Fatal("expected unsafe remote target rejection")
}
if err := ValidateRemoteAdapterRequest(domain.RemoteAdapterRequest{ServerInstanceID: "server-1", DeclarationKey: "sqlite-db", TargetKey: "scum-db", Capability: domain.JobCapabilityRemoteRunDBSQLiteExecute, IdempotencyKey: "sql-execute-1", Inputs: map[string]string{"mode": "execute", "sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';"}}); err != nil {
t.Fatalf("expected SQL text input to validate: %v", err)
}
}
func floatPtr(value float64) *float64 { return &value }
+15 -7
View File
@@ -21,7 +21,7 @@ const (
maxPluginDescriptionLength = 240
maxPluginPageTitleLength = 40
maxPluginBridgePayloadKeys = 16
maxPluginBridgePayloadSize = 4096
maxPluginBridgePayloadSize = 16 * 1024
maxProgressMessageLength = 256
maxServerConfigContentSize = 64 * 1024
maxJobExecutionContentSize = 64 * 1024
@@ -971,7 +971,11 @@ func ValidatePluginBridgeExecuteRequest(request domain.PluginBridgeExecuteReques
if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || len([]rune(key)) > 80 {
violations = append(violations, "payload key is invalid")
}
if len([]rune(value)) > 1024 {
valueLimit := 1024
if remoteAdapterSQLInputKey(strings.TrimPrefix(key, "input.")) {
valueLimit = 16 * 1024
}
if len([]rune(value)) > valueLimit {
violations = append(violations, "payload value is too long")
}
for _, reason := range unsafePluginStringReasons(key) {
@@ -2053,12 +2057,14 @@ func validateRemoteAccess(field string, remote domain.GamePluginRemoteAccess, de
violations = append(violations, field+".logTransfer requires remote.run.logs.transfer")
}
for _, engine := range remote.DatabaseEngines {
required := domain.JobCapabilityRemoteRunDBMySQLQuery
queryCapability := domain.JobCapabilityRemoteRunDBMySQLQuery
executeCapability := domain.JobCapabilityRemoteRunDBMySQLExecute
if engine == "sqlite" {
required = domain.JobCapabilityRemoteRunDBSQLiteQuery
queryCapability = domain.JobCapabilityRemoteRunDBSQLiteQuery
executeCapability = domain.JobCapabilityRemoteRunDBSQLiteExecute
}
if !containsString(remote.RunCapabilities, required) {
violations = append(violations, fmt.Sprintf("%s.databaseEngines requires %s", field, required))
if !containsString(remote.RunCapabilities, queryCapability) && !containsString(remote.RunCapabilities, executeCapability) {
violations = append(violations, fmt.Sprintf("%s.databaseEngines requires %s or %s", field, queryCapability, executeCapability))
}
}
return violations
@@ -2352,7 +2358,7 @@ func validPluginRunCapability(capability string) bool {
domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite,
domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite,
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLExecute, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteExecute,
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
domain.JobCapabilityRemoteRunProgram,
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
@@ -2386,7 +2392,9 @@ func remoteCapabilityRequiresInputRef(capability string) bool {
domain.JobCapabilityRemoteRsyncWrite,
domain.JobCapabilityRemoteRunFilesWrite,
domain.JobCapabilityRemoteRunDBMySQLQuery,
domain.JobCapabilityRemoteRunDBMySQLExecute,
domain.JobCapabilityRemoteRunDBSQLiteQuery,
domain.JobCapabilityRemoteRunDBSQLiteExecute,
domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProgram:
return true
default: