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:
@@ -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">
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user