/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
261 lines
9.6 KiB
Go
261 lines
9.6 KiB
Go
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))
|
|
}
|
|
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
|
|
}
|
|
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, a.settingsSchema()))
|
|
}
|
|
|
|
func openAPISpec(version string, schema map[string]any) 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.")},
|
|
}}
|
|
if schema != nil {
|
|
for p, ops := range areaPaths(schema) {
|
|
paths[p] = ops
|
|
}
|
|
}
|
|
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). " +
|
|
"The settings are also a tree of areas: /api/v1/settings/router/firewall, /router/lans/{name}, /system, ... (any depth) can be read, replaced (PUT), merged into (PATCH) or removed (DELETE); the documented ones go three levels deep.",
|
|
},
|
|
"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"}}},
|
|
},
|
|
}
|
|
}
|