Explore letting an enrolled iPad obtain its own WireGuard tunnel, instead of an admin fetching the config and presenting a QR on a second screen.
So the iPad self-registers, and then stops being self-service exactly where it needs the tunnel. Step 4 requires a second device, an admin session, and physical co-location. Enrolling a batch of iPads means repeating it per device.
The iPad already holds its own node token from the enrollment response, so it could in principle fetch its own tunnel without an admin. Options worth weighing:
A. iPad fetches its own config with its own token. Smallest change: let a head read GET /api/v1/heads/{id}/wireguard-config when the bearer token belongs to that head. See the security note below, which is the whole difficulty.
B. iPad generates the keypair and sends only the public key. The device creates its own keypair, posts the public key at enrollment, and hydraguard returns only the peer/server half. The private key never leaves the iPad and is never stored server-side. This is standard WireGuard practice and removes a class of problem rather than guarding it; it is a larger change to the hydraguard provisioning API.
C. iPad app installs the tunnel programmatically. Embed WireGuardKit/NetworkExtension in HydraHeadiPad so it configures the tunnel directly after enrollment, with no QR and no WireGuard app. Best end-user experience, most iOS work, and it needs the Network Extension entitlement.
D. Deep link into the WireGuard app. Hand off a .conf via the wireguard:// scheme or a document open-in, so the user taps through instead of scanning. Middle ground, still a manual step.
Note B and C compose well: the device generates the keypair and installs the tunnel itself, and no private key ever exists off-device.
Option A must not be implemented by moving the route onto requireAdminOrNodeToken as it stands. That middleware (handlers_body.go:77-107) authenticates ANY valid node token and puts the node in the request context, but never compares it to the {id} in the path. Handlers using it do not scope either: handleUpdateHead takes {id} straight from the path. So dropping wireguard-config onto it would let any node token in the fleet, including every body, read any head's private key.
If A is chosen, the handler must explicitly require that the authenticated node's ID equals the path ID. It is also worth auditing the existing requireAdminOrNodeToken routes for the same gap, which looks pre-existing and broader than this issue.
Also relevant: WireGuardConfig is stored at rest in nodes.yaml, embedding the private key. hydraguard deliberately does not keep it ("its private key was shown only at creation and is not stored"), so hydracluster is currently the weaker link. Option B removes that entirely.
Provisioning is non-fatal (handlers_head.go:799-806): if hydraguard errors, enrollment succeeds and the head silently has no config. On 2026-08-06 an iPad enrolled under the default name "ipad-head", collided with a stale hydraguard peer on 10.10.200.3, got a 409, and ended up with no tunnel. Nothing surfaced it; wireguard-config just returned 404 until someone went looking in the logs.
Whatever shape self-service takes should cover:
Re-scoping after reading HydraHeadiPad. Option C (app installs the tunnel itself) was described above as the most iOS work. It is in fact almost entirely present already — it is simply not wired up, and the server rejects it.
What exists today:
Sources/HydraHeadiPad/Services/HydraClusterClient.swift:58 — fetchWireGuardConfig(headID:) already calls GET /api/v1/heads/{id}/wireguard-config, sending Bearer <token> where the token is the head's own node token.Sources/HydraHeadiPad/WireGuardManager.swift — configure(with:) writes the config into the App Group group.com.experiencenet.hydraheadipad under key wireguardConfig, builds an NETunnelProviderManager pointing at com.experiencenet.hydraheadipad.tunnel, and saves it. Plus start() / stop() / isConnected.Sources/HydraHeadiPadTunnel/PacketTunnelProvider.swift:7-10 — reads that same App Group key and parses it with TunnelConfiguration(fromWgQuickConfig: configStr, called: "hydra").HydraHeadiPadTunnel/HydraHeadiPadTunnel.entitlements and a vendored Vendors/WireGuardKit.So the chain config string -> App Group -> packet tunnel provider -> tunnel up is complete, and the Network Extension entitlement work is done.
Missing links, all small:
fetchWireGuardConfig. Grep finds the definition and no call site.WireGuardManager.configure / start. The two halves have never been connected to each other.Sources/HydraHeadiPad/Views/.GET /api/v1/heads/{id}/wireguard-config is registered with RequireAuth (admin bearer) at pkg/api/server.go:396. The app sends the head's node token, so this returns 401 today even if wired.Server, hydracluster:
/api/v1/heads/{id}/wireguard-config onto node-token auth with explicit self-scoping: accept the admin token, or a node token whose node ID equals {id}. It must not simply be switched to requireAdminOrNodeToken, which would let any node in the fleet read any head's private key — see #456, which this route must not replicate.wireguard_config in the POST /api/v1/heads enrollment response. The config is generated during that very request and the fleet token is already proven, so first-run needs no new auth surface at all. handleFleetEnrollHead currently returns only head_id, token, server_url (handlers_head.go). This covers the common case; the fetch endpoint then only serves re-installs and heads enrolled earlier.provisionHeadIPadWireGuard is called only from the enrollment handler, so a head that hit a 409 can never recover without delete-and-re-enroll.App, HydraHeadiPad:
fetchWireGuardConfig -> WireGuardManager.configure(with:) -> start().saveToPreferences; after that the tunnel is installed and togglable.Bug to fix while wiring: WireGuardManager.configure constructs NETunnelProviderManager() fresh on every call and saves it. Repeated taps would create duplicate "Hydra" VPN profiles in iOS Settings. It should loadAllFromPreferences() first and reuse the existing manager if present.
Worth telling the user in-app: iOS permits only one packet tunnel active at a time, so enabling the Hydra tunnel disconnects any other VPN (Tailscale, for instance) and vice versa. Also note the app already prefers the LAN path — HeadConfig.resolvedHost returns stream_url_lan when present and only falls back to the tunnel address — so on the venue LAN the tunnel is not needed for streaming at all.
The above still ships a private key from server to device. The stronger variant (option B) has the device generate its own keypair and send only the public key, so the key never exists off-device and hydracluster stops storing it in nodes.yaml. That needs a HydraGuard provisioning API change and is worth doing separately, but the app work above is compatible with it: the same "Set up WireGuard" button, with the config assembled locally instead of fetched whole.
DELIVERED in hydraheadipad v0.2.156, TestFlight build 188 VALID.
An enrolled iPad now installs its own tunnel from inside the app: ellipsis menu > WireGuard setup > Set up tunnel. Verified end to end on ipad-head-cederik on 2026-08-18 — the head reported wireguard: connected and the hub showed the peer handshaking with traffic both ways.
What shipped:
requireAdminOrSelfNodeToken, so a head can fetch its own config with its own node token. Deliberately not requireAdminOrNodeToken, which would have let any node in the fleet read any head's private key (#456).HydraHeadiPadTunnel target. The sources and entitlements had been on disk since May but were referenced nowhere in the Xcode project, so the packet tunnel had never been compiled or shipped. Compiling it for the first time surfaced four latent defects, all fixed.Option C from the original list, in other words, rather than option A. The device installs the tunnel itself; no QR, no second screen, no admin session.
Phase 2 (option B) is tracked separately as #514: the device generating its own keypair so the private key never leaves it or gets stored in nodes.yaml. That needs a HydraGuard provisioning API change and is a separate piece of work.
Also worth carrying forward, all noted in #514: there is still no re-provision path, no supported way to remove a peer (#457), and renaming a head does not rename its HydraGuard peer.