Every tool now returns a Go struct (apitypes.go) instead of a map, and typeschema.go derives the OpenAPI response schemas from those types by reflection (json, doc and enum tags), so the document cannot drift from what is sent. Text results are documented as text/plain, settings in answers use the typed settings schema, and edits answer with EditResponse. TestResponsesMatchTheirSchemas checks real responses against the generated schemas. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
288 lines
10 KiB
Go
288 lines
10 KiB
Go
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 r, ok := res.(EditResponse); ok {
|
|
w.Header().Set("ETag", `"`+r.ETag+`"`)
|
|
a.sys.noteTokenEdit(tok.Name, r.ETag)
|
|
}
|
|
} 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, edited 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, "_"))
|
|
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 from the last read of the settings or of any part of them (the `ETag` header). The edit is refused with 409 if the settings changed since."}
|
|
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, "headers": etagHeader, "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.", edited), "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.", edited), "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.", edited), "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
|
|
}
|