Files
nixos-router/pkgs/router-webui/templates/wireguard.html
T
nilsandClaude Sonnet 5.5 b1fa7b1396 Add token expiry, edit notice, log allowlist, local-zone test; merge PLAN.md into SPECS.md
get_logs needs a unit from an allowlist (router-*, kea, unbound, networkd,
nftables, ddclient, acme, sshd, kernel). API tokens are valid for 1, 7, 30
(default) or 90 days, or without limit. The overview and the Changes page
say which token made the unapplied changes.

options.json (router://options) no longer holds the options' source paths:
they differ between builds of a release, so a router rebuilding itself
offline had to build the file and failed (found by the update test).

Web UI: panels flow into columns on wide screens and the width is no longer
capped; on phones the navigation is a menu button; the Advanced page uses
panels; VLAN dropdowns show their chosen names; the duplicate WireGuard
heading is gone.

basic: a subtest for wan -> local rules.

SPECS.md is now the only document (decisions, how each part works, and what
is not done yet); PLAN.md is gone.

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

139 lines
8.8 KiB
HTML

{{define "tunnelform"}}
{{$t := .T}}{{$e := $t.Errors}}{{$csrf := .CSRF}}
<form method="post" action="/wireguard/save">
<input type="hidden" name="csrf" value="{{$csrf}}">
<fieldset>
<legend>{{if .New}}New tunnel{{else}}{{$t.Name}}{{end}}</legend>
<label>Name <input name="name" value="{{$t.Name}}" {{if .New}}placeholder="family"{{else}}readonly{{end}} size="14"></label>
{{with index $e "name"}}<span class="field-error">{{.}}</span>{{end}}
<label>Listen port <input name="listenPort" value="{{$t.ListenPort}}" size="6" inputmode="numeric" placeholder="none (dial-out)"></label>
{{with index $e "listenPort"}}<span class="field-error">{{.}}</span>{{end}}
<label>Addresses <input name="addresses" value="{{$t.Addresses}}" size="34" placeholder="10.100.0.1/24, fd42:100::1/64"></label>
{{with index $e "addresses"}}<span class="field-error">{{.}}</span>{{end}}
<label><input type="checkbox" name="internet" {{if $t.Internet}}checked{{end}}> Peers may reach the internet</label>
<label><input type="checkbox" name="allowManagement" {{if $t.AllowManagement}}checked{{end}}> Peers may reach SSH on the router</label>
<label><input type="checkbox" name="dns" {{if $t.DNS}}checked{{end}}> The router answers DNS on the tunnel address</label>
<label><input type="checkbox" name="masquerade" {{if $t.Masquerade}}checked{{end}}> Source-NAT IPv4 leaving through this tunnel</label>
<label><input type="checkbox" name="killSwitch" {{if $t.KillSwitch}}checked{{end}}> Kill switch: drop the peers' networks instead of letting them leave via the uplink</label>
<details>
<summary>More</summary>
<label>Interface <input name="interface" value="{{$t.Interface}}" size="12" placeholder="wg-{{if $t.Name}}{{$t.Name}}{{else}}name{{end}}"></label>
{{with index $e "interface"}}<span class="field-error">{{.}}</span>{{end}}
<label>Firewall zone <input name="zone" value="{{$t.Zone}}" size="12" placeholder="{{if $t.Name}}{{$t.Name}}{{else}}name{{end}}"></label>
{{with index $e "zone"}}<span class="field-error">{{.}}</span>{{end}}
<label>MTU <input name="mtu" value="{{$t.MTU}}" size="5" inputmode="numeric" placeholder="1420"></label>
{{with index $e "mtu"}}<span class="field-error">{{.}}</span>{{end}}
<label>Source addresses <input name="sourceAddresses" value="{{$t.SourceAddresses}}" size="24" placeholder="192.168.10.1"></label>
{{with index $e "sourceAddresses"}}<span class="field-error">{{.}}</span>{{end}}
</details>
<p>
{{if $t.KeyPresent}}Public key: <code>{{$t.PublicKey}}</code>{{else if not .New}}<span class="field-error">No private key.</span>{{else}}A private key is generated for it unless you paste one.{{end}}
</p>
<label>Private key <input type="password" name="privateKey" autocomplete="off" size="46"
placeholder="{{if $t.KeyPresent}}stored; paste to replace{{else}}paste an existing key (optional){{end}}"></label>
{{with index $e "privateKey"}}<span class="field-error">{{.}}</span>{{end}}
{{if not .New}}<label><input type="checkbox" name="newKey"> Generate a new private key (every peer must be given the new public key)</label>{{end}}
</fieldset>
<fieldset>
<legend>Peers of {{if .New}}the new tunnel{{else}}{{$t.Name}}{{end}}</legend>
<input type="hidden" name="peers" value="{{len $t.Peers}}">
<div class="scroll">
<table>
<tr><th>Name</th><th>Public key</th><th>Endpoint</th><th>Allowed IPs</th><th>Keepalive</th><th>Preshared key</th><th>Now</th><th>Remove</th></tr>
{{range $i, $p := $t.Peers}}
{{$st := index $t.Live $p.PublicKey}}
<tr>
<td><input name="peer.{{$i}}.name" value="{{$p.Name}}" size="10" placeholder="new"></td>
<td><input name="peer.{{$i}}.publicKey" value="{{$p.PublicKey}}" size="30" spellcheck="false"></td>
<td><input name="peer.{{$i}}.endpoint" value="{{$p.Endpoint}}" size="18" placeholder="host:port"></td>
<td><input name="peer.{{$i}}.allowed" value="{{$p.Allowed}}" size="22"></td>
<td><input name="peer.{{$i}}.keepalive" value="{{$p.Keepalive}}" size="4" inputmode="numeric"></td>
<td><input type="password" name="peer.{{$i}}.psk" autocomplete="off" size="12" placeholder="{{if $p.PSKSet}}stored{{end}}">
<label><input type="checkbox" name="peer.{{$i}}.newPsk"> new</label></td>
<td>{{if $p.PublicKey}}{{if not $st.Handshake.IsZero}}<span class="ok">{{ago $st.Handshake}} ago</span>
<span class="muted">{{bytes $st.RX}} in, {{bytes $st.TX}} out</span>{{else if $st.RX}}<span class="muted">no handshake</span>{{else if $t.Live}}<span class="muted">never</span>{{end}}{{end}}</td>
<td>{{if $p.Name}}<input type="checkbox" name="peer.{{$i}}.remove">{{end}}</td>
</tr>
{{range $f := list "name" "publicKey" "endpoint" "allowed" "keepalive" "psk"}}{{with index $e (printf "peers[%d].%s" $i $f)}}<tr><td colspan="8" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
{{end}}
</table>
</div>
<p class="hint">Fill in the last row to add a peer. Allowed IPs are the destinations sent into the tunnel
(and what the router accepts from the peer). Pasted preshared keys are stored on the router and not shown again.</p>
</fieldset>
<button>Save</button>
</form>
{{if not .New}}
<form method="post" action="/wireguard/delete" class="inline">
<input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="name" value="{{$t.Name}}">
<label><input type="checkbox" name="confirm"> delete {{$t.Name}}</label> <button>Delete tunnel</button>
<span class="hint">The key file stays on the router.</span>
</form>
{{end}}
{{end}}
{{define "content"}}
{{with .Data}}
<p class="hint">Changes are saved here and take effect when applied. Keys are made on the router and stay on it.
{{if not .Live}}Live status is not available.{{end}}</p>
{{range .Tunnels}}
<section>
<h2>{{.Name}} <span class="muted">{{.Device}}</span></h2>
{{template "tunnelform" (tform . $.CSRF false)}}
</section>
{{end}}
<section>
<h2>Add a phone or laptop</h2>
{{if .Config}}
<div class="flash">
<p><strong>{{.Config.Name}}</strong>: scan this with the WireGuard app or save the text as a config file.
It contains the client's private key and is shown only now; apply the settings so the router accepts it.</p>
{{if .Config.QR}}<div class="qr">{{.Config.QR}}</div>{{end}}
<pre>{{.Config.Text}}</pre>
</div>
{{end}}
{{if .Tunnels}}
{{$c := .Client}}{{$e := $c.Errors}}
<form method="post" action="/wireguard/client" class="narrow">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<label>Tunnel <select name="tunnel">{{range .Tunnels}}<option value="{{.Name}}" {{if eq .Name $c.Tunnel}}selected{{end}}>{{.Name}}</option>{{end}}</select></label>
{{with index $e "tunnel"}}<span class="field-error">{{.}}</span>{{end}}
<label>Name <input name="name" value="{{$c.Name}}" placeholder="nils-phone"></label>
{{with index $e "name"}}<span class="field-error">{{.}}</span>{{end}}
<label>Client address <input name="address" value="{{$c.Address}}" placeholder="10.100.0.2"></label>
{{with index $e "address"}}<span class="field-error">{{.}}</span>{{end}}
<label>Router's public address <input name="endpoint" value="{{$c.Endpoint}}" placeholder="vpn.example.org:51820" spellcheck="false"></label>
{{with index $e "endpoint"}}<span class="field-error">{{.}}</span>{{end}}
<label>Send through the tunnel <input name="allowed" value="{{$c.Allowed}}"></label>
{{with index $e "allowed"}}<span class="field-error">{{.}}</span>{{end}}
<label>DNS server <input name="dns" value="{{$c.DNS}}" placeholder="optional"></label>
{{with index $e "dns"}}<span class="field-error">{{.}}</span>{{end}}
<button>Add and show the configuration</button>
</form>
{{else}}<p class="muted">Add a tunnel first.</p>{{end}}
</section>
<section>
<h2>Import a tunnel</h2>
<p class="hint">Paste an existing WireGuard config (a <code>wg-quick</code> file or <code>wg showconf</code> output).
Its keys are stored on the router as root-only files and not shown again. Options the router has no
equivalent for (DNS, PostUp, ...) are skipped, and a peer that sends everything (<code>0.0.0.0/0</code>) has to be
changed to the networks you want through the tunnel.</p>
<form method="post" action="/wireguard/import">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<label>Tunnel name <input name="importName" value="{{.ImportName}}" placeholder="family" size="14"></label>
<textarea name="importText" rows="12" spellcheck="false" autocomplete="off" placeholder="[Interface]&#10;PrivateKey = ...&#10;Address = 10.100.0.1/24&#10;ListenPort = 51820&#10;&#10;[Peer]&#10;PublicKey = ...&#10;AllowedIPs = 10.100.0.2/32">{{.ImportText}}</textarea>
<button>Import</button>
</form>
</section>
<section>
{{template "tunnelform" (tform .New $.CSRF true)}}
</section>
{{end}}
{{end}}