Add a web UI for settings-configured routers

router-webui (Go, standard library only) edits settings.json: host
name, WAN (DHCP or PPPoE), LANs with DHCP, and the raw JSON for the
rest; keys the forms do not know are kept. It applies and confirms by
starting router-apply.service and router-confirm.service, which a
switch never restarts, and shows the build log and the rollback
countdown. HTTPS with a self-signed certificate, a PBKDF2 password
hash, CSRF tokens and an Origin check; reachable from management LANs
only (new router.managementPorts).

router-apply records the settings it built and router-confirm keeps
them as settings.confirmed.json (new rollback.onConfirm hook), so the
UI can tell unapplied changes and discard them.

checks.apply now also logs in from a LAN client, discards the change
left by the rollback, edits the DHCP pool, applies and confirms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 22:43:58 +01:00
co-authored by Claude Opus 5.5
parent 2c8d5562f9
commit 753459f5d7
20 changed files with 1685 additions and 6 deletions
@@ -0,0 +1,9 @@
{{define "content"}}
<p>The complete settings: the <code>router</code> options of nixos-router as JSON, plus <code>system</code>.
They are checked when applied; nothing changes until then.</p>
<form method="post" action="/advanced">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<textarea name="settings" rows="30" spellcheck="false">{{.Data}}</textarea>
<button>Save</button>
</form>
{{end}}
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{if .Refresh}}<meta http-equiv="refresh" content="{{.Refresh}}">{{end}}
<title>{{.Title}} · router</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<strong>router</strong>
{{if .LoggedIn}}
<nav>
<a href="/">Overview</a>
<a href="/network">Network</a>
<a href="/advanced">Advanced</a>
<form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Log out</button></form>
</nav>
{{end}}
</header>
<main>
<h1>{{.Title}}</h1>
{{if .Flash}}<p class="flash">{{.Flash}}</p>{{end}}
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
{{template "content" .}}
</main>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
{{define "content"}}
<form method="post" action="/login" class="narrow">
<label>Password <input type="password" name="password" autocomplete="current-password" autofocus required></label>
<button>Log in</button>
</form>
{{end}}
+64
View File
@@ -0,0 +1,64 @@
{{define "content"}}
{{with .Data}}
{{$e := .Errors}}
<form method="post" action="/network">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<datalist id="ifaces">{{range .Interfaces}}<option value="{{.}}">{{end}}</datalist>
<fieldset>
<legend>System</legend>
<label>Host name <input name="hostName" value="{{.Form.HostName}}"></label>
{{with index $e "hostName"}}<span class="field-error">{{.}}</span>{{end}}
</fieldset>
<fieldset>
<legend>Uplink (WAN)</legend>
<label>Interface <input name="wan.interface" list="ifaces" value="{{.Form.WAN.Interface}}" required></label>
{{with index $e "wan.interface"}}<span class="field-error">{{.}}</span>{{end}}
<label>Type
<select name="wan.type">
<option value="dhcp" {{if eq .Form.WAN.Type "dhcp"}}selected{{end}}>DHCP</option>
<option value="pppoe" {{if eq .Form.WAN.Type "pppoe"}}selected{{end}}>PPPoE</option>
</select>
</label>
<div class="sub">
<p class="hint">PPPoE only:</p>
<label>Username <input name="wan.pppoe.username" value="{{.Form.WAN.PPPoEUser}}" autocomplete="off"></label>
{{with index $e "wan.pppoe.username"}}<span class="field-error">{{.}}</span>{{end}}
<label>Password <input type="password" name="wan.pppoe.password" autocomplete="new-password"
placeholder="{{if .Form.WAN.PasswordSet}}stored — leave empty to keep{{end}}"></label>
{{with index $e "wan.pppoe.password"}}<span class="field-error">{{.}}</span>{{end}}
<label>VLAN <input name="wan.pppoe.vlan" value="{{.Form.WAN.PPPoEVLAN}}" inputmode="numeric" placeholder="untagged"></label>
{{with index $e "wan.pppoe.vlan"}}<span class="field-error">{{.}}</span>{{end}}
</div>
</fieldset>
<fieldset>
<legend>LANs</legend>
{{with index $e "lans"}}<p class="field-error">{{.}}</p>{{end}}
<input type="hidden" name="lans" value="{{len .Form.LANs}}">
<table>
<tr><th>Name</th><th>Interface</th><th>VLAN</th><th>Address</th><th>Prefix</th><th>Internet</th><th>DHCP</th><th>Pool start</th><th>Pool end</th><th>Remove</th></tr>
{{range $i, $l := .Form.LANs}}
<tr>
<td><input name="lan.{{$i}}.name" value="{{$l.Name}}" size="8" placeholder="new"></td>
<td><input name="lan.{{$i}}.interface" list="ifaces" value="{{$l.Interface}}" size="8"></td>
<td><input name="lan.{{$i}}.vlan" value="{{$l.VLAN}}" size="4" inputmode="numeric"></td>
<td><input name="lan.{{$i}}.address" value="{{$l.Address}}" size="13"></td>
<td><input name="lan.{{$i}}.prefix" value="{{$l.Prefix}}" size="2" inputmode="numeric" placeholder="24"></td>
<td><input type="checkbox" name="lan.{{$i}}.internet" {{if $l.Internet}}checked{{end}}></td>
<td><input type="checkbox" name="lan.{{$i}}.dhcp" {{if $l.DHCP}}checked{{end}}></td>
<td><input name="lan.{{$i}}.poolStart" value="{{$l.PoolStart}}" size="13"></td>
<td><input name="lan.{{$i}}.poolEnd" value="{{$l.PoolEnd}}" size="13"></td>
<td>{{if $l.Name}}<input type="checkbox" name="lan.{{$i}}.remove">{{end}}</td>
</tr>
{{range $f := list "name" "interface" "vlan" "address" "prefix" "poolStart" "poolEnd"}}{{with index $e (printf "lans[%d].%s" $i $f)}}<tr><td colspan="10" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
{{end}}
</table>
<p class="hint">Fill in the last row to add a LAN. Other LAN settings (IPv6, reservations, zones) are kept; edit them under Advanced.</p>
</fieldset>
<button>Save</button>
</form>
{{end}}
{{end}}
+37
View File
@@ -0,0 +1,37 @@
{{define "content"}}
{{$s := .Data}}
<section>
<dl>
<dt>Running system</dt><dd><code>{{short $s.Current}}</code></dd>
<dt>Confirmed system</dt><dd><code>{{short $s.Confirmed}}</code></dd>
</dl>
</section>
{{if $s.Applying}}
<section class="busy"><h2>Applying…</h2><p>Building the new configuration. This page refreshes on its own.</p></section>
{{else if $s.ConfirmRunning}}
<section class="busy"><h2>Confirming…</h2></section>
{{else if gt (seconds $s.RollbackIn) 0}}
<section class="pending">
<h2>Waiting for confirmation</h2>
<p>The new configuration is active. Unless you confirm it within <strong>{{duration $s.RollbackIn}}</strong>,
the router goes back to the confirmed system on its own.</p>
<form method="post" action="/confirm"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Confirm</button></form>
</section>
{{else}}
<section>
{{if $s.Unapplied}}<p>The settings have changes that are not part of the confirmed system.</p>{{else}}<p>The settings match the confirmed system.</p>{{end}}
<div class="actions">
<form method="post" action="/apply"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Apply</button></form>
{{if $s.Unapplied}}<form method="post" action="/discard"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Discard changes</button></form>{{end}}
</div>
</section>
{{end}}
{{if $s.Log}}
<section>
<h2>Last apply{{if $s.ApplyResult}} — {{if eq $s.ApplyResult "success"}}succeeded{{else}}failed ({{$s.ApplyResult}}){{end}}{{end}}</h2>
<pre class="log">{{$s.Log}}</pre>
</section>
{{end}}
{{end}}