HydraIssues

Explore self-service WireGuard enrollment for iPad heads
done feature Project: hydracluster Reporter: cederik 6 Aug 2026 14:59

Description

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.

Current flow

  1. iPad scans the fleet enrollment QR on /enroll and self-registers via POST /api/v1/heads using the fleet enrollment token.
  2. handleFleetEnrollHead calls provisionHeadIPadWireGuard(node.Name) (handlers_head.go:800), hydraguard issues a keypair, and the full config is stored on the node as WireGuardConfig.
  3. The config is readable only by an admin: GET /api/v1/heads/{id}/wireguard-config (RequireAuth, bearer) or GET /admin/heads/{id}/wireguard-qr.png (RequireWebAuth, cookie, added in v2.0.101).
  4. An operator opens the head in hydracluster admin, expands the QR, and the iPad scans it off that 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.

What to explore

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.

Security constraint that shapes the design

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.

Related failure this would have avoided

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:

  • A re-provision path. Today provisionHeadIPadWireGuard is called only from the enrollment handler, so a head that fails provisioning can never recover without being deleted and re-enrolled.
  • Surfacing provisioning state on the head, so "no tunnel" is visible in admin rather than silent.
  • Device-specific names at enrollment. The iPad app sends no name, so handleFleetEnrollHead defaults every device to "ipad-head" (handlers_head.go:753), which is what caused the peer collision.
  • Note that renaming a head after enrollment does not rename its hydraguard peer, since the peer is keyed by the name held at provisioning time. A re-provision under a new name would orphan the old peer.

Open questions

  • Should the tunnel be established before the head is usable, or is it optional for iPads on a venue LAN? This determines whether provisioning failure should stay non-fatal.
  • Does the iPad need the tunnel at all when reaching the mesh via the mobilekit hydraneck, or only for remote/off-venue use?
  • If the device generates its own keypair (B), what re-keys a lost or replaced device, and what reaps the orphaned peer?

The client side is already built and dormant

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:

  1. Nothing calls fetchWireGuardConfig. Grep finds the definition and no call site.
  2. Nothing calls WireGuardManager.configure / start. The two halves have never been connected to each other.
  3. No UI. No "Set up WireGuard" affordance in any view under Sources/HydraHeadiPad/Views/.
  4. The server rejects it. 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.

Concrete plan

Server, hydracluster:

  • Move /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.
  • Consider also returning 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.
  • Add a re-provision path. Today 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:

  • Wire it: fetchWireGuardConfig -> WireGuardManager.configure(with:) -> start().
  • Add the "Set up WireGuard" button. iOS shows its one-time VPN profile approval prompt on first saveToPreferences; after that the tunnel is installed and togglable.
  • Distinguish the failure modes in the UI: 401 means the server has not shipped self-scoped auth, 404 means this head has no provisioned config (see the recovery runbook), network error means retry.
  • Skip the fetch when the App Group already holds a config, so a relaunch does not refetch a private key needlessly.

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.

Phase 2

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:

  • hydracluster v2.0.103 added 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).
  • hydraheadipad v0.2.152 added the 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.
  • The duplicate-NETunnelProviderManager bug is fixed: repeated taps no longer leave duplicate "Hydra" VPN profiles in iOS Settings.

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.