feat: game templates, schemas and validator
Validate game templates / validate (push) Successful in 10m6s

Public game templates for GBR Hosting, one games/<id>/game.yaml per game,
imported from the panel. A Go tool validates them against JSON Schemas and
checks for credentials, private addresses and consents that default to
accepted; CI runs it on every push.

Admin/RCON passwords are no longer shared defaults: they are blank with
x-generate so the panel creates one per server, and Palworld/ARK no longer
publish their RCON/REST ports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gregandClaude Opus 5.5 committed 2026-10-24 16:54:21 -04:00
commit 822b44e2e5
20 files changed
+2283

No files matched your search

+162
View File
@@ -0,0 +1,162 @@
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"math"
"os"
"path/filepath"
"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", "defaultStartupCommand", "requiredMemoryMb", "requiredDiskMb", "defaultPorts",
"installMethod", "installConfig",
"defaultConfig", "configSchema", "configFilePath", "configFileFormat", "configFormatOptions",
"agnosticCommandConfig", "clusterConfig",
"logFilePath", "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
}
var val yaml.Node
if err := val.Encode(normalize(v)); 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)
}
// 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
}
+34
View File
@@ -0,0 +1,34 @@
// Command gbrgames validates the game templates in this repo and imports them from a panel export.
//
// go run ./cmd/gbrgames validate # check every games/<id>/game.yaml (+ test.yaml)
// go run ./cmd/gbrgames import export.json # write games/<id>/ from GET /api/v1/admin/games
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
usage()
}
var err error
switch os.Args[1] {
case "validate":
err = runValidate(os.Args[2:])
case "import":
err = runImport(os.Args[2:])
default:
usage()
}
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: gbrgames validate [-games DIR] | import [-games DIR] [-all] EXPORT.json")
os.Exit(2)
}
+255
View File
@@ -0,0 +1,255 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/santhosh-tekuri/jsonschema/v6"
"gopkg.in/yaml.v3"
)
var (
// Config keys whose default value must never be a real credential.
sensitiveKey = regexp.MustCompile(`(?i)(pass(word)?|secret|token|api[_-]?key|rcon)`)
// A value made only of panel placeholders, e.g. "{saved:RCON_PASSWORD}".
placeholderOnly = regexp.MustCompile(`^(\{[A-Za-z0-9_:.-]+\})+$`)
// RFC 1918 addresses: home-lab and VPC details have no place in a public repo.
privateIP = regexp.MustCompile(`\b(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})\b`)
)
func runValidate(args []string) error {
fs := flag.NewFlagSet("validate", flag.ExitOnError)
gamesDir := fs.String("games", "games", "directory holding one folder per game")
schemaDir := fs.String("schemas", "schema", "directory holding game.schema.json and test.schema.json")
fs.Parse(args)
gameSchema, err := compileSchema(filepath.Join(*schemaDir, "game.schema.json"))
if err != nil {
return err
}
testSchema, err := compileSchema(filepath.Join(*schemaDir, "test.schema.json"))
if err != nil {
return err
}
entries, err := os.ReadDir(*gamesDir)
if err != nil {
return err
}
failed, checked := 0, 0
for _, e := range entries {
if !e.IsDir() {
continue
}
checked++
problems := validateGame(filepath.Join(*gamesDir, e.Name()), e.Name(), gameSchema, testSchema)
if len(problems) == 0 {
fmt.Printf("ok %s\n", e.Name())
continue
}
failed++
fmt.Printf("FAIL %s\n", e.Name())
for _, p := range problems {
fmt.Printf(" - %s\n", p)
}
}
if checked == 0 {
return fmt.Errorf("no games found in %s", *gamesDir)
}
if failed > 0 {
return fmt.Errorf("%d of %d games failed validation", failed, checked)
}
fmt.Printf("\nall %d games valid\n", checked)
return nil
}
func validateGame(dir, folder string, gameSchema, testSchema *jsonschema.Schema) []string {
var problems []string
add := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) }
raw, err := os.ReadFile(filepath.Join(dir, "game.yaml"))
if err != nil {
return []string{"missing game.yaml"}
}
game, err := loadYAML(raw)
if err != nil {
return []string{"game.yaml: " + err.Error()}
}
if err := gameSchema.Validate(game); err != nil {
add("game.yaml does not match schema:\n%s", indent(schemaErrors(err)))
return problems // later checks assume the basic shape is right
}
g := game.(map[string]any)
if g["id"] != folder {
add("id %q must match folder name %q", g["id"], folder)
}
scanPrivateIPs(raw, "game.yaml", add)
defaults, _ := g["defaultConfig"].(map[string]any)
props := schemaProperties(g)
known := map[string]bool{}
for k := range defaults {
known[k] = true
}
for k := range props {
known[k] = true
}
// publicIpConfigKey and maxPlayersConfigKey are injected by the panel, so only the map key must already exist.
if key, ok := g["mapConfigKey"].(string); ok && key != "" && !known[key] {
add("mapConfigKey %q is not in defaultConfig or configSchema", key)
}
for _, key := range sortedKeys(defaults) {
if sensitiveKey.MatchString(key) && isCredential(defaults[key]) {
add("defaultConfig.%s looks like a credential; leave it empty or use a placeholder like {saved:%s}", key, key)
}
}
for _, key := range sortedKeys(props) {
prop, _ := props[key].(map[string]any)
if sensitiveKey.MatchString(key) && isCredential(prop["default"]) {
add("configSchema.properties.%s.default looks like a credential; leave it empty", key)
}
}
for _, key := range sortedKeys(props) {
prop, _ := props[key].(map[string]any)
if c, ok := prop["x-consent"]; !ok || c == false {
continue
}
if truthy(prop["default"]) || truthy(defaults[key]) {
add("consent %q must not default to true: users have to accept it themselves", key)
}
}
testRaw, err := os.ReadFile(filepath.Join(dir, "test.yaml"))
if errors.Is(err, os.ErrNotExist) {
return problems
}
if err != nil {
add("test.yaml: %v", err)
return problems
}
test, err := loadYAML(testRaw)
if err != nil {
add("test.yaml: %v", err)
return problems
}
if err := testSchema.Validate(test); err != nil {
add("test.yaml does not match schema:\n%s", indent(schemaErrors(err)))
return problems
}
scanPrivateIPs(testRaw, "test.yaml", add)
t := test.(map[string]any)
cfg, _ := t["config"].(map[string]any)
secrets, _ := t["secrets"].(map[string]any)
for _, key := range sortedKeys(cfg) {
if !known[key] {
add("test.yaml config.%s is not a key of this game", key)
}
if sensitiveKey.MatchString(key) {
add("test.yaml config.%s looks like a credential; map it under 'secrets' to a Gitea secret instead", key)
}
}
for _, key := range sortedKeys(secrets) {
if !known[key] {
add("test.yaml secrets.%s is not a key of this game", key)
}
}
return problems
}
func compileSchema(path string) (*jsonschema.Schema, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
id, _ := doc.(map[string]any)["$id"].(string)
if id == "" {
return nil, fmt.Errorf("%s: missing $id", path)
}
c := jsonschema.NewCompiler()
if err := c.AddResource(id, doc); err != nil {
return nil, err
}
return c.Compile(id)
}
// loadYAML decodes YAML into the JSON data model the schema validator expects.
func loadYAML(raw []byte) (any, error) {
var v any
if err := yaml.Unmarshal(raw, &v); err != nil {
return nil, err
}
if v == nil {
return nil, errors.New("file is empty")
}
j, err := json.Marshal(v)
if err != nil {
return nil, err
}
return jsonschema.UnmarshalJSON(bytes.NewReader(j))
}
func schemaProperties(g map[string]any) map[string]any {
s, _ := g["configSchema"].(map[string]any)
p, _ := s["properties"].(map[string]any)
return p
}
func scanPrivateIPs(raw []byte, file string, add func(string, ...any)) {
for i, line := range strings.Split(string(raw), "\n") {
if ip := privateIP.FindString(line); ip != "" {
add("%s line %d contains private address %s", file, i+1, ip)
}
}
}
func schemaErrors(err error) string {
var ve *jsonschema.ValidationError
if errors.As(err, &ve) {
return fmt.Sprintf("%v", ve)
}
return err.Error()
}
func indent(s string) string {
return " " + strings.ReplaceAll(strings.TrimSpace(s), "\n", "\n ")
}
func isCredential(v any) bool {
s, ok := v.(string)
return ok && s != "" && !placeholderOnly.MatchString(s)
}
func truthy(v any) bool {
switch x := v.(type) {
case bool:
return x
case string:
return strings.EqualFold(strings.TrimSpace(x), "true")
}
return false
}
func sortedKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}