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:
@@ -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.
|
- `/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`.
|
- **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/<name>`, `.../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.
|
- **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.
|
- 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
|
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).
|
- **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.
|
- **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.
|
- **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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ func (a *app) registerAPI(mux *http.ServeMux) {
|
|||||||
for _, rt := range apiRoutes {
|
for _, rt := range apiRoutes {
|
||||||
mux.HandleFunc(rt.method+" "+rt.path, a.apiHandler(rt))
|
mux.HandleFunc(rt.method+" "+rt.path, a.apiHandler(rt))
|
||||||
}
|
}
|
||||||
|
a.registerAreas(mux)
|
||||||
mux.HandleFunc("GET /api/v1/options", a.apiOptions)
|
mux.HandleFunc("GET /api/v1/options", a.apiOptions)
|
||||||
mux.HandleFunc("GET /api/openapi.json", a.apiSpec)
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
enc := json.NewEncoder(w)
|
enc := json.NewEncoder(w)
|
||||||
enc.SetIndent("", " ")
|
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 {
|
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"}}}}
|
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{}}},
|
"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.")},
|
"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{
|
return map[string]any{
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": map[string]any{
|
"info": map[string]any{
|
||||||
"title": "nixos-router",
|
"title": "nixos-router",
|
||||||
"version": version,
|
"version": version,
|
||||||
"description": "The router's management API, equal to its MCP server: read its state and logs, edit the saved settings. " +
|
"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": "/"}},
|
"servers": []any{map[string]any{"url": "/"}},
|
||||||
"paths": paths,
|
"paths": paths,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,5 +70,48 @@ func apiReference(host string) []apiDoc {
|
|||||||
func (a *app) apiReference(w http.ResponseWriter, r *http.Request, sess session) {
|
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{
|
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 != "",
|
"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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.<name>.interface": {"loc": ["router","lans","<name>","interface"], "type": "string"},
|
||||||
|
"router.lans.<name>.internet": {"loc": ["router","lans","<name>","internet"], "type": "boolean"},
|
||||||
|
"router.lans.<name>.vlan": {"loc": ["router","lans","<name>","vlan"], "type": "null or integer between 1 and 4094 (both inclusive)"},
|
||||||
|
"router.lans.<name>.ipv4.address": {"loc": ["router","lans","<name>","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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -221,7 +221,9 @@ type app struct {
|
|||||||
|
|
||||||
tokens *tokenStore
|
tokens *tokenStore
|
||||||
settingsMu sync.Mutex // edits through the API read, patch and write the settings file
|
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 {
|
func withOptions(a *app, file string) *app {
|
||||||
|
|||||||
@@ -497,25 +497,37 @@ func (a *app) updateSettings(args json.RawMessage) (any, error) {
|
|||||||
if _, ok := in.Patch.(map[string]any); !ok {
|
if _, ok := in.Patch.(map[string]any); !ok {
|
||||||
return nil, errors.New("`patch` must be an object")
|
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()
|
a.settingsMu.Lock()
|
||||||
defer a.settingsMu.Unlock()
|
defer a.settingsMu.Unlock()
|
||||||
data, err := os.ReadFile(a.sys.settingsPath())
|
data, err := os.ReadFile(a.sys.settingsPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if in.Etag != etag(data) {
|
if wantEtag != etag(data) {
|
||||||
return nil, errStale
|
return nil, errStale
|
||||||
}
|
}
|
||||||
old, err := parseSettings(data)
|
old, err := parseSettings(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err := a.sys.checkMCPSettings(old, next); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -525,5 +537,5 @@ func (a *app) updateSettings(args json.RawMessage) (any, error) {
|
|||||||
after, _ := os.ReadFile(a.sys.settingsPath())
|
after, _ := os.ReadFile(a.sys.settingsPath())
|
||||||
return map[string]any{"saved": true, "applied": false, "etag": etag(after),
|
return map[string]any{"saved": true, "applied": false, "etag": etag(after),
|
||||||
"changes": diffLines(pretty(data), pretty(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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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; }
|
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; }
|
code { font-size: .88em; overflow-wrap: anywhere; background: var(--code); padding: .1em .35em; border-radius: 3px; }
|
||||||
pre code { background: none; padding: 0; }
|
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); }
|
.muted { color: var(--muted); }
|
||||||
.bad { color: var(--bad); }
|
.bad { color: var(--bad); }
|
||||||
.ok { color: var(--good); }
|
.ok { color: var(--good); }
|
||||||
|
|||||||
@@ -31,5 +31,34 @@
|
|||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Settings areas</h2>
|
||||||
|
<p>The settings are a tree, and every part of it has its own path below <code>/api/v1/settings</code>:
|
||||||
|
<code>router/firewall</code>, <code>router/lans/lan</code> (one LAN, by its name), <code>router/dns/hosts</code>,
|
||||||
|
<code>system</code>, down to a single option like <code>router/wan/interface</code> (lists are replaced whole; their
|
||||||
|
elements have no paths). The OpenAPI document describes each with the type of its value.</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>GET</code> reads the part (404 when it is not set and the defaults apply); the <code>ETag</code> header is the
|
||||||
|
settings' version.</li>
|
||||||
|
<li><code>PUT</code> replaces it, <code>PATCH</code> merges a JSON merge patch into it (objects only), <code>DELETE</code> removes
|
||||||
|
it. They need <code>If-Match</code> 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.</li>
|
||||||
|
<li>Like every edit through the API they are saved, never applied; <code>GET /api/v1/settings/check</code> evaluates them.</li>
|
||||||
|
</ul>
|
||||||
|
<pre class="log">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</pre>
|
||||||
|
</section>
|
||||||
|
{{range .Areas}}
|
||||||
|
<section class="panel">
|
||||||
|
<h2><code>{{.Path}}</code></h2>
|
||||||
|
<div class="scroll"><table class="opts">
|
||||||
|
<tr><th>Option</th><th>Type</th><th>Default</th><th>Description</th></tr>
|
||||||
|
{{range .Options}}<tr><td><code>{{.Name}}</code></td><td>{{.Type}}</td><td>{{with .Default}}<code>{{.}}</code>{{end}}</td><td>{{.Description}}</td></tr>{{end}}
|
||||||
|
</table></div>
|
||||||
|
<p class="hint"><code><name></code> is the key of an entry in a map, <code>*</code> an element of a list.</p>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -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"
|
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"))
|
spec = json.loads(client.succeed(f"curl -sk {ui}/api/openapi.json"))
|
||||||
assert spec["openapi"].startswith("3.") and "/api/v1/logs" in spec["paths"]
|
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.
|
# The log shows what was done, by whom.
|
||||||
assert "update_settings" in curl(f"{ui}/tokens") and "assistant" in curl(f"{ui}/tokens")
|
assert "update_settings" in curl(f"{ui}/tokens") and "assistant" in curl(f"{ui}/tokens")
|
||||||
'';
|
'';
|
||||||
|
|||||||
Reference in New Issue
Block a user