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

+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
}