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