346 lines
12 KiB
Go
346 lines
12 KiB
Go
// 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()
|
|
}
|