Add WireGuard, DNS and more to the web UI and router-setup
Web UI: a WireGuard page (tunnels, peers, keys made on the router, client configs with a QR code, import of wg-quick configs, live status), a DNS page (records, wildcards, forward zones), interface dropdowns with VLAN devices, IPv6 options and a local domain. router-setup asks about VLANs, IPv6 and the domain. The router publishes its own name in DNS and hands the local domain to DHCP clients; wg and jq are on the router. Includes a script that takes over antigua's tunnels. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
{{$e := .Errors}}
|
||||
<form method="post" action="/dns">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<fieldset>
|
||||
<legend>Records</legend>
|
||||
<input type="hidden" name="recs" value="{{len .Records}}">
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<tr><th>Name</th><th>Addresses</th><th>Remove</th></tr>
|
||||
{{range $i, $r := .Records}}
|
||||
<tr>
|
||||
<td><input name="rec.{{$i}}.name" value="{{$r.Name}}" size="30" spellcheck="false" placeholder="nas or *.k8s.example.org"></td>
|
||||
<td><input name="rec.{{$i}}.addresses" value="{{$r.Addresses}}" size="40" spellcheck="false" placeholder="192.168.10.20, fd00::20"></td>
|
||||
<td>{{if $r.Name}}<input type="checkbox" name="rec.{{$i}}.remove">{{end}}</td>
|
||||
</tr>
|
||||
{{range $f := list "name" "addresses"}}{{with index $e (printf "rec[%d].%s" $i $f)}}<tr><td colspan="3" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">An IPv4 address becomes an A record, an IPv6 address an AAAA record; several addresses give several records.
|
||||
A name without a dot lives in the local domain{{if .Domain}} (<code>{{.Domain}}</code>){{else}} (none is set: do that on the Network page){{end}}.
|
||||
A full name such as <code>photos.example.org</code> overrides public DNS for just that name.</p>
|
||||
<p class="hint"><code>*.k8s.example.org</code> is a wildcard: <code>k8s.example.org</code> itself and every name below it
|
||||
(<code>a.k8s.example.org</code>, <code>x.y.k8s.example.org</code>) answer with these addresses.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Forward zones</legend>
|
||||
<input type="hidden" name="zones" value="{{len .Zones}}">
|
||||
<table>
|
||||
<tr><th>Domain</th><th>Resolvers</th><th>Remove</th></tr>
|
||||
{{range $i, $z := .Zones}}
|
||||
<tr>
|
||||
<td><input name="zone.{{$i}}.name" value="{{$z.Name}}" size="30" spellcheck="false" placeholder="branch.example.org"></td>
|
||||
<td><input name="zone.{{$i}}.addresses" value="{{$z.Addresses}}" size="30" spellcheck="false" placeholder="10.1.0.254"></td>
|
||||
<td>{{if $z.Name}}<input type="checkbox" name="zone.{{$i}}.remove">{{end}}</td>
|
||||
</tr>
|
||||
{{range $f := list "name" "addresses"}}{{with index $e (printf "zone[%d].%s" $i $f)}}<tr><td colspan="3" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
<p class="hint">Names below these domains are asked from these resolvers (for example across a tunnel), not from the internet.</p>
|
||||
</fieldset>
|
||||
<button>Save</button>
|
||||
</form>
|
||||
|
||||
{{if .Auto}}
|
||||
<section>
|
||||
<h2>Published automatically</h2>
|
||||
<ul>{{range .Auto}}<li>{{.}}</li>{{end}}</ul>
|
||||
<p class="hint">The router's own name and the DHCP reservations (edit those on the DHCP page).</p>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -15,6 +15,8 @@
|
||||
<a href="/">Overview</a>
|
||||
<a href="/network">Network</a>
|
||||
<a href="/dhcp">DHCP</a>
|
||||
<a href="/dns">DNS</a>
|
||||
<a href="/wireguard">WireGuard</a>
|
||||
<a href="/update">Update</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>
|
||||
|
||||
@@ -1,29 +1,44 @@
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
{{$e := .Errors}}
|
||||
<form method="post" action="/vlan">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<fieldset>
|
||||
<legend>Interfaces</legend>
|
||||
<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}}
|
||||
</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>
|
||||
|
||||
<form method="post" action="/network">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<datalist id="ifaces">{{range .Interfaces}}<option value="{{.}}">{{end}}</datalist>
|
||||
<datalist id="devices">{{range .Devices}}<option value="{{.Name}}">{{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>Interfaces</legend>
|
||||
<table>
|
||||
<tr><th>Interface</th><th>Kind</th><th>Used as</th></tr>
|
||||
{{range .Devices}}<tr><td>{{.Name}}</td><td>{{.Kind}}</td><td>{{if .Role}}{{.Role}}{{else}}<span class="hint">unused</span>{{end}}</td></tr>{{end}}
|
||||
</table>
|
||||
<p class="hint">A VLAN is written <code>interface.id</code>, for example <code>enp2s0.1001</code>; naming one below creates it.</p>
|
||||
<label>Local domain <input name="domain" value="{{.Form.Domain}}" placeholder="home.example.org" spellcheck="false"></label>
|
||||
{{with index $e "domain"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<p class="hint">Optional. The router answers for it, publishes DHCP reservations in it, and clients get it as
|
||||
their search domain, so <code>nas</code> finds <code>nas.home.example.org</code>.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Uplink (WAN)</legend>
|
||||
<label>Interface <input name="wan.interface" list="devices" value="{{.Form.WAN.Interface}}" required></label>
|
||||
<label>Interface
|
||||
<select name="wan.interface">{{$wan := .Form.WAN.Interface}}
|
||||
{{range choices $.Data.Devices $wan}}<option value="{{.}}" {{if eq . $wan}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
{{with index $e "wan.interface"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<label>Type
|
||||
<select name="wan.type">
|
||||
@@ -47,12 +62,14 @@
|
||||
{{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 access</th><th>IPv6</th><th>DHCP</th><th>Pool start</th><th>Pool end</th><th>Remove</th></tr>
|
||||
<tr><th>Name</th><th>Interface</th><th>Address</th><th>Prefix</th><th>Internet access</th><th>IPv6</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><select name="lan.{{$i}}.interface">{{$cur := $l.Interface}}
|
||||
{{if not $cur}}<option value="" selected></option>{{end}}
|
||||
{{range choices $.Data.Devices $cur}}<option value="{{.}}" {{if eq . $cur}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select></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>
|
||||
@@ -62,7 +79,7 @@
|
||||
<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="11" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||
{{range $f := list "name" "interface" "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 (zones, a fixed IPv6 address) are kept; edit them under Advanced.</p>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
{{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] PrivateKey = ... Address = 10.100.0.1/24 ListenPort = 51820 [Peer] PublicKey = ... AllowedIPs = 10.100.0.2/32">{{.ImportText}}</textarea>
|
||||
<button>Import</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>New tunnel</h2>
|
||||
{{template "tunnelform" (tform .New $.CSRF true)}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user