feat: establish WebAssembly module runtime
This commit is contained in:
@@ -72,7 +72,7 @@ Only the main application's HTTP port is published. The agent and game-managemen
|
|||||||
|
|
||||||
## Status
|
## 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
|
## Validate the specification
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/klauspost/compress v1.18.0
|
github.com/klauspost/compress v1.18.0
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
|
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/crypto v0.53.0
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/sys v0.47.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
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/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 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
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 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
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 {
|
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if count != 5 {
|
if count != 6 {
|
||||||
t.Fatalf("got %d migrations, want 5", count)
|
t.Fatalf("got %d migrations, want 6", count)
|
||||||
}
|
}
|
||||||
for _, table := range []string{"instance_memberships", "permission_overrides", "installation_requests", "backup_policies", "backups", "imports"} {
|
for _, table := range []string{"instance_memberships", "permission_overrides", "installation_requests", "backup_policies", "backups", "imports"} {
|
||||||
var found int
|
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 {
|
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if count != 5 {
|
if count != 6 {
|
||||||
t.Fatalf("reopened database has %d migrations, want 5", count)
|
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
|
# 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
|
## Endpoint mapping
|
||||||
|
|
||||||
| Normalized operation | Palworld REST operation |
|
| Normalized operation | Palworld REST endpoint |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `test_connection`, `get_server_info` | server info |
|
| `test_connection`, `get_server_info`, `get_server_status` | `GET /v1/api/info` |
|
||||||
| `get_server_status` | info plus metrics readiness |
|
| `get_metrics` | `GET /v1/api/metrics` |
|
||||||
| `get_metrics` | metrics |
|
| `list_players` | `GET /v1/api/players` |
|
||||||
| `list_players` | players |
|
| `save_world` | `POST /v1/api/save` |
|
||||||
| `save_world` | save |
|
| `shutdown` | `POST /v1/api/shutdown` |
|
||||||
| `shutdown` | shutdown with bounded wait/message |
|
| `send_announcement` | `POST /v1/api/announce` |
|
||||||
| `send_announcement` | announce |
|
| `kick_player` | `POST /v1/api/kick` |
|
||||||
| `kick_player` | kick by stable player ID |
|
| `ban_player` | `POST /v1/api/ban` |
|
||||||
| `ban_player` | ban by stable player ID |
|
| `unban_player` | `POST /v1/api/unban` |
|
||||||
| `unban_player` | unban by stable player ID |
|
|
||||||
|
|
||||||
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:
|
```sh
|
||||||
|
CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm go build \
|
||||||
1. define the WIT/typed bindings for normalized API v1;
|
-trimpath -buildmode=c-shared \
|
||||||
2. implement and test all declared capabilities;
|
-o modules/palworld-rest/module.wasm ./modules/palworld-rest/src
|
||||||
3. compile `module.wasm` without ambient WASI capabilities;
|
sha256sum modules/palworld-rest/module.wasm
|
||||||
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.
|
|
||||||
|
|
||||||
|
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:
|
artifacts:
|
||||||
wasm: module.wasm
|
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