diff --git a/SPECS.md b/SPECS.md index 7cee500..ab509ff 100644 --- a/SPECS.md +++ b/SPECS.md @@ -124,6 +124,7 @@ Learned along the way: - `/api/v1/…` with an OpenAPI 3.1 description at `GET /api/openapi.json` (no token: it describes the interface only). Same tokens, scopes, origin check, limits and audit log as MCP. - **One implementation**: each operation runs an MCP tool (`apiRoutes` in `api.go` maps method and path to a tool; query parameters or `If-Match` and the body become its arguments), and `TestOpenAPI` checks that every tool has an operation. Status, settings (`ETag` header), `PATCH /settings` (merge patch, `If-Match`; 409 when stale), `settings/check`, `changes` (GET the diff as text, DELETE to discard), `interfaces`, `dhcp-leases`, `update`, `apply-log`, `logs`, `options`. +- **Settings areas** (`/api/v1/settings/router/firewall`, `.../router/lans/`, `.../system`, down to a single option): `GET` reads, `PUT` replaces, `PATCH` merges (objects), `DELETE` removes, with the settings' `ETag`/`If-Match` and the same limits as the whole-document edit (`editSettings`). Values are checked against a JSON Schema made from the option documentation (`schema.go` reads the types nixosOptionsDoc describes in words, e.g. "null or integer between 1 and 4094"; a type it does not know becomes "anything", so it never refuses a valid setting): a wrong type, a value out of range or a key that is not an option is refused with its place. The OpenAPI document has these paths (three levels deep) with their schemas; lists are replaced whole (elements have no paths). Tested with the real option file (`ROUTER_OPTIONS_JSON`, `LIVE_SETTINGS_JSON` for a router's own settings) and in `apply`. - **API reference** page in the web UI (`/apireference`, linked from API access): each operation with its access, parameters and a `curl` example for this router, from the same route list as the OpenAPI document. No "try it" form. - Errors are `{"error": "…"}` with 400 (refused or bad argument), 401, 403 (scope), 409 (stale ETag), 500. @@ -224,7 +225,7 @@ verity. The image module and its test (`checks.image`) stay as they are for anyo Remote access - **MCP and REST: an `apply` scope**: build, activate and confirm. A token with it changes the running system, so it needs its own design (probably a short expiry and a confirmation the token cannot give). - **More read tools**: metrics history, WireGuard status (handshakes, transfer), certificate status. -- **REST API**: the rest of what the web UI does (apply and confirm, metrics, updates); typed response schemas in the OpenAPI document (responses are untyped JSON now); the web UI could use the same handlers. +- **REST API**: the rest of what the web UI does (apply and confirm, metrics, updates); typed schemas for the responses of the read operations (status, leases, interfaces; settings areas are typed); the web UI could use the same handlers. - **GraphQL** on top of it, possibly: one query for the overview and for related settings. - Rule for all of it: changes go through `settings.json` and `router-apply` (commit-confirm), never straight to the live system; tokens have scopes; every call is logged. diff --git a/pkgs/router-webui/api.go b/pkgs/router-webui/api.go index 95ed304..9c558f7 100644 --- a/pkgs/router-webui/api.go +++ b/pkgs/router-webui/api.go @@ -54,6 +54,7 @@ func (a *app) registerAPI(mux *http.ServeMux) { for _, rt := range apiRoutes { mux.HandleFunc(rt.method+" "+rt.path, a.apiHandler(rt)) } + a.registerAreas(mux) mux.HandleFunc("GET /api/v1/options", a.apiOptions) mux.HandleFunc("GET /api/openapi.json", a.apiSpec) } @@ -172,10 +173,10 @@ func (a *app) apiSpec(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") enc := json.NewEncoder(w) enc.SetIndent("", " ") - _ = enc.Encode(openAPISpec(a.sys.installedRelease().Version)) + _ = enc.Encode(openAPISpec(a.sys.installedRelease().Version, a.settingsSchema())) } -func openAPISpec(version string) map[string]any { +func openAPISpec(version string, schema map[string]any) map[string]any { errResp := func(d string) map[string]any { return map[string]any{"description": d, "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"$ref": "#/components/schemas/Error"}}}} } @@ -235,13 +236,19 @@ func openAPISpec(version string) map[string]any { "security": []any{map[string]any{"bearer": []string{}}}, "responses": map[string]any{"200": map[string]any{"description": "The options (JSON)", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{}}}}, "404": errResp("Not available.")}, }} + if schema != nil { + for p, ops := range areaPaths(schema) { + paths[p] = ops + } + } return map[string]any{ "openapi": "3.1.0", "info": map[string]any{ "title": "nixos-router", "version": version, "description": "The router's management API, equal to its MCP server: read its state and logs, edit the saved settings. " + - "Changes are saved, never applied: a person applies them in the web UI. Tokens are made on the API access page (scope read or change).", + "Changes are saved, never applied: a person applies them in the web UI. Tokens are made on the API access page (scope read or change). " + + "The settings are also a tree of areas: /api/v1/settings/router/firewall, /router/lans/{name}, /system, ... (any depth) can be read, replaced (PUT), merged into (PATCH) or removed (DELETE); the documented ones go three levels deep.", }, "servers": []any{map[string]any{"url": "/"}}, "paths": paths, diff --git a/pkgs/router-webui/apiref.go b/pkgs/router-webui/apiref.go index 7f31d1c..a95144f 100644 --- a/pkgs/router-webui/apiref.go +++ b/pkgs/router-webui/apiref.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "slices" + "sort" "strings" ) @@ -69,5 +70,48 @@ func apiReference(host string) []apiDoc { func (a *app) apiReference(w http.ResponseWriter, r *http.Request, sess session) { a.render(w, "apiref", page{Title: "API reference", CSRF: sess.csrf, LoggedIn: true, Data: map[string]any{ "Base": "https://" + r.Host, "Ops": apiReference(r.Host), "HasOptions": a.optionsFile != "", + "Areas": settingsAreas(a.optionsFile), "Host": r.Host, }}) } + +type areaOption struct{ Name, Type, Default, Description string } + +type areaDoc struct { + Path string + Options []areaOption +} + +// settingsAreas lists the areas below router/ with their options (from the +// option documentation): what a person can read and write there. +func settingsAreas(optionsFile string) []areaDoc { + if optionsFile == "" { + return nil + } + entries, err := loadOptions(optionsFile) + if err != nil { + return nil + } + byArea := map[string][]areaOption{} + for _, e := range entries { + if len(e.Loc) < 3 || e.Loc[0] != "router" { + continue + } + desc := strings.TrimSpace(e.Description) + if i := strings.Index(desc, "\n\n"); i > 0 { + desc = desc[:i] + } + desc = strings.Join(strings.Fields(desc), " ") + if len(desc) > 300 { + desc = desc[:300] + "…" + } + name := strings.Join(e.Loc[2:], ".") + byArea[e.Loc[1]] = append(byArea[e.Loc[1]], areaOption{name, e.Type, literal(e.Default), desc}) + } + var areas []areaDoc + for k, opts := range byArea { + sort.Slice(opts, func(i, j int) bool { return opts[i].Name < opts[j].Name }) + areas = append(areas, areaDoc{Path: "/api/v1/settings/router/" + k, Options: opts}) + } + sort.Slice(areas, func(i, j int) bool { return areas[i].Path < areas[j].Path }) + return areas +} diff --git a/pkgs/router-webui/areas.go b/pkgs/router-webui/areas.go new file mode 100644 index 0000000..ddeba80 --- /dev/null +++ b/pkgs/router-webui/areas.go @@ -0,0 +1,289 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "sort" + "strings" + "time" +) + +// Area paths: /api/v1/settings/router/firewall, .../router/lans/lan, ... read, +// replace (PUT), merge into (PATCH) or remove (DELETE) one part of the +// settings. They edit the same saved settings as PATCH /api/v1/settings, with +// the same ETag and the same limits (editSettings), and check what is written +// against the schema made from the option documentation. + +func (a *app) settingsSchema() map[string]any { + a.schemaOnce.Do(func() { a.schemaVal = settingsSchema(a.optionsFile) }) + return a.schemaVal +} + +func (a *app) registerAreas(mux *http.ServeMux) { + for _, m := range []string{"GET", "PUT", "PATCH", "DELETE"} { + mux.HandleFunc(m+" /api/v1/settings/{path...}", a.areaHandler(m)) + } +} + +// areaSegments splits an area path; only router and system exist. +func areaSegments(p string) ([]string, error) { + segs := strings.Split(strings.Trim(p, "/"), "/") + if segs[0] != "router" && segs[0] != "system" { + return nil, errors.New("settings areas are below router/ and system/") + } + for _, s := range segs { + if s == "" || len(s) > 128 || strings.ContainsFunc(s, func(r rune) bool { return r < 0x20 || r == 0x7f }) { + return nil, errors.New("bad path") + } + } + return segs, nil +} + +func getPath(v any, segs []string) (any, bool) { + for _, s := range segs { + m, ok := v.(map[string]any) + if !ok { + return nil, false + } + if v, ok = m[s]; !ok { + return nil, false + } + } + return v, true +} + +func setPath(root map[string]any, segs []string, val any) error { + m := root + for i, s := range segs[:len(segs)-1] { + next, ok := m[s] + if !ok || next == nil { + child := map[string]any{} + m[s] = child + m = child + continue + } + child, ok := next.(map[string]any) + if !ok { + return fmt.Errorf("%s is not an object, so nothing can be set below it", strings.Join(segs[:i+1], "/")) + } + m = child + } + m[segs[len(segs)-1]] = val + return nil +} + +func delPath(root map[string]any, segs []string) { + parent, ok := getPath(root, segs[:len(segs)-1]) + if m, isMap := parent.(map[string]any); ok && isMap { + delete(m, segs[len(segs)-1]) + } +} + +func (a *app) areaHandler(method string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + tok, ok := a.bearer(w, r) + if !ok { + return + } + scope := scopeChange + if method == http.MethodGet { + scope = scopeRead + } + if !tok.allows(scope) { + apiError(w, http.StatusForbidden, "this token may not "+scope+" (scope)") + return + } + segs, err := areaSegments(r.PathValue("path")) + if err != nil { + apiError(w, http.StatusNotFound, err.Error()) + return + } + var area map[string]any + if schema := a.settingsSchema(); schema != nil { + if area, err = resolveSchema(schema, segs); err != nil { + apiError(w, http.StatusNotFound, err.Error()) + return + } + } + var body []byte + if method == http.MethodPut || method == http.MethodPatch { + if body, err = io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)); err != nil { + apiError(w, http.StatusRequestEntityTooLarge, "request too large") + return + } + } + res, status, err := a.area(method, segs, area, body, strings.Trim(strings.TrimPrefix(r.Header.Get("If-Match"), "W/"), `"`)) + entry := AuditEntry{Time: time.Now().UTC(), Token: tok.Name, Tool: method + " /" + strings.Join(segs, "/"), Args: string(body)} + if err != nil { + entry.Error = err.Error() + } + a.sys.audit(entry) + if err != nil { + apiError(w, status, err.Error()) + return + } + if method != http.MethodGet { + if m, ok := res.(map[string]any); ok { + if e, ok := m["etag"].(string); ok { + w.Header().Set("ETag", `"`+e+`"`) + a.sys.noteTokenEdit(tok.Name, e) + } + } + } else if data, err := os.ReadFile(a.sys.settingsPath()); err == nil { + w.Header().Set("ETag", `"`+etag(data)+`"`) + } + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(res) + } +} + +// area does one operation; the status is for an error. +func (a *app) area(method string, segs []string, area map[string]any, body []byte, ifMatch string) (any, int, error) { + if method == http.MethodGet { + data, err := os.ReadFile(a.sys.settingsPath()) + if err != nil { + return nil, http.StatusInternalServerError, err + } + doc, err := parseSettings(data) + if err != nil { + return nil, http.StatusInternalServerError, err + } + v, ok := getPath(map[string]any(doc), segs) + if !ok { + return nil, http.StatusNotFound, errors.New("not set in the settings (its defaults apply)") + } + return v, 0, nil + } + if ifMatch == "" { + return nil, http.StatusBadRequest, errors.New("send the settings' ETag in If-Match (GET /api/v1/settings)") + } + var val any + if method != http.MethodDelete { + if err := json.Unmarshal(body, &val); err != nil { + return nil, http.StatusBadRequest, errors.New("the body must be JSON") + } + } + path := "/" + strings.Join(segs, "/") + res, err := a.editSettings(ifMatch, func(next Settings) (Settings, error) { + root := map[string]any(next) + switch method { + case http.MethodDelete: + delPath(root, segs) + return next, nil + case http.MethodPatch: + patch, ok := val.(map[string]any) + if !ok { + return nil, argError("a PATCH body is a JSON merge patch (an object); use PUT to set a value") + } + cur, _ := getPath(root, segs) + if cur == nil { + cur = map[string]any{} + } + if _, ok := cur.(map[string]any); !ok { + return nil, argError(path + " is not an object; use PUT to replace it") + } + val = mergePatch(cur, patch) + case http.MethodPut: + if val == nil { + return nil, argError("use DELETE to remove a setting") + } + } + if area != nil { + if errs := validate(val, area, path); len(errs) > 0 { + if len(errs) > 5 { + errs = append(errs[:5], fmt.Sprintf("… and %d more", len(errs)-5)) + } + return nil, argError(strings.Join(errs, "; ")) + } + } + return next, setPath(root, segs, val) + }) + switch { + case errors.Is(err, errStale): + return nil, http.StatusConflict, err + case err != nil: + return nil, http.StatusBadRequest, err + } + return res, 0, nil +} + +// areaOps lists the paths of the areas for the OpenAPI document: the options +// to a depth of three (router/lans/{name}); deeper paths work the same. +func areaPaths(schema map[string]any) map[string]any { + paths := map[string]any{} + var emit func(parts []string, s map[string]any) + emit = func(parts []string, s map[string]any) { + path := "/api/v1/settings/" + strings.Join(parts, "/") + node := unwrapNullable(s) + id := strings.NewReplacer("/", "_", "{", "", "}", "").Replace(strings.Join(parts, "_")) + var params []any + for _, p := range parts { + if p == "{name}" { + params = append(params, map[string]any{"name": "name", "in": "path", "required": true, "schema": map[string]any{"type": "string"}, "description": "The name of the entry (the key in the map)."}) + } + } + withParams := func(extra ...any) []any { return append(append([]any{}, params...), extra...) } + errs := func(codes ...string) map[string]any { + m := map[string]any{"401": map[string]any{"description": "No or an unknown token."}, "403": map[string]any{"description": "The token's scope does not allow this."}} + for _, c := range codes { + m[c] = map[string]any{"description": map[string]string{"400": "Refused: the value does not fit the option's type (see the message).", "404": "Not set (its defaults apply), or no such option.", "409": "The settings changed since the ETag was read."}[c], "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"$ref": "#/components/schemas/Error"}}}} + } + return m + } + ifMatch := map[string]any{"name": "If-Match", "in": "header", "required": true, "schema": map[string]any{"type": "string"}, "description": "The ETag of the settings (GET /api/v1/settings or any area)."} + jsonBody := func(sch map[string]any) map[string]any { + return map[string]any{"required": true, "content": map[string]any{"application/json": map[string]any{"schema": sch}}} + } + ok200 := func(desc string, sch map[string]any) map[string]any { + r := errs("400", "404", "409") + r["200"] = map[string]any{"description": desc, "content": map[string]any{"application/json": map[string]any{"schema": sch}}} + return r + } + sec := []any{map[string]any{"bearer": []string{}}} + ops := map[string]any{ + "get": map[string]any{"operationId": "get_" + id, "summary": "Read " + strings.Join(parts, "/"), "tags": []string{"settings"}, "security": sec, + "parameters": params, "responses": ok200("The value.", s), "x-scope": scopeRead}, + "put": map[string]any{"operationId": "put_" + id, "summary": "Replace " + strings.Join(parts, "/"), "tags": []string{"settings"}, "security": sec, + "parameters": withParams(ifMatch), "requestBody": jsonBody(node), "responses": ok200("Saved, not applied: the result has the new ETag and the difference.", map[string]any{"type": "object"}), "x-scope": scopeChange}, + "delete": map[string]any{"operationId": "delete_" + id, "summary": "Remove " + strings.Join(parts, "/") + " (its defaults apply again)", "tags": []string{"settings"}, "security": sec, + "parameters": withParams(ifMatch), "responses": ok200("Saved, not applied.", map[string]any{"type": "object"}), "x-scope": scopeChange}, + } + if node["type"] == "object" { + ops["patch"] = map[string]any{"operationId": "patch_" + id, "summary": "Merge into " + strings.Join(parts, "/"), "tags": []string{"settings"}, "security": sec, + "parameters": withParams(ifMatch), "requestBody": jsonBody(map[string]any{"type": "object", "description": "A JSON merge patch (RFC 7386): objects merge, a value replaces, null removes a key, arrays are replaced whole."}), + "responses": ok200("Saved, not applied.", map[string]any{"type": "object"}), "x-scope": scopeChange} + } + paths[path] = ops + if len(parts) >= 3 { + return + } + props, _ := node["properties"].(map[string]any) + keys := make([]string, 0, len(props)) + for k := range props { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if c, ok := props[k].(map[string]any); ok { + emit(append(append([]string{}, parts...), k), c) + } + } + if add, ok := node["additionalProperties"].(map[string]any); ok && node["type"] == "object" { + emit(append(append([]string{}, parts...), "{name}"), add) + } + } + props, _ := schema["properties"].(map[string]any) + for _, top := range []string{"router", "system"} { + if s, ok := props[top].(map[string]any); ok { + emit([]string{top}, s) + } + } + return paths +} diff --git a/pkgs/router-webui/areas_test.go b/pkgs/router-webui/areas_test.go new file mode 100644 index 0000000..1a9a9ea --- /dev/null +++ b/pkgs/router-webui/areas_test.go @@ -0,0 +1,277 @@ +package main + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +// A few options in the format of nixosOptionsDoc. +const testOptions = `{ + "router.wan.interface": {"loc": ["router","wan","interface"], "type": "string", "description": "The uplink."}, + "router.lans": {"loc": ["router","lans"], "type": "attribute set of (submodule)", "description": "LANs.", "default": {"_type":"literalExpression","text":"{ }"}}, + "router.lans..interface": {"loc": ["router","lans","","interface"], "type": "string"}, + "router.lans..internet": {"loc": ["router","lans","","internet"], "type": "boolean"}, + "router.lans..vlan": {"loc": ["router","lans","","vlan"], "type": "null or integer between 1 and 4094 (both inclusive)"}, + "router.lans..ipv4.address": {"loc": ["router","lans","","ipv4","address"], "type": "string matching the pattern [0-9.]+"}, + "router.firewall.rules": {"loc": ["router","firewall","rules"], "type": "list of (submodule)"}, + "router.firewall.rules.*.from": {"loc": ["router","firewall","rules","*","from"], "type": "string"}, + "router.firewall.rules.*.to": {"loc": ["router","firewall","rules","*","to"], "type": "string"}, + "router.firewall.rules.*.protocol": {"loc": ["router","firewall","rules","*","protocol"], "type": "one of \"any\", \"tcp\", \"udp\", \"icmp\""}, + "router.firewall.rules.*.ports": {"loc": ["router","firewall","rules","*","ports"], "type": "list of (16 bit unsigned integer; between 0 and 65535 (both inclusive) or string matching the pattern [0-9]+-[0-9]+)"}, + "router.dns.domain": {"loc": ["router","dns","domain"], "type": "null or string"}, + "router.dns.hosts": {"loc": ["router","dns","hosts"], "type": "attribute set of list of string"} +}` + +func areaApp(t *testing.T) (*app, http.Handler, string) { + t.Helper() + a, sys := newTestApp(t) + opts := filepath.Join(t.TempDir(), "options.json") + os.WriteFile(opts, []byte(testOptions), 0o600) + a.optionsFile = opts + data, _ := os.ReadFile(sys.settingsPath()) + os.WriteFile(sys.confirmedPath(), data, 0o600) + _, tok, _ := a.tokens.Create("area", "change") + return a, a.routes(), tok +} + +func TestParseOptType(t *testing.T) { + sub := func() map[string]any { return map[string]any{"type": "object"} } + cases := map[string]string{ + "boolean": `{"type":"boolean"}`, + "null or string": `{"anyOf":[{"type":"string"},{"type":"null"}]}`, + "list of string": `{"items":{"type":"string"},"type":"array"}`, + "attribute set of list of string": `{"additionalProperties":{"items":{"type":"string"},"type":"array"},"type":"object"}`, + `one of "dhcp", "pppoe"`: `{"enum":["dhcp","pppoe"]}`, + "integer between 8 and 30 (both inclusive)": `{"maximum":30,"minimum":8,"type":"integer"}`, + "16 bit unsigned integer; between 0 and 65535 (both inclusive)": `{"maximum":65535,"minimum":0,"type":"integer"}`, + "list of (submodule)": `{"items":{"type":"object"},"type":"array"}`, + "something nobody has thought of yet": `{}`, + } + for in, want := range cases { + got, _ := json.Marshal(parseOptType(in, sub)) + if string(got) != want { + t.Errorf("%q: %s, want %s", in, got, want) + } + } + // Alternatives in parentheses. + got, _ := json.Marshal(parseOptType("list of (16 bit unsigned integer; between 0 and 65535 (both inclusive) or string matching the pattern [0-9]+-[0-9]+)", sub)) + if !strings.Contains(string(got), `"anyOf"`) || !strings.Contains(string(got), `[0-9]+-[0-9]+`) { + t.Errorf("ports: %s", got) + } +} + +func TestValidate(t *testing.T) { + a, _ := newTestApp(t) + opts := filepath.Join(t.TempDir(), "o.json") + os.WriteFile(opts, []byte(testOptions), 0o600) + a.optionsFile = opts + root := a.settingsSchema() + lans, err := resolveSchema(root, []string{"router", "lans"}) + if err != nil { + t.Fatal(err) + } + ok := map[string]any{"lan": map[string]any{"interface": "eth1", "internet": true, "vlan": float64(20), "ipv4": map[string]any{"address": "10.0.0.1"}}} + if errs := validate(ok, lans, "/router/lans"); len(errs) != 0 { + t.Fatalf("valid: %v", errs) + } + for name, bad := range map[string]map[string]any{ + "unknown option": {"lan": map[string]any{"interfce": "eth1"}}, + "wrong type": {"lan": map[string]any{"internet": "yes"}}, + "range": {"lan": map[string]any{"vlan": float64(5000)}}, + "not whole": {"lan": map[string]any{"vlan": 1.5}}, + "pattern": {"lan": map[string]any{"ipv4": map[string]any{"address": "ten"}}}, + } { + if errs := validate(bad, lans, "/router/lans"); len(errs) == 0 { + t.Errorf("%s was accepted", name) + } + } + if _, err := resolveSchema(root, []string{"router", "firewall", "rules", "0"}); err == nil { + t.Error("list elements are addressable") + } + if _, err := resolveSchema(root, []string{"router", "nothing"}); err == nil { + t.Error("unknown option resolves") + } +} + +func TestAreaPaths(t *testing.T) { + _, h, tok := areaApp(t) + if rec := rest(h, tok, "GET", "/api/v1/settings/router/lans/main", "", nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), `"interface"`) { + t.Fatalf("get a LAN: %d %s", rec.Code, rec.Body) + } + etag := rest(h, tok, "GET", "/api/v1/settings/router/lans", "", nil).Header().Get("ETag") + if etag == "" { + t.Fatal("no ETag") + } + for path, want := range map[string]int{ + "/api/v1/settings/router/nothing": 404, + "/api/v1/settings/other": 404, + "/api/v1/settings/router/dns": 404, // not in the sample: defaults apply + "/api/v1/settings/router/firewall/rules/0": 404, + } { + if rec := rest(h, tok, "GET", path, "", nil); rec.Code != want { + t.Errorf("GET %s: %d, want %d", path, rec.Code, want) + } + } + hm := map[string]string{"If-Match": etag} + // Typos and wrong types are refused with the place. + rec := rest(h, tok, "PUT", "/api/v1/settings/router/dns", `{"domain": 5}`, hm) + if rec.Code != 400 || !strings.Contains(rec.Body.String(), "/router/dns/domain") { + t.Fatalf("wrong type: %d %s", rec.Code, rec.Body) + } + if rec := rest(h, tok, "PUT", "/api/v1/settings/router/dns", `{"domian": "x"}`, hm); rec.Code != 400 || !strings.Contains(rec.Body.String(), "unknown option") { + t.Fatalf("typo: %d %s", rec.Code, rec.Body) + } + if rec := rest(h, tok, "PUT", "/api/v1/settings/router/dns", `{"domain": "home.test"}`, nil); rec.Code != 400 { + t.Fatalf("without If-Match: %d", rec.Code) + } + if rec := rest(h, tok, "PUT", "/api/v1/settings/router/dns", `{"domain": "home.test"}`, map[string]string{"If-Match": "0000"}); rec.Code != 409 { + t.Fatalf("stale: %d", rec.Code) + } + rec = rest(h, tok, "PUT", "/api/v1/settings/router/dns", `{"domain": "home.test", "hosts": {"nas": ["192.168.10.9"]}}`, hm) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"saved": true`) { + t.Fatalf("put: %d %s", rec.Code, rec.Body) + } + etag = rec.Header().Get("ETag") + hm = map[string]string{"If-Match": etag} + if rec := rest(h, tok, "GET", "/api/v1/settings/router/dns/hosts/nas", "", nil); !strings.Contains(rec.Body.String(), "192.168.10.9") { + t.Fatalf("read back: %d %s", rec.Code, rec.Body) + } + // PATCH merges: the other host stays. + rec = rest(h, tok, "PATCH", "/api/v1/settings/router/dns/hosts", `{"printer": ["192.168.10.8"]}`, hm) + if rec.Code != 200 { + t.Fatalf("patch: %d %s", rec.Code, rec.Body) + } + hm = map[string]string{"If-Match": rec.Header().Get("ETag")} + body := rest(h, tok, "GET", "/api/v1/settings/router/dns", "", nil).Body.String() + if !strings.Contains(body, "nas") || !strings.Contains(body, "printer") || !strings.Contains(body, "home.test") { + t.Fatalf("after patch: %s", body) + } + // What the API may not change stays out of reach. + if rec := rest(h, tok, "PUT", "/api/v1/settings/system/sshKeys", `[]`, hm); rec.Code != 400 { + t.Fatalf("ssh keys: %d %s", rec.Code, rec.Body) + } + rec = rest(h, tok, "PUT", "/api/v1/settings/router/wan", `{"interface":"x"}`, hm) + if rec.Code != 200 { + t.Fatalf("wan: %d %s", rec.Code, rec.Body) + } + hm = map[string]string{"If-Match": rec.Header().Get("ETag")} + // DELETE removes; the diff and the edit note work like for the whole document. + rec = rest(h, tok, "DELETE", "/api/v1/settings/router/dns", "", hm) + if rec.Code != 200 { + t.Fatalf("delete: %d %s", rec.Code, rec.Body) + } + if rec := rest(h, tok, "GET", "/api/v1/settings/router/dns", "", nil); rec.Code != 404 { + t.Fatalf("after delete: %d", rec.Code) + } + // A read token reads but cannot write. + a, _, _ := areaApp(t) + _, read, _ := a.tokens.Create("reader", "read") + if rec := rest(a.routes(), read, "GET", "/api/v1/settings/router/lans", "", nil); rec.Code != 200 { + t.Fatalf("read token reads: %d", rec.Code) + } + if rec := rest(a.routes(), read, "PUT", "/api/v1/settings/router/dns", `{}`, map[string]string{"If-Match": "x"}); rec.Code != 403 { + t.Fatalf("read token wrote: %d", rec.Code) + } +} + +func TestAreaOpenAPI(t *testing.T) { + _, h, _ := areaApp(t) + rec := rest(h, "", "GET", "/api/openapi.json", "", nil) + var spec struct { + Paths map[string]map[string]any `json:"paths"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &spec); err != nil { + t.Fatal(err) + } + for _, p := range []string{"/api/v1/settings/router/lans", "/api/v1/settings/router/lans/{name}", "/api/v1/settings/router/dns/domain", "/api/v1/settings/system/hostName"} { + if spec.Paths[p] == nil { + t.Errorf("%s is not documented", p) + } + } + lan := spec.Paths["/api/v1/settings/router/lans/{name}"] + for _, m := range []string{"get", "put", "patch", "delete"} { + if lan[m] == nil { + t.Errorf("lans/{name} has no %s", m) + } + } + if _, ok := spec.Paths["/api/v1/settings/router/dns/domain"]["patch"]; ok { + t.Error("a string option has a PATCH") + } + keys := make([]string, 0) + for p := range spec.Paths { + keys = append(keys, p) + } + if !slices.Contains(keys, "/api/v1/settings") { + t.Error("the whole settings are gone from the spec") + } +} + +// With ROUTER_OPTIONS_JSON set to the real options file, every option's type is +// understood (nothing falls back to "anything") and the sample settings of the +// tests validate. +func TestRealOptionsSchema(t *testing.T) { + path := os.Getenv("ROUTER_OPTIONS_JSON") + if path == "" { + t.Skip("ROUTER_OPTIONS_JSON not set") + } + entries, err := loadOptions(path) + if err != nil { + t.Fatal(err) + } + for k, e := range entries { + if !strings.HasPrefix(k, "router.") { + continue + } + s := parseOptType(e.Type, func() map[string]any { return map[string]any{"type": "object"} }) + if len(s) == 0 { + t.Errorf("%s: type %q is not understood", k, e.Type) + } + } + root := settingsSchema(path) + if root == nil { + t.Fatal("no schema") + } + var doc map[string]any + if err := json.Unmarshal([]byte(sample), &doc); err != nil { + t.Fatal(err) + } + if errs := validate(doc, root, ""); len(errs) != 0 { + t.Errorf("the sample settings do not validate: %v", errs) + } +} + +// LIVE_SETTINGS_JSON: a real router's settings must validate too. +func TestLiveSettingsValidate(t *testing.T) { + opts, live := os.Getenv("ROUTER_OPTIONS_JSON"), os.Getenv("LIVE_SETTINGS_JSON") + if opts == "" || live == "" { + t.Skip("ROUTER_OPTIONS_JSON and LIVE_SETTINGS_JSON not set") + } + data, err := os.ReadFile(live) + if err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatal(err) + } + if errs := validate(doc, settingsSchema(opts), ""); len(errs) != 0 { + t.Errorf("%v", errs) + } +} + +func TestAreasOnReferencePage(t *testing.T) { + a, h, _ := areaApp(t) + cookie := login(t, h) + _ = a + body := do(h, "GET", "/apireference", nil, cookie).Body.String() + for _, want := range []string{"/api/v1/settings/router/lans", "/api/v1/settings/router/firewall", "<name>.vlan", "null or integer between 1 and 4094", "Settings areas"} { + if !strings.Contains(body, want) { + t.Errorf("%q is missing from the reference page", want) + } + } +} diff --git a/pkgs/router-webui/main.go b/pkgs/router-webui/main.go index a35eda0..e4e8d84 100644 --- a/pkgs/router-webui/main.go +++ b/pkgs/router-webui/main.go @@ -221,7 +221,9 @@ type app struct { tokens *tokenStore settingsMu sync.Mutex // edits through the API read, patch and write the settings file - optionsFile string // JSON documentation of the router options, for the MCP resource ("" if none) + schemaOnce sync.Once + schemaVal map[string]any // the settings schema, from optionsFile (nil if none) + optionsFile string // JSON documentation of the router options, for the MCP resource ("" if none) } func withOptions(a *app, file string) *app { diff --git a/pkgs/router-webui/mcp.go b/pkgs/router-webui/mcp.go index d0ec4ce..87c0d01 100644 --- a/pkgs/router-webui/mcp.go +++ b/pkgs/router-webui/mcp.go @@ -497,25 +497,37 @@ func (a *app) updateSettings(args json.RawMessage) (any, error) { if _, ok := in.Patch.(map[string]any); !ok { return nil, errors.New("`patch` must be an object") } + return a.editSettings(in.Etag, func(next Settings) (Settings, error) { + patched, _ := mergePatch(map[string]any(next), in.Patch).(map[string]any) + return Settings(patched), nil + }) +} + +// editSettings saves what edit makes of the settings, if etag is the current +// version of them (so nobody's edit is lost) and the result stays within what +// the API may change. The settings stay unapplied. +func (a *app) editSettings(wantEtag string, edit func(next Settings) (Settings, error)) (any, error) { a.settingsMu.Lock() defer a.settingsMu.Unlock() data, err := os.ReadFile(a.sys.settingsPath()) if err != nil { return nil, err } - if in.Etag != etag(data) { + if wantEtag != etag(data) { return nil, errStale } old, err := parseSettings(data) if err != nil { return nil, err } - next, err := parseSettings(data) // a copy to patch + next, err := parseSettings(data) // a copy to edit + if err != nil { + return nil, err + } + next, err = edit(next) if err != nil { return nil, err } - patched, _ := mergePatch(map[string]any(next), in.Patch).(map[string]any) - next = Settings(patched) if err := a.sys.checkMCPSettings(old, next); err != nil { return nil, err } @@ -525,5 +537,5 @@ func (a *app) updateSettings(args json.RawMessage) (any, error) { after, _ := os.ReadFile(a.sys.settingsPath()) return map[string]any{"saved": true, "applied": false, "etag": etag(after), "changes": diffLines(pretty(data), pretty(after)), - "next": "Run check_settings; a person applies the changes in the web UI."}, nil + "next": "Run check_settings (GET /api/v1/settings/check); a person applies the changes in the web UI."}, nil } diff --git a/pkgs/router-webui/schema.go b/pkgs/router-webui/schema.go new file mode 100644 index 0000000..b2f7dec --- /dev/null +++ b/pkgs/router-webui/schema.go @@ -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 (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 +} diff --git a/pkgs/router-webui/static/style.css b/pkgs/router-webui/static/style.css index a827cfd..f9e825e 100644 --- a/pkgs/router-webui/static/style.css +++ b/pkgs/router-webui/static/style.css @@ -120,6 +120,8 @@ table tr:nth-child(even) td { background: var(--zebra); } pre.log { background: var(--code); padding: .85rem 1rem; border-radius: var(--radius); overflow-x: auto; font-size: 12.5px; max-height: 30rem; } code { font-size: .88em; overflow-wrap: anywhere; background: var(--code); padding: .1em .35em; border-radius: 3px; } pre code { background: none; padding: 0; } +.opts td:first-child code { white-space: nowrap; overflow-wrap: normal; } +.opts td { vertical-align: top; } .muted { color: var(--muted); } .bad { color: var(--bad); } .ok { color: var(--good); } diff --git a/pkgs/router-webui/templates/apiref.html b/pkgs/router-webui/templates/apiref.html index 09050d2..ab3682c 100644 --- a/pkgs/router-webui/templates/apiref.html +++ b/pkgs/router-webui/templates/apiref.html @@ -31,5 +31,34 @@ {{end}} + +
+

Settings areas

+

The settings are a tree, and every part of it has its own path below /api/v1/settings: + router/firewall, router/lans/lan (one LAN, by its name), router/dns/hosts, + system, down to a single option like router/wan/interface (lists are replaced whole; their + elements have no paths). The OpenAPI document describes each with the type of its value.

+
    +
  • GET reads the part (404 when it is not set and the defaults apply); the ETag header is the + settings' version.
  • +
  • PUT replaces it, PATCH merges a JSON merge patch into it (objects only), DELETE removes + it. They need If-Match with the ETag, need a change token, and are refused (400, with the place) when a value + does not fit its option's type or a key is not an option.
  • +
  • Like every edit through the API they are saved, never applied; GET /api/v1/settings/check evaluates them.
  • +
+
curl -sk -H "Authorization: Bearer $TOKEN" https://{{.Host}}/api/v1/settings/router/dns
+curl -sk -X PUT -H "Authorization: Bearer $TOKEN" -H 'If-Match: "ETAG"' \
+  -d '{"domain":"home.example.org","dynamicLeases":true}' https://{{.Host}}/api/v1/settings/router/dns
+
+{{range .Areas}} +
+

{{.Path}}

+
+ + {{range .Options}}{{end}} +
OptionTypeDefaultDescription
{{.Name}}{{.Type}}{{with .Default}}{{.}}{{end}}{{.Description}}
+

<name> is the key of an entry in a map, * an element of a list.

+
+{{end}} {{end}} {{end}} diff --git a/tests/apply.nix b/tests/apply.nix index 4b7af1d..0e15e19 100644 --- a/tests/apply.nix +++ b/tests/apply.nix @@ -296,6 +296,19 @@ pkgs.testers.runNixOSTest { assert client.succeed(f"curl -sk -o /dev/null -w '%{{http_code}}' {ui}/api/v1/status").strip() == "401" spec = json.loads(client.succeed(f"curl -sk {ui}/api/openapi.json")) assert spec["openapi"].startswith("3.") and "/api/v1/logs" in spec["paths"] + # Areas of the settings, typed by the real option documentation. + area = f"{rest} -H 'Content-Type: application/json'" + etag = client.succeed(f"{rest} -D - -o /dev/null {ui}/api/v1/settings/router/lans/main | tr -d '\\r' | sed -n 's/^[Ee][Tt]ag: *//p'").strip() + assert etag, "no ETag from an area" + assert json.loads(client.succeed(f"{rest} {ui}/api/v1/settings/router/lans/main"))["interface"] + code = client.succeed(f"{area} -o /tmp/area.json -w '%{{http_code}}' -X PUT -H 'If-Match: {etag}' -d '{{\"domian\": \"x\"}}' {ui}/api/v1/settings/router/dns").strip() + assert code == "400" and "unknown option" in client.succeed("cat /tmp/area.json"), code + code = client.succeed(f"{area} -o /tmp/area.json -w '%{{http_code}}' -X PUT -H 'If-Match: {etag}' -d '{{\"domain\": \"area.test\"}}' {ui}/api/v1/settings/router/dns").strip() + assert code == "200", (code, client.succeed("cat /tmp/area.json")) + assert "area.test" in mcp("get_pending_changes")[0] + assert not mcp("discard_changes")[1] + spec = json.loads(client.succeed(f"curl -sk {ui}/api/openapi.json")) + assert "/api/v1/settings/router/lans/{name}" in spec["paths"] # The log shows what was done, by whom. assert "update_settings" in curl(f"{ui}/tokens") and "assistant" in curl(f"{ui}/tokens") '';