From 1a28f6c52eef9b766ac81f1ff6a3eaa9a8ee291e Mon Sep 17 00:00:00 2001 From: Greg Reed Date: Sun, 8 Nov 2026 01:32:23 +0000 Subject: [PATCH] feat(dragonwilds): MATCH_PUBLIC so the join code works behind a relay 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 Claude-Session: https://claude.ai/code/session_01MZxvxViV6QwAtVWT3saGvs --- cmd/gbrgames/import.go | 408 +++++++++--------- .../game.yaml | 3 +- schema/game.schema.json | 4 + 3 files changed, 210 insertions(+), 205 deletions(-) diff --git a/cmd/gbrgames/import.go b/cmd/gbrgames/import.go index 79da1d5..5b0f191 100644 --- a/cmd/gbrgames/import.go +++ b/cmd/gbrgames/import.go @@ -1,204 +1,204 @@ -package main - -import ( - "encoding/json" - "errors" - "flag" - "fmt" - "math" - "os" - "path/filepath" - "sort" - "strings" - - "gopkg.in/yaml.v3" -) - -// Game fields in the order they are written to game.yaml. Panel-owned business settings -// (enabled, allowPaidHosting, allowPrivateHosting, imageUrl) are deliberately left out. -var gameFields = []string{ - "id", "name", - "defaultDockerImage", "containerUser", "defaultStartupCommand", "requiredMemoryMb", "requiredDiskMb", "defaultPorts", - "installMethod", "installConfig", - "defaultConfig", "configSchema", "configFilePath", "configFileFormat", "configFormatOptions", - "agnosticCommandConfig", "clusterConfig", - "logFilePath", "readyPattern", "failPatterns", "backupPaths", "maxBackupSizeMb", - "publicIpConfigKey", "maxPlayersConfigKey", "mapConfigKey", - "aiCoverPrompt", -} - -const gameHeader = "# yaml-language-server: $schema=../../schema/game.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" - -func runImport(args []string) error { - fs := flag.NewFlagSet("import", flag.ExitOnError) - 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") - fs.Parse(args) - if fs.NArg() != 1 { - return errors.New("import needs the path to a GET /api/v1/admin/games export") - } - - raw, err := os.ReadFile(fs.Arg(0)) - if err != nil { - return err - } - var games []map[string]any - if err := json.Unmarshal(raw, &games); err != nil { - return fmt.Errorf("export is not a JSON array of games: %w", err) - } - - for _, g := range games { - id, _ := g["id"].(string) - if id == "" { - return errors.New("export contains a game without an id") - } - if g["enabled"] == false && !*all { - fmt.Printf("skip %s (disabled in panel; use -all to include)\n", id) - continue - } - dir := filepath.Join(*gamesDir, id) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - if err := writeGame(filepath.Join(dir, "game.yaml"), g); err != nil { - return fmt.Errorf("%s: %w", id, err) - } - testPath := filepath.Join(dir, "test.yaml") - if _, err := os.Stat(testPath); errors.Is(err, os.ErrNotExist) { - if err := writeTest(testPath, g); err != nil { - return fmt.Errorf("%s: %w", id, err) - } - } - fmt.Printf("wrote %s\n", dir) - } - return nil -} - -func writeGame(path string, g map[string]any) error { - doc := &yaml.Node{Kind: yaml.MappingNode} - for _, f := range gameFields { - v, ok := g[f] - if !ok || isEmpty(v) { - continue - } - val, err := toNode(normalize(v)) - if err != nil { - return err - } - doc.Content = append(doc.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: f}, val) - } - return writeYAML(path, gameHeader, doc) -} - -// 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 { - cfg := map[string]any{} - for key, p := range schemaProperties(g) { - if prop, ok := p.(map[string]any); ok { - if c, ok := prop["x-consent"]; ok && c != false { - cfg[key] = true - } - } - } - timeout := 900 - if g["installMethod"] == "STEAMCMD" { - timeout = 2400 - } - test := map[string]any{"timeoutSec": timeout} - if len(cfg) > 0 { - test["config"] = cfg - } - var doc yaml.Node - if err := doc.Encode(test); err != nil { - return err - } - return writeYAML(path, testHeader, &doc) -} - -func writeYAML(path, header string, doc *yaml.Node) error { - var b strings.Builder - b.WriteString(header) - enc := yaml.NewEncoder(&b) - enc.SetIndent(2) - if err := enc.Encode(doc); err != nil { - return err - } - enc.Close() - 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 -// whitespace, like a "\n" config separator, into "" and the value is silently lost. -func toNode(v any) (*yaml.Node, error) { - switch x := v.(type) { - case map[string]any: - n := &yaml.Node{Kind: yaml.MappingNode} - keys := make([]string, 0, len(x)) - for k := range x { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - val, err := toNode(x[k]) - if err != nil { - return nil, err - } - n.Content = append(n.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k}, val) - } - return n, nil - case []any: - n := &yaml.Node{Kind: yaml.SequenceNode} - for _, e := range x { - val, err := toNode(e) - if err != nil { - return nil, err - } - n.Content = append(n.Content, val) - } - return n, nil - case string: - if x != "" && strings.TrimSpace(x) == "" { - return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: x, Style: yaml.DoubleQuotedStyle}, nil - } - } - n := &yaml.Node{} - if err := n.Encode(v); err != nil { - return nil, err - } - return n, nil -} - -// normalize turns whole-number floats from encoding/json back into ints so YAML shows 25565, not 25565.0. -func normalize(v any) any { - switch x := v.(type) { - case float64: - if x == math.Trunc(x) && math.Abs(x) < 1<<53 { - return int64(x) - } - case map[string]any: - for k, e := range x { - x[k] = normalize(e) - } - case []any: - for i, e := range x { - x[i] = normalize(e) - } - } - return v -} - -func isEmpty(v any) bool { - switch x := v.(type) { - case nil: - return true - case string: - return x == "" - case map[string]any: - return len(x) == 0 - case []any: - return len(x) == 0 - } - return false -} +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "math" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// Game fields in the order they are written to game.yaml. Panel-owned business settings +// (enabled, allowPaidHosting, allowPrivateHosting, imageUrl) are deliberately left out. +var gameFields = []string{ + "id", "name", + "defaultDockerImage", "containerUser", "defaultStartupCommand", "requiredMemoryMb", "requiredDiskMb", "defaultPorts", + "installMethod", "installConfig", + "defaultConfig", "configSchema", "configFilePath", "configFileFormat", "configFormatOptions", + "agnosticCommandConfig", "clusterConfig", + "logFilePath", "readyPattern", "failPatterns", "backupPaths", "maxBackupSizeMb", + "publicIpConfigKey", "portMode", "maxPlayersConfigKey", "mapConfigKey", + "aiCoverPrompt", +} + +const gameHeader = "# yaml-language-server: $schema=../../schema/game.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" + +func runImport(args []string) error { + fs := flag.NewFlagSet("import", flag.ExitOnError) + 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") + fs.Parse(args) + if fs.NArg() != 1 { + return errors.New("import needs the path to a GET /api/v1/admin/games export") + } + + raw, err := os.ReadFile(fs.Arg(0)) + if err != nil { + return err + } + var games []map[string]any + if err := json.Unmarshal(raw, &games); err != nil { + return fmt.Errorf("export is not a JSON array of games: %w", err) + } + + for _, g := range games { + id, _ := g["id"].(string) + if id == "" { + return errors.New("export contains a game without an id") + } + if g["enabled"] == false && !*all { + fmt.Printf("skip %s (disabled in panel; use -all to include)\n", id) + continue + } + dir := filepath.Join(*gamesDir, id) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + if err := writeGame(filepath.Join(dir, "game.yaml"), g); err != nil { + return fmt.Errorf("%s: %w", id, err) + } + testPath := filepath.Join(dir, "test.yaml") + if _, err := os.Stat(testPath); errors.Is(err, os.ErrNotExist) { + if err := writeTest(testPath, g); err != nil { + return fmt.Errorf("%s: %w", id, err) + } + } + fmt.Printf("wrote %s\n", dir) + } + return nil +} + +func writeGame(path string, g map[string]any) error { + doc := &yaml.Node{Kind: yaml.MappingNode} + for _, f := range gameFields { + v, ok := g[f] + if !ok || isEmpty(v) { + continue + } + val, err := toNode(normalize(v)) + if err != nil { + return err + } + doc.Content = append(doc.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: f}, val) + } + return writeYAML(path, gameHeader, doc) +} + +// 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 { + cfg := map[string]any{} + for key, p := range schemaProperties(g) { + if prop, ok := p.(map[string]any); ok { + if c, ok := prop["x-consent"]; ok && c != false { + cfg[key] = true + } + } + } + timeout := 900 + if g["installMethod"] == "STEAMCMD" { + timeout = 2400 + } + test := map[string]any{"timeoutSec": timeout} + if len(cfg) > 0 { + test["config"] = cfg + } + var doc yaml.Node + if err := doc.Encode(test); err != nil { + return err + } + return writeYAML(path, testHeader, &doc) +} + +func writeYAML(path, header string, doc *yaml.Node) error { + var b strings.Builder + b.WriteString(header) + enc := yaml.NewEncoder(&b) + enc.SetIndent(2) + if err := enc.Encode(doc); err != nil { + return err + } + enc.Close() + 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 +// whitespace, like a "\n" config separator, into "" and the value is silently lost. +func toNode(v any) (*yaml.Node, error) { + switch x := v.(type) { + case map[string]any: + n := &yaml.Node{Kind: yaml.MappingNode} + keys := make([]string, 0, len(x)) + for k := range x { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + val, err := toNode(x[k]) + if err != nil { + return nil, err + } + n.Content = append(n.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k}, val) + } + return n, nil + case []any: + n := &yaml.Node{Kind: yaml.SequenceNode} + for _, e := range x { + val, err := toNode(e) + if err != nil { + return nil, err + } + n.Content = append(n.Content, val) + } + return n, nil + case string: + if x != "" && strings.TrimSpace(x) == "" { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: x, Style: yaml.DoubleQuotedStyle}, nil + } + } + n := &yaml.Node{} + if err := n.Encode(v); err != nil { + return nil, err + } + return n, nil +} + +// normalize turns whole-number floats from encoding/json back into ints so YAML shows 25565, not 25565.0. +func normalize(v any) any { + switch x := v.(type) { + case float64: + if x == math.Trunc(x) && math.Abs(x) < 1<<53 { + return int64(x) + } + case map[string]any: + for k, e := range x { + x[k] = normalize(e) + } + case []any: + for i, e := range x { + x[i] = normalize(e) + } + } + return v +} + +func isEmpty(v any) bool { + switch x := v.(type) { + case nil: + return true + case string: + return x == "" + case map[string]any: + return len(x) == 0 + case []any: + return len(x) == 0 + } + return false +} diff --git a/games/runescape-dragonwilds-native-linux/game.yaml b/games/runescape-dragonwilds-native-linux/game.yaml index 78492c0..649b01b 100644 --- a/games/runescape-dragonwilds-native-linux/game.yaml +++ b/games/runescape-dragonwilds-native-linux/game.yaml @@ -2,7 +2,7 @@ id: runescape-dragonwilds-native-linux name: 'RuneScape: Dragonwilds (Native Linux Server)' defaultDockerImage: cm2network/steamcmd:root -defaultStartupCommand: ./RSDragonwildsServer.sh -log -Port=7777 +defaultStartupCommand: ./RSDragonwildsServer.sh -log -Port={PUBLIC_PORT_7777} -BeaconPort={PUBLIC_PORT_8888} requiredMemoryMb: 10240 requiredDiskMb: 20480 defaultPorts: @@ -113,5 +113,6 @@ backupPaths: - RSDragonwilds/Saved maxBackupSizeMb: 5000 publicIpConfigKey: MULTIHOME +portMode: MATCH_PUBLIC 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.' diff --git a/schema/game.schema.json b/schema/game.schema.json index d563340..18dfcb4 100644 --- a/schema/game.schema.json +++ b/schema/game.schema.json @@ -69,6 +69,10 @@ "maxBackupSizeMb": { "type": ["integer", "null"], "minimum": 1 }, "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_} in defaultStartupCommand.", + "enum": ["DEFAULT", "MATCH_PUBLIC", null] + }, "maxPlayersConfigKey": { "description": "Config key locked to the pricing tier's player count.", "type": ["string", "null"] }, "mapConfigKey": { "type": ["string", "null"] },