// 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 (
{p.name || unnamed} {p.online && online} {!p.handshake && never connected} {(p.allowed_ips ?? []).join(', ')} {editable && p.section && <> Rename Remove }
Last handshake{ago(p.handshake)}
{p.endpoint &&
{server ? 'Comes from' : 'Endpoint'}{p.endpoint}
}
Moved↓ {fmtBytes(p.rx)} · ↑ {fmtBytes(p.tx)}
{!!p.keepalive &&
Keepaliveevery {p.keepalive}s
}
Public key{shortKey(p.public_key)}
{p.psk &&
Pre-shared keyin use
}
) } function Tunnel({ t, onChanged }: { t: VPNTunnel; onChanged: () => void }) { const server = t.kind === 'server' const [adding, setAdding] = useState(false) const [made, setMade] = useState(null) const peers = t.peers ?? [] const online = peers.filter((p) => p.online).length return ( <>

{t.name}

{server ? 'devices dial in' : t.kind === 'client' ? 'this machine dials out' : 'tunnel'} {server ? `${online} of ${peers.length} online` : t.exit ? `via ${t.exit}` : ''} {t.managed && server && { setMade(null); setAdding(true) }}>+ Add device}
{!!t.addrs?.length &&
Address{t.addrs.join(', ')}
} {!!t.listen_port &&
Listening on{t.listen_port}/udp
} {!!t.mtu &&
MTU{t.mtu}
}
Public key{shortKey(t.public_key)}
{t.fwmark &&
Firewall mark{t.fwmark}
}
{adding && setAdding(false)} onMade={(c) => { setAdding(false); setMade(c); onChanged() }} />} {made && setMade(null)} />} {peers.map((p) => )} {peers.length === 0 &&
No peers are configured on this tunnel.
} ) } // 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 (
{err &&
{err}
}
) } // 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 (
{made.name}created{made.address}
{`WireGuard
Scan it in the WireGuard app, or copy the profile into one.
{made.config}
Copy profile Download .conf Shown once. The private key in it is not stored here — if it is lost, make the device again.
) } export default function VPN() { const [d, setD] = useState(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
{err ?
{err === 'not found' ? "This machine's Ham Lab is older than the VPN tab. Update it from Machines." : err}
: }
// 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 (
{tunnels.length === 0 && ( <>

VPN

{d.note || 'Nothing here.'}
Tunnels are the machine's own, so look on the one that carries them — the switcher at the top of the rail.
)} {tunnels.map((t) => )} {tunnels.length > 0 && (
Read from the kernel with wg show; 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.
)}
) }