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) // Go's regexp is a subset of Java's, which the node-agent uses; anything that compiles here works there patterns, _ := g["failPatterns"].([]any) if p, ok := g["readyPattern"].(string); ok { patterns = append(patterns, p) } for _, p := range patterns { if _, err := regexp.Compile(fmt.Sprint(p)); err != nil { add("pattern %q does not compile: %v", p, err) } } 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; remove it (boot tests fill password settings with a throwaway "+ "value), or, only if the server can't start without a real one, map it under 'secrets' to a CI secret", key) } } for _, key := range sortedKeys(secrets) { if !known[key] { add("test.yaml secrets.%s is not a key of this game", key) } } if example, ok := t["readyExample"].(string); ok { pattern, _ := g["readyPattern"].(string) if re, err := regexp.Compile(pattern); pattern == "" || err != nil { add("test.yaml has a readyExample but the game has no valid readyPattern") } else if !re.MatchString(example) { add("readyPattern %q does not match test.yaml readyExample %q", pattern, example) } } 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 }