Files
tsunagi/docs/exit-node.md
T
Aleksandr Bogomiakov 2631eb6975 Protect Linux exit-node connections and tidy the device table
Using an exit node could destroy direct peer connections: the agent's UID
bypass protected outbound routing, but reverse-path checks look up replies
without that UID and selected the overlay default. A real-host A/B check
confirmed that relaxing only strict reverse-path filtering restored direct
connections and exit HTTP traffic; the diagnostic host changes were removed.

Keep strict filtering and protect the Linux underlay with a dedicated masked
mark bit, 0x40000000. Mark the dedicated service UID in OUTPUT, save only that
bit in conntrack and restore it before PREROUTING reverse-path checks. Install
IPv4/IPv6 mark and UID main-table bypass rules before Tailscale's priorities;
leave application-specific and exit-default routing after them. Preserve
foreign mark bits, main-table routes and foreign firewall/policy rules.

Protect the first inbound TCP/UDP packet using bounded, locally owned socket
addresses/ports from /proc and an actual socket match. Refresh socket protection
before sending private transport negotiations and on discovery ticks, without
firewall writes for an unchanged socket set. Remote announcements never become
OS arguments. A failed refresh leaves active exit routing in place. Install
protection before routes, remove routes before protection, roll back partial
activation, and remove only exact interface tags, including legacy UID rules.

Reject strict kernel rp_filter without src_valid_mark with an actionable error;
never weaken filtering or change host sysctls. Document mark-aware Netfilter
requirements and provide the trusted ip6tables path in the NixOS module.
Other platforms retain their existing exit implementation. Peer wire formats,
identity derivation, local IPC and persistent storage are unchanged.

Make the tray devices grid fill and shrink with the window, constrain the first
cell and give remaining width to traffic plots. Keep displayed end-to-end
protocol and its short encryption tooltip consistent, independent of carrier
protocol. Move handshake/session age into its own column beside the existing
iroh control RTT. Use stable per-device hover identities and concise tooltips;
missing protocols/measurements remain explicit rather than invented.

Validation on Linux through nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets: 529 passed, 3 ignored;
  inherited TSUNAGI_CONTROL_SOCKET and TSUNAGI_CONTROL_GROUP were unset
- explicit privileged kernel regression: 1 passed, using disposable namespaces,
  real TCP/UDP IPv4/IPv6, strict kernel and Netfilter RPF, first inbound packets,
  dynamic UDP binding, application exit routing/IPv6 blocking, repeated apply
  and exact cleanup with foreign Tailscale-priority rules retained
- headless egui grid/scroll-area rendering at multiple sizes and control RTT,
  mark masks/order, passive socket parsing, partial failure and cleanup tests
- git diff --check

Earlier iterations caught rtnetlink family API and nonexhaustive initializer
errors, Clippy warnings, egui's implicit minimum column width, and kernel-test
fixture issues (wildcard echo source, blocked IPv6 neighbour discovery and a
closed child stdout pipe). Corrected before final checks; the stdout-only test
fix was checked with fmt, Clippy and the explicit kernel regression afterward.
Native tray rendering and macOS/Windows checks were not run. The running host
agent was not replaced or restarted. Requested release tag: v0.1.0-rc.32.
2026-10-14 10:40:30 +01:00

7.9 KiB

Exit nodes

A member can offer itself as an exit node in a network. Other members can then send all their internet traffic through it, the way a commercial VPN or Tailscale's exit nodes work. Linux only for now.

Using it

The offering side, per network, off by default:

tsng network exit-node <network> on      # or off

Rules are installed even when the kernel does not forward yet, and status says so. Turning forwarding on is left to you, on purpose:

sudo sysctl -w net.ipv4.ip_forward=1

The using side:

tsng exit-node                  # who offers one
tsng exit-node <name|id>        # send everything through it
tsng exit-node off

The tray does the same: a checkbox on the network tile to offer, and an icon on each device in the devices window that offers one, with a confirmation.

One device has one default route, so it uses one exit node at a time; choosing another replaces the first. Offering is per network, so a device can be an exit node in one network and not in another.

