package main import ( "encoding/json" "fmt" "os" "regexp" "slices" "sort" "strconv" "strings" ) // JSON schemas of the settings, made from the router options' documentation // (the file `-options`, from nixosOptionsDoc): the types there are described in // words ("null or integer between 1 and 4094 (both inclusive)"), which are read // back into JSON Schema. What cannot be read becomes "anything" ({}), so a type // this does not know never refuses a valid setting. They describe the area // paths of the REST API and check what is written to them. type optionEntry struct { Loc []string `json:"loc"` Type string `json:"type"` Description string `json:"description"` Default json.RawMessage `json:"default"` Example json.RawMessage `json:"example"` } // literal returns the Nix text of a default or example (a literalExpression). func literal(raw json.RawMessage) string { var l struct { Text string `json:"text"` } if json.Unmarshal(raw, &l) == nil { return l.Text } return "" } // optNode is the tree of the options: containers (router.firewall), options // and the markers (a map's keys) and * (a list's elements) below them. type optNode struct { entry *optionEntry kids map[string]*optNode } func newOptNode() *optNode { return &optNode{kids: map[string]*optNode{}} } func buildOptTree(entries map[string]optionEntry) *optNode { root := newOptNode() for _, e := range entries { e := e if len(e.Loc) == 0 || e.Loc[0] != "router" { continue } n := root for _, seg := range e.Loc { k := n.kids[seg] if k == nil { k = newOptNode() n.kids[seg] = k } n = k } n.entry = &e } return root.kids["router"] } func loadOptions(path string) (map[string]optionEntry, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var entries map[string]optionEntry return entries, json.Unmarshal(data, &entries) } func (n *optNode) objectSchema() map[string]any { props := map[string]any{} for k, c := range n.kids { if k != "" && k != "*" { props[k] = c.schema() } } return map[string]any{"type": "object", "properties": props, "additionalProperties": false} } // element is the node whose children are the fields of a map's or list's // submodule (or the node itself for a plain submodule option). func (n *optNode) element() *optNode { if c := n.kids[""]; c != nil { return c } if c := n.kids["*"]; c != nil { return c } return n } func (n *optNode) schema() map[string]any { if n.entry == nil { return n.objectSchema() } s := parseOptType(n.entry.Type, func() map[string]any { return n.element().objectSchema() }) if d := strings.TrimSpace(n.entry.Description); d != "" { s["description"] = d } if d := literal(n.entry.Default); d != "" { s["x-nix-default"] = d } if _, ok := s["x-nix-type"]; !ok { s["x-nix-type"] = n.entry.Type } return s } var ( intRangeRe = regexp.MustCompile(`^(?:\d+ bit unsigned integer; )?(?:integer )?between (-?\d+) and (-?\d+) \(both inclusive\)$`) patternRe = regexp.MustCompile(`^string matching the pattern (.+)$`) ) // parseOptType reads a type description; sub makes the schema of "(submodule)". func parseOptType(t string, sub func() map[string]any) map[string]any { t = strings.TrimSpace(t) switch { case strings.HasPrefix(t, "null or "): return map[string]any{"anyOf": []any{parseOptType(t[len("null or "):], sub), map[string]any{"type": "null"}}} case strings.HasPrefix(t, "list of "): return map[string]any{"type": "array", "items": parseOptType(t[len("list of "):], sub)} case strings.HasPrefix(t, "attribute set of "): return map[string]any{"type": "object", "additionalProperties": parseOptType(t[len("attribute set of "):], sub)} case strings.HasPrefix(t, "one of "): var vals []any for _, v := range strings.Split(t[len("one of "):], ", ") { v = strings.TrimSpace(v) if u, err := strconv.Unquote(v); err == nil { vals = append(vals, u) } else { return map[string]any{} } } return map[string]any{"enum": vals} case t == "(submodule)" || t == "submodule": return sub() case strings.HasPrefix(t, "(") && closingParen(t, 0) == len(t)-1: inner := t[1 : len(t)-1] parts := splitTopLevel(inner, " or ") if len(parts) == 1 { return parseOptType(inner, sub) } var any_ []any for _, p := range parts { any_ = append(any_, parseOptType(p, sub)) } return map[string]any{"anyOf": any_} case t == "boolean": return map[string]any{"type": "boolean"} case t == "string": return map[string]any{"type": "string"} case t == "absolute path not in the Nix store": return map[string]any{"type": "string", "pattern": "^/"} case t == "signed integer" || t == "integer": return map[string]any{"type": "integer"} case strings.HasPrefix(t, "unsigned integer"): return map[string]any{"type": "integer", "minimum": 0} } if m := intRangeRe.FindStringSubmatch(t); m != nil { lo, _ := strconv.Atoi(m[1]) hi, _ := strconv.Atoi(m[2]) return map[string]any{"type": "integer", "minimum": lo, "maximum": hi} } if m := patternRe.FindStringSubmatch(t); m != nil { if _, err := regexp.Compile("^(?:" + m[1] + ")$"); err == nil { return map[string]any{"type": "string", "pattern": "^(?:" + m[1] + ")$"} } } return map[string]any{} } // closingParen is the index of the parenthesis that closes the one at i. func closingParen(s string, i int) int { depth := 0 for j := i; j < len(s); j++ { switch s[j] { case '(': depth++ case ')': depth-- if depth == 0 { return j } } } return -1 } // splitTopLevel splits s at sep, not inside parentheses. func splitTopLevel(s, sep string) []string { var parts []string depth, start := 0, 0 for i := 0; i < len(s); i++ { switch { case s[i] == '(': depth++ case s[i] == ')': depth-- case depth == 0 && strings.HasPrefix(s[i:], sep): parts = append(parts, s[start:i]) start = i + len(sep) i += len(sep) - 1 } } return append(parts, s[start:]) } // systemSchema describes `system`, which the router options do not cover. func systemSchema() map[string]any { str := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} } return map[string]any{"type": "object", "additionalProperties": false, "properties": map[string]any{ "hostName": str("The router's host name."), "timeZone": str("A time zone name like Europe/Berlin (default UTC)."), "sshKeys": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "SSH keys of root. Read only through the API."}, "stateVersion": str("system.stateVersion. Read only through the API."), }} } // settingsSchema is the schema of the settings document below `router` and // `system` (nil when there is no option documentation). func settingsSchema(optionsFile string) map[string]any { if optionsFile == "" { return nil } entries, err := loadOptions(optionsFile) if err != nil { return nil } tree := buildOptTree(entries) if tree == nil { return nil } return map[string]any{"type": "object", "additionalProperties": false, "properties": map[string]any{ "version": map[string]any{"type": "integer"}, "router": tree.schema(), "system": systemSchema(), }} } func unwrapNullable(s map[string]any) map[string]any { if branches, ok := s["anyOf"].([]any); ok && len(branches) == 2 { for i, b := range branches { if m, ok := b.(map[string]any); ok && m["type"] == "null" { if other, ok := branches[1-i].(map[string]any); ok { return other } } } } return s } // resolveSchema walks the schema along path segments (area paths). func resolveSchema(root map[string]any, segs []string) (map[string]any, error) { s := root for i, seg := range segs { s = unwrapNullable(s) props, _ := s["properties"].(map[string]any) if p, ok := props[seg].(map[string]any); ok { s = p continue } if add, ok := s["additionalProperties"].(map[string]any); ok { s = add continue } if s["type"] == "array" { return nil, fmt.Errorf("%s is a list: its elements cannot be addressed, use the whole list", strings.Join(segs[:i], "/")) } return nil, fmt.Errorf("unknown path %s (no option %q there)", strings.Join(segs, "/"), seg) } return s, nil } // validate checks a value against a schema made here; it returns what is wrong. func validate(v any, s map[string]any, path string) []string { if len(s) == 0 { return nil } if branches, ok := s["anyOf"].([]any); ok { for _, b := range branches { if m, ok := b.(map[string]any); ok && len(validate(v, m, path)) == 0 { return nil } } // Say what is wrong with the likeliest branch: the one that is not null. for _, b := range branches { if m, ok := b.(map[string]any); ok && m["type"] != "null" { if errs := validate(v, m, path); len(errs) > 0 { return errs } } } return []string{path + ": not a valid value"} } if enum, ok := s["enum"].([]any); ok { if !slices.Contains(enum, v) { return []string{fmt.Sprintf("%s: must be one of %s", path, strings.TrimPrefix(fmt.Sprint(s["x-nix-type"]), "one of "))} } return nil } fail := func(want string) []string { return []string{fmt.Sprintf("%s: expected %s", path, want)} } switch s["type"] { case "object": m, ok := v.(map[string]any) if !ok { return fail("an object") } props, _ := s["properties"].(map[string]any) var errs []string keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { child := path + "/" + k if p, ok := props[k].(map[string]any); ok { errs = append(errs, validate(m[k], p, child)...) } else if add, ok := s["additionalProperties"].(map[string]any); ok { errs = append(errs, validate(m[k], add, child)...) } else if s["additionalProperties"] == false { errs = append(errs, child+": unknown option") } } return errs case "array": a, ok := v.([]any) if !ok { return fail("a list") } items, _ := s["items"].(map[string]any) var errs []string for i, e := range a { errs = append(errs, validate(e, items, fmt.Sprintf("%s[%d]", path, i))...) } return errs case "string": str, ok := v.(string) if !ok { return fail("a string") } if p, ok := s["pattern"].(string); ok { if re, err := regexp.Compile(p); err == nil && !re.MatchString(str) { return []string{fmt.Sprintf("%s: %q does not match the required form (%v)", path, str, s["x-nix-type"])} } } case "integer": f, ok := v.(float64) if !ok || f != float64(int64(f)) { return fail("a whole number") } if lo, ok := s["minimum"].(int); ok && f < float64(lo) { return []string{fmt.Sprintf("%s: %v is below the minimum %d", path, f, lo)} } if hi, ok := s["maximum"].(int); ok && f > float64(hi) { return []string{fmt.Sprintf("%s: %v is above the maximum %d", path, f, hi)} } case "boolean": if _, ok := v.(bool); !ok { return fail("true or false") } case "null": if v != nil { return fail("null") } } return nil }