feat: establish WebAssembly module runtime

This commit is contained in:
2026-08-07 16:33:35 +00:00
parent 455ad36f83
commit 6187178444
13 changed files with 872 additions and 32 deletions
+1 -1
View File
@@ -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
+1
View File
@@ -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
+2
View File
@@ -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=
+345
View File
@@ -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()
}
+147
View File
@@ -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")
}
}
+4 -4
View File
@@ -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)
}
}
+29
View File
@@ -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);
+17
View File
@@ -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.
+24 -26
View File
@@ -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.
+1 -1
View File
@@ -58,4 +58,4 @@ configuration:
artifacts:
wasm: module.wasm
sha256: "0000000000000000000000000000000000000000000000000000000000000000"
sha256: "e4b19ecfe66f4d9fb2c6451b82e6989c6eaf8887ec12cef2e97811527b1ff4ea"
Binary file not shown.
+294
View File
@@ -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() {}
+7
View File
@@ -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() {}