Create, rename and remove the devices that dial in
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
32fdbf169a
commit
8fb21ee95e
+107
-7
@@ -3,10 +3,11 @@
|
||||
// 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 { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import qrcode from 'qrcode-generator'
|
||||
import { api, CreatedVPNPeer, VPNReport, VPNPeer, VPNTunnel } from './api'
|
||||
import { fmtBytes } from './fmt'
|
||||
import { Loading } from './ui'
|
||||
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
|
||||
@@ -23,7 +24,19 @@ const ago = (iso?: string) => {
|
||||
// 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 }) {
|
||||
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">
|
||||
@@ -33,6 +46,10 @@ function Peer({ p, server }: { p: VPNPeer; server: boolean }) {
|
||||
{!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>
|
||||
@@ -46,8 +63,10 @@ function Peer({ p, server }: { p: VPNPeer; server: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Tunnel({ t }: { t: VPNTunnel }) {
|
||||
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 (
|
||||
@@ -57,6 +76,7 @@ function Tunnel({ t }: { t: VPNTunnel }) {
|
||||
<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">
|
||||
@@ -67,12 +87,92 @@ function Tunnel({ t }: { t: VPNTunnel }) {
|
||||
{t.fwmark && <div><span className="k">Firewall mark</span><span className="mono">{t.fwmark}</span></div>}
|
||||
</div>
|
||||
</section>
|
||||
{peers.map((p) => <Peer key={p.public_key} p={p} server={server} />)}
|
||||
{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('')
|
||||
@@ -105,7 +205,7 @@ export default function VPN() {
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
{tunnels.map((t) => <Tunnel key={t.name} t={t} />)}
|
||||
{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.
|
||||
|
||||
Reference in New Issue
Block a user