Skip to content

Add support for per-route multihop#105

Open
dlon wants to merge 12 commits intomainfrom
add-split-multihop
Open

Add support for per-route multihop#105
dlon wants to merge 12 commits intomainfrom
add-split-multihop

Conversation

@dlon
Copy link
Copy Markdown
Member

@dlon dlon commented Mar 6, 2026

This adds several adapters for chaining WireGuard devices: we can have an inner device whose encrypted traffic is routed through an outer device, but only for certain destination prefixes. I.e. multihop with "routing".

How it works (simplified)

See the multihop-router example for actual usage.

We essentially need NAT and routing. NAT to ensure that the IP address is translated between the tunnel IP address of the actual TUN device and any "inner" userspace device. Routing to determine whether packets written to the TUN device should first take a detour via the inner device, and same for incoming UDP packets.

Inbound packets

                  Outer WG device
   UDP ────────────decapsulate()────────────► route by source address
  recv()                                       |
                                 ┌─────────────┴──────────────┐
                                 │                            │
                            inner tunnel                  everything
                             endpoint                        else
                                 │                            │
                                 ▼                            ▼
                           Inner WG device               TUN device write()
                            decapsulate()      (app receives stuff on its sockets)
                                 │
                                 ▼
                        DNAT: rewrite dest IP
                          (inner → outer)
                                 │
                                 ▼
                             TUN device write()
                 (app receives stuff on its sockets)
  1. Data comes in from entry hop. It's decapsulated by the outer WG device.
    ("Device" is fully in userspace here, not an actual TUN device.)
  2. If source IP of the packet is the inner WG endpoint, pass it to the inner device.
    Otherwise, pass it to the TUN device.
  3. Inner WG device: Decapsulate again and NAT it (its tunnel device).
    This replaces the tunnel IP used for the inner VPN tunnel with the IP assigned to the TUN
    device.

Outgoing packets

                             TUN device
                               read()
                          (default route)
                                 │
                                 ▼
                     route by destination address
                                 │
                  ┌──────────────┴───────────────┐
                  │                              │
             inner tunnel                    everything
             allowed IPs                        else
                  │                              │
                  ▼                              ▼
           NAT: rewrite src IP            Outer WG device
           (outer → inner)             encapsulate() ──► UDP
                  │                                     send()
                  ▼
            Inner WG device
            encapsulate()
                  │
                  ▼
            Outer WG device
            encapsulate() ──► UDP send()
  1. Packets arrive at our TUN device (route 0.0.0.0/0 to TUN).
  2. If an IP packet's destination is included in our inner tunnel's allowed IPs:
    The tunnel IP is replaced (NAT'd) with that of the inner VPN. It is encapsulated by the
    inner WG device.
  3. The packet is encapsulated with the outer WG device and sent to the first hop.

This change is Reviewable

@dlon dlon marked this pull request as draft March 6, 2026 17:31
@dlon dlon force-pushed the add-split-multihop branch 2 times, most recently from 161c06c to 7d4dc7b Compare March 6, 2026 18:04
@dlon dlon changed the title Add support for route-based multihop Add support for per-route multihop Mar 9, 2026
@dlon dlon force-pushed the add-split-multihop branch from 7d4dc7b to e0e8d2e Compare March 27, 2026 08:28
@dlon dlon force-pushed the add-split-multihop branch from 9689483 to 9737ac8 Compare April 23, 2026 09:53
@dlon dlon requested a review from MarkusPettersson98 April 23, 2026 12:37
@dlon dlon marked this pull request as ready for review April 23, 2026 12:37
@dlon dlon requested a review from hulthe April 28, 2026 12:34
Comment thread gotatun/examples/multihop-router.rs
@dlon dlon force-pushed the add-split-multihop branch from e0ba93b to 7354b6d Compare May 4, 2026 08:58
Comment thread gotatun/src/packet/ip.rs
pub fn payload(&self) -> Option<&[u8]> {
match self.as_v4_or_v6()? {
Either::Left(ipv4) => ipv4.payload(),
Either::Right(ipv6) => Some(&ipv6.payload),
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check if ipv6.payload works when there are extension headers.

Comment thread gotatun/src/tun/merge.rs
/// Create a new `MergingIpRecv` that merges packets from `a` and `b`.
///
/// `pool` is used as the packet buffer pool when receiving from `b`.
pub fn new(a: A, b: B, pool: PacketBufPool) -> Self {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we get rid of the extra pool?

Comment thread gotatun/src/tun/merge.rs
}

fn mtu(&self) -> MtuWatcher {
// TODO
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove TODO or fix this.

Comment thread gotatun/src/tun/router.rs
};

if tx.send(packet).await.is_err() {
// TODO: Should we only stop if all channels have been dropped?
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably fine for now.

Comment thread gotatun/src/tun/router.rs
Ok(packets) => packets,
Err(e) => {
log::error!("TUN router recv error: {e}");
// TODO: Must stop if TUN goes down, but is this always unrecoverable?
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use is_fatal_tun_error here.

Comment thread gotatun/src/tun/nat.rs
.into();
}
IpNextProtocol::Tcp => {
// TCP checksum is at byte offset 16 in the TCP header.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider merging Tcp type from #10

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant