adminVpn tRPC router.
How users, networks and credentials fit together
There are three levels, and they nest strictly:- A network defines the tunnel address space and the endpoint that devices
dial. It also carries the
require_mfapolicy flag. - A user is an identity scoped to exactly one network.
network_idisNOT NULL— a user cannot exist without a network, because the config a device runs is rendered from the network’s endpoint. With no network configured, this screen links you to Networks rather than offering a form that could only fail. - A credential is per device. You add devices after the user exists, and each device gets its own independently revocable credential.
The list view polls
listUsers every 10 seconds, which is also how the live
connection counts stay current.
Create or edit a user
upsertUser takes the whole row, not a patch. The console always sends every
field, because a partial write would silently clear the fields it omitted —
suspending someone must not drop their pinned IP.
staticIp as an IPv4 address, bandwidthBps as a
number ≥ 0, maxConns as an integer ≥ 0, and every entry in routes as a CIDR.
An invalid form does not disable the save button — clicking it reveals the
per-field messages instead, naming the first bad list entry.
Per-device credentials and the one-time token
issueCredential returns a token, and that is the only time the token is
shown. Only its SHA-256 is stored, so there is no “show again” and no recovery
path. The console therefore reveals it in a modal that is explicit about this and
offers copy buttons for both the token and the install command.
If a token is lost, you do not recover it. You revoke that device and issue a new
credential.
Immediately after a successful issue, the console calls configFor with the same
token so the runnable command can be shown alongside it — it is now or never:
command is a single line to run on the device. It targets Linux and needs root,
because it creates the TUN interface. The command is rendered from the network’s
endpoint, so if the network has no endpoint host set, the console shows
(set an endpoint host on the network to generate this) in its place — fix the
network, then issue again.
Revoke, suspend and delete: what reaches the engine and when
Every mutation on this screen re-projects the credential set that the engine authenticates against into Redis. That has one consequence worth internalising:- Revoke means now.
revokeCredentialdoes not merely refuse the next reconnect. The engine drops the live session on its next refresh, roughly five seconds later. The live connection count next to each device is how you confirm it actually happened. - Suspend means now. Setting
status: "suspended"viaupsertUsertakes effect on the same refresh, for the same reason. - Delete means now.
deleteUserbehaves identically.
Pinned addresses, bandwidth and connection caps
Pinned address.static_ip pins the user to one tunnel address. The column
is Postgres inet, so it reads back as a host route (10.88.0.10/32) while the
procedure input takes a bare IPv4 address — the console strips the prefix on read
and sends the bare form on write. The engine rejects a credential row whose IP
will not parse, so the field is validated as IPv4 before it is saved.
Bandwidth. The engine’s bucket is measured in bits per second, but operators
think in Mbit/s, so the console converts on both edges: Mbit/s × 1,000,000 → bandwidthBps
on write, and bandwidth_bps ÷ 1,000,000 → Mbit/s (three decimal places) on
read. If you call upsertUser directly, send bits per second.
Connection cap. maxConns is an integer and bounds how many concurrent
connections the user may hold.
Both bandwidthBps and maxConns default to 0 in the new-user form and must be
≥ 0.
Extra routes for a user
routes is a list of CIDRs attached to the individual user, on top of whatever
the network already pushes. In the console you type them comma- or
space-separated; the API takes a string[]:
routes is
part of the full-row write — omitting it clears it.
TOTP enrolment, re-enrolment and reset
Enrolment is deliberately two-phase.beginMfaEnrollment({ orgId, vpnUserId })returns asecretand a provisioninguri. The console renders the URI as a QR code and also shows the secret grouped in fours for manual entry. The factor is time-based, six digits, 30-second step. If the QR fails to render, the printed key is the same secret and any authenticator accepts manual entry.confirmMfaEnrollment({ orgId, vpnUserId, code })verifies a code produced by that authenticator.
resetMfa to clear the factor, then run
enrolment again against the new device. Both confirmMfaEnrollment and
resetMfa invalidate listUsers and mfaReadiness.
Where it is enforced. Enforcement is a property of the network, not the user:
the screen reads require_mfa from listNetworks and treats “not enrolled” as a
warning for users on a network that requires MFA, and as a neutral note
elsewhere. Change the requirement on the network itself.
Tags and filtering
Users are taggable under the resource typevpn_user. The tag filter in the page
header narrows the table client-side and reports how many of the total rows
matched, so you can, for example, pull up every contractor identity across a
network before revoking their devices.
When a mutation fails
upsertUser, deleteUser, issueCredential, revokeCredential and resetMfa
all change who may connect, and all of them therefore end in a re-projection.
The screen surfaces their errors in a banner rather than swallowing them, because
a failure means the engine still holds the previous credential set. Errors are
normalised for display — a Zod issue array is rendered as
Email: Invalid email rather than raw JSON.
If you see that banner after clicking Revoke, treat the device as still
connected and retry.