Merge pull request 'feat: establish WebAssembly module runtime' (#7) from feat/wasm-runtime-foundation into main
Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -58,4 +58,4 @@ configuration:
|
||||
|
||||
artifacts:
|
||||
wasm: module.wasm
|
||||
sha256: "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
sha256: "e4b19ecfe66f4d9fb2c6451b82e6989c6eaf8887ec12cef2e97811527b1ff4ea"
|
||||
|
||||
Binary file not shown.
@@ -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() {}
|
||||
@@ -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() {}
|
||||
Reference in New Issue
Block a user