feat: game templates, schemas and validator
Validate game templates / validate (push) Successful in 10m6s
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:
commit
822b44e2e5
20 files changed
+2283
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user