// 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 { useCallback, useEffect, useState } from 'react'
import { api, VPNReport, VPNPeer, VPNTunnel } from './api'
import { fmtBytes } from './fmt'
import { Loading } 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 }: { p: VPNPeer; server: boolean }) {
return (
{p.name || unnamed}
{p.online && online}
{!p.handshake && never connected}
{(p.allowed_ips ?? []).join(', ')}
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 }: { t: VPNTunnel }) {
const server = t.kind === 'server'
const online = t.peers.filter((p) => p.online).length
return (
<>
{t.name}
{server ? 'devices dial in' : t.kind === 'client' ? 'this machine dials out' : 'tunnel'}
{server ? `${online} of ${t.peers.length} online` : t.exit ? `via ${t.exit}` : ''}
{!!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}
}
{t.peers.map((p) => )}
{t.peers.length === 0 && No peers are configured on this tunnel.
}
>
)
}
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}
:
}
return (
{d.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.
>
)}
{d.tunnels.map((t) =>
)}
{d.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.
)}
)
}