package main import ( "encoding/json" "errors" "io" "net/http" "os" "reflect" "slices" "strconv" "strings" ) // A REST API (described by OpenAPI at /api/openapi.json) with the same // features, tokens, scopes, limits and audit log as the MCP server: every // operation runs one of the MCP tools (mcpTools), so the two cannot drift. // The routes below say how a request becomes a tool's arguments. type apiRoute struct { method, path, tool string summary string description string // instead of the tool's, where that names MCP tools } var apiRoutes = []apiRoute{ {"GET", "/api/v1/status", "get_status", "State now: uplink, pings, system, whether settings are unapplied", ""}, {"GET", "/api/v1/settings", "get_settings", "The saved settings and their ETag", "The saved settings (`system` and `router`) and whether they differ from the confirmed (running) system. The response carries the settings' ETag, which an edit must send back in `If-Match`. Secrets are not in the settings, only paths of files that hold them."}, {"PATCH", "/api/v1/settings", "update_settings", "Edit the saved settings with a JSON merge patch (saved, not applied)", "Edit the saved settings with a JSON merge patch (RFC 7386): objects merge, a value replaces, `null` removes a key, arrays are replaced whole. Send the ETag from the last read in `If-Match`; if the settings changed since, nothing is written and the answer is 409. The settings are saved but not applied: a person applies them in the web UI. Not allowed: ssh keys, the schema version, the state version, and file paths outside the router's state directory. Call `GET /api/v1/settings/check` afterwards."}, {"GET", "/api/v1/settings/check", "check_settings", "Evaluate the saved settings without applying them", ""}, {"GET", "/api/v1/changes", "get_pending_changes", "The difference between the confirmed and the saved settings", ""}, {"DELETE", "/api/v1/changes", "discard_changes", "Throw the unapplied changes away", ""}, {"GET", "/api/v1/interfaces", "list_interfaces", "Network cards and VLAN devices", ""}, {"GET", "/api/v1/dhcp-leases", "list_dhcp_leases", "DHCP clients holding a lease", ""}, {"GET", "/api/v1/update", "get_update_status", "The installed release and the last update run", ""}, {"GET", "/api/v1/apply-log", "get_apply_log", "The log and result of the last apply", ""}, {"GET", "/api/v1/logs", "get_logs", "Lines of the system journal", ""}, } // etagHeader documents the ETag header of the answers about the settings. var etagHeader = map[string]any{"ETag": map[string]any{"description": "The settings' ETag (in quotes): send it in `If-Match` to edit them.", "schema": map[string]any{"type": "string"}}} // resultContent is the 200 response's content: the result's type as JSON, or text. func resultContent(b *schemaBuilder, tool *mcpTool) map[string]any { if tool.output == nil { return map[string]any{"text/plain": map[string]any{"schema": map[string]any{"type": "string"}}} } return map[string]any{"application/json": map[string]any{"schema": b.of(reflect.TypeOf(tool.output))}} } func routeText(rt apiRoute, tool *mcpTool) string { if rt.description != "" { return rt.description } return tool.description } func toolNamed(name string) *mcpTool { for i := range mcpTools { if mcpTools[i].name == name { return &mcpTools[i] } } return nil } func apiError(w http.ResponseWriter, code int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) } func (a *app) registerAPI(mux *http.ServeMux) { for _, rt := range apiRoutes { mux.HandleFunc(rt.method+" "+rt.path, a.apiHandler(rt)) } a.registerAreas(mux) mux.HandleFunc("GET /api/v1/options", a.apiOptions) mux.HandleFunc("GET /api/openapi.json", a.apiSpec) } func (a *app) apiHandler(rt apiRoute) 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 } tool := toolNamed(rt.tool) if !tok.allows(tool.scope) { apiError(w, http.StatusForbidden, "this token may not "+tool.scope+" (scope)") return } args, err := apiArgs(w, r, tool) if err != nil { apiError(w, http.StatusBadRequest, err.Error()) return } res, err := a.runTool(tok, tool, args) if err != nil { var bad argError switch { case errors.Is(err, errStale): apiError(w, http.StatusConflict, err.Error()) case errors.As(err, &bad) || tool.scope == scopeChange: apiError(w, http.StatusBadRequest, err.Error()) default: apiError(w, http.StatusInternalServerError, err.Error()) } return } if text, ok := res.(string); ok { w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = io.WriteString(w, text+"\n") return } switch r := res.(type) { case SettingsResponse: w.Header().Set("ETag", `"`+r.ETag+`"`) case EditResponse: w.Header().Set("ETag", `"`+r.ETag+`"`) } w.Header().Set("Content-Type", "application/json") enc := json.NewEncoder(w) enc.SetIndent("", " ") _ = enc.Encode(res) } } // apiArgs builds a tool's arguments from the request: the query for GET // (typed by the tool's schema), If-Match and the body for the settings patch. func apiArgs(w http.ResponseWriter, r *http.Request, tool *mcpTool) (json.RawMessage, error) { switch r.Method { case http.MethodPatch: body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil { return nil, errors.New("request too large") } var patch any if err := json.Unmarshal(body, &patch); err != nil { return nil, errors.New("the body must be a JSON merge patch") } etag := strings.Trim(strings.TrimPrefix(r.Header.Get("If-Match"), "W/"), `"`) if etag == "" { return nil, errors.New("send the settings' ETag in If-Match (GET /api/v1/settings)") } return json.Marshal(map[string]any{"etag": etag, "patch": patch}) case http.MethodGet: props, _ := tool.schema["properties"].(map[string]any) args := map[string]any{} for name, p := range props { v := r.URL.Query().Get(name) if v == "" { continue } if typ, _ := p.(map[string]any)["type"].(string); typ == "integer" { n, err := strconv.Atoi(v) if err != nil { return nil, errors.New(name + " must be a whole number") } args[name] = n } else { args[name] = v } } return json.Marshal(args) } return nil, nil } func (a *app) apiOptions(w http.ResponseWriter, r *http.Request) { tok, ok := a.bearer(w, r) if !ok { return } _ = tok data, err := os.ReadFile(a.optionsFile) if a.optionsFile == "" || err != nil { apiError(w, http.StatusNotFound, "no option documentation on this router") return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(data) } // apiSpec serves the OpenAPI description. It describes the interface only (no // router data), so it needs no token. func (a *app) apiSpec(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") enc := json.NewEncoder(w) enc.SetIndent("", " ") _ = enc.Encode(openAPISpec(a.sys.installedRelease().Version, a.settingsSchema(), r.Host)) } func openAPISpec(version string, schema map[string]any, host 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"}}}} } b := newSchemaBuilder() paths := map[string]any{} for _, rt := range apiRoutes { tool := toolNamed(rt.tool) op := map[string]any{ "operationId": rt.tool, "summary": rt.summary, "description": routeText(rt, tool), "tags": []string{tool.scope}, "security": []any{map[string]any{"bearer": []string{}}}, "x-scope": tool.scope, "responses": map[string]any{ "200": map[string]any{"description": "The result.", "content": resultContent(b, tool)}, "401": errResp("No or an unknown token."), "403": errResp("The token's scope does not allow this."), }, } var params []any switch rt.method { case "GET": props, _ := tool.schema["properties"].(map[string]any) required, _ := tool.schema["required"].([]string) for name, p := range props { params = append(params, map[string]any{"name": name, "in": "query", "schema": p, "description": p.(map[string]any)["description"], "required": slices.Contains(required, name)}) } if rt.tool == "get_logs" { op["responses"].(map[string]any)["400"] = errResp("An argument is not valid.") } case "PATCH": props := tool.schema["properties"].(map[string]any) params = append(params, map[string]any{"name": "If-Match", "in": "header", "required": true, "description": props["etag"].(map[string]any)["description"], "schema": map[string]any{"type": "string"}}) op["requestBody"] = map[string]any{"required": true, "content": map[string]any{ "application/merge-patch+json": map[string]any{"schema": props["patch"]}, "application/json": map[string]any{"schema": props["patch"]}, }} op["responses"].(map[string]any)["400"] = errResp("The patch is refused (see the message).") op["responses"].(map[string]any)["409"] = errResp("The settings changed since the ETag was read.") } if params != nil { op["parameters"] = params } if rt.tool == "get_settings" || rt.tool == "update_settings" { op["responses"].(map[string]any)["200"].(map[string]any)["headers"] = etagHeader } entry, _ := paths[rt.path].(map[string]any) if entry == nil { entry = map[string]any{} paths[rt.path] = entry } entry[strings.ToLower(rt.method)] = op } paths["/api/v1/options"] = map[string]any{"get": map[string]any{ "operationId": "get_options", "summary": "Every router option: type, default, description", "tags": []string{scopeRead}, "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.")}, }} if schema != nil { for p, ops := range areaPaths(schema, b.of(reflect.TypeOf(EditResponse{}))) { paths[p] = ops } } addCurlSamples(paths, "https://"+host) if schema != nil { // the settings in the answers are the typed settings if sr, ok := b.defs["SettingsResponse"].(map[string]any); ok { sr["properties"].(map[string]any)["settings"] = schema } } schemas := map[string]any{"Error": map[string]any{"type": "object", "properties": map[string]any{"error": map[string]any{"type": "string"}}, "required": []string{"error"}}} for k, v := range b.defs { schemas[k] = v } return map[string]any{ "openapi": "3.1.0", "info": map[string]any{ "title": "nixos-router", "version": version, "description": apiIntro, }, "tags": []any{ map[string]any{"name": scopeRead, "x-displayName": "Read", "description": "Allowed for every token."}, map[string]any{"name": scopeChange, "x-displayName": "Change", "description": "Needs a token with the access *change*."}, map[string]any{"name": "settings", "x-displayName": "Settings areas", "description": settingsAreasText}, }, "servers": []any{map[string]any{"url": "/"}}, "paths": paths, "components": map[string]any{ "securitySchemes": map[string]any{"bearer": map[string]any{"type": "http", "scheme": "bearer", "description": "A token from the API access page (rtr_…)."}}, "schemas": schemas, }, } }