How it works

  • An offering agent says so in its control-plane Announce (exit_node), and only once its rules are in place. Members see which peers offer one.
  • Server: iptables masquerades the overlay range of that network out of any other interface and allows forwarding for it. Rules carry the tag tsunagi-exit:<interface>:<range> and are removed when the offer ends, the network stops, and when the agent exits. Only networks that offer have rules, so the others' traffic is not forwarded.
  • Client: policy routing, with no change to the main table. Table 29811 holds default dev <interface>. Rules 5205 fwmark 0x40000000/0x40000000 lookup main and 5206 uidrange <agent uid> lookup main keep the agent's sockets and their replies outside the tunnel, before Tailscale's rules. 5290 lookup main suppress_prefixlength 0 preserves LAN and other specific routes, and 5300 lookup 29811 directs other traffic through the exit. The exit table is 29811 (0x7473).
  • Linux reverse-path filtering: only the agent's dedicated UID is marked in mangle OUTPUT. Its own bit, 0x40000000, is saved to conntrack and restored in PREROUTING before the host firewall checks reply routing. Other mark bits, including Tailscale's 0xff0000 mask, are preserved. The rules are tagged tsunagi-exit-underlay:<interface> and never accept packets around the host firewall. IPv4 and IPv6 protection is installed before exit routes; routes are removed before protection, including rollback on failure. Locally owned TCP listeners and bound UDP sockets also protect the first incoming packet, before any outgoing packet has created a connection mark. Socket addresses and ports come from local kernel socket tables and match an actual socket; remote announcements never supply these firewall arguments. The socket set is refreshed before private pair negotiation is sent and on discovery ticks, so fresh AWG ports are included. An unchanged set causes no firewall writes; a socket-refresh failure leaves active exit routes in place. Linux needs both iptables and ip6tables when IPv6 is present.
  • Kernel strict rp_filter: if an interface uses strict mode (1), the kernel must include marks in reverse lookups: set net.ipv4.conf.all.src_valid_mark=1, or enable it on those interfaces. The agent checks this before installing exit routes and reports an actionable error if marks would be ignored. It never disables rp_filter or changes host sysctls. Netfilter strict reverse checks also need their mark-aware form, such as rpfilter --validmark, which NixOS already uses.
  • Packets for addresses outside every overlay range are sent to the exit node by the router; the exit node's replies are accepted from any source. A member that is not your exit node cannot do that.
  • IPv6 is blocked while you use one. There is no IPv6 through an exit node, and letting it go the ordinary way would leak it. The same three rules exist for IPv6 with an unreachable default in the table, so applications get an immediate "network unreachable" and fall back to IPv4. LAN, ULA and other specific IPv6 routes keep working, and so does the agent's own IPv6. A host with no IPv6 has nothing to block and is left alone.

macOS

macOS has no iptables and no per-user policy routing, so the same behaviour is built from pf and route. The agent runs as root there.

  • Offering: a pf anchor com.apple/tsunagi-exit-<interface> (the stock /etc/pf.conf already evaluates com.apple/*) holds nat on <egress> from <range> to any -> (<egress>) and a pass in rule. pf is enabled if it was off, and that reference is released when the rules go. Forwarding is only read: sudo sysctl -w net.inet.ip.forwarding=1, which macOS forgets at reboot. The egress is the default route's interface at the time the rules are applied; after it changes (Wi-Fi to Ethernet) switch the offer off and on.
  • Using: 0.0.0.0/1 and 128.0.0.0/1 routes through the overlay interface, and a pf route-to rule that sends the agent's own traffic (matched by its user id, root) back out the physical interface, with translation to that interface's address. Anything else you run as root also bypasses the tunnel. IPv6 is blocked with rejecting ::/1 and 8000::/1 routes.
  • Not verified on a live host when this was written: run tsng exit-node and check sudo pfctl -a com.apple/tsunagi-exit-utunN -s rules -s nat.

Windows

  • Offering: each offered range gets a WinNAT object tsunagi-exit:<interface>:<range> (New-NetNat), removed with the offer, the network and the agent. Forwarding is read for the overlay interface and never changed; Windows forwards per interface, so enable it on the ones involved (Get-NetIPInterface | Set-NetIPInterface -Forwarding Enabled, administrator PowerShell). Some Windows versions allow one NAT per host; a second range then reports its own failure.
  • Using: Windows has no per-user routing, so the agent's own traffic is kept off the tunnel by destination. 0.0.0.0/1 and 128.0.0.0/1 go through the overlay interface; a /32 route with metric 7373 (the marker for "ours") through the physical default gateway is added for every address the agent talks to directly: verified direct paths of its connections, plus the relays it uses, resolved when the rules are applied. The set follows path changes. A path that appears after the last apply goes through the tunnel until the next one. IPv6 is blackholed with ::/1 and 8000::/1 on the overlay interface, so applications fall back to IPv4 after a delay rather than at once. Everything is in the active store: a reboot clears it.
  • Not verified on a live host when this was written. To inspect: Get-NetRoute -PolicyStore ActiveStore | ? { $_.RouteMetric -eq 7373 -or $_.InterfaceAlias -like 'tsun*' }.

What it does not do

  • The agent must run as a system service (its own user). The uid exemption would otherwise cover everything you run. A per-user agent refuses to use an exit node and says why.
  • If the exit node disappears the rules stay and traffic is dropped until it returns or you run tsng exit-node off. This is deliberate: falling back to the direct path would leak traffic nobody agreed to send in the clear. The agent logs it once, status and the tray say so loudly.
  • IPv6-only destinations are unreachable meanwhile; carrying IPv6 through the exit node is not implemented. DNS is untouched.
  • Kernel forwarding is only read, never changed.

Trust

Anyone who knows the network's secret can pick your exit node if you offer one, and you carry what they send: it leaves from your address and is yours to answer for. The exit node sees every destination and, for plain traffic, its contents. Choosing an exit node means trusting its owner completely. Offer one only in networks whose members you trust, and only from a machine you are willing to have that traffic come from.