package main import ( "encoding/json" "errors" "io" "net/http" "os" "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 } 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"}, {"PATCH", "/api/v1/settings", "update_settings", "Edit the saved settings with a JSON merge patch (saved, not applied)"}, {"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"}, } 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)) } 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 } if m, ok := res.(map[string]any); ok && rt.tool == "get_settings" { if e, ok := m["etag"].(string); ok { w.Header().Set("ETag", `"`+e+`"`) } } if m, ok := res.(map[string]any); ok && rt.tool == "update_settings" { if e, ok := m["etag"].(string); ok { w.Header().Set("ETag", `"`+e+`"`) } } 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)) } func openAPISpec(version 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"}}}} } paths := map[string]any{} for _, rt := range apiRoutes { tool := toolNamed(rt.tool) op := map[string]any{ "operationId": rt.tool, "summary": rt.summary, "description": tool.description, "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 of " + rt.tool, "content": map[string]any{ "application/json": map[string]any{"schema": map[string]any{}}, "text/plain": map[string]any{"schema": map[string]any{"type": "string"}}, }}, "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 } 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.")}, }} return map[string]any{ "openapi": "3.1.0", "info": map[string]any{ "title": "nixos-router", "version": version, "description": "The router's management API, equal to its MCP server: read its state and logs, edit the saved settings. " + "Changes are saved, never applied: a person applies them in the web UI. Tokens are made on the API access page (scope read or change).", }, "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": map[string]any{"Error": map[string]any{"type": "object", "properties": map[string]any{"error": map[string]any{"type": "string"}}, "required": []string{"error"}}}, }, } }