On a tunnel this machine configures — today that means OpenWrt's UCI, which is where the server here lives — the VPN tab can add a device, rename it and take it away. A wg-quick host is reported as unmanaged and shows no buttons, because half-supporting a second configuration format is worse than an honest refusal. Adding generates the keypair and a pre-shared key on the machine, picks the lowest free address in the tunnel's range, and hands back the complete client profile with a QR code for the WireGuard apps. The private key in it is generated for that one call and stored nowhere, so the profile is shown until dismissed and then gone — the same bargain as any password this console makes. Nothing restarts the network, which matters when the interface belongs to the router the house is behind. `uci commit` writes the file and applies nothing; `wg set` changes one peer on the live interface and touches nothing else. The order of the two is chosen so they cannot disagree quietly. Adding writes the configuration first and rolls it back if the live tunnel refuses, because a device that works only after the next reboot is the worst of the three outcomes. Removing does the live tunnel first, since that is the moment access actually ends, and says plainly if the configuration half then fails. Removing asks for the device's name to be typed back, at both ends. It is the one action here that cannot be undone and it sits one tap from a list of devices that all look alike. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
219 lines
12 KiB
TypeScript
219 lines
12 KiB
TypeScript
// VPN: this machine's WireGuard tunnels, and the devices on them.
|
|
//
|
|
// Per machine, like every other tab, which here is the whole point: the tunnel people dial
|
|
// into and a tunnel this machine dials out through are different machines, and showing them
|
|
// together would invite the question "which box is this actually on".
|
|
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
|
import qrcode from 'qrcode-generator'
|
|
import { api, CreatedVPNPeer, VPNReport, VPNPeer, VPNTunnel } from './api'
|
|
import { fmtBytes } from './fmt'
|
|
import { Btn, Loading, askText, toast, useEscape } from './ui'
|
|
import { useRefreshMs } from './prefs'
|
|
|
|
// WireGuard says nothing when it has nothing to say, so "online" is a handshake in the last
|
|
// few minutes rather than a connection — there is no connection to have.
|
|
const ago = (iso?: string) => {
|
|
if (!iso) return 'never'
|
|
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000)
|
|
if (s < 60) return `${Math.floor(s)}s ago`
|
|
if (s < 3600) return `${Math.floor(s / 60)} min ago`
|
|
if (s < 86400) return `${Math.floor(s / 3600)} h ago`
|
|
return `${Math.floor(s / 86400)} d ago`
|
|
}
|
|
|
|
// A public key is long and nobody reads the middle of it; the ends are what you compare.
|
|
const shortKey = (k: string) => (k.length > 16 ? `${k.slice(0, 8)}…${k.slice(-6)}` : k)
|
|
|
|
function Peer({ p, server, iface, editable, onChanged }: { p: VPNPeer; server: boolean; iface: string; editable: boolean; onChanged: () => void }) {
|
|
const rename = async () => {
|
|
const name = await askText(`Rename ${p.name || 'this device'}`, p.name ?? '')
|
|
if (!name || name.trim() === p.name) return
|
|
try { await api.editVPNPeer(iface, p.section!, { name: name.trim() }); toast('renamed'); onChanged() }
|
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
|
}
|
|
const remove = async () => {
|
|
const typed = await askText(`Remove ${p.name}? Its profile stops working at once and cannot be brought back — the device would need a new one.\n\nType its name to confirm.`, '', 'Remove')
|
|
if (typed === null) return
|
|
try { await api.removeVPNPeer(iface, p.section!, typed.trim()); toast(`${p.name} removed`); onChanged() }
|
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
|
}
|
|
return (
|
|
<div className="card iface vpn-peer">
|
|
<div className="sub-head">
|
|
<span className={'sdot ' + (p.online ? 'ok' : p.handshake ? '' : 'bad')} />
|
|
<span className="dname">{p.name || <span className="muted">unnamed</span>}</span>
|
|
{p.online && <span className="badge ok">online</span>}
|
|
{!p.handshake && <span className="badge">never connected</span>}
|
|
<span className="spacer" />
|
|
<span className="muted small mono">{(p.allowed_ips ?? []).join(', ')}</span>
|
|
{editable && p.section && <>
|
|
<Btn className="btn" onClick={rename}>Rename</Btn>
|
|
<Btn className="btn danger" onClick={remove}>Remove</Btn>
|
|
</>}
|
|
</div>
|
|
<div className="props">
|
|
<div><span className="k">Last handshake</span><span>{ago(p.handshake)}</span></div>
|
|
{p.endpoint && <div><span className="k">{server ? 'Comes from' : 'Endpoint'}</span><span className="mono">{p.endpoint}</span></div>}
|
|
<div><span className="k">Moved</span><span className="mono">↓ {fmtBytes(p.rx)} · ↑ {fmtBytes(p.tx)}</span></div>
|
|
{!!p.keepalive && <div><span className="k">Keepalive</span><span>every {p.keepalive}s</span></div>}
|
|
<div><span className="k">Public key</span><span className="mono" title={p.public_key}>{shortKey(p.public_key)}</span></div>
|
|
{p.psk && <div><span className="k">Pre-shared key</span><span>in use</span></div>}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Tunnel({ t, onChanged }: { t: VPNTunnel; onChanged: () => void }) {
|
|
const server = t.kind === 'server'
|
|
const [adding, setAdding] = useState(false)
|
|
const [made, setMade] = useState<CreatedVPNPeer | null>(null)
|
|
const peers = t.peers ?? []
|
|
const online = peers.filter((p) => p.online).length
|
|
return (
|
|
<>
|
|
<div className="dash-bar">
|
|
<h2 className="section">{t.name}</h2>
|
|
<span className="badge">{server ? 'devices dial in' : t.kind === 'client' ? 'this machine dials out' : 'tunnel'}</span>
|
|
<span className="spacer" />
|
|
<span className="muted small">{server ? `${online} of ${peers.length} online` : t.exit ? `via ${t.exit}` : ''}</span>
|
|
{t.managed && server && <Btn className="btn on" onClick={() => { setMade(null); setAdding(true) }}>+ Add device</Btn>}
|
|
</div>
|
|
<section className="card">
|
|
<div className="props">
|
|
{!!t.addrs?.length && <div><span className="k">Address</span><span className="mono">{t.addrs.join(', ')}</span></div>}
|
|
{!!t.listen_port && <div><span className="k">Listening on</span><span className="mono">{t.listen_port}/udp</span></div>}
|
|
{!!t.mtu && <div><span className="k">MTU</span><span>{t.mtu}</span></div>}
|
|
<div><span className="k">Public key</span><span className="mono" title={t.public_key}>{shortKey(t.public_key)}</span></div>
|
|
{t.fwmark && <div><span className="k">Firewall mark</span><span className="mono">{t.fwmark}</span></div>}
|
|
</div>
|
|
</section>
|
|
{adding && <AddDevice t={t} onClose={() => setAdding(false)} onMade={(c) => { setAdding(false); setMade(c); onChanged() }} />}
|
|
{made && <Profile made={made} onClose={() => setMade(null)} />}
|
|
{peers.map((p) => <Peer key={p.public_key} p={p} server={server} iface={t.name} editable={!!t.managed} onChanged={onChanged} />)}
|
|
{peers.length === 0 && <section className="card"><div className="empty">No peers are configured on this tunnel.</div></section>}
|
|
</>
|
|
)
|
|
}
|
|
|
|
// The form for a new device. The endpoint is what the device will dial from outside, so it is
|
|
// the one thing Ham Lab cannot work out for itself — remembered per machine once given.
|
|
function AddDevice({ t, onClose, onMade }: { t: VPNTunnel; onClose: () => void; onMade: (c: CreatedVPNPeer) => void }) {
|
|
const key = `hamlab.wg.endpoint.${t.name}`
|
|
const [name, setName] = useState('')
|
|
const [endpoint, setEndpoint] = useState(localStorage.getItem(key) ?? '')
|
|
const [address, setAddress] = useState('')
|
|
const [dns, setDns] = useState('1.1.1.1, 1.0.0.1')
|
|
const [split, setSplit] = useState(false)
|
|
const [busy, setBusy] = useState(false)
|
|
const [err, setErr] = useState('')
|
|
useEscape(onClose)
|
|
const submit = async (e: FormEvent) => {
|
|
e.preventDefault()
|
|
setBusy(true); setErr('')
|
|
try {
|
|
const made = await api.addVPNPeer(t.name, { name: name.trim(), endpoint: endpoint.trim(), address: address.trim(), dns: dns.trim(), split })
|
|
localStorage.setItem(key, endpoint.trim())
|
|
onMade(made)
|
|
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
|
finally { setBusy(false) }
|
|
}
|
|
return (
|
|
<form className="card addform" onSubmit={submit} style={{ marginTop: 8 }}>
|
|
<label>Device name <input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="phone" spellCheck={false} required /></label>
|
|
<label>What it dials <input value={endpoint} onChange={(e) => setEndpoint(e.target.value)} placeholder="home.example.com:51820" spellCheck={false} autoCapitalize="off" required /></label>
|
|
<label>Address in the tunnel <input value={address} onChange={(e) => setAddress(e.target.value)} placeholder="the next free one" spellCheck={false} /></label>
|
|
<label>DNS while connected <input value={dns} onChange={(e) => setDns(e.target.value)} placeholder="none" spellCheck={false} /></label>
|
|
<label className="row-check"><input type="checkbox" checked={split} onChange={(e) => setSplit(e.target.checked)} /> Only route the home networks, not everything</label>
|
|
{err && <div className="error">{err}</div>}
|
|
<div className="svc-actions">
|
|
<button className="btn on" disabled={busy || !name.trim() || !endpoint.trim()}>Create device</button>
|
|
<span className="spacer" />
|
|
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
|
</div>
|
|
</form>
|
|
)
|
|
}
|
|
|
|
// The profile, once. Everything a device needs is in it, including a private key that exists
|
|
// nowhere else — so it is shown until dismissed, with the QR the WireGuard apps expect.
|
|
function Profile({ made, onClose }: { made: CreatedVPNPeer; onClose: () => void }) {
|
|
const qr = qrcode(0, 'M')
|
|
qr.addData(made.config)
|
|
qr.make()
|
|
const copy = async () => {
|
|
try { await navigator.clipboard.writeText(made.config); toast('profile copied') }
|
|
catch { toast('select and copy it by hand', 'err') }
|
|
}
|
|
const download = () => {
|
|
const url = URL.createObjectURL(new Blob([made.config], { type: 'text/plain' }))
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `${made.name.replace(/[^a-zA-Z0-9._-]+/g, '-')}.conf`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
return (
|
|
<section className="card fresh vpn-profile" style={{ marginTop: 8 }}>
|
|
<div className="sub-head"><span className="dname">{made.name}</span><span className="badge ok">created</span><span className="spacer" /><span className="muted small mono">{made.address}</span></div>
|
|
<div className="vpn-qr">
|
|
<img src={qr.createDataURL(5, 8)} alt={`WireGuard profile for ${made.name}`} width={220} height={220} />
|
|
<div>
|
|
<div className="muted small">Scan it in the WireGuard app, or copy the profile into one.</div>
|
|
<pre className="mono vpn-conf">{made.config}</pre>
|
|
</div>
|
|
</div>
|
|
<div className="svc-actions" style={{ marginTop: 8 }}>
|
|
<Btn className="btn on" onClick={copy}>Copy profile</Btn>
|
|
<Btn className="btn" onClick={download}>Download .conf</Btn>
|
|
<span className="muted small">Shown once. The private key in it is not stored here — if it is lost, make the device again.</span>
|
|
<span className="spacer" />
|
|
<button className="btn" onClick={onClose}>Done</button>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
export default function VPN() {
|
|
const [d, setD] = useState<VPNReport | null>(null)
|
|
const [err, setErr] = useState('')
|
|
const rate = useRefreshMs()
|
|
const load = useCallback(async () => {
|
|
try { setD(await api.vpn()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
|
}, [])
|
|
useEffect(() => { load() }, [load])
|
|
// Handshakes and counters move; nothing here is expensive to read.
|
|
useEffect(() => {
|
|
const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate))
|
|
return () => clearInterval(t)
|
|
}, [load, rate])
|
|
|
|
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the VPN tab. Update it from Machines." : err}</div> : <Loading label="Reading the tunnels…" />}</div>
|
|
|
|
// A machine running an older build answers with null rather than an empty list, and taking
|
|
// the length of that is what a blank page is made of.
|
|
const tunnels = d.tunnels ?? []
|
|
|
|
return (
|
|
<div className="dash net">
|
|
{tunnels.length === 0 && (
|
|
<>
|
|
<div className="dash-bar"><h2 className="section">VPN</h2></div>
|
|
<section className="card"><div className="empty">{d.note || 'Nothing here.'}</div>
|
|
<div className="muted small" style={{ marginTop: 6 }}>
|
|
Tunnels are the machine's own, so look on the one that carries them — the switcher at the top of the rail.
|
|
</div>
|
|
</section>
|
|
</>
|
|
)}
|
|
{tunnels.map((t) => <Tunnel key={t.name} t={t} onChanged={load} />)}
|
|
{tunnels.length > 0 && (
|
|
<div className="muted small method" style={{ marginTop: 12 }}>
|
|
Read from the kernel with <span className="mono">wg show</span>; names come from the configuration beside it.
|
|
Online means a handshake in the last three minutes — WireGuard is silent when idle, so there is no connection to be in.
|
|
Private and pre-shared keys are never read.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|