add: WireGuard legs in the double hop #214

Merged
mysticalsoap merged 2 commits from change/wireguard-doublehop-176 into trunk 2026-08-30 13:41:39 -04:00
Owner

Stage 3 of #176 - the payoff. Either leg of a double hop may now be OpenVPN or WireGuard, in any combination.

How:

  • The hop launch moves out of openvpn() to the work() level, protocol-neutral: an OpenVPN leg still connects on its own thread and is waited for; a WireGuard leg's bring-up is synchronous (no process, no wait). Either way the chain is routed before the main leg launches, through the same hop_routes model from #211.
  • link_up grows the chain's policy knobs: a hop leg takes no default (it only carries the main leg, steered into it by the chain route), a chained main leg gets no endpoint pin (the chain route into the hop device owns its endpoint - a pin would yank the outer packets back onto the physical link), and an MTU cap sizes the inner tunnel to what fits through the outer one: 1340 for WG-over-WG, 1420 (the WG default, no-op) over an OpenVPN hop.
  • The service constructs/tears down a hop-role protocol object next to the main one; a WG hop teardown announces tunnel_terminated_hop itself.
  • TUNNEL_SECONDARY splits into TUNNEL_HOP/TUNNEL_BYPASS - one flag couldn't say hop-yes-bypass-no, which is WireGuard's state now. The gui hop button and bypass picker gate on their own tables; WG bypass remains refused at construction.
  • README: the "does not support WireGuard" sentence dies; OpenVPN over SSL/SSH remains the one exclusion.
  • Incidental fix: a custom-provider hop no longer overwrites the main leg's OpenVPN working directory (the legs' launches used to share locals).

Verification: suite 533 passed - new coverage for the WG-hop flow (pin precedes launch, chain route, no wait, failure aborts before the main leg), chained-main policy (def1 without pin, MTU capping both directions), role/table pinning, and the service's hop-protocol lifecycle.

