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 }