Files
nixos-router/pkgs/router-webui/mcp.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

523 lines
19 KiB
Go

package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// An MCP server (Model Context Protocol, Streamable HTTP transport) on
// POST /mcp, so an assistant can read the router's state and edit its
// settings. It is a thin layer over what the web UI does:
//
// - tokens (tokens.go) instead of the browser session; scope read or change;
// - changes only edit settings.json, like the Advanced page: they stay
// unapplied until a person applies them in the web UI (commit-confirm);
// - secrets are never returned (the settings hold only paths to them), and
// file paths in the settings must stay under the state directory;
// - SSH keys, the release schema and the state version are out of reach;
// - every call is written to the audit log.
//
// Only requests and responses of JSON-RPC 2.0 over POST are implemented: no
// server-initiated messages, no streaming.
var mcpVersions = []string{"2025-06-18", "2025-03-26", "2024-11-05"}
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
const mcpInstructions = `This is the management interface of a nixos-router.
Read the state with get_status, list_interfaces, list_dhcp_leases, get_settings and get_update_status;
get_logs reads the system journal (a unit, a time range, a priority, a text to search for).
Settings are one JSON document (the resource router://settings; router://options describes every option).
Change them with update_settings (a JSON merge patch; pass the etag from get_settings), then run check_settings,
which evaluates the configuration and reports the router's own assertions. Changes are saved but not applied:
a person reviews them (get_pending_changes shows the difference) and applies them in the web UI.
Never put secrets into the settings: they refer to files (passwordFile, secretFile, ...) that the person provides.`
// mcp is the HTTP handler of the endpoint.
func (a *app) mcp(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "this endpoint takes POST (JSON-RPC)", http.StatusMethodNotAllowed)
return
}
tok, ok := a.bearer(w, r)
if !ok {
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "request too large", http.StatusRequestEntityTooLarge)
return
}
var req rpcRequest
if err := json.Unmarshal(body, &req); err != nil || req.JSONRPC != "2.0" || req.Method == "" {
a.rpcReply(w, rpcResponse{ID: json.RawMessage("null"), Error: &rpcError{-32700, "not a JSON-RPC 2.0 request"}})
return
}
if len(req.ID) == 0 { // a notification
w.WriteHeader(http.StatusAccepted)
return
}
resp := rpcResponse{ID: req.ID}
result, rerr := a.mcpDispatch(tok, req)
if rerr != nil {
resp.Error = rerr
} else {
resp.Result = result
}
a.rpcReply(w, resp)
}
// bearer authenticates an API request (MCP or REST) by its token; it has
// answered the request itself when it returns false.
func (a *app) bearer(w http.ResponseWriter, r *http.Request) (Token, bool) {
// A browser page must not be able to talk to the router with a token it
// does not have, but DNS rebinding could present a foreign Origin.
if o := r.Header.Get("Origin"); o != "" {
u, err := url.Parse(o)
if err != nil || u.Host != r.Host {
http.Error(w, "forbidden origin", http.StatusForbidden)
return Token{}, false
}
}
secret, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
tok, valid := a.tokens.Lookup(strings.TrimSpace(secret))
if !ok || !valid {
time.Sleep(time.Second) // a guess costs a second
w.Header().Set("WWW-Authenticate", `Bearer realm="router"`)
http.Error(w, "a valid token is required", http.StatusUnauthorized)
return Token{}, false
}
return tok, true
}
func (a *app) rpcReply(w http.ResponseWriter, resp rpcResponse) {
resp.JSONRPC = "2.0"
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func (a *app) mcpDispatch(tok Token, req rpcRequest) (any, *rpcError) {
switch req.Method {
case "initialize":
var p struct {
ProtocolVersion string `json:"protocolVersion"`
}
_ = json.Unmarshal(req.Params, &p)
version := mcpVersions[0]
for _, v := range mcpVersions {
if v == p.ProtocolVersion {
version = v
}
}
return map[string]any{
"protocolVersion": version,
"capabilities": map[string]any{"tools": map[string]any{}, "resources": map[string]any{}},
"serverInfo": map[string]any{"name": "nixos-router", "version": a.sys.installedRelease().Version},
"instructions": mcpInstructions,
}, nil
case "ping":
return map[string]any{}, nil
case "tools/list":
var out []map[string]any
for _, t := range mcpTools {
if tok.allows(t.scope) {
out = append(out, map[string]any{"name": t.name, "description": t.description, "inputSchema": t.schema})
}
}
return map[string]any{"tools": out}, nil
case "tools/call":
var p struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &p); err != nil {
return nil, &rpcError{-32602, "bad parameters"}
}
return a.mcpCall(tok, p.Name, p.Arguments)
case "resources/list":
res := []map[string]any{
{"uri": "router://settings", "name": "settings", "description": "The saved settings (settings.json).", "mimeType": "application/json"},
}
if a.optionsFile != "" {
res = append(res, map[string]any{"uri": "router://options", "name": "options",
"description": "Every router option: type, default, description (what settings.json may contain).", "mimeType": "application/json"})
}
return map[string]any{"resources": res}, nil
case "resources/read":
var p struct {
URI string `json:"uri"`
}
if err := json.Unmarshal(req.Params, &p); err != nil {
return nil, &rpcError{-32602, "bad parameters"}
}
var text string
switch p.URI {
case "router://settings":
data, err := os.ReadFile(a.sys.settingsPath())
if err != nil {
return nil, &rpcError{-32603, err.Error()}
}
text = string(data)
case "router://options":
data, err := os.ReadFile(a.optionsFile)
if a.optionsFile == "" || err != nil {
return nil, &rpcError{-32002, "no such resource"}
}
text = string(data)
default:
return nil, &rpcError{-32002, "no such resource"}
}
return map[string]any{"contents": []map[string]any{{"uri": p.URI, "mimeType": "application/json", "text": text}}}, nil
}
return nil, &rpcError{-32601, "method not found: " + req.Method}
}
type mcpTool struct {
name, description, scope string
schema map[string]any
run func(a *app, args json.RawMessage) (any, error)
}
// mcpCall runs a tool. A failing tool is not a protocol error: the assistant
// is told what went wrong and can correct itself.
func (a *app) mcpCall(tok Token, name string, args json.RawMessage) (any, *rpcError) {
var tool *mcpTool
for i := range mcpTools {
if mcpTools[i].name == name {
tool = &mcpTools[i]
}
}
if tool == nil || !tok.allows(tool.scope) {
return nil, &rpcError{-32602, "unknown tool: " + name}
}
res, err := a.runTool(tok, tool, args)
if err != nil {
return map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": err.Error()}}}, nil
}
text, ok := res.(string)
if !ok {
data, merr := json.MarshalIndent(res, "", " ")
if merr != nil {
return nil, &rpcError{-32603, merr.Error()}
}
text = string(data)
}
return map[string]any{"content": []map[string]any{{"type": "text", "text": text}}}, nil
}
// runTool runs a tool for a token (the scope is checked by the caller) and
// writes the call to the audit log.
func (a *app) runTool(tok Token, tool *mcpTool, args json.RawMessage) (any, error) {
if len(args) == 0 {
args = json.RawMessage("{}")
}
res, err := tool.run(a, args)
entry := AuditEntry{Time: time.Now().UTC(), Token: tok.Name, Tool: tool.name, Args: string(args)}
if err != nil {
entry.Error = err.Error()
}
a.sys.audit(entry)
return res, err
}
// errStale: the settings changed since the caller read them.
var errStale = errors.New("the settings changed since you read them (someone else edited, or the etag is wrong): call get_settings again")
// argError is a tool's complaint about its arguments (HTTP 400 in the REST API).
type argError string
func (e argError) Error() string { return string(e) }
// etag identifies a version of the settings file.
func etag(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:8])
}
func finite(f float64) any {
if math.IsNaN(f) || math.IsInf(f, 0) {
return nil
}
return math.Round(f*10) / 10
}
var emptySchema = map[string]any{"type": "object", "properties": map[string]any{}}
var mcpTools = []mcpTool{
{
name: "get_status", scope: scopeRead, schema: emptySchema,
description: "The router's state now: uplink (connected, addresses, gateway, pings), system (uptime, load, CPU, memory, disk, temperature, kernel), whether settings are unapplied, and whether an apply is running or waits for confirmation.",
run: func(a *app, _ json.RawMessage) (any, error) {
snap := a.metrics.Snapshot()
st := a.sys.Status()
u, s := snap.Uplink, snap.System
var pings []map[string]any
for _, p := range snap.Pings {
pings = append(pings, map[string]any{"target": p.Target, "address": p.Address, "ok": p.OK, "rttMs": p.RTT.Milliseconds()})
}
return map[string]any{
"time": snap.Time,
"uplink": map[string]any{"device": u.Device, "type": u.Type, "connected": u.Connected, "reachable": u.Reachable,
"ipv4": u.IPv4, "ipv6": u.IPv6, "gateway": u.Gateway, "since": u.Since},
"pings": pings,
"system": map[string]any{"uptimeSeconds": int(s.Uptime.Seconds()), "load": s.Load, "kernel": s.Kernel,
"rebootForKernel": s.KernelNext, "rebootForConfiguration": s.BootPending, "cpuPercent": finite(s.CPU),
"memoryUsedBytes": s.MemUsed, "memoryTotalBytes": s.MemTotal, "temperatureC": finite(s.Temperature),
"diskUsedBytes": s.DiskUsed, "diskTotalBytes": s.DiskTotal},
"settings": map[string]any{"unapplied": st.Unapplied, "applying": st.Applying,
"lastApplyResult": st.ApplyResult, "confirmationDueInSeconds": int(st.RollbackIn.Seconds())},
}, nil
},
},
{
name: "get_settings", scope: scopeRead, schema: emptySchema,
description: "The saved settings (settings.json: `system` and `router`) with the etag to pass to update_settings, and whether they differ from the confirmed (running) system. Secrets are not in it, only paths of files that hold them.",
run: func(a *app, _ json.RawMessage) (any, error) {
data, err := os.ReadFile(a.sys.settingsPath())
if err != nil {
return nil, err
}
var v any
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return map[string]any{"etag": etag(data), "unapplied": a.sys.Status().Unapplied, "settings": v}, nil
},
},
{
name: "get_pending_changes", scope: scopeRead, schema: emptySchema,
description: "The difference between the confirmed settings and the saved ones (lines starting with - are removed, + added); empty when there is none.",
run: func(a *app, _ json.RawMessage) (any, error) {
if d := a.sys.pendingChanges(); d != "" {
return d, nil
}
return "No changes: the saved settings are the confirmed ones.", nil
},
},
{
name: "list_interfaces", scope: scopeRead, schema: emptySchema,
description: "Network interfaces: physical cards (name now, name from the settings, MAC address) and VLAN devices, and how the settings use each (WAN, a LAN).",
run: func(a *app, _ json.RawMessage) (any, error) {
s, err := loadSettings(a.sys.settingsPath())
if err != nil {
return nil, err
}
var out []map[string]any
for _, d := range a.devices(s, s.form()) {
e := map[string]any{"name": d.Name, "kind": d.Kind, "usedAs": d.Role}
if d.Kernel != "" {
e["kernelName"] = d.Kernel
}
if d.MAC != "" {
e["mac"] = d.MAC
}
if d.Custom != "" {
e["deviceName"] = d.Custom
}
out = append(out, e)
}
return out, nil
},
},
{
name: "list_dhcp_leases", scope: scopeRead, schema: emptySchema,
description: "The DHCP clients currently holding a lease: address, MAC, hostname, LAN, when the lease ends.",
run: func(a *app, _ json.RawMessage) (any, error) {
s, err := loadSettings(a.sys.settingsPath())
if err != nil {
return nil, err
}
var out []map[string]any
for _, l := range readLeases(a.metrics.leasesPath, s.form().LANs, time.Now()) {
out = append(out, map[string]any{"address": l.Address, "mac": l.MAC, "hostname": l.Hostname, "lan": l.LAN, "expires": l.Expires})
}
return out, nil
},
},
{
name: "get_update_status", scope: scopeRead, schema: emptySchema,
description: "The installed release (version, schema) and the state of the last update run.",
run: func(a *app, _ json.RawMessage) (any, error) {
st := a.sys.UpdateStatus()
return map[string]any{"installed": a.sys.installedRelease(), "running": st.Running, "lastResult": st.Result, "log": st.Log}, nil
},
},
{
name: "get_apply_log", scope: scopeRead, schema: emptySchema,
description: "The log of the last apply (build and activation) and its result.",
run: func(a *app, _ json.RawMessage) (any, error) {
st := a.sys.Status()
return map[string]any{"applying": st.Applying, "result": st.ApplyResult, "log": st.Log}, nil
},
},
logsTool,
{
name: "check_settings", scope: scopeRead,
schema: emptySchema,
description: "Evaluates the saved settings the way an apply would (the router's assertions and the option types) without building or activating anything. Takes about a minute; if it is still running, call again to get the result. Reports whether the result is for the current saved settings.",
run: func(a *app, _ json.RawMessage) (any, error) { return a.checkSettings() },
},
{
name: "update_settings", scope: scopeChange,
schema: map[string]any{
"type": "object",
"properties": map[string]any{
"etag": map[string]any{"type": "string", "description": "The etag from get_settings: the change is refused if the settings changed since."},
"patch": map[string]any{"type": "object", "description": "A JSON merge patch (RFC 7386) on the settings: objects merge, a value replaces, null removes a key, arrays are replaced whole."},
},
"required": []string{"etag", "patch"},
},
description: "Edit the saved settings with a JSON merge patch. 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. Run check_settings afterwards.",
run: func(a *app, args json.RawMessage) (any, error) { return a.updateSettings(args) },
},
{
name: "discard_changes", scope: scopeChange, schema: emptySchema,
description: "Throw away the unapplied changes: the settings go back to the confirmed ones.",
run: func(a *app, _ json.RawMessage) (any, error) {
if err := a.sys.Discard(); err != nil {
return nil, err
}
return "The settings are the confirmed ones again.", nil
},
},
}
// mergePatch applies a JSON merge patch (RFC 7386).
func mergePatch(target, patch any) any {
p, ok := patch.(map[string]any)
if !ok {
return patch
}
t, ok := target.(map[string]any)
if !ok {
t = map[string]any{}
}
for k, v := range p {
if v == nil {
delete(t, k)
} else {
t[k] = mergePatch(t[k], v)
}
}
return t
}
// checkMCPSettings refuses what an assistant may not change.
func (s System) checkMCPSettings(old, next Settings) error {
for k := range next {
if k != "version" && k != "system" && k != "router" {
return fmt.Errorf("unknown top-level key %q (only system and router)", k)
}
}
same := func(a, b any) bool { x, _ := json.Marshal(a); y, _ := json.Marshal(b); return string(x) == string(y) }
if !same(old["version"], next["version"]) {
return errors.New("`version` (the settings schema) cannot be changed")
}
oldSys, newSys := obj(Settings{"system": old["system"]}, "system"), obj(Settings{"system": next["system"]}, "system")
for _, k := range []string{"sshKeys", "stateVersion"} {
if !same(oldSys[k], newSys[k]) {
return fmt.Errorf("`system.%s` cannot be changed through this interface", k)
}
}
for k := range newSys {
if k != "hostName" && k != "timeZone" && !same(oldSys[k], newSys[k]) {
return fmt.Errorf("`system.%s` cannot be changed through this interface (only hostName and timeZone)", k)
}
}
var walk func(path string, v any) error
walk = func(path string, v any) error {
switch x := v.(type) {
case map[string]any:
for k, e := range x {
if str, ok := e.(string); ok && strings.HasSuffix(k, "File") {
if !strings.HasPrefix(str, s.StateDir+"/") || strings.Contains(str, "..") {
return fmt.Errorf("%s.%s: files must be under %s (the person who runs the router puts secrets there)", path, k, s.StateDir)
}
}
if err := walk(path+"."+k, e); err != nil {
return err
}
}
case []any:
for i, e := range x {
if err := walk(fmt.Sprintf("%s[%d]", path, i), e); err != nil {
return err
}
}
}
return nil
}
return walk("router", next["router"])
}
func (a *app) updateSettings(args json.RawMessage) (any, error) {
var in struct {
Etag string `json:"etag"`
Patch any `json:"patch"`
}
if err := json.Unmarshal(args, &in); err != nil || in.Patch == nil {
return nil, errors.New("give `etag` and `patch` (an object)")
}
if _, ok := in.Patch.(map[string]any); !ok {
return nil, errors.New("`patch` must be an object")
}
a.settingsMu.Lock()
defer a.settingsMu.Unlock()
data, err := os.ReadFile(a.sys.settingsPath())
if err != nil {
return nil, err
}
if in.Etag != etag(data) {
return nil, errStale
}
old, err := parseSettings(data)
if err != nil {
return nil, err
}
next, err := parseSettings(data) // a copy to patch
if err != nil {
return nil, err
}
patched, _ := mergePatch(map[string]any(next), in.Patch).(map[string]any)
next = Settings(patched)
if err := a.sys.checkMCPSettings(old, next); err != nil {
return nil, err
}
if err := next.save(a.sys.settingsPath()); err != nil {
return nil, err
}
after, _ := os.ReadFile(a.sys.settingsPath())
return map[string]any{"saved": true, "applied": false, "etag": etag(after),
"changes": diffLines(pretty(data), pretty(after)),
"next": "Run check_settings; a person applies the changes in the web UI."}, nil
}