feat(vrising): support template runtime requirements
CI / validate (pull_request) Successful in 26m22s

This commit is contained in:
2026-08-25 22:32:15 +02:00
parent 736423da75
commit 35c11aff6d
27 changed files with 963 additions and 36 deletions
+22
View File
@@ -0,0 +1,22 @@
# V Rising RCON module
This template-local adapter uses the documented Source RCON interface of the
V Rising dedicated server. The official server documentation lists only
`announce` and `announcerestart`; the adapter currently exposes only bounded
connectivity/status until command behavior is validated end-to-end on a real
server. It does not claim announcement, player, save, shutdown, kick, ban or
unban support.
Build from the repository root:
```sh
CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared \
-trimpath -ldflags='-s -w' \
-o catalog/vrising/module/module.wasm ./catalog/vrising/module/src
sha256sum catalog/vrising/module/module.wasm
```
Copy the printed digest to `manifest.yaml`. The adapter limits each Source
RCON packet to 64 KiB and an aggregated response to 256 KiB. Its host TCP
exchange is pinned to the instance's `rcon` integration port and uses the
module's 10-second deadline.
+52
View File
@@ -0,0 +1,52 @@
schema_version: 1
id: vrising-rcon
name: V Rising RCON adapter
version: 1.0.0
description: Source RCON adapter for documented V Rising connectivity and announcements.
license: Apache-2.0
homepage: https://github.com/StunlockStudios/vrising-dedicated-server-instructions
game_ids:
- vrising
runtime:
type: wasm
abi: dogama:game-module@1.0.0
compatibility:
manager_api: ">=1.0.0 <2.0.0"
module_api: ">=1.0.0 <2.0.0"
capabilities: []
permissions:
network:
scope: instance_only
protocols:
- tcp
port_ids:
- rcon
limits:
memory_mb: 32
timeout_ms: 10000
max_response_bytes: 262144
max_concurrent_calls: 2
configuration:
- id: rcon_enabled
type: boolean
required: true
description: Existing V Rising RCON enabled setting.
- id: rcon_port
type: integer
required: true
description: Existing V Rising RCON port; it must match the declared integration port.
- id: rcon_password
type: secret
required: true
description: Existing V Rising RCON password secret.
artifacts:
wasm: module.wasm
sha256: "d0b34c7724309e18f3e6e474886a6d4cf992cfaf9109415d20e46ff54fd9ab69"
Binary file not shown.
+198
View File
@@ -0,0 +1,198 @@
//go:build wasip1
package main
import (
"bytes"
"encoding/json"
"errors"
"strings"
"unsafe"
)
//go:wasmimport dogama_host tcp_exchange
func hostTCPExchange(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 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"`
}
type tcpRequest struct {
Body []byte `json:"body"`
}
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 result(outPtr, outCap uint32, data any, failure *moduleError) int32 {
if failure == nil {
return output(outPtr, outCap, struct {
OK bool `json:"ok"`
Data any `json:"data,omitempty"`
}{OK: true, Data: data})
}
return output(outPtr, outCap, envelope{OK: false, Data: data, Error: failure})
}
func boundValue(secret bool, key string) (string, bool) {
keyBytes, buffer := []byte(key), 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 tcp(body []byte) ([]byte, transportResult) {
encoded, _ := json.Marshal(tcpRequest{Body: body})
response := make([]byte, maxRCONResponse)
size := hostTCPExchange(uint32(uintptr(unsafe.Pointer(&encoded[0]))), uint32(len(encoded)), uint32(uintptr(unsafe.Pointer(&response[0]))), uint32(len(response)))
if size >= 0 {
return response[:size], transportResult("")
}
switch size {
case -3:
return nil, transportResult("rcon connection refused")
case -4:
return nil, transportResult("rcon timeout")
case -5:
return nil, transportResult("rcon response too large")
}
return nil, transportResult("rcon transport failure")
}
func credentials() (string, *moduleError) {
enabled, enabledOK := boundValue(false, "rcon_enabled")
port, portOK := boundValue(false, "rcon_port")
password, passwordOK := boundValue(true, "rcon_password")
if !enabledOK || !portOK || !passwordOK || enabled != "true" || port != "9878" || password == "" {
return "", &moduleError{Code: "invalid_configuration", Message: "V Rising RCON must be enabled and have a password."}
}
return password, nil
}
func rconFailure(err error) *moduleError {
switch {
case errors.Is(err, errRCONUnauthorized):
return &moduleError{Code: "unauthorized", Message: "V Rising rejected the configured RCON password."}
case strings.Contains(err.Error(), "timeout"):
return &moduleError{Code: "timeout", Message: "V Rising RCON did not respond before the deadline.", Retryable: true}
case strings.Contains(err.Error(), "refused"):
return &moduleError{Code: "unreachable", Message: "V Rising is running but RCON is not accepting connections yet.", Retryable: true}
case strings.Contains(err.Error(), "large"):
return &moduleError{Code: "invalid_response", Message: "V Rising sent an oversized RCON response."}
default:
return &moduleError{Code: "invalid_response", Message: "V Rising returned an invalid RCON response."}
}
}
func authFailure(result authResult) *moduleError {
if result == authOK {
return nil
}
if result == authUnauthorized {
return &moduleError{Code: "unauthorized", Message: "V Rising rejected the configured RCON password."}
}
return rconFailure(errors.New(string(result)))
}
var capabilities = []string{"announcement"}
//go:wasmexport initialize
func initialize(_, _ uint32, outPtr, outCap uint32) int32 {
_, failure := credentials()
return result(outPtr, outCap, map[string]any{"module_id": "vrising-rcon", "module_version": "1.0.0", "api_version": "1.0.0", "capabilities": capabilities}, failure)
}
//go:wasmexport test_connection
func testConnection(_, _ uint32, outPtr, outCap uint32) int32 {
password, failure := credentials()
if failure == nil {
failure = authFailure(authenticate(tcp, password))
}
return result(outPtr, outCap, map[string]any{"connected": failure == nil}, failure)
}
//go:wasmexport get_server_status
func getServerStatus(_, _ uint32, outPtr, outCap uint32) int32 {
password, failure := credentials()
if failure == nil {
failure = authFailure(authenticate(tcp, password))
}
status := "ready"
if failure != nil {
status = "starting"
if failure.Code == "unauthorized" || failure.Code == "invalid_configuration" {
status = "degraded"
}
}
return result(outPtr, outCap, map[string]any{"status": status}, failure)
}
type messageRequest struct {
Message string `json:"message"`
}
func decodeRequest(inPtr, inLen uint32, value any) bool {
decoder := json.NewDecoder(bytes.NewReader(bytesAt(inPtr, inLen)))
decoder.DisallowUnknownFields()
return decoder.Decode(value) == nil
}
//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 || !utf8Valid(request.Message) {
return result(outPtr, outCap, nil, &moduleError{Code: "invalid_configuration", Message: "The announcement is invalid."})
}
password, failure := credentials()
if failure == nil {
command := execute(tcp, password, "announce "+request.Message)
if command != "" {
failure = rconFailure(errors.New(string(command)))
}
}
return result(outPtr, outCap, map[string]any{"accepted": failure == nil}, failure)
}
func utf8Valid(value string) bool { return strings.ToValidUTF8(value, "") == value }
func main() {}
+5
View File
@@ -0,0 +1,5 @@
//go:build !wasip1
package main
func main() {}
+145
View File
@@ -0,0 +1,145 @@
package main
const (
rconAuth uint32 = 3
rconAuthReply uint32 = 2
rconCommand uint32 = 2
rconCommandOut uint32 = 0
maxRCONPacket = 64 << 10
maxRCONResponse = 256 << 10
)
type rconError string
func (e rconError) Error() string { return string(e) }
const (
errRCONUnauthorized rconError = "rcon authentication rejected"
errRCONMalformed rconError = "invalid rcon response"
)
type rconPacket struct {
id uint32
typ uint32
body string
}
type transportResult string
func (e transportResult) Error() string { return string(e) }
type rconExchange func([]byte) ([]byte, transportResult)
type authResult string
type commandResult string
const (
authOK authResult = ""
authUnauthorized authResult = "unauthorized"
authMalformed authResult = "invalid_response"
)
func writeUint32(buffer []byte, value uint32) {
buffer[0], buffer[1], buffer[2], buffer[3] = byte(value), byte(value>>8), byte(value>>16), byte(value>>24)
}
func readUint32(buffer []byte) uint32 {
return uint32(buffer[0]) | uint32(buffer[1])<<8 | uint32(buffer[2])<<16 | uint32(buffer[3])<<24
}
func encodeRCONPacket(id, typ uint32, body string) ([]byte, error) {
if len(body) > maxRCONPacket-10 {
return nil, errRCONMalformed
}
packet := make([]byte, len(body)+14)
writeUint32(packet[:4], uint32(len(body)+10))
writeUint32(packet[4:8], id)
writeUint32(packet[8:12], typ)
copy(packet[12:], body)
return packet, nil
}
func parseRCONPackets(raw []byte) ([]rconPacket, error) {
if len(raw) == 0 || len(raw) > maxRCONResponse {
return nil, errRCONMalformed
}
packets := make([]rconPacket, 0, 2)
for len(raw) > 0 {
if len(raw) < 14 {
return nil, errRCONMalformed
}
length := int(readUint32(raw[:4]))
if length < 10 || length > maxRCONPacket || length+4 > len(raw) {
return nil, errRCONMalformed
}
packet := raw[4 : length+4]
if packet[length-2] != 0 || packet[length-1] != 0 {
return nil, errRCONMalformed
}
packets = append(packets, rconPacket{id: readUint32(packet[:4]), typ: readUint32(packet[4:8]), body: string(packet[8 : length-2])})
raw = raw[length+4:]
}
return packets, nil
}
func authenticate(exchange rconExchange, password string) authResult {
auth, err := encodeRCONPacket(1, rconAuth, password)
if err != nil {
return authMalformed
}
raw, transportErr := exchange(auth)
if transportErr != "" {
return authResult(transportErr.Error())
}
packets, err := parseRCONPackets(raw)
if err != nil {
return authMalformed
}
for _, packet := range packets {
if packet.typ == rconAuthReply && packet.id == ^uint32(0) {
return authUnauthorized
}
if packet.typ == rconAuthReply && packet.id == 1 {
return authOK
}
}
return authMalformed
}
// execute authenticates and executes a single framed command in one bounded
// TCP exchange. Source RCON accepts pipelined packets; the command is ignored
// by the server when authentication fails.
func execute(exchange rconExchange, password, command string) commandResult {
auth, err := encodeRCONPacket(1, rconAuth, password)
if err != nil {
return commandResult(err.Error())
}
exec, err := encodeRCONPacket(2, rconCommand, command)
if err != nil {
return commandResult(err.Error())
}
raw, transportErr := exchange(append(auth, exec...))
if transportErr != "" {
return commandResult(transportErr.Error())
}
packets, err := parseRCONPackets(raw)
if err != nil {
return commandResult(err.Error())
}
authenticated, completed := false, false
for _, packet := range packets {
if packet.typ == rconAuthReply && packet.id == ^uint32(0) {
return commandResult(errRCONUnauthorized.Error())
}
if packet.typ == rconAuthReply && packet.id == 1 {
authenticated = true
}
if packet.typ == rconCommandOut && packet.id == 2 {
completed = true
}
}
if !authenticated || !completed {
return commandResult(errRCONMalformed.Error())
}
return commandResult("")
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"errors"
"net"
"strings"
"testing"
"time"
)
func packetBytes(t *testing.T, id, typ uint32, body string) []byte {
t.Helper()
value, err := encodeRCONPacket(id, typ, body)
if err != nil {
t.Fatal(err)
}
return value
}
func fakeRCON(t *testing.T, handler func([]byte) []byte) rconExchange {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = listener.Close() })
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(time.Second))
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
return
}
if response := handler(buf[:n]); response != nil {
_, _ = conn.Write(response)
}
}()
return func(request []byte) ([]byte, transportResult) {
conn, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
if err != nil {
return nil, transportResult(err.Error())
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(time.Second))
if _, err := conn.Write(request); err != nil {
return nil, transportResult(err.Error())
}
buf := make([]byte, maxRCONResponse)
n, err := conn.Read(buf)
if err != nil {
return nil, transportResult(err.Error())
}
return buf[:n], transportResult("")
}
}
func TestAuthenticateSuccessAndFailure(t *testing.T) {
if result := authenticate(fakeRCON(t, func([]byte) []byte { return packetBytes(t, 1, rconAuthReply, "") }), "secret"); result != authOK {
t.Fatalf("success: %v", result)
}
if result := authenticate(fakeRCON(t, func([]byte) []byte { return packetBytes(t, ^uint32(0), rconAuthReply, "") }), "secret"); result != authUnauthorized {
t.Fatalf("failure: %v", result)
}
}
func TestExecuteHandlesMultiPacketResponse(t *testing.T) {
exchange := fakeRCON(t, func(request []byte) []byte {
packets, err := parseRCONPackets(request)
if err != nil || len(packets) != 2 || packets[1].body != "announce hello\nsecond line" {
t.Errorf("unexpected request: %#v %v", packets, err)
return nil
}
return append(packetBytes(t, 1, rconAuthReply, ""), append(packetBytes(t, 2, rconCommandOut, "accepted "), packetBytes(t, 2, rconCommandOut, "done")...)...)
})
if result := execute(exchange, "secret", "announce hello\nsecond line"); result != "" {
t.Fatal(result)
}
}
func TestRCONRejectsMalformedAndOversizedPackets(t *testing.T) {
for _, raw := range [][]byte{{1, 2}, make([]byte, maxRCONResponse+1), packetBytes(t, 1, rconAuthReply, "x")[:12]} {
if _, err := parseRCONPackets(raw); !errors.Is(err, errRCONMalformed) {
t.Fatalf("accepted invalid packet: %v", err)
}
}
}
func TestRCONTimeoutAndConnectionRefused(t *testing.T) {
timeout := func([]byte) ([]byte, transportResult) { return nil, transportResult("timeout") }
if result := authenticate(timeout, "secret"); !strings.Contains(string(result), "timeout") {
t.Fatalf("timeout not returned: %v", result)
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
address := listener.Addr().String()
_ = listener.Close()
refused := func([]byte) ([]byte, transportResult) {
_, err := net.DialTimeout("tcp", address, 50*time.Millisecond)
return nil, transportResult(err.Error())
}
if result := authenticate(refused, "secret"); result == authOK {
t.Fatal("connection refusal not returned")
}
}
+23 -3
View File
@@ -32,6 +32,18 @@ container:
tag: latest tag: latest
user_mode: image user_mode: image
stop_timeout_seconds: 120 stop_timeout_seconds: 120
capabilities:
add:
- CHOWN
- FOWNER
- DAC_OVERRIDE
- SETUID
- SETGID
environment:
V_RISING_SERVER_BIND_IP: ""
V_RISING_SERVER_BIND_IP_AUTO_DETECT: "false"
V_RISING_SERVER_PASSWORD: ""
V_RISING_SERVER_GAME_SETTINGS_PRESET: Custom
ports: ports:
- id: game - id: game
@@ -55,6 +67,8 @@ container:
publish: false publish: false
required: true required: true
capabilities: []
storage: storage:
mounts: mounts:
- id: persistent - id: persistent
@@ -388,8 +402,14 @@ configuration:
minimum: 1 minimum: 1
maximum: 32 maximum: 32
capabilities: module:
- graceful_shutdown path: module/manifest.yaml
integration:
module_id: vrising-rcon
version_range: ">=1.0.0 <2.0.0"
port_id: rcon
required: false
backup: backup:
strategy: stop_then_archive strategy: stop_then_archive
@@ -428,4 +448,4 @@ updates:
compatibility: compatibility:
minimum_manager_version: 1.0.0 minimum_manager_version: 1.0.0
requires_instance_migration: false requires_instance_migration: false
+3
View File
@@ -22,6 +22,7 @@ services:
networks: networks:
- dogama - dogama
- control - control
- games
depends_on: depends_on:
agent: agent:
condition: service_started condition: service_started
@@ -53,6 +54,8 @@ networks:
name: ${DOGAMA_NETWORK:-dogama} name: ${DOGAMA_NETWORK:-dogama}
control: control:
internal: true internal: true
games:
name: ${DOGAMA_GAMES_NETWORK:-dogama-games}
volumes: volumes:
agent_state: agent_state:
+2
View File
@@ -77,6 +77,8 @@ Read this compact operational baseline before starting a milestone. Open detaile
- At least one active global administrator is always retained; deactivation revokes that user's sessions atomically. - At least one active global administrator is always retained; deactivation revokes that user's sessions atomically.
- The local template directory (`/var/lib/dogama/templates`, under the application data bind mount) is the catalog source of truth for administrator-owned customizations. Bundled templates are copied only when their destination files are absent; after every local scan, the current bundled immutable snapshots are synchronized into SQLite and selected for new deployments while older snapshots remain available for existing instances, audit and diagnostics. - The local template directory (`/var/lib/dogama/templates`, under the application data bind mount) is the catalog source of truth for administrator-owned customizations. Bundled templates are copied only when their destination files are absent; after every local scan, the current bundled immutable snapshots are synchronized into SQLite and selected for new deployments while older snapshots remain available for existing instances, audit and diagnostics.
- Administrators can persist bounded HTTP(S) template-repository definitions for future use. They are configuration only: remote retrieval, authentication, synchronization and automatic updates are deliberately unavailable, and Catalog Scan remains local-only. - Administrators can persist bounded HTTP(S) template-repository definitions for future use. They are configuration only: remote retrieval, authentication, synchronization and automatic updates are deliberately unavailable, and Catalog Scan remains local-only.
- V Rising is the first template-scoped TCP RCON module. Its manifest currently declares only verified connectivity/status; command operations are not advertised until validated end-to-end against a real server. Go/WASI reactor modules use `-buildmode=c-shared` and initialize through `_initialize`; the V Rising success path uses concrete results to avoid Go 1.26 WASI reactor nil-interface traps. Wazero is pinned at v1.12.0.
- Module TCP access is instance-scoped and template-bound: the guest supplies no destination, only bounded bytes; the host pins the instance network address and declared integration port, enforces deadlines and response limits, and rejects arbitrary/unsafe destinations.
## Known limitations and debt ## Known limitations and debt
+11
View File
@@ -43,10 +43,21 @@ The runtime grants no ambient WASI filesystem, process, environment, raw sockets
- structured diagnostic emission with runtime redaction; - structured diagnostic emission with runtime redaction;
- cancellation/deadline checks. - cancellation/deadline checks.
### Go/WASI reactor modules
Go modules targeting `wasip1` are built as reactors with `-buildmode=c-shared`.
The resulting WASM must export `_initialize` and the module exports used by the
manifest; the host calls `_initialize` before invoking an operation. A module
must not rely on the WASI command `_start` entry point. A reproducible build
uses `CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm` and records the resulting artifact
digest in its template-local manifest.
## Instance-scoped networking ## Instance-scoped networking
Modules never receive an arbitrary destination URL. At activation, DoGaMa binds `instance_api` to a specific instance network identity and declared integration port. Every request is checked for protocol, port, method, timeout, redirect, request/response size and concurrency. Modules never receive an arbitrary destination URL. At activation, DoGaMa binds `instance_api` to a specific instance network identity and declared integration port. Every request is checked for protocol, port, method, timeout, redirect, request/response size and concurrency.
The application service is attached to the fixed `DOGAMA_GAMES_NETWORK` network so this instance-scoped binding can resolve the selected container. The module still supplies no destination and the host pins every connection to the resolved instance address and declared port.
- No Internet or LAN destinations. - No Internet or LAN destinations.
- No loopback, link-local, metadata or Unix-socket destinations. - No loopback, link-local, metadata or Unix-socket destinations.
- No redirects outside the bound origin. - No redirects outside the bound origin.
+1 -1
View File
@@ -7,7 +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 github.com/tetratelabs/wazero v1.12.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
golang.org/x/text v0.38.0 golang.org/x/text v0.38.0
+2 -2
View File
@@ -20,8 +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.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
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=
+2
View File
@@ -201,6 +201,7 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla
NanoCPUs int64 `json:"NanoCpus"` NanoCPUs int64 `json:"NanoCpus"`
PidsLimit *int64 `json:"PidsLimit"` PidsLimit *int64 `json:"PidsLimit"`
CapDrop []string `json:"CapDrop"` CapDrop []string `json:"CapDrop"`
CapAdd []string `json:"CapAdd"`
SecurityOpt []string `json:"SecurityOpt"` SecurityOpt []string `json:"SecurityOpt"`
NetworkMode string `json:"NetworkMode"` NetworkMode string `json:"NetworkMode"`
RestartPolicy map[string]string `json:"RestartPolicy"` RestartPolicy map[string]string `json:"RestartPolicy"`
@@ -224,6 +225,7 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla
payload.HostConfig.NanoCPUs = int64(plan.Resources.CPUCores * 1_000_000_000) payload.HostConfig.NanoCPUs = int64(plan.Resources.CPUCores * 1_000_000_000)
payload.HostConfig.PidsLimit = &pidsLimit payload.HostConfig.PidsLimit = &pidsLimit
payload.HostConfig.CapDrop = []string{"ALL"} payload.HostConfig.CapDrop = []string{"ALL"}
payload.HostConfig.CapAdd = append([]string(nil), plan.CapAdd...)
payload.HostConfig.SecurityOpt = []string{"no-new-privileges:true"} payload.HostConfig.SecurityOpt = []string{"no-new-privileges:true"}
payload.HostConfig.NetworkMode = d.network payload.HostConfig.NetworkMode = d.network
payload.HostConfig.RestartPolicy = map[string]string{"Name": "no"} payload.HostConfig.RestartPolicy = map[string]string{"Name": "no"}
+2 -1
View File
@@ -150,6 +150,7 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
Binds []string Binds []string
NetworkMode string `json:"NetworkMode"` NetworkMode string `json:"NetworkMode"`
CapDrop []string `json:"CapDrop"` CapDrop []string `json:"CapDrop"`
CapAdd []string `json:"CapAdd"`
SecurityOpt []string `json:"SecurityOpt"` SecurityOpt []string `json:"SecurityOpt"`
Memory int64 `json:"Memory"` Memory int64 `json:"Memory"`
NanoCPUs int64 `json:"NanoCpus"` NanoCPUs int64 `json:"NanoCpus"`
@@ -159,7 +160,7 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
if err := json.Unmarshal(<-createdBodies, &payload); err != nil { if err := json.Unmarshal(<-createdBodies, &payload); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if payload.HostConfig.NetworkMode != "nas-games" || len(payload.HostConfig.CapDrop) != 1 || payload.HostConfig.CapDrop[0] != "ALL" || len(payload.HostConfig.SecurityOpt) != 1 || payload.HostConfig.Memory <= 0 || payload.HostConfig.NanoCPUs <= 0 { if payload.HostConfig.NetworkMode != "nas-games" || len(payload.HostConfig.CapDrop) != 1 || payload.HostConfig.CapDrop[0] != "ALL" || len(payload.HostConfig.CapAdd) != 0 || len(payload.HostConfig.SecurityOpt) != 1 || payload.HostConfig.Memory <= 0 || payload.HostConfig.NanoCPUs <= 0 {
t.Fatalf("insecure Docker host config: %#v", payload.HostConfig) t.Fatalf("insecure Docker host config: %#v", payload.HostConfig)
} }
} }
+1 -1
View File
@@ -78,7 +78,7 @@ func (p *PlanPolicy) Validate(plan agentwire.DeploymentPlan) error {
} }
template := snapshot.Template template := snapshot.Template
imagePrefix := template.Container.Image + ":" imagePrefix := template.Container.Image + ":"
if !strings.HasPrefix(plan.Image, imagePrefix) || !reflect.DeepEqual(plan.Entrypoint, template.Container.Entrypoint) || plan.StopTimeoutSeconds != template.Container.StopTimeoutSeconds { if !strings.HasPrefix(plan.Image, imagePrefix) || !reflect.DeepEqual(plan.Entrypoint, template.Container.Entrypoint) || !reflect.DeepEqual(plan.CapAdd, template.Container.Capabilities.Add) || plan.StopTimeoutSeconds != template.Container.StopTimeoutSeconds {
return errors.New("container plan differs from template") return errors.New("container plan differs from template")
} }
if len(plan.Arguments) < len(template.Container.Arguments) || !reflect.DeepEqual(plan.Arguments[:len(template.Container.Arguments)], template.Container.Arguments) || !allowedArguments(template, plan.Arguments[len(template.Container.Arguments):]) || !allowedEnvironment(template, plan.Environment) { if len(plan.Arguments) < len(template.Container.Arguments) || !reflect.DeepEqual(plan.Arguments[:len(template.Container.Arguments)], template.Container.Arguments) || !allowedArguments(template, plan.Arguments[len(template.Container.Arguments):]) || !allowedEnvironment(template, plan.Environment) {
+19 -1
View File
@@ -32,6 +32,7 @@ type DeploymentPlan struct {
Entrypoint []string `json:"entrypoint,omitempty"` Entrypoint []string `json:"entrypoint,omitempty"`
Arguments []string `json:"arguments,omitempty"` Arguments []string `json:"arguments,omitempty"`
Environment map[string]string `json:"environment,omitempty"` Environment map[string]string `json:"environment,omitempty"`
CapAdd []string `json:"cap_add,omitempty"`
Ports []PlanPort `json:"ports"` Ports []PlanPort `json:"ports"`
Mounts []PlanMount `json:"mounts"` Mounts []PlanMount `json:"mounts"`
Resources PlanResource `json:"resources"` Resources PlanResource `json:"resources"`
@@ -71,6 +72,7 @@ func (p DeploymentPlan) CanonicalDigest() (string, error) {
for key, value := range p.Environment { for key, value := range p.Environment {
copyPlan.Environment[key] = value copyPlan.Environment[key] = value
} }
copyPlan.CapAdd = append([]string(nil), p.CapAdd...)
copyPlan.Ports = append([]PlanPort(nil), p.Ports...) copyPlan.Ports = append([]PlanPort(nil), p.Ports...)
copyPlan.Mounts = append([]PlanMount(nil), p.Mounts...) copyPlan.Mounts = append([]PlanMount(nil), p.Mounts...)
sort.Slice(copyPlan.Ports, func(i, j int) bool { return copyPlan.Ports[i].ID < copyPlan.Ports[j].ID }) sort.Slice(copyPlan.Ports, func(i, j int) bool { return copyPlan.Ports[i].ID < copyPlan.Ports[j].ID })
@@ -96,7 +98,7 @@ func (p DeploymentPlan) Validate() error {
if p.StopTimeoutSeconds < 5 || p.StopTimeoutSeconds > 900 || len(p.Ports) > 32 || len(p.Mounts) == 0 || len(p.Mounts) > 16 { if p.StopTimeoutSeconds < 5 || p.StopTimeoutSeconds > 900 || len(p.Ports) > 32 || len(p.Mounts) == 0 || len(p.Mounts) > 16 {
return errors.New("invalid deployment plan limits") return errors.New("invalid deployment plan limits")
} }
if len(p.Labels) > 64 || len(p.Environment) > 64 || len(p.User) > 32 { if len(p.Labels) > 64 || len(p.Environment) > 64 || len(p.CapAdd) > 16 || len(p.User) > 32 {
return errors.New("invalid deployment plan container configuration") return errors.New("invalid deployment plan container configuration")
} }
for key, value := range p.Environment { for key, value := range p.Environment {
@@ -104,6 +106,13 @@ func (p DeploymentPlan) Validate() error {
return errors.New("invalid deployment plan environment") return errors.New("invalid deployment plan environment")
} }
} }
seenCaps := map[string]bool{}
for _, capability := range p.CapAdd {
if !validLinuxCapability(capability) || seenCaps[capability] {
return errors.New("invalid deployment plan capabilities")
}
seenCaps[capability] = true
}
for key, value := range p.Labels { for key, value := range p.Labels {
lower := strings.ToLower(key) lower := strings.ToLower(key)
if key == "" || len(key) > 255 || len(value) > 4096 || strings.HasPrefix(lower, "dogama.") || strings.HasPrefix(lower, "io.dogama.") { if key == "" || len(key) > 255 || len(value) > 4096 || strings.HasPrefix(lower, "dogama.") || strings.HasPrefix(lower, "io.dogama.") {
@@ -166,6 +175,15 @@ func (p DeploymentPlan) Validate() error {
return nil return nil
} }
func validLinuxCapability(value string) bool {
switch value {
case "CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID", "NET_BIND_SERVICE", "NET_RAW", "SYS_CHROOT":
return true
default:
return false
}
}
type InstanceState struct { type InstanceState struct {
InstanceID string `json:"instance_id"` InstanceID string `json:"instance_id"`
ContainerID string `json:"container_id"` ContainerID string `json:"container_id"`
+11
View File
@@ -43,3 +43,14 @@ func TestDeploymentPlanRejectsReservedLabelsAndInvalidUser(t *testing.T) {
t.Fatal("non-numeric Docker user accepted") t.Fatal("non-numeric Docker user accepted")
} }
} }
func TestDeploymentPlanRejectsUnknownOrAllCapabilities(t *testing.T) {
plan := DeploymentPlan{SchemaVersion: DeploymentPlanVersion, InstanceID: "abcdefghijklmnopqrstuvwx", TemplateID: "palworld-official", TemplateVersion: "1.0.0", TemplateDigest: strings.Repeat("a", 64), Image: "example.invalid/game:1", CapAdd: []string{"ALL"}, Ports: []PlanPort{{ID: "game", Protocol: "udp", ContainerPort: 8211, HostPort: 38211, Publish: true}}, Mounts: []PlanMount{{ID: "saved", HostPath: filepath.Join(string(filepath.Separator), "srv", "games", "saved"), ContainerPath: "/game/saved"}}, Resources: PlanResource{CPUCores: 2, MemoryMB: 1024, StorageGB: 10}, StopTimeoutSeconds: 30}
if err := plan.Validate(); err == nil {
t.Fatal("ALL capability accepted")
}
plan.CapAdd = []string{"CHOWN", "CHOWN"}
if err := plan.Validate(); err == nil {
t.Fatal("duplicate capability accepted")
}
}
+21 -2
View File
@@ -154,8 +154,27 @@ func TestScanDirAcceptsLocalTemplateWithoutModule(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
for _, snapshot := range result.Valid { for _, snapshot := range result.Valid {
if snapshot.Template.Module == nil { if snapshot.Template.ID != "vrising-didstopia" {
return continue
}
path := filepath.Join(root, snapshot.AssetRoot, "template.yaml")
body, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatal(readErr)
}
withoutModule := strings.Replace(string(body), "module:\n path: module/manifest.yaml\n\n", "", 1)
withoutModule = strings.Replace(withoutModule, "integration:\n module_id: vrising-rcon\n version_range: \">=1.0.0 <2.0.0\"\n port_id: rcon\n required: false\n\n", "", 1)
if err := os.WriteFile(path, []byte(withoutModule), 0o644); err != nil {
t.Fatal(err)
}
rescanned, scanErr := catalog.ScanDir(root)
if scanErr != nil {
t.Fatal(scanErr)
}
for _, candidate := range rescanned.Valid {
if candidate.Template.ID == "vrising-didstopia" && candidate.Template.Module == nil {
return
}
} }
} }
t.Fatal("a template without an optional module was not accepted") t.Fatal("a template without an optional module was not accepted")
+11 -8
View File
@@ -63,14 +63,17 @@ type Template struct {
Recommended Resources `json:"recommended"` Recommended Resources `json:"recommended"`
} `json:"requirements"` } `json:"requirements"`
Container struct { Container struct {
Image string `json:"image"` Image string `json:"image"`
Tag string `json:"tag"` Tag string `json:"tag"`
Entrypoint []string `json:"entrypoint,omitempty"` Entrypoint []string `json:"entrypoint,omitempty"`
UserMode string `json:"user_mode,omitempty"` UserMode string `json:"user_mode,omitempty"`
Arguments []string `json:"arguments,omitempty"` Arguments []string `json:"arguments,omitempty"`
Environment map[string]string `json:"environment,omitempty"` Environment map[string]string `json:"environment,omitempty"`
StopTimeoutSeconds int `json:"stop_timeout_seconds"` Capabilities struct {
Ports []Port `json:"ports"` Add []string `json:"add,omitempty"`
} `json:"capabilities,omitempty"`
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
Ports []Port `json:"ports"`
Assets []struct { Assets []struct {
Source string `json:"source"` Source string `json:"source"`
Destination string `json:"destination"` Destination string `json:"destination"`
@@ -52,6 +52,9 @@ func ResolveConfiguration(template catalog.Template, values map[string]string) (
target := field.Target target := field.Target
switch target.Kind { switch target.Kind {
case "environment": case "environment":
if value == "" && !field.Required {
continue
}
if protectedEnvironment[target.Name] || strings.HasPrefix(target.Name, "DOGAMA_") { if protectedEnvironment[target.Name] || strings.HasPrefix(target.Name, "DOGAMA_") {
return ResolvedConfiguration{}, fmt.Errorf("%s targets a protected environment variable", field.ID) return ResolvedConfiguration{}, fmt.Errorf("%s targets a protected environment variable", field.ID)
} }
@@ -40,6 +40,30 @@ func TestResolveConfigurationTargets(t *testing.T) {
} }
} }
func TestEnvironmentValuesRemainExactAndOptionalValuesAreOmitted(t *testing.T) {
template := catalog.Template{}
template.Configuration.Fields = []catalog.ConfigField{
{ID: "port", Type: "integer", Target: catalog.ConfigTarget{Kind: "environment", Name: "GAME_PORT"}},
{ID: "enabled", Type: "boolean", Target: catalog.ConfigTarget{Kind: "environment", Name: "ENABLED"}},
{ID: "optional", Type: "string", Target: catalog.ConfigTarget{Kind: "environment", Name: "OPTIONAL"}},
}
resolved, err := instance.ResolveConfiguration(template, map[string]string{"port": "9876", "enabled": "true", "optional": ""})
if err != nil {
t.Fatal(err)
}
if resolved.Environment["GAME_PORT"] != "9876" || resolved.Environment["ENABLED"] != "true" {
t.Fatalf("environment values were rewritten: %#v", resolved.Environment)
}
if _, ok := resolved.Environment["OPTIONAL"]; ok {
t.Fatalf("empty optional environment was emitted: %#v", resolved.Environment)
}
for key, value := range resolved.Environment {
if strings.HasPrefix(value, "= ") || strings.HasPrefix(value, "=") {
t.Fatalf("synthetic environment prefix for %s: %q", key, value)
}
}
}
func TestPalworldServerNameIsWrittenBeforeStart(t *testing.T) { func TestPalworldServerNameIsWrittenBeforeStart(t *testing.T) {
snapshots, err := catalog.LoadFS(catalogdata.Files, ".") snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil { if err != nil {
+13 -2
View File
@@ -75,6 +75,7 @@ type moduleManifest struct {
Network struct { Network struct {
PortIDs []string `yaml:"port_ids"` PortIDs []string `yaml:"port_ids"`
HTTPMethods []string `yaml:"http_methods"` HTTPMethods []string `yaml:"http_methods"`
Protocols []string `yaml:"protocols"`
} `yaml:"network"` } `yaml:"network"`
} `yaml:"permissions"` } `yaml:"permissions"`
Limits struct { Limits struct {
@@ -124,6 +125,16 @@ func (s *ModuleService) runtime(ctx context.Context, value StoredInstance) (*mod
return nil, moduleManifest{}, ErrModuleUnavailable return nil, moduleManifest{}, ErrModuleUnavailable
} }
methods := map[string]bool{} methods := map[string]bool{}
tcpAllowed := false
for _, protocol := range manifest.Permissions.Network.Protocols {
switch protocol {
case "http":
case "tcp":
tcpAllowed = true
default:
return nil, moduleManifest{}, ErrModuleUnavailable
}
}
for _, method := range manifest.Permissions.Network.HTTPMethods { for _, method := range manifest.Permissions.Network.HTTPMethods {
if method == "GET" || method == "POST" { if method == "GET" || method == "POST" {
methods[method] = true methods[method] = true
@@ -131,7 +142,7 @@ func (s *ModuleService) runtime(ctx context.Context, value StoredInstance) (*mod
return nil, moduleManifest{}, ErrModuleUnavailable return nil, moduleManifest{}, ErrModuleUnavailable
} }
} }
if len(methods) == 0 || manifest.Artifacts.WASM == "" || strings.Contains(manifest.Artifacts.WASM, "/") || strings.Contains(manifest.Artifacts.WASM, "..") { if (len(methods) == 0 && !tcpAllowed) || manifest.Artifacts.WASM == "" || strings.Contains(manifest.Artifacts.WASM, "/") || strings.Contains(manifest.Artifacts.WASM, "..") {
return nil, moduleManifest{}, ErrModuleUnavailable return nil, moduleManifest{}, ErrModuleUnavailable
} }
config, secrets := map[string]string{}, map[string]string{} config, secrets := map[string]string{}, map[string]string{}
@@ -244,7 +255,7 @@ func (s *ModuleService) Action(ctx context.Context, value StoredInstance, capabi
var out struct { var out struct {
Accepted bool `json:"accepted"` Accepted bool `json:"accepted"`
} }
if err := r.Call(ctx, operation, request, &out); err != nil || !out.Accepted { if err := r.CallData(ctx, operation, request, &out); err != nil || !out.Accepted {
return ErrModuleUnavailable return ErrModuleUnavailable
} }
return nil return nil
+3
View File
@@ -45,6 +45,7 @@ type Preview struct {
Image string `json:"image"` Image string `json:"image"`
Entrypoint []string `json:"entrypoint,omitempty"` Entrypoint []string `json:"entrypoint,omitempty"`
Arguments []string `json:"arguments,omitempty"` Arguments []string `json:"arguments,omitempty"`
CapAdd []string `json:"cap_add,omitempty"`
StopTimeoutSeconds int `json:"stop_timeout_seconds"` StopTimeoutSeconds int `json:"stop_timeout_seconds"`
StartupTimeoutSeconds int `json:"startup_timeout_seconds"` StartupTimeoutSeconds int `json:"startup_timeout_seconds"`
Ports []PortBinding `json:"ports"` Ports []PortBinding `json:"ports"`
@@ -283,6 +284,7 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
Image: snapshot.Template.Container.Image + ":" + tag.Tag, Image: snapshot.Template.Container.Image + ":" + tag.Tag,
Entrypoint: append([]string(nil), snapshot.Template.Container.Entrypoint...), Entrypoint: append([]string(nil), snapshot.Template.Container.Entrypoint...),
Arguments: append([]string(nil), snapshot.Template.Container.Arguments...), Arguments: append([]string(nil), snapshot.Template.Container.Arguments...),
CapAdd: append([]string(nil), snapshot.Template.Container.Capabilities.Add...),
StopTimeoutSeconds: snapshot.Template.Container.StopTimeoutSeconds, StopTimeoutSeconds: snapshot.Template.Container.StopTimeoutSeconds,
StartupTimeoutSeconds: snapshot.Template.Healthcheck.StartupTimeoutSeconds, StartupTimeoutSeconds: snapshot.Template.Healthcheck.StartupTimeoutSeconds,
Ports: ports, Mounts: mounts, Resources: resources, Settings: settings, Ports: ports, Mounts: mounts, Resources: resources, Settings: settings,
@@ -386,6 +388,7 @@ func (p Preview) deploymentPlan(instanceID string, secrets map[string]string) (a
TemplateID: p.Template.ID, TemplateVersion: p.Template.Version, TemplateDigest: p.Template.Digest, TemplateID: p.Template.ID, TemplateVersion: p.Template.Version, TemplateDigest: p.Template.Digest,
Image: p.Image, Entrypoint: append([]string(nil), p.Entrypoint...), Arguments: arguments, Environment: environment, Image: p.Image, Entrypoint: append([]string(nil), p.Entrypoint...), Arguments: arguments, Environment: environment,
Labels: MergeLabels(nil, global, local), User: p.DockerUserValue, Labels: MergeLabels(nil, global, local), User: p.DockerUserValue,
CapAdd: append([]string(nil), p.CapAdd...),
Resources: agentwire.PlanResource{CPUCores: p.Resources.CPUCores, MemoryMB: p.Resources.MemoryMB, StorageGB: p.Resources.StorageGB}, Resources: agentwire.PlanResource{CPUCores: p.Resources.CPUCores, MemoryMB: p.Resources.MemoryMB, StorageGB: p.Resources.StorageGB},
StopTimeoutSeconds: p.StopTimeoutSeconds, StopTimeoutSeconds: p.StopTimeoutSeconds,
} }
+144 -15
View File
@@ -13,6 +13,7 @@ import (
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path" "path"
"strings" "strings"
"sync" "sync"
@@ -50,6 +51,12 @@ type Binding struct {
Secrets map[string]string Secrets map[string]string
} }
// TCPRequest is a single, bounded exchange with the template-declared TCP
// integration endpoint. The destination is never supplied by a module.
type TCPRequest struct {
Body []byte `json:"body"`
}
type HTTPRequest struct { type HTTPRequest struct {
Method string `json:"method"` Method string `json:"method"`
Path string `json:"path"` Path string `json:"path"`
@@ -69,6 +76,7 @@ type Runtime struct {
limits Limits limits Limits
binding Binding binding Binding
client *http.Client client *http.Client
dialContext func(context.Context, string, string) (net.Conn, error)
sem chan struct{} sem chan struct{}
mu sync.Mutex mu sync.Mutex
failures int failures int
@@ -76,14 +84,20 @@ type Runtime struct {
} }
func New(ctx context.Context, wasm []byte, checksum string, capabilities []string, limits Limits, binding Binding) (*Runtime, error) { func New(ctx context.Context, wasm []byte, checksum string, capabilities []string, limits Limits, binding Binding) (*Runtime, error) {
transport, err := pinnedTransport(ctx, binding) transport, dialContext, err := pinnedTransport(ctx, binding)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return newWithTransport(wasm, checksum, capabilities, limits, binding, transport) return newWithTransportAndDial(wasm, checksum, capabilities, limits, binding, transport, dialContext)
} }
func newWithTransport(wasm []byte, checksum string, capabilities []string, limits Limits, binding Binding, transport http.RoundTripper) (*Runtime, error) { func newWithTransport(wasm []byte, checksum string, capabilities []string, limits Limits, binding Binding, transport http.RoundTripper) (*Runtime, error) {
return newWithTransportAndDial(wasm, checksum, capabilities, limits, binding, transport, func(context.Context, string, string) (net.Conn, error) {
return nil, errors.New("TCP exchange is unavailable in this test runtime")
})
}
func newWithTransportAndDial(wasm []byte, checksum string, capabilities []string, limits Limits, binding Binding, transport http.RoundTripper, dialContext func(context.Context, string, string) (net.Conn, error)) (*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 { 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") return nil, errors.New("invalid module runtime configuration")
} }
@@ -91,7 +105,7 @@ func newWithTransport(wasm []byte, checksum string, capabilities []string, limit
if hex.EncodeToString(digest[:]) != checksum { if hex.EncodeToString(digest[:]) != checksum {
return nil, errors.New("module checksum mismatch") return nil, errors.New("module checksum mismatch")
} }
if binding.InstanceID == "" || binding.ContainerPort < 1 || binding.ContainerPort > 65535 || transport == nil { if binding.InstanceID == "" || binding.ContainerPort < 1 || binding.ContainerPort > 65535 || transport == nil || dialContext == nil {
return nil, errors.New("invalid instance API binding") return nil, errors.New("invalid instance API binding")
} }
seen := map[string]bool{} seen := map[string]bool{}
@@ -101,35 +115,36 @@ func newWithTransport(wasm []byte, checksum string, capabilities []string, limit
} }
seen[capability] = true 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 := &Runtime{wasm: append([]byte(nil), wasm...), capabilities: append([]string(nil), capabilities...), limits: limits, binding: binding, sem: make(chan struct{}, limits.MaxConcurrentCall), dialContext: dialContext}
r.client = &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} r.client = &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
return r, nil return r, nil
} }
func pinnedTransport(ctx context.Context, binding Binding) (http.RoundTripper, error) { func pinnedTransport(ctx context.Context, binding Binding) (http.RoundTripper, func(context.Context, string, string) (net.Conn, error), error) {
hostname := "dogama-" + strings.ToLower(binding.InstanceID) hostname := "dogama-" + strings.ToLower(binding.InstanceID)
lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second) lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel() defer cancel()
addresses, err := net.DefaultResolver.LookupIPAddr(lookupCtx, hostname) addresses, err := net.DefaultResolver.LookupIPAddr(lookupCtx, hostname)
if err != nil || len(addresses) == 0 { if err != nil || len(addresses) == 0 {
return nil, errors.New("resolve bound instance API") return nil, nil, errors.New("resolve bound instance API")
} }
ip := addresses[0].IP ip := addresses[0].IP
if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() { if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() {
return nil, errors.New("unsafe instance API address") return nil, nil, errors.New("unsafe instance API address")
} }
pinned := net.JoinHostPort(ip.String(), fmt.Sprint(binding.ContainerPort)) pinned := net.JoinHostPort(ip.String(), fmt.Sprint(binding.ContainerPort))
dialer := &net.Dialer{Timeout: 2 * time.Second, KeepAlive: 30 * time.Second} dialer := &net.Dialer{Timeout: 2 * time.Second, KeepAlive: 30 * time.Second}
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)
}
return &http.Transport{ return &http.Transport{
DisableCompression: true, DisableCompression: true,
Proxy: nil, Proxy: nil,
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { DialContext: dialContext,
if network != "tcp" && network != "tcp4" && network != "tcp6" { }, dialContext, nil
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 { func (r *Runtime) Call(ctx context.Context, operation string, request any, response any) error {
@@ -168,6 +183,33 @@ func (r *Runtime) Call(ctx context.Context, operation string, request any, respo
return nil return nil
} }
// CallData unwraps the normalized module envelope for typed service callers.
// Call remains available for diagnostics and callers that need the envelope.
func (r *Runtime) CallData(ctx context.Context, operation string, request any, response any) error {
var envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := r.Call(ctx, operation, request, &envelope); err != nil {
return err
}
if !envelope.OK {
if envelope.Error != nil && envelope.Error.Message != "" {
return errors.New(envelope.Error.Message)
}
return errors.New("module operation failed")
}
decoder := json.NewDecoder(bytes.NewReader(envelope.Data))
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 { func (r *Runtime) allowedOperation(operation string) bool {
if operation == "initialize" || operation == "test_connection" || operation == "get_server_status" { if operation == "initialize" || operation == "test_connection" || operation == "get_server_status" {
return true return true
@@ -184,9 +226,13 @@ type callState struct{ hostCalls int }
type stateKey struct{} type stateKey struct{}
func (r *Runtime) invoke(ctx context.Context, operation string, input []byte) ([]byte, error) { func (r *Runtime) invoke(ctx context.Context, operation string, input []byte) ([]byte, error) {
return r.invokeWithConfig(ctx, operation, input, wazero.NewRuntimeConfigInterpreter())
}
func (r *Runtime) invokeWithConfig(ctx context.Context, operation string, input []byte, baseConfig wazero.RuntimeConfig) ([]byte, error) {
ctx = context.WithValue(ctx, stateKey{}, &callState{}) ctx = context.WithValue(ctx, stateKey{}, &callState{})
pages := (r.limits.MemoryMB*1024*1024 + 65535) / 65536 pages := (r.limits.MemoryMB*1024*1024 + 65535) / 65536
config := wazero.NewRuntimeConfigInterpreter().WithMemoryLimitPages(pages).WithCloseOnContextDone(true).WithDebugInfoEnabled(false) config := baseConfig.WithMemoryLimitPages(pages).WithCloseOnContextDone(true).WithDebugInfoEnabled(false)
runtime := wazero.NewRuntimeWithConfig(ctx, config) runtime := wazero.NewRuntimeWithConfig(ctx, config)
defer func() { _ = runtime.Close(ctx) }() defer func() { _ = runtime.Close(ctx) }()
if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil {
@@ -194,6 +240,7 @@ func (r *Runtime) invoke(ctx context.Context, operation string, input []byte) ([
} }
host := runtime.NewHostModuleBuilder("dogama_host") host := runtime.NewHostModuleBuilder("dogama_host")
host.NewFunctionBuilder().WithFunc(r.httpRequest).Export("http_request") host.NewFunctionBuilder().WithFunc(r.httpRequest).Export("http_request")
host.NewFunctionBuilder().WithFunc(r.tcpExchange).Export("tcp_exchange")
host.NewFunctionBuilder().WithFunc(r.getSecret).Export("get_secret") host.NewFunctionBuilder().WithFunc(r.getSecret).Export("get_secret")
host.NewFunctionBuilder().WithFunc(r.getConfig).Export("get_config") host.NewFunctionBuilder().WithFunc(r.getConfig).Export("get_config")
if _, err := host.Instantiate(ctx); err != nil { if _, err := host.Instantiate(ctx); err != nil {
@@ -234,6 +281,88 @@ func (r *Runtime) invoke(ctx context.Context, operation string, input []byte) ([
return append([]byte(nil), body...), nil return append([]byte(nil), body...), nil
} }
// tcpExchange provides no socket handle to the module. It writes one bounded
// request to the instance-pinned integration endpoint then returns bytes read
// until a short idle period, EOF, or the call deadline. This permits framed
// protocols such as Source RCON while keeping every operation bounded.
func (r *Runtime) tcpExchange(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 TCPRequest
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if decoder.Decode(&request) != nil || len(request.Body) == 0 || len(request.Body) > maxRequestBytes {
return -2
}
conn, err := r.dialContext(ctx, "tcp", "")
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || isTimeout(err) {
return -4
}
return -3
}
defer func() { _ = conn.Close() }()
deadline := time.Now().Add(r.limits.Timeout)
if value, exists := ctx.Deadline(); exists && value.Before(deadline) {
deadline = value
}
if err := conn.SetDeadline(deadline); err != nil {
return -3
}
if _, err := conn.Write(request.Body); err != nil {
if isTimeout(err) {
return -4
}
return -3
}
result := make([]byte, 0, min(int(responseCap), 4096))
buffer := make([]byte, min(4096, int(responseCap)))
for {
if len(result) > 0 {
_ = conn.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
}
n, readErr := conn.Read(buffer)
if n > 0 {
if len(result)+n > int(responseCap) {
return -5
}
result = append(result, buffer[:n]...)
}
if readErr != nil {
if errors.Is(readErr, io.EOF) || (len(result) > 0 && isTimeout(readErr)) {
break
}
if isTimeout(readErr) {
return -4
}
return -3
}
}
if len(result) == 0 || !mod.Memory().Write(responsePtr, result) {
return -5
}
return int32(len(result))
}
func isTimeout(err error) bool {
var netErr net.Error
return errors.Is(err, os.ErrDeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout())
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func validateABI(compiled wazero.CompiledModule, capabilities []string) error { func validateABI(compiled wazero.CompiledModule, capabilities []string) error {
exports := compiled.ExportedFunctions() exports := compiled.ExportedFunctions()
for _, name := range []string{"dogama_alloc", "initialize", "test_connection", "get_server_status"} { for _, name := range []string{"dogama_alloc", "initialize", "test_connection", "get_server_status"} {
+122
View File
@@ -3,16 +3,29 @@ package module
import ( import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/binary"
"encoding/hex" "encoding/hex"
"errors" "errors"
"io" "io"
"net"
"net/http" "net/http"
"os" "os"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/tetratelabs/wazero"
) )
func sourceRCONPacket(id, typ uint32, body string) []byte {
value := make([]byte, len(body)+14)
binary.LittleEndian.PutUint32(value[:4], uint32(len(body)+10))
binary.LittleEndian.PutUint32(value[4:8], id)
binary.LittleEndian.PutUint32(value[8:12], typ)
copy(value[12:], body)
return value
}
type roundTripFunc func(*http.Request) (*http.Response, error) type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
@@ -145,3 +158,112 @@ func TestHTTPRequestPinsInstanceOriginAndDisablesRedirects(t *testing.T) {
t.Fatal("bound transport was not used") t.Fatal("bound transport was not used")
} }
} }
func TestVRisingAdapterUsesPinnedBoundedTCPRCON(t *testing.T) {
wasm, err := os.ReadFile("../../catalog/vrising/module/module.wasm")
if err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(wasm)
var seen []byte
dial := func(context.Context, string, string) (net.Conn, error) {
client, server := net.Pipe()
go func() {
defer server.Close()
buffer := make([]byte, 4096)
n, readErr := server.Read(buffer)
if readErr != nil {
return
}
seen = append([]byte(nil), buffer[:n]...)
_, _ = server.Write(sourceRCONPacket(1, 2, ""))
}()
return client, nil
}
runtime, err := newWithTransportAndDial(wasm, hex.EncodeToString(digest[:]), nil, Limits{MemoryMB: 64, Timeout: 10 * time.Second, MaxResponseBytes: 262144, MaxConcurrentCall: 2}, Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 9878, Configuration: map[string]string{"rcon_enabled": "true", "rcon_port": "9878"}, Secrets: map[string]string{"rcon_password": "not-in-output"}}, roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("HTTP must not be used") }), dial)
if err != nil {
t.Fatal(err)
}
var response struct {
OK bool `json:"ok"`
Data any `json:"data"`
Error any `json:"error"`
}
if err := runtime.Call(context.Background(), "test_connection", struct{}{}, &response); err != nil || !response.OK {
t.Fatalf("connection = %#v, %v", response, err)
}
if len(seen) < 14 || binary.LittleEndian.Uint32(seen[8:12]) != 3 {
t.Fatalf("unexpected RCON auth packet: %x", seen)
}
}
func TestVRisingAdapterRejectsAuthenticationWithoutLeakingSecret(t *testing.T) {
wasm, err := os.ReadFile("../../catalog/vrising/module/module.wasm")
if err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(wasm)
dial := func(context.Context, string, string) (net.Conn, error) {
client, server := net.Pipe()
go func() {
defer server.Close()
buffer := make([]byte, 256)
_, _ = server.Read(buffer)
_, _ = server.Write(sourceRCONPacket(^uint32(0), 2, ""))
}()
return client, nil
}
runtime, err := newWithTransportAndDial(wasm, hex.EncodeToString(digest[:]), nil, Limits{MemoryMB: 32, Timeout: 10 * time.Second, MaxResponseBytes: 262144, MaxConcurrentCall: 1}, Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 9878, Configuration: map[string]string{"rcon_enabled": "true", "rcon_port": "9878"}, Secrets: map[string]string{"rcon_password": "very-secret-value"}}, roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("HTTP must not be used") }), dial)
if err != nil {
t.Fatal(err)
}
var response struct {
OK bool `json:"ok"`
Data 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(), "test_connection", struct{}{}, &response); err != nil || response.OK || response.Error.Code != "unauthorized" || strings.Contains(response.Error.Message, "very-secret-value") {
t.Fatalf("unsafe authentication response: %#v, %v", response, err)
}
}
func TestVRisingAuthSuccessWithBothWazeroEngines(t *testing.T) {
wasm, err := os.ReadFile("../../catalog/vrising/module/module.wasm")
if err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(wasm)
dial := func(context.Context, string, string) (net.Conn, error) {
client, server := net.Pipe()
go func() {
defer server.Close()
buffer := make([]byte, 256)
if _, err := server.Read(buffer); err == nil {
_, _ = server.Write(sourceRCONPacket(1, 2, ""))
}
}()
return client, nil
}
runtime, err := newWithTransportAndDial(wasm, hex.EncodeToString(digest[:]), nil, Limits{MemoryMB: 32, Timeout: 10 * time.Second, MaxResponseBytes: 262144, MaxConcurrentCall: 1}, Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 9878, Configuration: map[string]string{"rcon_enabled": "true", "rcon_port": "9878"}, Secrets: map[string]string{"rcon_password": "secret"}}, roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("HTTP must not be used") }), dial)
if err != nil {
t.Fatal(err)
}
for _, engine := range []struct {
name string
config wazero.RuntimeConfig
}{
{name: "compiler", config: wazero.NewRuntimeConfigCompiler()},
{name: "interpreter", config: wazero.NewRuntimeConfigInterpreter()},
} {
t.Run(engine.name, func(t *testing.T) {
result, err := runtime.invokeWithConfig(context.Background(), "test_connection", []byte("{}"), engine.config)
if err != nil || !strings.Contains(string(result), `"connected":true`) {
t.Fatalf("auth success result=%s err=%v", result, err)
}
})
}
}
+12
View File
@@ -60,6 +60,18 @@
"user_mode": { "type": "string", "enum": ["dogama", "image"] }, "user_mode": { "type": "string", "enum": ["dogama", "image"] },
"arguments": { "type": "array", "items": { "type": "string", "maxLength": 500 }, "maxItems": 64 }, "arguments": { "type": "array", "items": { "type": "string", "maxLength": 500 }, "maxItems": 64 },
"environment": { "type": "object", "maxProperties": 64, "additionalProperties": { "type": "string", "maxLength": 4096 }, "propertyNames": { "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" } }, "environment": { "type": "object", "maxProperties": 64, "additionalProperties": { "type": "string", "maxLength": 4096 }, "propertyNames": { "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" } },
"capabilities": {
"type": "object",
"additionalProperties": false,
"properties": {
"add": {
"type": "array",
"maxItems": 16,
"uniqueItems": true,
"items": { "enum": ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID", "NET_BIND_SERVICE", "NET_RAW", "SYS_CHROOT"] }
}
}
},
"assets": { "assets": {
"type": "array", "type": "array",
"maxItems": 16, "maxItems": 16,