Add typed settings areas to the REST API
/api/v1/settings/router/firewall, .../router/lans/<name>, .../system, down to a single option: GET reads, PUT replaces, PATCH merges, DELETE removes, with the settings' ETag/If-Match and the same limits as the whole-document edit (editSettings is now shared). Values are checked against a JSON Schema made from the option documentation (schema.go reads the types, which nixosOptionsDoc describes in words); a wrong type, a value out of range or an unknown key is refused with its place. The OpenAPI document has the area paths with their schemas; the API reference page lists every area's options. Tests: unit tests for the type reader, the validator and the paths; the real option file and a router's own settings validate (ROUTER_OPTIONS_JSON, LIVE_SETTINGS_JSON); a subtest in apply. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
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 <name> (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 != "<name>" && 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["<name>"]; 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
|
||||
}
|
||||
Reference in New Issue
Block a user