In Part 1 I left my family’s Windows PC with a dead Tailscale exit node and a plan.

TL;DR - Using Ethernet fixed the issue, but I still don’t understand why.

The fix

The most important item on my Part 1 next-steps list was verify if the exit node works over Ethernet — because I’m the one using this exit node, and I was tired of being blocked. The issue is the PC wasn’t close to any router and there wasn’t a non-invasive way to extend an Ethernet cable to it. So, I had my family get a travel router that can connect to the WiFi network and use an Ethernet cable to plug it into the PC. This takes the USB WiFi dongle out of the internet path.

The exit node came back immediately. Traffic routed, connections stopped hanging, I can get back to work.

Great… but why? Ethernet doesn’t eliminate ICS. For my theory to survive, it would have to be something like ‘ICS works over Ethernet but breaks on USB WiFi adapters’ — which already sounds like a theory making excuses. Time to reproduce it in a VM.

Trying to reproduce in a VM

I set up a Windows VM, installed tailscale and Wireshark, verified the exit node worked without issue. Used Wrireshark to capture the exit node packets so I could later confirm whether they show up or got stuck during my experiment. Since my prime suspect was ICS, I tried disabling ICS, rebooting, and checking if the exit node still worked….it did. Wireshark showed the packets.

Ok, this eliminates ICS, so my assumption about how an exit node works on Windows is wrong. Time to read the code and understand how an exit node on Tailscale works.

What an exit node actually does on Windows

Not what I thought. Part 1 said exit nodes require ICS. That was wrong, and it was wrong because I never checked. An exit node doesn’t forward packets in the kernel, and it never touches ICS. When a peer sends internet-bound traffic through the node, Tailscale’s userspace networking stack (netstack, from gVisor) terminates that connection inside tailscaled and opens a brand-new outbound socket to the destination, with an ordinary dialer:

func (ns *Impl) forwardTCP(...) {
//.....
	backend, err := dialFunc(ctx, "tcp", dialAddrStr)
	if err != nil {
		ns.logf("netstack: could not connect to local backend server at %s: %v", dialAddr.String(), err)
		return
	}
	defer backend.Close()
//....
}

From wgengine/netstack/netstack.go:1752-1757

That new socket is an ordinary Windows socket. The packet leaves with the host’s own IP, out whatever interface the routing table picks. There is no NAT step, because the connection was re-originated locally — the re-dial is the NAT. Three things in the repo confirm ICS and kernel forwarding were never involved:

  • Grep for SharedAccess, INetSharing, HNetCfg, ICS: nothing.
  • The only real SNAT/masquerade code in the tree is Linux-only (util/linuxfw). There’s no Windows equivalent because Windows doesn’t need one.
  • I traced the path on Windows and found the kernel-forwarding check is deliberately skipped — because there’s no kernel forwarding to check.

Code Path Trace

  • LocalBackend.CheckIPForwarding() returns early when the node is a netstack router, before it ever runs the real check:
      func (b *LocalBackend) CheckIPForwarding() error {
          // ...
          if b.sys.IsNetstackRouter() {
              return nil
          }
          // only past here does it call netutil.CheckIPForwarding(...)
      }

From ipn/ipnlocal/local.go

  • IsNetstackRouter() is true whenever Tailscale routes subnets/exit-node traffic through netstack instead of the OS (NetstackRouter“using Netstack at all (either entirely or at least for subnets)”). From tsd/tsd.go
  • And that’s the default on Windows. handleSubnetsInNetstack() returns true for windows, with the reason right there in the comment:
      // handleSubnetsInNetstack reports whether netstack should handle subnet routers
      // as opposed to the OS. We do this if the OS doesn't support subnet routers
      // (e.g. Windows) or if the user has explicitly requested it (e.g.
      // --tun=userspace-networking).
      func handleSubnetsInNetstack() bool {
          // ...
          switch runtime.GOOS {
          case "windows", "darwin", "freebsd", "openbsd", "solaris", "illumos":
              return true
          }
          return false
      }

From cmd/tailscaled/tailscaled.go

The chain closes: Windows doesn’t do subnet routing at the OS level → Tailscale marks the node a netstack router → the kernel-forwarding check is skipped → forwarding runs in userspace (forwardTCP above). No kernel forwarding anywhere in the path, so there’s nothing for ICS to enable.

So the mental model is simple: an exit node is a proxy, and a proxy is only as good as its uplink. Whatever NIC carries the host’s internet traffic now carries everyone’s.

Point the evidence at the WiFi path and it lines up:

  • Over the USB dongle: exit node hangs.
  • Over Ethernet: works.
  • In a VM with a virtual NIC (different machine, different network — so this only proves “exit nodes work on Windows generally,” nothing about this house): works.

And one detail that makes it stranger: it wasn’t just the old dongle. The uplink at the time was a 2015 Linksys WUSB600N — decent dual-band hardware for its day, running Microsoft’s in-box Ralink driver, untouched since 2015. When the exit node died, I had the family buy a current TP-Link adapter — new chipset, fresh vendor driver, a decade newer. Same hang. Two adapters with nothing in common except being USB WiFi on this machine, failing identically. Whatever this is, it isn’t “old dongle bad.”

The loose end

What I can’t tell you is the mechanism. My working guess was load: an exit node’s uplink carries every byte twice (tunnel in, re-dial out), and maybe the WiFi path just couldn’t sustain being a household’s gateway. But one observation from my own debugging refuses to fit that neatly: while exit-node traffic hung — even a Google search — my RDP session to the same PC, through the same Tailscale tunnel, over the same dongle, was somewhat stable. A saturated link shouldn’t be that selective. I noted it, couldn’t explain it, and filed it under “things the mechanism has to account for.”

Pinning it down needs measurements on that interface while the failure is happening, and the machine is in a different country. So the conclusion I can defend: it’s not ICS and something in the WiFi uplink path.

Where this leaves things

Ethernet is the fix and it’s stable. Why the WiFi path fails, and why it fails selectively, is the investigation for Part 3.