Compare commits

...
3 Commits
Author SHA1 Message Date
git_pipeline f80c715b72 Merge pull request 'feat(dragonwilds): MATCH_PUBLIC so the join code works behind a relay' (#29) from feat/dragonwilds-match-public into main
Validate game templates / validate (push) Successful in 24s
Builder image / publish (push) Successful in 25s
Validate game templates / boot-test (push) Successful in 3m19s
Validate game templates / publish (push) Successful in 18s
2026-11-08 01:48:00 +00:00
Greg ReedandClaude Opus 5.5 bb4fd3e11c fix(boot-test): fill {PUBLIC_*} in the startup command like the panel does
Validate game templates / validate (push) Successful in 20s
Validate game templates / boot-test (push) Successful in 3m19s
Validate game templates / publish (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZxvxViV6QwAtVWT3saGvs
2026-11-08 01:39:57 +00:00
Greg ReedandClaude Opus 5.5 1a28f6c52e feat(dragonwilds): MATCH_PUBLIC so the join code works behind a relay
Validate game templates / validate (push) Successful in 28s
Validate game templates / boot-test (push) Failing after 3m51s
Validate game templates / publish (push) Skipped
The server advertises the address its outgoing traffic comes from, so it
binds its public ports (-Port / -BeaconPort from {PUBLIC_PORT_n}) and, behind
Public Bypass, sends its traffic out through the relay. Adds portMode to the
schema and to import's field order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZxvxViV6QwAtVWT3saGvs
2026-11-08 01:32:23 +00:00
5 changed files with 233 additions and 206 deletions

No files matched your search

+8 -1
View File
@@ -153,6 +153,13 @@ func bootTestPayload(gamesDir, id string, fillBlank bool) (map[string]any, error
} }
} }
// The panel fills {PUBLIC_IP}, {PUBLIC_HOST} and {PUBLIC_PORT_n} in the startup command too
startup := g["defaultStartupCommand"]
if cmd, ok := startup.(string); ok {
cmd = strings.NewReplacer("{PUBLIC_IP}", "127.0.0.1", "{PUBLIC_HOST}", "127.0.0.1").Replace(cmd)
startup = publicPort.ReplaceAllString(cmd, "$1")
}
return map[string]any{ return map[string]any{
"command": "DEPLOY_SERVER", "command": "DEPLOY_SERVER",
"serverId": "boottest-" + id, "serverId": "boottest-" + id,
@@ -166,7 +173,7 @@ func bootTestPayload(gamesDir, id string, fillBlank bool) (map[string]any, error
"configFilePath": g["configFilePath"], "configFilePath": g["configFilePath"],
"configFileFormat": g["configFileFormat"], "configFileFormat": g["configFileFormat"],
"configFormatOptions": g["configFormatOptions"], "configFormatOptions": g["configFormatOptions"],
"startupCommand": g["defaultStartupCommand"], "startupCommand": startup,
"memoryLimitMb": g["requiredMemoryMb"], "memoryLimitMb": g["requiredMemoryMb"],
"containerUser": g["containerUser"], "containerUser": g["containerUser"],
"dockerNetwork": bootTestNetwork, "dockerNetwork": bootTestNetwork,
+15
View File
@@ -132,3 +132,18 @@ func TestSteamCacheStampAge(t *testing.T) {
t.Error("old, missing or garbled stamps are not fresh") t.Error("old, missing or garbled stamps are not fresh")
} }
} }
func TestBootTestPayloadFillsPublicPlaceholdersInStartupCommand(t *testing.T) {
dir := t.TempDir()
game := filepath.Join(dir, "example")
os.MkdirAll(game, 0o755)
os.WriteFile(filepath.Join(game, "game.yaml"), []byte("id: example\nname: Example\ndefaultDockerImage: x\n"+
"defaultStartupCommand: ./server -Port={PUBLIC_PORT_7777} -ip={PUBLIC_IP}\ndefaultPorts:\n - 7777/udp\n"), 0o644)
p, err := bootTestPayload(dir, "example", false)
if err != nil {
t.Fatal(err)
}
if p["startupCommand"] != "./server -Port=7777 -ip=127.0.0.1" {
t.Errorf("startupCommand %v", p["startupCommand"])
}
}
+204 -204
View File
@@ -1,204 +1,204 @@
package main package main
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"math" "math"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// Game fields in the order they are written to game.yaml. Panel-owned business settings // Game fields in the order they are written to game.yaml. Panel-owned business settings
// (enabled, allowPaidHosting, allowPrivateHosting, imageUrl) are deliberately left out. // (enabled, allowPaidHosting, allowPrivateHosting, imageUrl) are deliberately left out.
var gameFields = []string{ var gameFields = []string{
"id", "name", "id", "name",
"defaultDockerImage", "containerUser", "defaultStartupCommand", "requiredMemoryMb", "requiredDiskMb", "defaultPorts", "defaultDockerImage", "containerUser", "defaultStartupCommand", "requiredMemoryMb", "requiredDiskMb", "defaultPorts",
"installMethod", "installConfig", "installMethod", "installConfig",
"defaultConfig", "configSchema", "configFilePath", "configFileFormat", "configFormatOptions", "defaultConfig", "configSchema", "configFilePath", "configFileFormat", "configFormatOptions",
"agnosticCommandConfig", "clusterConfig", "agnosticCommandConfig", "clusterConfig",
"logFilePath", "readyPattern", "failPatterns", "backupPaths", "maxBackupSizeMb", "logFilePath", "readyPattern", "failPatterns", "backupPaths", "maxBackupSizeMb",
"publicIpConfigKey", "maxPlayersConfigKey", "mapConfigKey", "publicIpConfigKey", "portMode", "maxPlayersConfigKey", "mapConfigKey",
"aiCoverPrompt", "aiCoverPrompt",
} }
const gameHeader = "# yaml-language-server: $schema=../../schema/game.schema.json\n" const gameHeader = "# yaml-language-server: $schema=../../schema/game.schema.json\n"
const testHeader = "# yaml-language-server: $schema=../../schema/test.schema.json\n" + const testHeader = "# yaml-language-server: $schema=../../schema/test.schema.json\n" +
"# Values CI uses to boot-test this game. Never put real credentials here: map them under 'secrets'.\n" "# Values CI uses to boot-test this game. Never put real credentials here: map them under 'secrets'.\n"
func runImport(args []string) error { func runImport(args []string) error {
fs := flag.NewFlagSet("import", flag.ExitOnError) fs := flag.NewFlagSet("import", flag.ExitOnError)
gamesDir := fs.String("games", "games", "directory to write one folder per game into") gamesDir := fs.String("games", "games", "directory to write one folder per game into")
all := fs.Bool("all", false, "also import games that are disabled in the panel") all := fs.Bool("all", false, "also import games that are disabled in the panel")
fs.Parse(args) fs.Parse(args)
if fs.NArg() != 1 { if fs.NArg() != 1 {
return errors.New("import needs the path to a GET /api/v1/admin/games export") return errors.New("import needs the path to a GET /api/v1/admin/games export")
} }
raw, err := os.ReadFile(fs.Arg(0)) raw, err := os.ReadFile(fs.Arg(0))
if err != nil { if err != nil {
return err return err
} }
var games []map[string]any var games []map[string]any
if err := json.Unmarshal(raw, &games); err != nil { if err := json.Unmarshal(raw, &games); err != nil {
return fmt.Errorf("export is not a JSON array of games: %w", err) return fmt.Errorf("export is not a JSON array of games: %w", err)
} }
for _, g := range games { for _, g := range games {
id, _ := g["id"].(string) id, _ := g["id"].(string)
if id == "" { if id == "" {
return errors.New("export contains a game without an id") return errors.New("export contains a game without an id")
} }
if g["enabled"] == false && !*all { if g["enabled"] == false && !*all {
fmt.Printf("skip %s (disabled in panel; use -all to include)\n", id) fmt.Printf("skip %s (disabled in panel; use -all to include)\n", id)
continue continue
} }
dir := filepath.Join(*gamesDir, id) dir := filepath.Join(*gamesDir, id)
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
return err return err
} }
if err := writeGame(filepath.Join(dir, "game.yaml"), g); err != nil { if err := writeGame(filepath.Join(dir, "game.yaml"), g); err != nil {
return fmt.Errorf("%s: %w", id, err) return fmt.Errorf("%s: %w", id, err)
} }
testPath := filepath.Join(dir, "test.yaml") testPath := filepath.Join(dir, "test.yaml")
if _, err := os.Stat(testPath); errors.Is(err, os.ErrNotExist) { if _, err := os.Stat(testPath); errors.Is(err, os.ErrNotExist) {
if err := writeTest(testPath, g); err != nil { if err := writeTest(testPath, g); err != nil {
return fmt.Errorf("%s: %w", id, err) return fmt.Errorf("%s: %w", id, err)
} }
} }
fmt.Printf("wrote %s\n", dir) fmt.Printf("wrote %s\n", dir)
} }
return nil return nil
} }
func writeGame(path string, g map[string]any) error { func writeGame(path string, g map[string]any) error {
doc := &yaml.Node{Kind: yaml.MappingNode} doc := &yaml.Node{Kind: yaml.MappingNode}
for _, f := range gameFields { for _, f := range gameFields {
v, ok := g[f] v, ok := g[f]
if !ok || isEmpty(v) { if !ok || isEmpty(v) {
continue continue
} }
val, err := toNode(normalize(v)) val, err := toNode(normalize(v))
if err != nil { if err != nil {
return err return err
} }
doc.Content = append(doc.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: f}, val) doc.Content = append(doc.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: f}, val)
} }
return writeYAML(path, gameHeader, doc) return writeYAML(path, gameHeader, doc)
} }
// writeTest seeds a boot-test fixture: consents (EULAs) accepted, a timeout sized to the install method. // writeTest seeds a boot-test fixture: consents (EULAs) accepted, a timeout sized to the install method.
func writeTest(path string, g map[string]any) error { func writeTest(path string, g map[string]any) error {
cfg := map[string]any{} cfg := map[string]any{}
for key, p := range schemaProperties(g) { for key, p := range schemaProperties(g) {
if prop, ok := p.(map[string]any); ok { if prop, ok := p.(map[string]any); ok {
if c, ok := prop["x-consent"]; ok && c != false { if c, ok := prop["x-consent"]; ok && c != false {
cfg[key] = true cfg[key] = true
} }
} }
} }
timeout := 900 timeout := 900
if g["installMethod"] == "STEAMCMD" { if g["installMethod"] == "STEAMCMD" {
timeout = 2400 timeout = 2400
} }
test := map[string]any{"timeoutSec": timeout} test := map[string]any{"timeoutSec": timeout}
if len(cfg) > 0 { if len(cfg) > 0 {
test["config"] = cfg test["config"] = cfg
} }
var doc yaml.Node var doc yaml.Node
if err := doc.Encode(test); err != nil { if err := doc.Encode(test); err != nil {
return err return err
} }
return writeYAML(path, testHeader, &doc) return writeYAML(path, testHeader, &doc)
} }
func writeYAML(path, header string, doc *yaml.Node) error { func writeYAML(path, header string, doc *yaml.Node) error {
var b strings.Builder var b strings.Builder
b.WriteString(header) b.WriteString(header)
enc := yaml.NewEncoder(&b) enc := yaml.NewEncoder(&b)
enc.SetIndent(2) enc.SetIndent(2)
if err := enc.Encode(doc); err != nil { if err := enc.Encode(doc); err != nil {
return err return err
} }
enc.Close() enc.Close()
return os.WriteFile(path, []byte(b.String()), 0o644) return os.WriteFile(path, []byte(b.String()), 0o644)
} }
// toNode builds the YAML node tree by hand because yaml.v3's Node.Encode turns a string made only of // toNode builds the YAML node tree by hand because yaml.v3's Node.Encode turns a string made only of
// whitespace, like a "\n" config separator, into "" and the value is silently lost. // whitespace, like a "\n" config separator, into "" and the value is silently lost.
func toNode(v any) (*yaml.Node, error) { func toNode(v any) (*yaml.Node, error) {
switch x := v.(type) { switch x := v.(type) {
case map[string]any: case map[string]any:
n := &yaml.Node{Kind: yaml.MappingNode} n := &yaml.Node{Kind: yaml.MappingNode}
keys := make([]string, 0, len(x)) keys := make([]string, 0, len(x))
for k := range x { for k := range x {
keys = append(keys, k) keys = append(keys, k)
} }
sort.Strings(keys) sort.Strings(keys)
for _, k := range keys { for _, k := range keys {
val, err := toNode(x[k]) val, err := toNode(x[k])
if err != nil { if err != nil {
return nil, err return nil, err
} }
n.Content = append(n.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k}, val) n.Content = append(n.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k}, val)
} }
return n, nil return n, nil
case []any: case []any:
n := &yaml.Node{Kind: yaml.SequenceNode} n := &yaml.Node{Kind: yaml.SequenceNode}
for _, e := range x { for _, e := range x {
val, err := toNode(e) val, err := toNode(e)
if err != nil { if err != nil {
return nil, err return nil, err
} }
n.Content = append(n.Content, val) n.Content = append(n.Content, val)
} }
return n, nil return n, nil
case string: case string:
if x != "" && strings.TrimSpace(x) == "" { if x != "" && strings.TrimSpace(x) == "" {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: x, Style: yaml.DoubleQuotedStyle}, nil return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: x, Style: yaml.DoubleQuotedStyle}, nil
} }
} }
n := &yaml.Node{} n := &yaml.Node{}
if err := n.Encode(v); err != nil { if err := n.Encode(v); err != nil {
return nil, err return nil, err
} }
return n, nil return n, nil
} }
// normalize turns whole-number floats from encoding/json back into ints so YAML shows 25565, not 25565.0. // normalize turns whole-number floats from encoding/json back into ints so YAML shows 25565, not 25565.0.
func normalize(v any) any { func normalize(v any) any {
switch x := v.(type) { switch x := v.(type) {
case float64: case float64:
if x == math.Trunc(x) && math.Abs(x) < 1<<53 { if x == math.Trunc(x) && math.Abs(x) < 1<<53 {
return int64(x) return int64(x)
} }
case map[string]any: case map[string]any:
for k, e := range x { for k, e := range x {
x[k] = normalize(e) x[k] = normalize(e)
} }
case []any: case []any:
for i, e := range x { for i, e := range x {
x[i] = normalize(e) x[i] = normalize(e)
} }
} }
return v return v
} }
func isEmpty(v any) bool { func isEmpty(v any) bool {
switch x := v.(type) { switch x := v.(type) {
case nil: case nil:
return true return true
case string: case string:
return x == "" return x == ""
case map[string]any: case map[string]any:
return len(x) == 0 return len(x) == 0
case []any: case []any:
return len(x) == 0 return len(x) == 0
} }
return false return false
} }
@@ -2,7 +2,7 @@
id: runescape-dragonwilds-native-linux id: runescape-dragonwilds-native-linux
name: 'RuneScape: Dragonwilds (Native Linux Server)' name: 'RuneScape: Dragonwilds (Native Linux Server)'
defaultDockerImage: cm2network/steamcmd:root defaultDockerImage: cm2network/steamcmd:root
defaultStartupCommand: ./RSDragonwildsServer.sh -log -Port=7777 defaultStartupCommand: ./RSDragonwildsServer.sh -log -Port={PUBLIC_PORT_7777} -BeaconPort={PUBLIC_PORT_8888}
requiredMemoryMb: 10240 requiredMemoryMb: 10240
requiredDiskMb: 20480 requiredDiskMb: 20480
defaultPorts: defaultPorts:
@@ -113,5 +113,6 @@ backupPaths:
- RSDragonwilds/Saved - RSDragonwilds/Saved
maxBackupSizeMb: 5000 maxBackupSizeMb: 5000
publicIpConfigKey: MULTIHOME publicIpConfigKey: MULTIHOME
portMode: MATCH_PUBLIC
maxPlayersConfigKey: MaxPlayers maxPlayersConfigKey: MaxPlayers
aiCoverPrompt: 'A cinematic wide shot of Ashenfall in the fantasy world of Gielinor from RuneScape: Dragonwilds. Ancient stone spires and forgotten fortresses rise amidst untamed wilderness. Colossal ancient dragons circle through stormy twilight skies illuminated by magical embers, while adventurers gather around a runic encampment wielding enchanted sorcery and survival gear.' aiCoverPrompt: 'A cinematic wide shot of Ashenfall in the fantasy world of Gielinor from RuneScape: Dragonwilds. Ancient stone spires and forgotten fortresses rise amidst untamed wilderness. Colossal ancient dragons circle through stormy twilight skies illuminated by magical embers, while adventurers gather around a runic encampment wielding enchanted sorcery and survival gear.'
+4
View File
@@ -69,6 +69,10 @@
"maxBackupSizeMb": { "type": ["integer", "null"], "minimum": 1 }, "maxBackupSizeMb": { "type": ["integer", "null"], "minimum": 1 },
"publicIpConfigKey": { "description": "Config key that receives the server's public address.", "type": ["string", "null"] }, "publicIpConfigKey": { "description": "Config key that receives the server's public address.", "type": ["string", "null"] },
"portMode": {
"description": "MATCH_PUBLIC: for games that advertise the address their own traffic comes from. The container binds the port players use (the relay port behind Public Bypass) and, behind a relay, all its outgoing traffic leaves through the relay. Tell the game its port with {PUBLIC_PORT_<defaultPort>} in defaultStartupCommand.",
"enum": ["DEFAULT", "MATCH_PUBLIC", null]
},
"maxPlayersConfigKey": { "description": "Config key locked to the pricing tier's player count.", "type": ["string", "null"] }, "maxPlayersConfigKey": { "description": "Config key locked to the pricing tier's player count.", "type": ["string", "null"] },
"mapConfigKey": { "type": ["string", "null"] }, "mapConfigKey": { "type": ["string", "null"] },