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:
2026-11-01 20:09:20 +01:00
co-authored by Claude Sonnet 5.5
parent 42ceb78d2b
commit 64e08fb485
11 changed files with 1064 additions and 10 deletions
+17 -5
View File
@@ -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
}