Files
nixos-router/pkgs/router-webui/api.go
T
nilsandClaude Sonnet 5.5 586274af4e Add the appliance web UI look, interface names, MCP and REST API, local zone
Web UI: dark/light themes (appliance layout, sidebar, switch checkboxes),
theme preference on the Advanced page, Changes page with the pending diff.

Interface names: router.interfaces.<name>.mac, lans.<l>.deviceName and
pppoe.vlanName; a rename is staged for the next reboot by router-apply.

Remote access: an MCP server (POST /mcp) and an OpenAPI REST API
(/api/v1, /api/openapi.json) with the same tokens, scopes, limits and audit
log; tools to read status, settings, leases, logs (get_logs), and to edit
and check the saved settings (never applied). Tokens on the API access page.

Firewall: a reserved `local` zone (the router itself) as the destination of
rules, so the web UI or SSH can be allowed from the WAN.

PLAN.md lists what is still left.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-01 16:56:59 +01:00

252 lines
9.1 KiB
Go

package main
import (
"encoding/json"
"errors"
"io"
"net/http"
"os"
"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)
for name, p := range props {
params = append(params, map[string]any{"name": name, "in": "query", "schema": p, "description": p.(map[string]any)["description"]})
}
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"}}},
},
}
}