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
This commit is contained in:
2026-11-01 16:56:59 +01:00
co-authored by Claude Sonnet 5.5
parent 95ab7223b9
commit 586274af4e
41 changed files with 3138 additions and 152 deletions
+16
View File
@@ -10,6 +10,22 @@
</form>
</section>
<section>
<h2>Appearance</h2>
<form method="post" action="/prefs" class="narrow">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Theme
<select name="theme">
<option value="" {{if eq .Data.Theme ""}}selected{{end}}>Automatic (follow the browser or system)</option>
<option value="light" {{if eq .Data.Theme "light"}}selected{{end}}>Light</option>
<option value="dark" {{if eq .Data.Theme "dark"}}selected{{end}}>Dark</option>
</select>
</label>
<p class="hint">Applies to everyone using this web UI, right away.</p>
<button>Save</button>
</form>
</section>
<section>
<h2>Dashboard</h2>
<form method="post" action="/prefs" class="narrow">
+15
View File
@@ -0,0 +1,15 @@
{{define "content"}}
{{with .Data}}
{{if .Diff}}
<p>The saved settings differ from the confirmed (running) ones: <code>-</code> lines go away, <code>+</code> lines are new.
Nothing is applied until you press Apply.</p>
<pre class="log">{{.Diff}}</pre>
<div class="actions">
<form method="post" action="/apply"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button>Apply</button></form>
<form method="post" action="/discard"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="secondary">Discard changes</button></form>
</div>
{{else}}
<p>No changes: the saved settings are the confirmed ones.</p>
{{end}}
{{end}}
{{end}}
@@ -34,6 +34,7 @@
<dl>
<dt>Uptime</dt><dd>{{age $sys.Uptime}}</dd>
{{if $sys.Kernel}}<dt>Kernel</dt><dd>{{$sys.Kernel}}{{if $sys.KernelNext}} <span class="bad">reboot to run {{$sys.KernelNext}}</span> <a href="/advanced">Reboot</a>{{end}}</dd>{{end}}
{{if $sys.BootPending}}<dt>Reboot</dt><dd><span class="bad">a new configuration starts at the next reboot</span> <a href="/advanced">Reboot</a></dd>{{end}}
<dt>Load</dt><dd>{{$sys.Load}}</dd>
<dt>CPU</dt><dd>{{value $sys.CPU "%"}}</dd>
{{if $sys.MemTotal}}<dt>Memory</dt><dd>{{bytes $sys.MemUsed}} of {{bytes $sys.MemTotal}}</dd>{{end}}
+2 -2
View File
@@ -49,7 +49,7 @@
<td><input name="rule.{{$i}}.sources" value="{{$r.Sources}}" size="16" spellcheck="false" placeholder="any"></td>
<td><input name="rule.{{$i}}.sourceports" value="{{$r.SourcePorts}}" size="8" placeholder="any"></td>
<td><select name="rule.{{$i}}.to"><option value=""></option>
{{range $.Data.Zones}}<option value="{{.}}" {{if eq . $r.To}}selected{{end}}>{{.}}</option>{{end}}
{{range $.Data.ToZones}}<option value="{{.}}" {{if eq . $r.To}}selected{{end}}>{{.}}</option>{{end}}
</select></td>
<td><input name="rule.{{$i}}.destination" value="{{$r.Destination}}" size="16" spellcheck="false" placeholder="whole zone"></td>
<td><input name="rule.{{$i}}.ports" value="{{$r.Ports}}" size="10" placeholder="all"></td>
@@ -69,7 +69,7 @@
to address is one address or network. Ports (tcp and udp only): <code>22, 443, 5000-5100</code>.
From <code>wan</code> (the internet) a rule must name the address inside, the protocol and the ports, and only lets
traffic through; a port forward is set up below. <strong>Forwarded only</strong> limits a rule to connections that a
port forward translated. Rate limit caps new connections per sender, like <code>10/minute</code>.</p>
port forward translated. To zone <code>local</code> is the router itself (SSH, the web UI): no address needed, but protocol and ports are. A port forward on the same port wins. Rate limit caps new connections per sender, like <code>10/minute</code>.</p>
</fieldset>
<fieldset>
+20 -19
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en"{{with .Theme}} data-theme="{{.}}"{{end}}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -7,28 +7,29 @@
<title>{{.Title}} · router</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<body{{if not .LoggedIn}} class="bare"{{end}}>
<header>
<strong>router</strong>
{{if .LoggedIn}}
<nav>
<a href="/">Overview</a>
<details name="menu"><summary>Network</summary><div>
<a href="/network">Interfaces</a>
<a href="/dhcp">DHCP</a>
<a href="/dns">DNS</a>
<a href="/wireguard">WireGuard</a>
<a href="/firewall">Firewall</a>
</div></details>
<details name="menu"><summary>Services</summary><div>
<a href="/ddns">Dynamic DNS</a>
<a href="/dnsproviders">DNS providers</a>
<a href="/certificate">Certificate</a>
</div></details>
<details name="menu"><summary>System</summary><div>
<a href="/update">Update</a>
<a href="/advanced">Advanced</a>
</div></details>
<a href="/"{{if eq .Title "Overview"}} aria-current="page"{{end}}>Overview</a>
<div class="group"><span>Network</span>
<a href="/network"{{if eq .Title "Network"}} aria-current="page"{{end}}>Interfaces</a>
<a href="/dhcp"{{if eq .Title "DHCP"}} aria-current="page"{{end}}>DHCP</a>
<a href="/dns"{{if eq .Title "DNS"}} aria-current="page"{{end}}>DNS</a>
<a href="/wireguard"{{if eq .Title "WireGuard"}} aria-current="page"{{end}}>WireGuard</a>
<a href="/firewall"{{if eq .Title "Firewall"}} aria-current="page"{{end}}>Firewall</a>
</div>
<div class="group"><span>Services</span>
<a href="/ddns"{{if eq .Title "Dynamic DNS"}} aria-current="page"{{end}}>Dynamic DNS</a>
<a href="/dnsproviders"{{if eq .Title "DNS providers"}} aria-current="page"{{end}}>DNS providers</a>
<a href="/certificate"{{if eq .Title "Certificate"}} aria-current="page"{{end}}>Certificate</a>
</div>
<div class="group"><span>System</span>
<a href="/update"{{if eq .Title "Update"}} aria-current="page"{{end}}>Update</a>
<a href="/tokens"{{if eq .Title "API access"}} aria-current="page"{{end}}>API access</a>
<a href="/advanced"{{if eq .Title "Advanced"}} aria-current="page"{{end}}>Advanced</a>
</div>
<form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Log out</button></form>
</nav>
{{end}}
+41 -14
View File
@@ -1,23 +1,50 @@
{{define "content"}}
{{with .Data}}
{{$e := .Errors}}
<form method="post" action="/vlan">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<fieldset>
<legend>Interfaces</legend>
<fieldset>
<legend>Interfaces</legend>
<form method="post" action="/interfaces">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="rows" value="{{len .Devices}}">
<button class="default" tabindex="-1" aria-hidden="true">Save names</button>
<div class="scroll">
<table>
<tr><th>Interface</th><th>Kind</th><th>Used as</th><th></th></tr>
{{range .Devices}}<tr><td>{{.Name}}</td><td>{{.Kind}}</td><td>{{if .Role}}{{.Role}}{{else}}<span class="hint">unused</span>{{end}}</td>
<td>{{if .Removable}}<button name="remove" value="{{.Name}}" class="link">remove</button>{{end}}</td></tr>{{end}}
<tr><th>Interface</th><th>MAC address</th><th title="The name Linux gives it: the web UI, the firewall and the logs use it">Name</th><th>Kind</th><th>Used as</th><th></th></tr>
{{range $i, $d := .Devices}}<tr>
<td>{{if $d.Kernel}}{{$d.Kernel}}{{else}}{{$d.Name}}{{end}}</td>
<td>{{if $d.MAC}}<code>{{$d.MAC}}</code>{{end}}</td>
<td>
<input type="hidden" name="dev.{{$i}}" value="{{$d.Name}}">
<input type="hidden" name="mac.{{$i}}" value="{{$d.MAC}}">
{{if eq $d.Kind "VLAN"}}
<input type="hidden" name="from.{{$i}}" value="{{$d.Custom}}">
<input name="name.{{$i}}" value="{{$d.Custom}}" size="12" placeholder="{{$d.Name}}" spellcheck="false" {{if not $d.Role}}disabled{{end}}>
{{else}}
<input type="hidden" name="from.{{$i}}" value="{{$d.Name}}">
<input name="name.{{$i}}" value="{{$d.Name}}" size="12" spellcheck="false" {{if not $d.MAC}}disabled{{end}}>
{{if and $d.Kernel (ne $d.Kernel $d.Name)}}<span class="hint">after the next reboot</span>{{end}}
{{end}}
{{with index $e (printf "name[%d]" $i)}}<span class="field-error">{{.}}</span>{{end}}
</td>
<td>{{$d.Kind}}</td><td>{{if $d.Role}}{{$d.Role}}{{else}}<span class="hint">unused</span>{{end}}</td>
<td>{{if $d.Removable}}<button formaction="/vlan" name="remove" value="{{$d.Name}}" class="link">remove</button>{{end}}</td>
</tr>{{end}}
</table>
<div class="sub">
<p class="hint">Add a VLAN (a tagged network on one of the interfaces); it is then offered below.</p>
<label>On <select name="parent">{{range .Devices}}{{if eq .Kind "physical"}}<option value="{{.Name}}">{{.Name}}</option>{{end}}{{end}}</select></label>
<label>VLAN ID <input name="id" size="5" inputmode="numeric"></label>
<button name="add" value="1">Add VLAN</button>
</div>
</fieldset>
</form>
<p class="hint">A name is what Linux calls the interface: <code>wan</code> or <code>lan</code> instead of <code>enp2s0</code>,
<code>dmz</code> instead of <code>lan.1002</code>. Cards are found by their MAC address. Linux cannot rename an
interface that is in use: after you save and apply, the router starts using the new names at its next reboot.
Clear a name to go back to the default.</p>
<button>Save names</button>
</form>
<form method="post" action="/vlan" class="sub">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<p class="hint">Add a VLAN (a tagged network on one of the interfaces); it is then offered below.</p>
<label>On <select name="parent">{{range .Devices}}{{if eq .Kind "physical"}}<option value="{{.Name}}">{{.Name}}</option>{{end}}{{end}}</select></label>
<label>VLAN ID <input name="id" size="5" inputmode="numeric"></label>
<button name="add" value="1">Add VLAN</button>
</form>
</fieldset>
<form method="post" action="/network">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
+1 -1
View File
@@ -13,7 +13,7 @@
</section>
{{else if $s.Unapplied}}
<section class="pending">
<p>The settings have changes that are not part of the confirmed system.</p>
<p>The settings have changes that are not part of the confirmed system. <a href="/changes">Review the changes</a>.</p>
<div class="actions">
<form method="post" action="/apply"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Apply</button></form>
<form method="post" action="/discard"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Discard changes</button></form>
+63
View File
@@ -0,0 +1,63 @@
{{define "content"}}
{{with .Data}}
<section>
<p>An assistant or a script can read this router's state and logs and edit its settings through an
<a href="https://modelcontextprotocol.io">MCP</a> endpoint at <code>{{.URL}}</code> and, with the same features, a
REST API described by <a href="/api/openapi.json">OpenAPI</a> (<code>/api/v1/…</code>). It needs a token, sent as
<code>Authorization: Bearer …</code>. A <strong>read</strong> token reads state and settings; a <strong>change</strong>
token can also edit the settings. Edits are saved but never applied: you review them under
<a href="/changes">Changes</a> and apply them yourself. Secrets are never sent, and every call is logged below.</p>
</section>
{{if .Secret}}
<section class="pending">
<p>Token for <strong>{{.Name}}</strong>:</p>
<p><code>{{.Secret}}</code></p>
<p class="hint">For Claude Code: <code>claude mcp add --transport http router {{.URL}} --header "Authorization: Bearer {{.Secret}}"</code>.
The web UI's certificate is self-signed unless you set up a <a href="/certificate">certificate</a>: the client must trust it.</p>
</section>
{{end}}
<fieldset>
<legend>Tokens</legend>
{{if .Tokens}}
<div class="scroll">
<table>
<tr><th>Name</th><th>Access</th><th>Created</th><th>Last used</th><th></th></tr>
{{range .Tokens}}<tr><td>{{.Name}}</td><td>{{.Scope}}</td><td>{{.Created.Format "2006-01-02"}}</td>
<td>{{if .LastUsed.IsZero}}<span class="hint">never</span>{{else}}{{.LastUsed.Format "2006-01-02 15:04"}}{{end}}</td>
<td><form method="post" action="/tokens/revoke" data-confirm="Revoke this token?"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><button class="link">revoke</button></form></td></tr>{{end}}
</table>
</div>
{{else}}<p class="hint">No tokens yet.</p>{{end}}
</fieldset>
<form method="post" action="/tokens">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<fieldset>
<legend>New token</legend>
<label>Name <input name="name" size="20" placeholder="claude on my laptop" required></label>
<label>Access
<select name="scope">
<option value="read">read: state and settings</option>
<option value="change">change: also edit the settings</option>
</select>
</label>
<button>Create token</button>
</fieldset>
</form>
<fieldset>
<legend>Recent calls</legend>
{{if .Audit}}
<div class="scroll">
<table>
<tr><th>Time</th><th>Token</th><th>Tool</th><th>Arguments</th><th>Result</th></tr>
{{range .Audit}}<tr><td>{{.Time.Format "2006-01-02 15:04:05"}}</td><td>{{.Token}}</td><td><code>{{.Tool}}</code></td>
<td><code>{{.Args}}</code></td><td>{{if .Error}}<span class="bad">{{.Error}}</span>{{else}}ok{{end}}</td></tr>{{end}}
</table>
</div>
{{else}}<p class="hint">Nothing yet.</p>{{end}}
</fieldset>
{{end}}
{{end}}