Live matrix before merge (all four combos, egress + teardown):

  1. OpenVPN → OpenVPN (regression: worked in #211)
  2. WireGuard hop → OpenVPN main
  3. OpenVPN hop → WireGuard main
  4. WireGuard → WireGuard (throughput worth a look - 1340 MTU)

Closes #176.

🤖 Generated with Claude Code

Stage 3 of #176 - the payoff. Either leg of a double hop may now be OpenVPN or WireGuard, in any combination. **How**: - The hop launch moves out of `openvpn()` to the `work()` level, protocol-neutral: an OpenVPN leg still connects on its own thread and is waited for; a WireGuard leg's bring-up is synchronous (no process, no wait). Either way the chain is routed before the main leg launches, through the same `hop_routes` model from #211. - `link_up` grows the chain's policy knobs: a **hop leg takes no default** (it only carries the main leg, steered into it by the chain route), a **chained main leg gets no endpoint pin** (the chain route into the hop device owns its endpoint - a pin would yank the outer packets back onto the physical link), and an **MTU cap** sizes the inner tunnel to what fits through the outer one: 1340 for WG-over-WG, 1420 (the WG default, no-op) over an OpenVPN hop. - The service constructs/tears down a hop-role protocol object next to the main one; a WG hop teardown announces `tunnel_terminated_hop` itself. - `TUNNEL_SECONDARY` splits into `TUNNEL_HOP`/`TUNNEL_BYPASS` - one flag couldn't say hop-yes-bypass-no, which is WireGuard's state now. The gui hop button and bypass picker gate on their own tables; WG bypass remains refused at construction. - README: the "does not support WireGuard" sentence dies; OpenVPN over SSL/SSH remains the one exclusion. - Incidental fix: a custom-provider hop no longer overwrites the main leg's OpenVPN working directory (the legs' launches used to share locals). **Verification**: suite 533 passed - new coverage for the WG-hop flow (pin precedes launch, chain route, no wait, failure aborts before the main leg), chained-main policy (def1 without pin, MTU capping both directions), role/table pinning, and the service's hop-protocol lifecycle. **Live matrix before merge** (all four combos, egress + teardown): 1. OpenVPN → OpenVPN (regression: worked in #211) 2. WireGuard hop → OpenVPN main 3. OpenVPN hop → WireGuard main 4. WireGuard → WireGuard (throughput worth a look - 1340 MTU) Closes #176. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
add: WireGuard legs in the double hop
All checks were successful
ci / test (pull_request) Successful in 25s
03d6d57441
The hop launch moves out of openvpn() to the work() level and becomes
protocol-neutral: an OpenVPN leg still connects on its own thread and
is waited for, a WireGuard leg's bring-up is synchronous - either way
the chain is routed before the main leg launches, through the same
hop_routes model both protocols now share.

link_up grows the policy knobs a chain needs: a hop leg takes no
default (it only carries the main leg), a chained main leg gets no
endpoint pin (the chain route into the hop device owns its endpoint -
a pin would yank the outer packets back onto the physical link), and
an mtu cap sizes the inner tunnel to what fits through the outer one
(1340 for WireGuard over WireGuard).

The service constructs and tears down a hop-role protocol object next
to the main one. TUNNEL_SECONDARY splits into TUNNEL_HOP/TUNNEL_BYPASS
- one flag could not say hop-yes-bypass-no, which is WireGuard's state
now; the gui gates each purpose on its own table.

A custom-provider hop no longer overwrites the main leg's working
directory - the legs' launches no longer share locals.

Closes #176

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix: keep the hop pin through bring-up, gate established on a handshake
All checks were successful
ci / test (pull_request) Successful in 26s
ci / test (push) Successful in 32s
39495d8446
Two faults from the first live matrix, one visible root each:

link_up's pre-clean ran a full link_down, which reads the conf just
written and deletes that endpoint's proto-111 route - for a hop leg
that is the tunnel thread's pre-launch pin (#202). The chain then
worked exactly until the main leg's def1 pair landed, at which point
the hop's outer packets looped into the main tunnel: connected on
screen, black hole underneath. The pre-clean is device-only now.

And a WireGuard bring-up is silent success - the handshake only fires
on first traffic - so wg() reported established over that dead chain,
and on a healthy one the gui monitor's ip check was itself the first
traffic, racing the handshake it depended on (the empty external-IP
field). wg() now nudges traffic and waits for the handshake;
established means the server answered, and a timeout is a failed
attempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

First live matrix round diagnosed from the log - both failures had one root each, fixed in 39495d8:

WG hop (combos 2/4): link_up's pre-clean ran a full link_down, which reads the conf just written and deletes that endpoint's proto-111 route - the tunnel thread's pre-launch pin. The log shows it exactly: run D pinned 149.102.227.1, the main leg connected fine through the chain ("Successfully connected to node-us-216" at 13:09:33), and then the def1 pair landed and looped the hop's outer packets into the main tunnel. Connected on screen, black hole underneath. Pre-clean is device-only now; a test pins the invariant.

Empty external IP (combo 3): a WireGuard bring-up is silent success - the handshake only fires on first traffic - and the gui monitor's ip check was itself the first traffic, racing the handshake it depended on with a 1s timeout. wg() now nudges a packet through and waits for the handshake before reporting established; a timeout is a proper failed attempt. This also un-lies the WG-hop case above, which reported "Successfully connected" over a dead chain.

Suite 537. Ready for matrix round 2 - all four combos again; combo 1 unaffected by either fix.

First live matrix round diagnosed from the log - both failures had one root each, fixed in 39495d8: **WG hop (combos 2/4)**: `link_up`'s pre-clean ran a full `link_down`, which reads the conf just written and deletes that endpoint's proto-111 route - the tunnel thread's pre-launch pin. The log shows it exactly: run D pinned 149.102.227.1, the main leg connected fine through the chain ("Successfully connected to node-us-216" at 13:09:33), and then the def1 pair landed and looped the hop's outer packets into the main tunnel. Connected on screen, black hole underneath. Pre-clean is device-only now; a test pins the invariant. **Empty external IP (combo 3)**: a WireGuard bring-up is silent success - the handshake only fires on first traffic - and the gui monitor's ip check was itself the first traffic, racing the handshake it depended on with a 1s timeout. `wg()` now nudges a packet through and waits for the handshake before reporting established; a timeout is a proper failed attempt. This also un-lies the WG-hop case above, which reported "Successfully connected" over a dead chain. Suite 537. Ready for matrix round 2 - all four combos again; combo 1 unaffected by either fix.
Author
Owner

Matrix round 2 passed live (2026-08-30): all four combos connect and pass traffic, external IP shows, teardown clean. WG→WG's first bring-up is a touch slower by design - two lazy handshakes stack (the hop's only initiates when the main leg's handshake traffic hits it) plus the nudge-and-poll; well inside the 10s gate. Verified done.

Matrix round 2 passed live (2026-08-30): all four combos connect and pass traffic, external IP shows, teardown clean. WG→WG's first bring-up is a touch slower by design - two lazy handshakes stack (the hop's only initiates when the main leg's handshake traffic hits it) plus the nudge-and-poll; well inside the 10s gate. Verified done.
mysticalsoap deleted branch change/wireguard-doublehop-176 2026-08-30 13:41:39 -04:00
Sign in to join this conversation.
No description provided.