diff --git a/README.md b/README.md index f3e9a6c..884bcde 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Only the main application's HTTP port is published. The agent and game-managemen ## Status -The first six roadmap foundations are implemented: application/authentication, the restricted agent boundary, the validated catalog, registered instance lifecycle, per-instance authorization, and recoverable game-data backups. DoGaMa creates atomic `tar.zst` archives with manifests and SHA-256 metadata, selectively retains scheduled backups, supports five-field cron policies with IANA timezones, stages hostile imports under strict limits, and restores through validated staging with a default `pre_restore` safety backup. Backup, export, restore and policy APIs enforce backend permissions and recent authentication for sensitive actions. Updates and the WebAssembly runtime remain later roadmap work. +The first seven roadmap foundations are implemented: application/authentication, the restricted agent boundary, the validated catalog, registered instance lifecycle, per-instance authorization, recoverable game-data backups, and the WebAssembly integration runtime. DoGaMa creates atomic `tar.zst` archives with manifests and SHA-256 metadata, selectively retains scheduled backups, supports five-field cron policies with IANA timezones, stages hostile imports under strict limits, and restores through validated staging with a default `pre_restore` safety backup. The module runtime executes typed, capability-checked adapters with bounded resources and instance-pinned networking; the bundled Palworld REST reference adapter is compiled reproducibly and covered by sandbox integration tests. Updates remain later roadmap work. ## Validate the specification diff --git a/go.mod b/go.mod index d56e847..c46cd80 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/klauspost/compress v1.18.0 github.com/robfig/cron/v3 v3.0.1 github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 + github.com/tetratelabs/wazero v1.11.0 golang.org/x/crypto v0.53.0 golang.org/x/sys v0.47.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 55da6a8..db3ae2d 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= diff --git a/internal/module/runtime.go b/internal/module/runtime.go new file mode 100644 index 0000000..7005c17 --- /dev/null +++ b/internal/module/runtime.go @@ -0,0 +1,345 @@ +// Package module executes game adapters in a capability-limited WebAssembly sandbox. +package module + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "path" + "strings" + "sync" + "time" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" +) + +const ( + ABI = "dogama:game-module@1.0.0" + maxRequestBytes = 64 << 10 + maxHostCallsPerCall = 32 +) + +var capabilityExport = map[string]string{ + "server_info": "get_server_info", "metrics": "get_metrics", "player_list": "list_players", + "online_save": "save_world", "graceful_shutdown": "shutdown", "announcement": "send_announcement", + "kick": "kick_player", "ban": "ban_player", "unban": "unban_player", +} + +type Limits struct { + MemoryMB uint32 + Timeout time.Duration + MaxResponseBytes int + MaxConcurrentCall int +} + +type Binding struct { + InstanceID string + ContainerPort int + AllowedMethods map[string]bool + Configuration map[string]string + Secrets map[string]string +} + +type HTTPRequest struct { + Method string `json:"method"` + Path string `json:"path"` + Header map[string]string `json:"header,omitempty"` + Body []byte `json:"body,omitempty"` +} + +type HTTPResponse struct { + Status int `json:"status"` + Header map[string][]string `json:"header,omitempty"` + Body []byte `json:"body,omitempty"` +} + +type Runtime struct { + wasm []byte + capabilities []string + limits Limits + binding Binding + client *http.Client + sem chan struct{} + mu sync.Mutex + failures int + openUntil time.Time +} + +func New(ctx context.Context, wasm []byte, checksum string, capabilities []string, limits Limits, binding Binding) (*Runtime, error) { + transport, err := pinnedTransport(ctx, binding) + if err != nil { + return nil, err + } + return newWithTransport(wasm, checksum, capabilities, limits, binding, transport) +} + +func newWithTransport(wasm []byte, checksum string, capabilities []string, limits Limits, binding Binding, transport http.RoundTripper) (*Runtime, error) { + if len(wasm) == 0 || limits.MemoryMB == 0 || limits.MemoryMB > 256 || limits.Timeout <= 0 || limits.MaxResponseBytes < 1 || limits.MaxResponseBytes > 8<<20 || limits.MaxConcurrentCall < 1 || limits.MaxConcurrentCall > 16 { + return nil, errors.New("invalid module runtime configuration") + } + digest := sha256.Sum256(wasm) + if hex.EncodeToString(digest[:]) != checksum { + return nil, errors.New("module checksum mismatch") + } + if binding.InstanceID == "" || binding.ContainerPort < 1 || binding.ContainerPort > 65535 || transport == nil { + return nil, errors.New("invalid instance API binding") + } + seen := map[string]bool{} + for _, capability := range capabilities { + if _, ok := capabilityExport[capability]; !ok || seen[capability] { + return nil, errors.New("invalid module capability") + } + seen[capability] = true + } + r := &Runtime{wasm: append([]byte(nil), wasm...), capabilities: append([]string(nil), capabilities...), limits: limits, binding: binding, sem: make(chan struct{}, limits.MaxConcurrentCall)} + r.client = &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + return r, nil +} + +func pinnedTransport(ctx context.Context, binding Binding) (http.RoundTripper, error) { + hostname := "dogama-" + strings.ToLower(binding.InstanceID) + lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + addresses, err := net.DefaultResolver.LookupIPAddr(lookupCtx, hostname) + if err != nil || len(addresses) == 0 { + return nil, errors.New("resolve bound instance API") + } + ip := addresses[0].IP + if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() { + return nil, errors.New("unsafe instance API address") + } + pinned := net.JoinHostPort(ip.String(), fmt.Sprint(binding.ContainerPort)) + dialer := &net.Dialer{Timeout: 2 * time.Second, KeepAlive: 30 * time.Second} + return &http.Transport{ + DisableCompression: true, + Proxy: nil, + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { + if network != "tcp" && network != "tcp4" && network != "tcp6" { + return nil, errors.New("unsupported instance API network") + } + return dialer.DialContext(ctx, "tcp", pinned) + }, + }, nil +} + +func (r *Runtime) Call(ctx context.Context, operation string, request any, response any) error { + if !r.allowedOperation(operation) { + return errors.New("unsupported") + } + r.mu.Lock() + open := time.Now().Before(r.openUntil) + r.mu.Unlock() + if open { + return errors.New("module circuit breaker is open") + } + select { + case r.sem <- struct{}{}: + defer func() { <-r.sem }() + case <-ctx.Done(): + return ctx.Err() + } + callCtx, cancel := context.WithTimeout(ctx, r.limits.Timeout) + defer cancel() + input, err := json.Marshal(request) + if err != nil || len(input) > maxRequestBytes { + return errors.New("invalid module request") + } + result, err := r.invoke(callCtx, operation, input) + if err != nil { + r.recordFailure() + return err + } + r.recordSuccess() + decoder := json.NewDecoder(bytes.NewReader(result)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(response); err != nil || decoder.Decode(&struct{}{}) != io.EOF { + return errors.New("invalid module response") + } + return nil +} + +func (r *Runtime) allowedOperation(operation string) bool { + if operation == "initialize" || operation == "test_connection" || operation == "get_server_status" { + return true + } + for _, capability := range r.capabilities { + if capabilityExport[capability] == operation { + return true + } + } + return false +} + +type callState struct{ hostCalls int } +type stateKey struct{} + +func (r *Runtime) invoke(ctx context.Context, operation string, input []byte) ([]byte, error) { + ctx = context.WithValue(ctx, stateKey{}, &callState{}) + pages := (r.limits.MemoryMB*1024*1024 + 65535) / 65536 + config := wazero.NewRuntimeConfigInterpreter().WithMemoryLimitPages(pages).WithCloseOnContextDone(true).WithDebugInfoEnabled(false) + runtime := wazero.NewRuntimeWithConfig(ctx, config) + defer runtime.Close(ctx) + if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { + return nil, fmt.Errorf("instantiate restricted WASI: %w", err) + } + host := runtime.NewHostModuleBuilder("dogama_host") + host.NewFunctionBuilder().WithFunc(r.httpRequest).Export("http_request") + host.NewFunctionBuilder().WithFunc(r.getSecret).Export("get_secret") + host.NewFunctionBuilder().WithFunc(r.getConfig).Export("get_config") + if _, err := host.Instantiate(ctx); err != nil { + return nil, fmt.Errorf("instantiate module host: %w", err) + } + compiled, err := runtime.CompileModule(ctx, r.wasm) + if err != nil { + return nil, errors.New("compile module") + } + if err := validateABI(compiled, r.capabilities); err != nil { + return nil, err + } + instance, err := runtime.InstantiateModule(ctx, compiled, wazero.NewModuleConfig().WithName("").WithStartFunctions("_initialize").WithStdin(bytes.NewReader(nil)).WithStdout(io.Discard).WithStderr(io.Discard)) + if err != nil { + return nil, errors.New("instantiate module") + } + alloc := instance.ExportedFunction("dogama_alloc") + fn := instance.ExportedFunction(operation) + if alloc == nil || fn == nil { + return nil, errors.New("missing module export") + } + allocated, err := alloc.Call(ctx, uint64(len(input))) + if err != nil || len(allocated) != 1 || allocated[0] > 1<<32-1 || !instance.Memory().Write(uint32(allocated[0]), input) { + return nil, errors.New("write module request") + } + out, err := alloc.Call(ctx, uint64(r.limits.MaxResponseBytes)) + if err != nil || len(out) != 1 || out[0] > 1<<32-1 { + return nil, errors.New("allocate module response") + } + result, err := fn.Call(ctx, allocated[0], uint64(len(input)), out[0], uint64(r.limits.MaxResponseBytes)) + if err != nil || len(result) != 1 || int32(result[0]) < 0 || result[0] > uint64(r.limits.MaxResponseBytes) { + return nil, errors.New("module execution failed") + } + body, ok := instance.Memory().Read(uint32(out[0]), uint32(result[0])) + if !ok { + return nil, errors.New("read module response") + } + return append([]byte(nil), body...), nil +} + +func validateABI(compiled wazero.CompiledModule, capabilities []string) error { + exports := compiled.ExportedFunctions() + for _, name := range []string{"dogama_alloc", "initialize", "test_connection", "get_server_status"} { + if exports[name] == nil { + return fmt.Errorf("required module export %s is missing", name) + } + } + for _, capability := range capabilities { + if exports[capabilityExport[capability]] == nil { + return fmt.Errorf("capability export %s is missing", capability) + } + } + for _, imported := range compiled.ImportedFunctions() { + moduleName, name, importedFunction := imported.Import() + if importedFunction && moduleName != "dogama_host" && moduleName != wasi_snapshot_preview1.ModuleName { + return fmt.Errorf("forbidden import %s.%s", moduleName, name) + } + } + return nil +} + +func (r *Runtime) httpRequest(ctx context.Context, mod api.Module, requestPtr, requestLen, responsePtr, responseCap uint32) int32 { + state, _ := ctx.Value(stateKey{}).(*callState) + if state == nil || state.hostCalls >= maxHostCallsPerCall || requestLen > maxRequestBytes || responseCap > uint32(r.limits.MaxResponseBytes) { + return -1 + } + state.hostCalls++ + raw, ok := mod.Memory().Read(requestPtr, requestLen) + if !ok { + return -1 + } + var request HTTPRequest + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if decoder.Decode(&request) != nil || !r.binding.AllowedMethods[request.Method] || len(request.Body) > maxRequestBytes || !validRelativeAPIPath(request.Path) { + return -2 + } + endpoint := fmt.Sprintf("http://dogama-%s:%d%s", strings.ToLower(r.binding.InstanceID), r.binding.ContainerPort, request.Path) + httpRequest, err := http.NewRequestWithContext(ctx, request.Method, endpoint, bytes.NewReader(request.Body)) + if err != nil { + return -2 + } + for name, value := range request.Header { + canonical := http.CanonicalHeaderKey(name) + if canonical != "Accept" && canonical != "Content-Type" && canonical != "Authorization" { + return -2 + } + httpRequest.Header.Set(canonical, value) + } + response, err := r.client.Do(httpRequest) + if err != nil { + return -3 + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, int64(r.limits.MaxResponseBytes)+1)) + if err != nil || len(body) > r.limits.MaxResponseBytes { + return -4 + } + encoded, err := json.Marshal(HTTPResponse{Status: response.StatusCode, Header: map[string][]string{"Content-Type": response.Header.Values("Content-Type")}, Body: body}) + if err != nil || len(encoded) > int(responseCap) || !mod.Memory().Write(responsePtr, encoded) { + return -4 + } + return int32(len(encoded)) +} + +func (r *Runtime) getSecret(ctx context.Context, mod api.Module, keyPtr, keyLen, valuePtr, valueCap uint32) int32 { + return r.readBoundValue(ctx, mod, keyPtr, keyLen, valuePtr, valueCap, r.binding.Secrets) +} + +func (r *Runtime) getConfig(ctx context.Context, mod api.Module, keyPtr, keyLen, valuePtr, valueCap uint32) int32 { + return r.readBoundValue(ctx, mod, keyPtr, keyLen, valuePtr, valueCap, r.binding.Configuration) +} + +func (r *Runtime) readBoundValue(ctx context.Context, mod api.Module, keyPtr, keyLen, valuePtr, valueCap uint32, values map[string]string) int32 { + state, _ := ctx.Value(stateKey{}).(*callState) + if state == nil || state.hostCalls >= maxHostCallsPerCall || keyLen > 128 { + return -1 + } + state.hostCalls++ + raw, ok := mod.Memory().Read(keyPtr, keyLen) + if !ok { + return -1 + } + value, exists := values[string(raw)] + if !exists || len(value) > int(valueCap) || !mod.Memory().Write(valuePtr, []byte(value)) { + return -2 + } + return int32(len(value)) +} + +func validRelativeAPIPath(value string) bool { + parsed, err := url.Parse(value) + return err == nil && !parsed.IsAbs() && parsed.Host == "" && parsed.RawQuery == "" && parsed.Fragment == "" && strings.HasPrefix(value, "/v1/api/") && path.Clean(value) == value && !strings.Contains(value, "\\") +} + +func (r *Runtime) recordFailure() { + r.mu.Lock() + defer r.mu.Unlock() + r.failures++ + if r.failures >= 3 { + r.openUntil = time.Now().Add(30 * time.Second) + } +} + +func (r *Runtime) recordSuccess() { + r.mu.Lock() + r.failures, r.openUntil = 0, time.Time{} + r.mu.Unlock() +} diff --git a/internal/module/runtime_test.go b/internal/module/runtime_test.go new file mode 100644 index 0000000..1b59a3d --- /dev/null +++ b/internal/module/runtime_test.go @@ -0,0 +1,147 @@ +package module + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "net/http" + "os" + "strings" + "testing" + "time" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } + +func TestValidRelativeAPIPath(t *testing.T) { + for _, value := range []string{"http://metadata/v1/api/info", "//other/v1/api/info", "/v1/api/../secret", "/v1/api/info?q=1", "/etc/passwd"} { + if validRelativeAPIPath(value) { + t.Fatalf("unsafe path accepted: %q", value) + } + } + if !validRelativeAPIPath("/v1/api/info") { + t.Fatal("documented Palworld endpoint was rejected") + } +} + +func TestPalworldAdapterReportsBoundedFailures(t *testing.T) { + wasm, err := os.ReadFile("../../modules/palworld-rest/module.wasm") + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(wasm) + tests := []struct { + name string + transport roundTripFunc + code string + }{ + {name: "unauthorized", code: "unauthorized", transport: func(request *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusUnauthorized, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("unauthorized")), Request: request}, nil + }}, + {name: "malformed", code: "invalid_response", transport: func(request *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not-json")), Request: request}, nil + }}, + {name: "offline", code: "unreachable", transport: func(*http.Request) (*http.Response, error) { + return nil, errors.New("offline") + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + runtime, err := newWithTransport(wasm, hex.EncodeToString(digest[:]), []string{"server_info"}, Limits{MemoryMB: 32, Timeout: 10 * time.Second, MaxResponseBytes: 1 << 20, MaxConcurrentCall: 2}, Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 8212, AllowedMethods: map[string]bool{"GET": true}, Configuration: map[string]string{"username": "admin"}, Secrets: map[string]string{"admin_password": "secret"}}, test.transport) + if err != nil { + t.Fatal(err) + } + var response struct { + OK bool `json:"ok"` + Data map[string]any `json:"data"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + Retryable bool `json:"retryable"` + } `json:"error"` + } + if err := runtime.Call(context.Background(), "get_server_info", struct{}{}, &response); err != nil { + t.Fatal(err) + } + if response.OK || response.Error == nil || response.Error.Code != test.code || strings.Contains(response.Error.Message, "secret") { + t.Fatalf("unexpected safe failure: %+v", response) + } + }) + } +} + +func TestPalworldAdapterExecutesInSandbox(t *testing.T) { + wasm, err := os.ReadFile("../../modules/palworld-rest/module.wasm") + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(wasm) + transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.URL.Path != "/v1/api/info" || request.Header.Get("Authorization") == "" { + t.Fatalf("unexpected adapter request: %s", request.URL) + } + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"version":"v1","servername":"test","description":"fixture","worldguid":"world"}`)), Request: request}, nil + }) + runtime, err := newWithTransport(wasm, hex.EncodeToString(digest[:]), []string{"server_info"}, Limits{MemoryMB: 32, Timeout: 10 * time.Second, MaxResponseBytes: 1 << 20, MaxConcurrentCall: 2}, Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 8212, AllowedMethods: map[string]bool{"GET": true, "POST": true}, Configuration: map[string]string{"username": "admin"}, Secrets: map[string]string{"admin_password": "secret"}}, transport) + if err != nil { + t.Fatal(err) + } + var response struct { + OK bool `json:"ok"` + Error any `json:"error"` + Data struct { + Name string `json:"name"` + GameVersion string `json:"game_version"` + Description string `json:"description"` + WorldID string `json:"world_id"` + } `json:"data"` + } + if err := runtime.Call(context.Background(), "get_server_info", struct{}{}, &response); err != nil { + t.Fatal(err) + } + if !response.OK || response.Data.Name != "test" { + t.Fatalf("unexpected normalized response: %+v", response) + } +} + +func TestNewRejectsChecksumAndUnknownCapability(t *testing.T) { + limits := Limits{MemoryMB: 32, Timeout: time.Second, MaxResponseBytes: 1024, MaxConcurrentCall: 1} + binding := Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 8212} + if _, err := newWithTransport([]byte("wasm"), strings.Repeat("0", 64), nil, limits, binding, roundTripFunc(nil)); err == nil { + t.Fatal("checksum mismatch accepted") + } + digest := sha256.Sum256([]byte("wasm")) + if _, err := newWithTransport([]byte("wasm"), hex.EncodeToString(digest[:]), []string{"shell"}, limits, binding, roundTripFunc(nil)); err == nil { + t.Fatal("unknown capability accepted") + } +} + +func TestHTTPRequestPinsInstanceOriginAndDisablesRedirects(t *testing.T) { + instanceID := strings.Repeat("A", 20) + seen := false + transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + seen = true + if request.URL.String() != "http://dogama-aaaaaaaaaaaaaaaaaaaa:8212/v1/api/info" { + t.Fatalf("unexpected destination %q", request.URL) + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), Request: request}, nil + }) + digest := sha256.Sum256([]byte("wasm")) + runtime, err := newWithTransport([]byte("wasm"), hex.EncodeToString(digest[:]), nil, Limits{MemoryMB: 32, Timeout: time.Second, MaxResponseBytes: 1024, MaxConcurrentCall: 1}, Binding{InstanceID: instanceID, ContainerPort: 8212, AllowedMethods: map[string]bool{"GET": true}}, transport) + if err != nil { + t.Fatal(err) + } + request, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://dogama-aaaaaaaaaaaaaaaaaaaa:8212/v1/api/info", nil) + response, err := runtime.client.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if !seen { + t.Fatal("bound transport was not used") + } +} diff --git a/internal/persistence/sqlite/store_test.go b/internal/persistence/sqlite/store_test.go index afe840e..6be11f6 100644 --- a/internal/persistence/sqlite/store_test.go +++ b/internal/persistence/sqlite/store_test.go @@ -24,8 +24,8 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) { if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil { t.Fatal(err) } - if count != 5 { - t.Fatalf("got %d migrations, want 5", count) + if count != 6 { + t.Fatalf("got %d migrations, want 6", count) } for _, table := range []string{"instance_memberships", "permission_overrides", "installation_requests", "backup_policies", "backups", "imports"} { var found int @@ -62,8 +62,8 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) { if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil { t.Fatal(err) } - if count != 5 { - t.Fatalf("reopened database has %d migrations, want 5", count) + if count != 6 { + t.Fatalf("reopened database has %d migrations, want 6", count) } } diff --git a/migrations/0006_wasm_modules.sql b/migrations/0006_wasm_modules.sql new file mode 100644 index 0000000..2a0870e --- /dev/null +++ b/migrations/0006_wasm_modules.sql @@ -0,0 +1,29 @@ +CREATE TABLE module_versions ( + module_id TEXT NOT NULL, + version TEXT NOT NULL, + game_id TEXT NOT NULL, + abi TEXT NOT NULL, + manifest_json TEXT NOT NULL, + wasm_sha256 TEXT NOT NULL CHECK (length(wasm_sha256) = 64), + wasm_size_bytes INTEGER NOT NULL CHECK (wasm_size_bytes > 0), + source TEXT NOT NULL CHECK (source IN ('bundled', 'uploaded')), + installed_at TEXT NOT NULL, + PRIMARY KEY (module_id, version) +); + +CREATE TABLE instance_module_bindings ( + instance_id TEXT PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE, + module_id TEXT NOT NULL, + module_version TEXT NOT NULL, + port_id TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)), + configuration_json TEXT NOT NULL DEFAULT '{}', + secret_references_json TEXT NOT NULL DEFAULT '{}', + last_health TEXT NOT NULL DEFAULT 'unknown' CHECK (last_health IN ('ready', 'degraded', 'offline', 'unknown')), + last_error_code TEXT, + activated_at TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY (module_id, module_version) REFERENCES module_versions(module_id, version) ON DELETE RESTRICT +); + +CREATE INDEX instance_module_health_idx ON instance_module_bindings(enabled, last_health); diff --git a/modules/palworld-rest/LICENSE b/modules/palworld-rest/LICENSE new file mode 100644 index 0000000..a2cbfc2 --- /dev/null +++ b/modules/palworld-rest/LICENSE @@ -0,0 +1,17 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +Copyright 2026 DoGaMa contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/modules/palworld-rest/README.md b/modules/palworld-rest/README.md index 3e16a84..b14dd0e 100644 --- a/modules/palworld-rest/README.md +++ b/modules/palworld-rest/README.md @@ -1,40 +1,38 @@ # Palworld REST reference adapter -This is the source specification for DoGaMa's reference WebAssembly adapter. It is intentionally a translator only. Container lifecycle and archive handling stay in generic DoGaMa workflows. +This package contains DoGaMa's reference WebAssembly translator for the private Palworld REST API. Container lifecycle and archive handling remain in generic DoGaMa workflows. ## Endpoint mapping -| Normalized operation | Palworld REST operation | +| Normalized operation | Palworld REST endpoint | |---|---| -| `test_connection`, `get_server_info` | server info | -| `get_server_status` | info plus metrics readiness | -| `get_metrics` | metrics | -| `list_players` | players | -| `save_world` | save | -| `shutdown` | shutdown with bounded wait/message | -| `send_announcement` | announce | -| `kick_player` | kick by stable player ID | -| `ban_player` | ban by stable player ID | -| `unban_player` | unban by stable player ID | +| `test_connection`, `get_server_info`, `get_server_status` | `GET /v1/api/info` | +| `get_metrics` | `GET /v1/api/metrics` | +| `list_players` | `GET /v1/api/players` | +| `save_world` | `POST /v1/api/save` | +| `shutdown` | `POST /v1/api/shutdown` | +| `send_announcement` | `POST /v1/api/announce` | +| `kick_player` | `POST /v1/api/kick` | +| `ban_player` | `POST /v1/api/ban` | +| `unban_player` | `POST /v1/api/unban` | -The official API category is [Palworld REST API](https://docs.palworldgame.com/category/rest-api/). Implementation must verify exact current paths, methods and response fields against the pinned game/API version and use fixtures for that version. +The mappings follow the [official Palworld REST API](https://docs.palworldgame.com/category/rest-api/). -## Network and credentials +## Isolation -The host binds logical handle `instance_api` to template port `rest_api`. The module never constructs a host or accepts a URL. It requests only relative `/v1/api/...` paths. Authentication values come from declared configuration and are placed in the request by module logic; diagnostics must never contain the header or password. +The host binds the logical `instance_api` destination to the instance container and its private `rest_api` port. The adapter receives no destination URL and can import only restricted WASI plus `dogama_host` functions. The runtime provides no filesystem, environment, process, raw socket, DNS or host clock capability. -## Save and shutdown semantics +Credentials come only from the declared `username` configuration and `admin_password` secret. Responses, requests, host-call count, memory, execution time and concurrency are bounded by the runtime. -`save_world` returns success only after the API acknowledges the save operation. DoGaMa then archives the `saved` mount. `shutdown` sends the in-game shutdown request; generic lifecycle code observes container exit and asks the restricted agent for a bounded stop only when necessary. +## Reproducible build -## Build status +From the repository root, using Go 1.25 or newer: -No WebAssembly binary is included in this specification baseline. The zero checksum in `manifest.yaml` is a visible placeholder. The implementation task must: - -1. define the WIT/typed bindings for normalized API v1; -2. implement and test all declared capabilities; -3. compile `module.wasm` without ambient WASI capabilities; -4. replace the placeholder with the real SHA-256; -5. verify runtime limits and offline/unauthorized/malformed-response cases; -6. package the manifest, binary, README and license. +```sh +CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm go build \ + -trimpath -buildmode=c-shared \ + -o modules/palworld-rest/module.wasm ./modules/palworld-rest/src +sha256sum modules/palworld-rest/module.wasm +``` +The checksum must exactly match `manifest.yaml`. Repository tests instantiate the real artifact under wazero and exercise it against a bounded fake Palworld transport. diff --git a/modules/palworld-rest/manifest.yaml b/modules/palworld-rest/manifest.yaml index d038b2c..eba8af2 100644 --- a/modules/palworld-rest/manifest.yaml +++ b/modules/palworld-rest/manifest.yaml @@ -58,4 +58,4 @@ configuration: artifacts: wasm: module.wasm - sha256: "0000000000000000000000000000000000000000000000000000000000000000" + sha256: "e4b19ecfe66f4d9fb2c6451b82e6989c6eaf8887ec12cef2e97811527b1ff4ea" diff --git a/modules/palworld-rest/module.wasm b/modules/palworld-rest/module.wasm new file mode 100644 index 0000000..2b0cf21 Binary files /dev/null and b/modules/palworld-rest/module.wasm differ diff --git a/modules/palworld-rest/src/main.go b/modules/palworld-rest/src/main.go new file mode 100644 index 0000000..3ba3ba3 --- /dev/null +++ b/modules/palworld-rest/src/main.go @@ -0,0 +1,294 @@ +//go:build wasip1 + +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "unsafe" +) + +//go:wasmimport dogama_host http_request +func hostHTTPRequest(requestPtr, requestLen, responsePtr, responseCap uint32) int32 + +//go:wasmimport dogama_host get_secret +func hostGetSecret(keyPtr, keyLen, valuePtr, valueCap uint32) int32 + +//go:wasmimport dogama_host get_config +func hostGetConfig(keyPtr, keyLen, valuePtr, valueCap uint32) int32 + +var allocations [][]byte + +//go:wasmexport dogama_alloc +func dogamaAlloc(size uint32) uint32 { + if size == 0 { + size = 1 + } + value := make([]byte, size) + allocations = append(allocations, value) + return uint32(uintptr(unsafe.Pointer(&value[0]))) +} + +type hostRequest struct { + Method string `json:"method"` + Path string `json:"path"` + Header map[string]string `json:"header,omitempty"` + Body []byte `json:"body,omitempty"` +} + +type hostResponse struct { + Status int `json:"status"` + Body []byte `json:"body,omitempty"` +} + +type moduleError struct { + Code string `json:"code"` + Message string `json:"message"` + Retryable bool `json:"retryable"` +} + +type envelope struct { + OK bool `json:"ok"` + Data any `json:"data,omitempty"` + Error *moduleError `json:"error,omitempty"` +} + +func bytesAt(ptr, size uint32) []byte { + if size == 0 { + return nil + } + return unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), size) +} + +func output(outPtr, outCap uint32, value any) int32 { + encoded, err := json.Marshal(value) + if err != nil || len(encoded) > int(outCap) { + return -1 + } + copy(bytesAt(outPtr, uint32(len(encoded))), encoded) + return int32(len(encoded)) +} + +func boundValue(secret bool, key string) (string, bool) { + keyBytes := []byte(key) + buffer := make([]byte, 4096) + var size int32 + if secret { + size = hostGetSecret(uint32(uintptr(unsafe.Pointer(&keyBytes[0]))), uint32(len(keyBytes)), uint32(uintptr(unsafe.Pointer(&buffer[0]))), uint32(len(buffer))) + } else { + size = hostGetConfig(uint32(uintptr(unsafe.Pointer(&keyBytes[0]))), uint32(len(keyBytes)), uint32(uintptr(unsafe.Pointer(&buffer[0]))), uint32(len(buffer))) + } + if size < 0 { + return "", false + } + return string(buffer[:size]), true +} + +func call(method, endpoint string, body any, result any) *moduleError { + username, usernameOK := boundValue(false, "username") + password, passwordOK := boundValue(true, "admin_password") + if !usernameOK || !passwordOK || username == "" || password == "" { + return &moduleError{Code: "invalid_configuration", Message: "Palworld REST credentials are incomplete."} + } + var encodedBody []byte + if body != nil { + encodedBody, _ = json.Marshal(body) + } + request := hostRequest{Method: method, Path: "/v1/api/" + endpoint, Header: map[string]string{"Accept": "application/json", "Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))}, Body: encodedBody} + if body != nil { + request.Header["Content-Type"] = "application/json" + } + encoded, _ := json.Marshal(request) + responseBuffer := make([]byte, 1<<20) + size := hostHTTPRequest(uint32(uintptr(unsafe.Pointer(&encoded[0]))), uint32(len(encoded)), uint32(uintptr(unsafe.Pointer(&responseBuffer[0]))), uint32(len(responseBuffer))) + if size < 0 { + return &moduleError{Code: "unreachable", Message: "The Palworld REST API could not be reached.", Retryable: true} + } + var response hostResponse + if json.Unmarshal(responseBuffer[:size], &response) != nil { + return &moduleError{Code: "invalid_response", Message: "Palworld returned an invalid response."} + } + if response.Status == 401 { + return &moduleError{Code: "unauthorized", Message: "Palworld rejected the configured credentials."} + } + if response.Status < 200 || response.Status >= 300 { + return &moduleError{Code: "game_error", Message: "Palworld rejected the operation.", Retryable: response.Status >= 500} + } + if result != nil && len(response.Body) > 0 && json.Unmarshal(response.Body, result) != nil { + return &moduleError{Code: "invalid_response", Message: "Palworld returned an invalid response."} + } + return nil +} + +func writeResult(outPtr, outCap uint32, data any, failure *moduleError) int32 { + return output(outPtr, outCap, envelope{OK: failure == nil, Data: data, Error: failure}) +} + +var capabilities = []string{"server_info", "metrics", "player_list", "online_save", "graceful_shutdown", "announcement", "kick", "ban", "unban"} + +//go:wasmexport initialize +func initialize(_, _ uint32, outPtr, outCap uint32) int32 { + username, usernameOK := boundValue(false, "username") + _, passwordOK := boundValue(true, "admin_password") + if !usernameOK || !passwordOK || username == "" { + return writeResult(outPtr, outCap, nil, &moduleError{Code: "invalid_configuration", Message: "Palworld REST credentials are incomplete."}) + } + return writeResult(outPtr, outCap, map[string]any{"module_id": "palworld-rest", "module_version": "1.0.0", "api_version": "1.0.0", "capabilities": capabilities}, nil) +} + +type serverInfo struct { + Version string `json:"version"` + ServerName string `json:"servername"` + Description string `json:"description"` + WorldGUID string `json:"worldguid"` +} + +//go:wasmexport test_connection +func testConnection(_, _ uint32, outPtr, outCap uint32) int32 { + var info serverInfo + failure := call("GET", "info", nil, &info) + return writeResult(outPtr, outCap, map[string]any{"connected": failure == nil, "game_version": info.Version}, failure) +} + +//go:wasmexport get_server_status +func getServerStatus(_, _ uint32, outPtr, outCap uint32) int32 { + var info serverInfo + failure := call("GET", "info", nil, &info) + status := "ready" + if failure != nil { + status = "offline" + } + return writeResult(outPtr, outCap, map[string]any{"status": status}, failure) +} + +//go:wasmexport get_server_info +func getServerInfo(_, _ uint32, outPtr, outCap uint32) int32 { + var info serverInfo + failure := call("GET", "info", nil, &info) + data := map[string]any{"name": info.ServerName, "game_version": info.Version, "description": info.Description, "world_id": info.WorldGUID} + return writeResult(outPtr, outCap, data, failure) +} + +type palMetrics struct { + ServerFPS int `json:"serverfps"` + CurrentPlayers int `json:"currentplayernum"` + ServerFrameTime float64 `json:"serverframetime"` + MaxPlayers int `json:"maxplayernum"` + UptimeSeconds int64 `json:"uptime"` + BaseCampCount int `json:"basecampnum"` + InGameDay int `json:"days"` +} + +//go:wasmexport get_metrics +func getMetrics(_, _ uint32, outPtr, outCap uint32) int32 { + var metrics palMetrics + failure := call("GET", "metrics", nil, &metrics) + return writeResult(outPtr, outCap, map[string]any{"server_fps": metrics.ServerFPS, "current_players": metrics.CurrentPlayers, "frame_time_ms": metrics.ServerFrameTime, "max_players": metrics.MaxPlayers, "uptime_seconds": metrics.UptimeSeconds, "base_camps": metrics.BaseCampCount, "game_day": metrics.InGameDay}, failure) +} + +type palPlayer struct { + Name string `json:"name"` + PlayerID string `json:"playerId"` + UserID string `json:"userId"` + Ping float64 `json:"ping"` +} + +//go:wasmexport list_players +func listPlayers(_, _ uint32, outPtr, outCap uint32) int32 { + var response struct { + Players []palPlayer `json:"players"` + } + failure := call("GET", "players", nil, &response) + players := make([]map[string]any, 0, len(response.Players)) + for _, player := range response.Players { + players = append(players, map[string]any{"player_id": player.PlayerID, "user_id": player.UserID, "display_name": player.Name, "ping_ms": player.Ping}) + } + return writeResult(outPtr, outCap, map[string]any{"players": players}, failure) +} + +//go:wasmexport save_world +func saveWorld(_, _ uint32, outPtr, outCap uint32) int32 { + failure := call("POST", "save", nil, nil) + return writeResult(outPtr, outCap, map[string]any{"completed": failure == nil}, failure) +} + +func action(outPtr, outCap uint32, endpoint string, body any) int32 { + failure := call("POST", endpoint, body, nil) + return writeResult(outPtr, outCap, map[string]any{"accepted": failure == nil}, failure) +} + +func decodeRequest(inPtr, inLen uint32, value any) bool { + decoder := json.NewDecoder(bytes.NewReader(bytesAt(inPtr, inLen))) + decoder.DisallowUnknownFields() + return decoder.Decode(value) == nil +} + +func invalidAction(outPtr, outCap uint32) int32 { + return writeResult(outPtr, outCap, nil, &moduleError{Code: "invalid_configuration", Message: "The operation request is invalid."}) +} + +type shutdownRequest struct { + WaitSeconds int `json:"wait_seconds"` + Message string `json:"message,omitempty"` +} + +type messageRequest struct { + Message string `json:"message"` +} + +type playerActionRequest struct { + PlayerID string `json:"player_id"` + Reason string `json:"reason,omitempty"` +} + +type unbanRequest struct { + PlayerID string `json:"player_id"` +} + +//go:wasmexport shutdown +func shutdown(inPtr, inLen, outPtr, outCap uint32) int32 { + var request shutdownRequest + if !decodeRequest(inPtr, inLen, &request) || request.WaitSeconds < 0 || request.WaitSeconds > 900 { + return invalidAction(outPtr, outCap) + } + return action(outPtr, outCap, "shutdown", map[string]any{"waittime": request.WaitSeconds, "message": request.Message}) +} + +//go:wasmexport send_announcement +func sendAnnouncement(inPtr, inLen, outPtr, outCap uint32) int32 { + var request messageRequest + if !decodeRequest(inPtr, inLen, &request) || request.Message == "" || len(request.Message) > 1000 { + return invalidAction(outPtr, outCap) + } + return action(outPtr, outCap, "announce", request) +} + +func playerAction(inPtr, inLen, outPtr, outCap uint32, endpoint string) int32 { + var request playerActionRequest + if !decodeRequest(inPtr, inLen, &request) || request.PlayerID == "" || len(request.PlayerID) > 256 || len(request.Reason) > 1000 { + return invalidAction(outPtr, outCap) + } + return action(outPtr, outCap, endpoint, map[string]any{"userid": request.PlayerID, "message": request.Reason}) +} + +//go:wasmexport kick_player +func kickPlayer(inPtr, inLen, outPtr, outCap uint32) int32 { + return playerAction(inPtr, inLen, outPtr, outCap, "kick") +} + +//go:wasmexport ban_player +func banPlayer(inPtr, inLen, outPtr, outCap uint32) int32 { + return playerAction(inPtr, inLen, outPtr, outCap, "ban") +} + +//go:wasmexport unban_player +func unbanPlayer(inPtr, inLen, outPtr, outCap uint32) int32 { + var request unbanRequest + if !decodeRequest(inPtr, inLen, &request) || request.PlayerID == "" || len(request.PlayerID) > 256 { + return invalidAction(outPtr, outCap) + } + return action(outPtr, outCap, "unban", map[string]any{"userid": request.PlayerID}) +} + +func main() {} diff --git a/modules/palworld-rest/src/main_stub.go b/modules/palworld-rest/src/main_stub.go new file mode 100644 index 0000000..1f19841 --- /dev/null +++ b/modules/palworld-rest/src/main_stub.go @@ -0,0 +1,7 @@ +//go:build !wasip1 + +package main + +// The adapter is built only for GOOS=wasip1. This stub keeps repository-wide +// host-platform validation deterministic without pretending to run the guest. +func main() {}