Chute Devs

Your Mac, Your Exit Node: Tailscale Serving on macOS

Hello everyone.

Last month we shipped Tailscale inside the engine — the client half. You could join a tailnet, reach peers, use someone else’s exit node. This week Chute macOS grows the other half: your Mac can now serve as an exit node and a subnet router for your tailnet. Peers route their internet traffic through it, or reach your LAN subnets through it, exactly as they would through a Linux box running the official client.

This is a bigger step than it sounds. Being a Tailscale client is mostly about sending packets. Being an exit node is about accepting long-lived forwarding state from devices you do not control, on a process with no goroutine scheduler to bail you out, on an OS that would really rather you slept. It took three rounds of self-review to ship — we documented every defect we found along the way — and the result is the first exit node server that runs without the Go runtime, anywhere.

What Changed in the Config

Two new keys land in the Tailscale config section, macOS only:

1
2
3
[Tailscale]
advertise-exit-node = true
advertise-routes = 192.168.1.0/24, fd7a:115c:a1e0::/48

That is the request, not the grant. Tailscale exit nodes are opt-in by design: advertising only tells the coordination server what you can do, and an admin (or you, in the admin console) still has to approve the route or the exit-node flag before peers may use it. A less obvious rule: we do not forward based on what we asked for, only on what we were given. The engine reads its own approved route set back from the network map — not the values it posted — and forwards only for that approved set. Every approval transition (route approved, route revoked, exit-node flag toggled) is reported live, so you see the state your tailnet actually sees, not the state you wished for.

The keys still parse everywhere — one profile is shared across iOS, macOS, tvOS — but on iOS/tvOS they warn rather than error. Serving is long-lived state pinned by peers you do not control, and the Network Extension’s jetsam budget is not the right host for that.

The Data Plane

This is the part that does not exist in any other non-Go client, so it is worth explaining honestly.

When a peer sends your Mac a packet addressed to the internet, something has to terminate that flow as if it were a local application, then relay it out the real network. On Linux that “something” is the kernel’s TUN device plus the system’s IP forwarding. We have neither — the engine’s TUN already belongs to the local device, and turning on system-wide forwarding just to proxy for a tailnet was never on the table.

The answer is a dedicated forwarding gateway, and it reuses machinery the engine already trusts:

  • TCP arrives through a wildcard listener riding the same virtual net interface the TUN capture uses. When a peer opens example.com:443, the gateway’s listener accepts it locally, and each accepted connection is pumped to an ordinary outbound socket. Flow control, abort handling, and backpressure are exactly the ones the rest of the engine relies on.
  • UDP is a NAT table over connected sockets. Each peer-side mapping gets a socket connected toward the real destination; inbound replies are looked up and written back. No per-datagram allocation, bounded table sizes, and the same shared memory budgets the rest of the engine is accountable to.
  • Refused flows fail fast. A peer that targets something you did not advertise — a tailnet-space address when exit-node is not approved, a route you do not serve — gets answered with a protocol-level reject, not silence. Notably this is the first time the engine has sent one of those control messages: until now we only ever consumed them. The point of the reject is to spare the peer from retransmitting into a black hole.

One peer cannot exhaust the device. Both directions are capped and accounted in the shared budgets, the same discipline the rest of the engine uses to survive on the 50 MB iOS extension budget — now applied to a Mac serving a whole tailnet.

Two Integration Details That Almost Bit Us

The feature sounds modular — “add a gateway” — but two engine-wide invariants made it harder than that:

Forwarding has to run before the engine’s UDP fan-out. The UDP path consumes every inbound packet unconditionally, on the assumption that anything arriving is aimed at a local endpoint. A forwarded flow violates that assumption. So the gateway’s hook had to be inserted upstream of the fan-out, where it can claim packets addressed outside the box before the tunnel layer swallows them.

Forwarded flows leave through sockets that explicitly pin the physical egress. Without that pin, an outbound forwarded packet is recaptured by our own TUN — the packet comes out of the gateway, hits the virtual interface, and loops back into the very tunnel it just left. The sockets the gateway uses already carry the “use the real interface” marker; reusing them was not just expedient, it was the only correct choice.

Finding the Bugs Ourselves

This is the part we are proudest of, and the reason we are writing a whole post instead of a bullet point.

An exit-node gateway is the kind of code that seems fine until it is under load. So we wrote a TCP close-torture harness — an ACK-clocked pacer that drives the gateway through thousands of half-close cycles, because ACK-clocking is how you expose race windows that a naive loop never hits. Then we reviewed our own code three separate times, and the harness plus the reviews caught defects a checklist would not have:

  • A double gateway flush could resend the same bytes. If a path update triggered a re-flush of pending outbound packets, the second pass had to know what the first had already sent.
  • The gateway had to outlive an advertising toggle. Turning advertise-exit-node off mid-flight should not orphan peers with live flows; the gateway stays up until its flows finish, and a stopped gateway stays inert — it refuses new flows and does not reopen.
  • Half-closes must drain before FIN. A peer closing its write side early had to wait for the response bytes already in flight; cutting FIN first would have truncated the reply.
  • A stopped gateway stayed stopped. The teardown had to finish, and the next start had to see a clean slate, including the forwarding window.

Each round ended with a committed defect doc. That is the culture we have been building across the engine: you do not fix and forget, you fix and write down what you found, so the next reviewer — often us, three days later — inherits the full context.

Deliberately Out of Scope

Two conscious exclusions:

  • We do not forward ICMP. No ping through the gateway, no traceroute. The forwarding path carries IP protocols we already understand, and we did not want to add a second, less-audited path for a diagnostic protocol. If a peer pings your exit node, the honest answer today is the protocol-level reject.
  • The Mac stays awake while it serves, but that is your call. Serving as an exit node holds a power assertion so the machine does not doze off mid-flow and strand a peer. If you would rather your Mac sleep, don’t enable serving — the client side is unchanged.

What This Means

If you already run a Linux box as your tailnet’s exit node, nothing forces you to switch. But if the machine you have in hand is a Mac, it is now a first-class citizen: check one box, approve the route in your tailnet’s admin console, and your phone / laptop / travel router can use it as the way out — no Go runtime, no second daemon, no extra process.

Exit-node serving and subnet routing are rolling out now on Chute macOS. The Chute Manual has the full set of Tailscale options, including the approval flow and how approval state surfaces in the dashboard.

Thanks.

Chute Devs