A VPN tab: the tunnels, and who is on them
Beside Network, per machine. Every WireGuard interface on the machine in the switcher, with its address, listen port and public key, and under it the peers: name, allowed addresses, last handshake, where they connect from, and what they have moved. Per machine is the point rather than a limitation. The tunnel devices dial into and a tunnel a machine dials out through are different boxes here, and putting them on one page would invite the question of which box you were looking at. The kernel is the truth and the configuration only supplies names: wg show all dump has the handshakes and the counters and knows nothing but public keys, so the # comment above a peer in a wg-quick file, or a UCI section's description on OpenWrt, is read alongside purely to label them. Whether a tunnel is dialled or dialling is worked out from the shape of its peers, and says neither when it cannot tell. Online means a handshake in the last three minutes, and the tab says so in as many words: WireGuard is silent when idle, so there is no connection to be in. Read-only, and no secrets leave: the private key is the second column of the dump and is read past, pre-shared keys are noted as present and never read. The Network tab keeps listing tunnels as the interfaces they are, and hands off to here for anything about them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9c7848907b
commit
6f634bbe11
+8
-1
@@ -16,7 +16,13 @@ import Sparkline from './Sparkline'
|
||||
const scenery = (f: Iface) =>
|
||||
!f.default && (/^br-[0-9a-f]{12}$/.test(f.name) || /^(veth|docker)/.test(f.name) || f.kind === 'loopback')
|
||||
|
||||
const KIND: Record<string, string> = { ethernet: 'wired', wifi: 'Wi-Fi', bridge: 'bridge', bond: 'bond', loopback: 'loopback', virtual: 'virtual', vlan: 'VLAN', wlan: 'Wi-Fi' }
|
||||
const KIND: Record<string, string> = { ethernet: 'wired', wifi: 'Wi-Fi', bridge: 'bridge', bond: 'bond', loopback: 'loopback', virtual: 'virtual', vlan: 'VLAN', wlan: 'Wi-Fi', wireguard: 'VPN tunnel', none: 'tunnel' }
|
||||
|
||||
// A WireGuard interface is listed here because it is an interface and carries traffic, but
|
||||
// everything about it that is worth knowing — who is on it, when they last spoke — belongs to
|
||||
// the VPN tab, and is only there.
|
||||
const isTunnel = (f: Iface) => f.kind === 'wireguard' || /^(wg|tun|tap)[0-9]/.test(f.name)
|
||||
const goVPN = () => window.dispatchEvent(new CustomEvent('hamlab:view', { detail: 'vpn' }))
|
||||
|
||||
// Where the bytes came from. The usual source is anycast and its edge is the interesting
|
||||
// part; a fallback source is somewhere fixed, and then *which* one is the interesting part,
|
||||
@@ -173,6 +179,7 @@ export default function Network() {
|
||||
<span className="dname mono">{f.name}</span>
|
||||
{f.default && <span className="badge ok" title="the default route leaves by this one">internet</span>}
|
||||
<span className="badge">{KIND[f.kind] ?? f.kind}</span>
|
||||
{isTunnel(f) && <button className="link" onClick={goVPN} title="who is on this tunnel">peers →</button>}
|
||||
{!f.up ? <span className="badge bad">down</span> : !f.carrier && <span className="badge bad">no link</span>}
|
||||
<span className="spacer" />
|
||||
<span className="muted small mono">{typeof rx === 'number' ? `↓ ${fmtValue(rx, 'B/s')}` : ''} {typeof tx === 'number' ? `↑ ${fmtValue(tx, 'B/s')}` : ''}</span>
|
||||
|
||||
+26
-3
@@ -13,6 +13,7 @@ import Updates from './Updates'
|
||||
import Disks from './Disks'
|
||||
import Hardware from './Hardware'
|
||||
import Network from './Network'
|
||||
import VPN from './VPN'
|
||||
import ZFS from './ZFS'
|
||||
import KVM from './KVM'
|
||||
import Files from './Files'
|
||||
@@ -60,6 +61,14 @@ const GlobeIcon = (
|
||||
</svg>
|
||||
)
|
||||
|
||||
// A padlock-ish shield: the tab is about who may come in, and a keyhole reads at 14px where
|
||||
// a padlock's shackle does not.
|
||||
const ShieldIcon = (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M8 1.6 13.2 3.5v4.2c0 3-2.1 5.4-5.2 6.7-3.1-1.3-5.2-3.7-5.2-6.7V3.5z" /><circle cx="8" cy="7.2" r="1.3" /><path d="M8 8.5v2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const GROUPS: Group[] = [
|
||||
{ id: 'procs', label: 'Processes', icon: '▚' },
|
||||
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
||||
@@ -68,6 +77,7 @@ const GROUPS: Group[] = [
|
||||
{ id: 'disks', label: 'Disks', icon: '▤' },
|
||||
{ id: 'files', label: 'Files', icon: FolderIcon },
|
||||
{ id: 'network', label: 'Network', icon: GlobeIcon },
|
||||
{ id: 'vpn', label: 'VPN', icon: ShieldIcon },
|
||||
{ id: 'usage', label: 'Usage', icon: '▧' },
|
||||
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
|
||||
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
||||
@@ -81,8 +91,8 @@ const GROUPS: Group[] = [
|
||||
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
||||
]
|
||||
|
||||
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
|
||||
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
|
||||
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
|
||||
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
|
||||
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
||||
|
||||
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
||||
@@ -139,6 +149,18 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
|
||||
useEffect(() => { reload() }, [reload])
|
||||
|
||||
// One page sending you to another — the Network tab handing a tunnel over to the VPN tab.
|
||||
// A custom event rather than a hash assignment, because assigning location.hash does not
|
||||
// reliably raise popstate, which is what this shell navigates by.
|
||||
useEffect(() => {
|
||||
const h = (e: Event) => {
|
||||
const id = (e as CustomEvent<string>).detail
|
||||
if (VIEWS.includes(id as View)) { setView(id as View); setDrawer(false) }
|
||||
}
|
||||
window.addEventListener('hamlab:view', h)
|
||||
return () => window.removeEventListener('hamlab:view', h)
|
||||
}, [])
|
||||
|
||||
// A shell opened inside a container from the Docker tab is a terminal like any other:
|
||||
// pick it up, show the Terminal tab, and make it the active one.
|
||||
useEffect(() => {
|
||||
@@ -313,6 +335,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
{view === 'disks' && <Disks />}
|
||||
{view === 'hardware' && <Hardware />}
|
||||
{view === 'network' && <Network />}
|
||||
{view === 'vpn' && <VPN />}
|
||||
{view === 'zfs' && <ZFS />}
|
||||
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
|
||||
{termView && <TermTabs terms={ordered} active={active} creating={creating}
|
||||
@@ -340,7 +363,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||
<span>up {fmtUptime(me.uptime)}</span>
|
||||
<span className="spacer" />
|
||||
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
||||
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'vpn' ? 'VPN' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
// 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 (
|
||||
<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>
|
||||
</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 }: { t: VPNTunnel }) {
|
||||
const server = t.kind === 'server'
|
||||
const online = t.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 ${t.peers.length} online` : t.exit ? `via ${t.exit}` : ''}</span>
|
||||
</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>
|
||||
{t.peers.map((p) => <Peer key={p.public_key} p={p} server={server} />)}
|
||||
{t.peers.length === 0 && <section className="card"><div className="empty">No peers are configured on this tunnel.</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>
|
||||
|
||||
return (
|
||||
<div className="dash net">
|
||||
{d.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>
|
||||
</>
|
||||
)}
|
||||
{d.tunnels.map((t) => <Tunnel key={t.name} t={t} />)}
|
||||
{d.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>
|
||||
)
|
||||
}
|
||||
@@ -189,6 +189,19 @@ export interface SpeedResult {
|
||||
export interface SpeedRun { started: string; running: boolean; phase: string; done: number; mbps: number; bytes: number; result?: SpeedResult }
|
||||
export interface NetworkInfo { report: NetReport; outside: Outside; run?: SpeedRun; history: SpeedResult[]; method: string }
|
||||
|
||||
// ---- the VPN tab ----
|
||||
// A peer is a device that dials in (on a server tunnel) or the way out (on a client one).
|
||||
// `online` is a handshake in the last few minutes, not a connection: WireGuard has none.
|
||||
export interface VPNPeer {
|
||||
name?: string; public_key: string; endpoint?: string; allowed_ips: string[]
|
||||
handshake?: string; online: boolean; rx: number; tx: number; keepalive?: number; psk?: boolean
|
||||
}
|
||||
export interface VPNTunnel {
|
||||
name: string; public_key: string; kind: 'server' | 'client' | ''; listen_port?: number
|
||||
fwmark?: string; addrs: string[]; mtu?: number; peers: VPNPeer[]; exit?: string
|
||||
}
|
||||
export interface VPNReport { at: string; tunnels: VPNTunnel[]; tool: boolean; root: boolean; note?: string }
|
||||
|
||||
export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; keep_for: string; keep_min: number; prefix?: string; enabled: boolean }
|
||||
export interface ZFSRun { at?: string; taken?: string; pruned?: string[]; error?: string }
|
||||
export interface ZFSExternal { tool: string; schedule: string; command: string; user: string; source: string; id: string; next?: number; last?: number }
|
||||
@@ -303,6 +316,7 @@ export const api = {
|
||||
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
|
||||
hardware: (refresh = false) => req<Hardware>('GET', `/api/hardware${refresh ? '?refresh=1' : ''}`),
|
||||
network: () => req<NetworkInfo>('GET', '/api/network'),
|
||||
vpn: () => req<VPNReport>('GET', '/api/vpn'),
|
||||
speedtest: () => req<SpeedRun>('GET', '/api/network/speedtest'),
|
||||
startSpeedtest: () => req<SpeedRun>('POST', '/api/network/speedtest'),
|
||||
cancelSpeedtest: () => req<void>('POST', '/api/network/speedtest/cancel'),
|
||||
|
||||
Reference in New Issue
Block a user