Skip to content

lib: add /connect-ipip endpoint for IPIP tunnel peers#12

Open
eltonkl wants to merge 12 commits into
mainfrom
codesmith/connect-ipip-endpoint
Open

lib: add /connect-ipip endpoint for IPIP tunnel peers#12
eltonkl wants to merge 12 commits into
mainfrom
codesmith/connect-ipip-endpoint

Conversation

@eltonkl
Copy link
Copy Markdown

@eltonkl eltonkl commented May 15, 2026

Adds the server-side companion to /connect that lets vprox terminate IPIP tunnels in addition to WireGuard. Clients that can't run WireGuard (e.g. the macOS static-IP path going through pf) can now POST /connect-ipip with the same Bearer <password> header, get back an inner IP from the same WgCidr pool, and immediately have a kernel IPIP tunnel pointing at their HTTPS source address.

How it works:

  • connectIpipHandler authenticates with the existing password, parses the HTTPS source address, and allocates an inner IP via the shared srv.ipAllocator so WG and IPIP peers can't collide. Repeated calls from the same client IP are idempotent (the existing tunnel is reused and lastSeen is refreshed); a race between two parallel requests cleans up the loser instead of leaking interfaces or IPs. If the kernel interface vanished out-of-band, the stale entry is dropped and the request falls through to fresh allocation; only a genuine netlink.LinkNotFoundError counts as vanished so transient lookup failures don't tear down working tunnels.
  • createIpipLink creates a vp<srv.Index>-<offset> netlink.Iptun with local=srv.BindAddr, remote=<client_ip>, brings it up, and installs a <peerIP>/32 dev <ifname> host route. The suffix is the peer's offset from srv.WgCidr.Addr() (length-checked against IFNAMSIZ) so it stays unique across any allowed CIDR width. I intentionally don't put srv.WgCidr.Addr()/16 on the interface (as in the sketch) because that address is already on the WireGuard interface; the /32 route does the equivalent job for return traffic without colliding.
  • StartIptables adds wildcard vp<srv.Index>-+ TCP MSS clamping (inbound and outbound). The FORWARD ACCEPT is installed per peer by addIpipPeerFilter at /connect-ipip time, paired with a DROP so each tunnel only forwards transit traffic whose inner source IP matches the peer it was assigned to. The existing MASQUERADE -o BindIface rule already handles outbound NAT.
  • The control plane is gated by a requireExternalRemote middleware that wraps /connect and /connect-ipip: it rejects requests whose r.RemoteAddr is inside srv.WgCidr, i.e. requests that arrived from inside a VPN tunnel. Both handlers treat RemoteAddr as identity-relevant input (peer-map key for IPIP, log for WG), so this enforces "RemoteAddr is the client's real outer address" once at the seam instead of each handler having to recheck. An earlier raw/PREROUTING host guard was tried and dropped: the threat it was meant to stop (peer-map poisoning via a forged inner source on /connect-ipip) is not practically exploitable because the SYN-ACK for the TLS handshake routes to the forged address, not back to the attacker, and the guard broke legitimate inner-to-host ICMP including the mac agent's ping 10.100.0.1 health check.
  • A removeIdleIpipPeersLoop polls each tunnel's rx_bytes/tx_bytes via netlink, refreshes lastSeen on activity, and prunes peers idle longer than PeerIdleTimeout, freeing both the interface and the allocator slot. CleanupIpip tears down every remaining tunnel on shutdown.

