package main import ( "crypto/rand" "encoding/json" "errors" "flag" "fmt" "math/big" "os" "path/filepath" "regexp" "strings" ) // Exit code for "this game needs a secret CI doesn't have": the workflow skips it instead of failing. const exitMissingSecret = 3 const bootTestNetwork = "gbr-boottest" var publicPort = regexp.MustCompile(`\{PUBLIC_PORT_(\d+)\}`) var savedValue = regexp.MustCompile(`\{saved:[A-Za-z0-9_.-]+\}`) // Narrower than the validator's sensitiveKey: that one also matches settings like RCON_PORT, which // are fine to flag as "don't put secrets here" but must never be overwritten with a password. var passwordKey = regexp.MustCompile(`(?i)(pass(word)?|secret|token)`) // runBootTestPayload writes the DEPLOY_SERVER message the panel would send for this game, plus the // boot-test settings the node-agent's BootTest reads. Secrets come from environment variables named // in test.yaml's 'secrets'. func runBootTestPayload(args []string) error { fs := flag.NewFlagSet("boottest-payload", flag.ExitOnError) gamesDir := fs.String("games", "games", "directory holding one folder per game") out := fs.String("out", "", "file to write (default stdout)") asDeployed := fs.Bool("as-deployed", false, "settings exactly as a new server gets them: only x-generate fields filled, "+ "no throwaway passwords (the AI builder boots drafts this way)") fs.Parse(args) if fs.NArg() != 1 { return errors.New("boottest-payload needs a game id") } id := fs.Arg(0) dir := filepath.Join(*gamesDir, id) g, err := loadMap(filepath.Join(dir, "game.yaml")) if err != nil { return err } test := map[string]any{} if _, err := os.Stat(filepath.Join(dir, "test.yaml")); err == nil { if test, err = loadMap(filepath.Join(dir, "test.yaml")); err != nil { return err } } config := map[string]any{} if d, ok := g["defaultConfig"].(map[string]any); ok { for k, v := range d { config[k] = v } } if c, ok := test["config"].(map[string]any); ok { for k, v := range c { config[k] = v } } if s, ok := test["secrets"].(map[string]any); ok { for key, name := range s { value, ok := os.LookupEnv(fmt.Sprint(name)) if !ok || value == "" { fmt.Fprintf(os.Stderr, "skip: %s needs secret %s for %s\n", id, name, key) os.Exit(exitMissingSecret) } config[key] = value } } // Like the panel fills blank x-generate fields on deploy; other blank passwords (which an owner // would type in, and some games refuse to start without) get a throwaway value too, except // -as-deployed: a draft must start with exactly what a new server gets for key, p := range schemaProperties(g) { prop, _ := p.(map[string]any) if prop["x-generate"] == nil && (*asDeployed || !passwordKey.MatchString(key)) { continue } // Only missing or empty text: ports, booleans and other values are never touched if v, present := config[key]; !present || v == "" { config[key] = "boot-" + randomSuffix() } } for k, v := range config { if s, ok := v.(string); ok { config[k] = resolvePlaceholders(s) } } timeout, settle := 900, 120 if g["installMethod"] == "STEAMCMD" { timeout = 2400 } if v, ok := test["timeoutSec"].(json.Number); ok { if n, err := v.Int64(); err == nil { timeout = int(n) } } if v, ok := test["settleSec"].(json.Number); ok { if n, err := v.Int64(); err == nil { settle = int(n) } } payload := map[string]any{ "command": "DEPLOY_SERVER", "serverId": "boottest-" + id, "containerName": "boottest-" + id, "dockerImage": g["defaultDockerImage"], "gameConfig": config, // Readiness is read from logs, so nothing is published on the runner "ports": []any{}, "installMethod": g["installMethod"], "installConfig": g["installConfig"], "configFilePath": g["configFilePath"], "configFileFormat": g["configFileFormat"], "configFormatOptions": g["configFormatOptions"], "startupCommand": g["defaultStartupCommand"], "memoryLimitMb": g["requiredMemoryMb"], "containerUser": g["containerUser"], "dockerNetwork": bootTestNetwork, "logFilePath": g["logFilePath"], "readyPattern": g["readyPattern"], "failPatterns": g["failPatterns"], "timeoutSec": timeout, "settleSec": settle, } b, err := json.MarshalIndent(payload, "", " ") if err != nil { return err } if *out == "" { _, err = os.Stdout.Write(b) return err } return os.WriteFile(*out, b, 0o644) } // resolvePlaceholders stands in for what the panel fills per server; saved values have no owner here. func resolvePlaceholders(s string) string { s = strings.NewReplacer("{SERVER_NAME}", "gbr-boot-test", "{PUBLIC_IP}", "127.0.0.1", "{PUBLIC_HOST}", "127.0.0.1").Replace(s) s = publicPort.ReplaceAllString(s, "$1") s = savedValue.ReplaceAllString(s, "") // The panel strips line breaks from config values before they reach a node return strings.NewReplacer("\r", "", "\n", "").Replace(s) } func loadMap(path string) (map[string]any, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } v, err := loadYAML(raw) if err != nil { return nil, fmt.Errorf("%s: %w", path, err) } m, ok := v.(map[string]any) if !ok { return nil, fmt.Errorf("%s: not a mapping", path) } return m, nil } func randomSuffix() string { n, _ := rand.Int(rand.Reader, big.NewInt(1_000_000)) return fmt.Sprintf("%06d", n.Int64()) }