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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZxvxViV6QwAtVWT3saGvs
This commit is contained in:
1 parent
cb79e172b9
commit
1a28f6c52e
3 files changed
+210
-205
No files matched your search
+204
-204
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user