Response shape matches the request: {\"AssignedAddr\": \"10.100.0.X/<bits>\"}. Until this lands, the macOS branch's agent will get a clean 404 on /connect-ipip rather than waste a WireGuard peer slot.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag @codesmith with what you need. Autofix is disabled. (Staging)

Adds a companion to /connect that returns an inner IP from the same
WgCidr pool but provisions a Linux IPIP tunnel keyed on the HTTPS
source address instead of a WireGuard peer. Each request creates
vp<srv.Index>-<host> with local=srv.BindAddr, remote=<client_ip>,
and installs a /32 host route so decapsulated return traffic exits
the right tunnel. Wildcard FORWARD/TCP-MSS rules cover every IPIP
interface this server creates. Idle peers (no observed rx for
PeerIdleTimeout) are pruned in a background loop and their IPs
returned to the shared allocator.

Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

View 6 additional findings in Devin Review.

Open in Devin Review

Comment thread lib/ipip.go
eltonkl and others added 2 commits May 15, 2026 16:58
netlink.LinkAdd does not populate Index on the passed Iptun struct,
so the host route was being installed with LinkIndex 0 and never
bound to the tunnel. Look up the link by name after creation and
use the resolved attrs for both LinkSetUp and RouteReplace.

Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>
Defense in depth against inner-source spoofing on the ipip data
plane. Once a tunnel is registered, anyone who can deliver an ipip
packet to us with the registered client's outer source IP can
inject inner traffic. Two cheap server-side mitigations:

- Set rp_filter=1 on each ipip iface as it's created. With the
  per-peer /32 route we install, the kernel drops decapsulated
  packets whose inner source IP wouldn't route back through the
  same iface they arrived on.

- Replace the broad 'FORWARD ACCEPT for vp<idx>-+' wildcard with
  per-peer iptables rules: ACCEPT -i <ifname> -s <peerIP>/32, then
  DROP -i <ifname>. Each tunnel only forwards traffic with the
  inner source we assigned to it.

Wildcard TCP MSS clamping stays as-is; it doesn't depend on the
inner source. tearDownIpipLink now takes peerIP so it can clean up
the per-peer rules before deleting the interface.

Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>
Comment thread lib/ipip.go Outdated
eltonkl and others added 2 commits May 15, 2026 17:10
When iptablesIpipMssRules ran with enabled=false, a failure to
delete the outbound rule returned immediately and skipped the
inbound delete, leaking that rule. Split the add and cleanup paths:
add still fails fast, cleanup attempts both deletes independently
and logs errors, matching the WG MSS cleanup pattern in
CleanupIptables.

Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>
- connectIpipHandler: refresh lastSeen on the winner in the race-loser
  path, matching the idempotent path
- createIpipLink: correct the rp_filter comment — max(all, iface) means
  setting the per-iface value to 1 degrades to loose mode when
  conf.all.rp_filter is 2; the per-peer iptables filter is authoritative
- removeIdleIpipPeers: count tx_bytes as well as rx_bytes so a peer in a
  one-way transfer isn't pruned mid-stream
- connectIpipHandler: note that a stale idempotent assignment is healed
  by removeIdleIpipPeers within one poll interval
- add ipip_test.go covering ifname/wildcard/iptables-rule helpers,
  including the IFNAMSIZ bound

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread lib/ipip.go
eltonkl and others added 2 commits May 15, 2026 21:56
If an ipip interface is destroyed out-of-band, the peer entry
becomes unusable but two paths kept it alive forever:

- The idempotent path in connectIpipHandler refreshed lastSeen on
  every retry without checking whether the interface still existed,
  handing the client back a broken assignment indefinitely.
- removeIdleIpipPeers added vanished entries to toRemove but still
  applied the lastSeen <= PeerIdleTimeout guard in the final loop,
  so a freshly-refreshed (broken) peer never aged out.

connectIpipHandler now verifies the interface with LinkByName
before reusing an existing entry; if it's gone, the stale entry
is dropped (iptables filter removed, IP returned to the
allocator) and the request falls through to fresh allocation.
removeIdleIpipPeers tracks a 'vanished' flag per snapshot and
skips the freshness guard for vanished entries so they reap
unconditionally.

Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>
- drop the per-interface rp_filter layer. On these hosts
  conf.all.rp_filter is forced to 2 (loose) fleet-wide for the
  WireGuard path, so max(all, iface) can never reach strict mode and
  the per-interface setting was a no-op masquerading as a control.
- add iptablesIpipHostGuardRule: a wildcard raw/PREROUTING DROP for
  IPIP traffic destined to a host-local address. The per-peer FORWARD
  filter only covers transit traffic, leaving packets that terminate
  on the host (notably the vprox HTTPS control plane) reachable with a
  forged inner source. raw/PREROUTING runs before conntrack and ufw's
  filter chains, so ufw's port accepts can't shadow it.
- drop the always-nil error return from removeIdleIpipPeers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread lib/ipip.go
eltonkl and others added 2 commits May 18, 2026 21:05
The old encoding used only the low 16 bits of peerIP for the suffix.
For WgCidr widths wider than /16 (allowed: wgBlockPerIp can equal
wgBlock.Bits(), which has no explicit lower bound), two distinct
peers could produce the same ifname (e.g. 10.0.0.1 and 10.1.0.1 in
a /8 both become vp0-1). createIpipLink does a best-effort LinkDel
of any existing same-named interface before LinkAdd, so a collision
would silently destroy a live peer's tunnel.

Use the offset from srv.WgCidr.Addr() as the suffix instead, which
is unique within any CIDR by construction, and length-check the
result against IFNAMSIZ so a wildly oversized CIDR fails the
/connect-ipip request loudly rather than producing a colliding or
truncated name. Tests updated to set WgCidr on the test Server and
cover both the wide-CIDR regression and the IFNAMSIZ rejection.

Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>
connectIpipHandler's idempotent path held ipipMu across a
netlink.LinkByName syscall and treated any lookup error as
"interface vanished". Read the entry under ipipMu, release it,
probe the interface lock-free, then re-acquire and re-check the
entry's identity before mutating. Only netlink.LinkNotFoundError
now counts as vanished; other errors log and reuse the tunnel.

Also validate in InitState that WgCidr isn't wide enough for a
peer's IPIP interface name to exceed IFNAMSIZ, so a misconfigured
server fails fast instead of 500-ing every /connect-ipip request.
The test helper now builds WgCidr the way server_manager.go does
(network base + 1), so test ifnames match a real server's.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread lib/ipip.go
eltonkl and others added 2 commits May 18, 2026 21:39
removeIdleIpipPeers treated any LinkByName error as vanished and
set vanished=true on the removal entry, which then bypasses the
lastSeen freshness guard. A transient netlink failure (kernel
resource pressure, brief glitch) would unconditionally tear down
an actively-used tunnel.

Mirror the discrimination already used in connectIpipHandler:
only a genuine netlink.LinkNotFoundError counts as vanished. Any
other error is logged and the peer is left in place for the next
poll to retry.

Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>
The "Protects the fields below" comment on Server.mu predated
ipipMu/ipipPeers being added below it. mu only guards newPeers;
ipipPeers has its own ipipMu. Scope the comment to newPeers so it
no longer over-claims.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The raw/PREROUTING host guard was framed as anti-spoofing for
host-local traffic from IPIP peers, but on closer inspection the
threat it was meant to stop (peer-map poisoning via forged
RemoteAddr in connectIpipHandler) is not practically exploitable:
the SYN-ACK for a TCP handshake with a forged inner source routes
to the forged address, not back to the attacker, so the TLS+HTTP
exchange never completes. Meanwhile the guard breaks legitimate
inner-to-host ICMP -- including the mac agent's `ping 10.100.0.1`
health check.

Drop the guard and instead encode the actual design invariant at
the right layer: a requireExternalRemote middleware rejects
control-plane requests whose r.RemoteAddr is inside srv.WgCidr,
i.e. requests that arrived from inside a VPN tunnel. Both
/connect and /connect-ipip key on RemoteAddr (for peer-map and
log respectively), so the assumption "RemoteAddr is the client's
real outer address" is now enforced once at the seam rather than
each handler having to recheck. New control-plane endpoints
inherit the protection by wrapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit fdd882f. Configure here.

Comment thread lib/server.go
@eltonkl eltonkl requested a review from bruce-y May 21, 2026 20:19
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