A stock Fedora Workstation laptop on coffee shop Wi-Fi accepts inbound TCP and UDP on every port from 1025 to 65535, plus SSH. That's the default FedoraWorkstation zone, and every new Wi-Fi profile lands in it unless you tell NetworkManager otherwise. Tailscale shows "Connected" in the tray the whole time, which makes it easy to assume you're covered. Unless you've set an exit node, though, your DNS queries and web traffic go straight out the local access point like everyone else's.
This post is for anyone who runs Tailscale on a Linux laptop and carries it onto networks they don't control: hotels, airports, conference Wi-Fi. The idea is simple. No Wi-Fi network is trusted. The Wi-Fi is a dumb pipe, the tailnet is the security boundary, and a NetworkManager dispatcher script enforces that on every connect so you don't have to remember to.
The default state is worse than you think
Here's what an untouched Fedora Workstation looks like after joining a new network. The interface name and SSID are examples:
$ nmcli -g connection.zone connection show "CoffeeShop"
# empty: falls back to the default zone
$ firewall-cmd --get-zone-of-interface=wlp0s20f3
FedoraWorkstation
$ firewall-cmd --zone=FedoraWorkstation --list-all
FedoraWorkstation (active)
target: default
interfaces: wlp0s20f3
services: dhcpv6-client samba-client ssh
ports: 1025-65535/udp 1025-65535/tcp
An empty connection.zone means "use whatever the default zone is." On Workstation, the default zone was designed so local dev servers and casual LAN sharing just work. That's a reasonable desktop default at home. On a network shared with 200 strangers it means any dev server you left running on 0.0.0.0:8000, any Jupyter kernel and any debug port is reachable by everyone on the same access point.
Tailscale doesn't change any of this. It adds a tailscale0 interface and routes 100.64.0.0/10 (plus whatever subnet routes you accept) through it. Everything else, including your DNS lookups unless MagicDNS is answering them, still exits via wlp0s20f3. If you've read my post on Tailscale subnet routers, this is the other half of that story: the subnet router gets you into your LAN, but it does nothing for traffic that isn't headed there.
What I tried first
Per-SSID allowlists
The first instinct is to classify networks: home is trusted, everything else gets the strict zone. You set connection.zone on each profile by hand.
This falls apart for two reasons. The first is that the set of "everything else" is unbounded. Every new hotel, every airport lounge, every "Guest-5G" creates a new profile, and each new profile inherits the permissive default zone. One forgotten profile and you're wide open. That's configuration drift with a nasty failure mode, because nothing tells you it happened.
The second is that it's backwards. An allowlist of safe networks means the default for unknown networks is unsafe. You want the inverse: everything is hostile, and the exceptions are explicit and small.
Runtime-only fixes
The other trap is fixing things live and calling it done:
sudo firewall-cmd --zone=public --change-interface=wlp0s20f3
sudo tailscale set --exit-node=exit-a
Both of these work immediately. The firewall change is runtime-only, so it evaporates on the next reconnect when NetworkManager reapplies the profile's zone (empty, so default). The exit node setting does persist in Tailscale's prefs, which sounds good until you get home and wonder why your LAN printer vanished and every request is hairpinning through a remote node. You end up toggling it by hand, and then forgetting to toggle it back on the next trip.
Tailscale's built-in auto exit node
Recent Tailscale releases have tailscale exit-node suggest and --exit-node=auto:any. These pick an exit node for you, optimized for latency. What they don't do is know anything about the network you're on. There's no "use an exit node only on untrusted Wi-Fi" setting, and as far as I can tell SSID-aware routing still lives in long-running feature requests on GitHub rather than in the client. So the policy decision has to happen locally, and NetworkManager is the thing that knows when you joined a network.
Trusting the CGNAT range
For the "allow SSH from my tailnet" half, a common suggestion is:
# Don't do this
sudo firewall-cmd --permanent --zone=trusted --add-source=100.64.0.0/10
Firewalld source bindings match on source address regardless of which interface the packet arrived on, and source zones take precedence over interface zones. A host on the same Wi-Fi can put 100.64.1.1 in the source field of a packet and send it to your wlp0s20f3 address. The return path for a TCP handshake would go out tailscale0, which limits what an attacker gets, but UDP services and anything stateless are fair game, and "the routing table will probably save me" isn't a security model.
Tailscale's default netfilter mode on Linux does install a rule dropping 100.64.0.0/10 sources that don't arrive on tailscale0. That's good defense in depth. I still don't want my firewall policy to be correct only because another daemon happened to insert a rule I didn't write. Bind to the interface instead.
The actual solution
There are three pieces: two firewalld zones, a dispatcher script, and a tiny opt-out list.
Step 1: A zone for hostile Wi-Fi
Fedora's built-in public zone is the usual recommendation, but on Fedora it still allows ssh and mdns. I'd rather not expose sshd on an airport network, so I build a dedicated zone with almost nothing in it:
sudo firewall-cmd --permanent --new-zone=hostile-wifi
sudo firewall-cmd --permanent --zone=hostile-wifi --add-service=dhcpv6-client
sudo firewall-cmd --reload
sudo firewall-cmd --zone=hostile-wifi --list-all
The zone target stays at default, which rejects unsolicited inbound traffic. Established and related return traffic is accepted by firewalld's base rules, so outbound browsing works normally. DHCPv4 doesn't need a service entry because NetworkManager's DHCP client uses a raw socket. If you'd rather drop silently than reject, --set-target=DROP works, though I'd test IPv6 on a few networks before committing to it.
Step 2: A zone for the tailnet, bound to the interface
sudo firewall-cmd --permanent --new-zone=tailnet
sudo firewall-cmd --permanent --zone=tailnet --add-interface=tailscale0
sudo firewall-cmd --permanent --zone=tailnet --add-service=ssh
sudo firewall-cmd --reload
NetworkManager doesn't manage tailscale0, so a permanent firewalld interface binding sticks here (unlike Wi-Fi interfaces, where NM owns the zone assignment). Now SSH is reachable only on packets that actually arrived through the WireGuard tunnel, which means they were authenticated by a tailnet peer key. That's the whole point: the trust decision is cryptographic, not based on an address someone can type into a packet. Pair it with Tailscale ACLs to decide which tailnet peers can reach port 22, the same least-privilege thinking I use for Kubernetes service accounts.
Step 3: The dispatcher script
NetworkManager runs every executable in /etc/NetworkManager/dispatcher.d/ on network events, in alphabetical order, passing the interface as $1 and the action as $2. I care about two actions: up (a connection activated) and connectivity-change (NM's connectivity check moved between states like portal and full).
The script is split into three parts here so each stays readable. First, the setup and filtering:
#!/usr/bin/env bash
# /etc/NetworkManager/dispatcher.d/50-wifi-safe
# Every Wi-Fi profile is hostile unless its UUID is explicitly listed.
set -u
ACTION="${2:-}"
EXIT_NODE="exit-a" # MagicDNS name or 100.x IP of your exit node
HOSTILE_ZONE="hostile-wifi"
TRUSTED_LIST="/etc/wifi-safe/trusted-uuids" # one connection UUID per line
log() { logger -t wifi-safe -- "$*"; }
case "$ACTION" in
up|connectivity-change) ;;
*) exit 0 ;;
esac
# connectivity-change isn't tied to an interface, so find the active Wi-Fi profile ourselves
read -r UUID DEV < <(nmcli -t -f TYPE,UUID,DEVICE connection show --active \
| awk -F: '$1=="802-11-wireless" {print $2, $3; exit}')
[ -z "${UUID:-}" ] && exit 0
I deliberately don't use set -e. A dispatcher script that dies halfway through on a non-zero grep leaves the machine in a half-hardened state with no log line explaining why. I'd rather check each step and log it.
Second, the trust check and zone pinning:
if [ -f "$TRUSTED_LIST" ] && grep -qxF "$UUID" "$TRUSTED_LIST"; then
log "trusted profile $UUID on $DEV, clearing exit node"
tailscale set --exit-node= || log "failed to clear exit node"
exit 0
fi
# Persist the zone on the profile so reconnects and reboots keep it...
if [ "$(nmcli -g connection.zone connection show "$UUID")" != "$HOSTILE_ZONE" ]; then
nmcli connection modify "$UUID" connection.zone "$HOSTILE_ZONE" \
&& log "pinned $UUID to zone $HOSTILE_ZONE"
fi
# ...and apply it now, because 'modify' doesn't touch the live device
firewall-cmd --zone="$HOSTILE_ZONE" --change-interface="$DEV" >/dev/null \
|| log "firewall-cmd failed for $DEV, interface may be in the default zone"
The two-step zone handling is the runtime-vs-permanent fix from earlier. nmcli connection modify writes the zone into the profile, so the next activation of that network comes up hostile before the dispatcher even runs. firewall-cmd --change-interface covers the current session. On the first connection to a brand-new network, there's a short window where the interface sits in the default zone before this runs. More on that in the lessons section.
Third, the captive portal check and the exit node:
state=$(nmcli -t networking connectivity)
if [ "$state" != "full" ]; then
log "connectivity=$state on $UUID, deferring exit node"
exit 0 # a later connectivity-change event will re-run us
fi
backend=$(tailscale status --json 2>/dev/null | jq -r '.BackendState')
if [ "$backend" != "Running" ]; then
log "tailscaled state=$backend on $UUID: NOT PROTECTED"
exit 1
fi
if [ -z "$(tailscale status --json | jq -r '.ExitNodeStatus.ID // empty')" ]; then
tailscale set --exit-node="$EXIT_NODE" --exit-node-allow-lan-access=false \
&& log "exit node $EXIT_NODE enabled for $UUID" \
|| log "FAILED to set exit node for $UUID"
fi
The captive portal check matters more than it looks. If you force all traffic through an exit node while the hotel network is still intercepting HTTP for its login page, the portal never loads. Your browser's request goes through the tunnel, reaches the real internet, and you sit there with "Connected, no internet" forever. NetworkManager's connectivity check reports portal in that state. The script backs off, you log in, NM's next check flips to full, a connectivity-change event fires, and the script runs again and sets the exit node.
The script needs jq. You could parse tailscale status text output instead, but the text format isn't a stable interface and the JSON one is close enough to it.
Step 4: Install it with the right ownership
NetworkManager is picky about dispatcher scripts, and it's quiet about it. The script must be owned by root, executable, and not writable by group or others. If any of that is wrong, the dispatcher skips it and the only trace is at debug log level.
sudo install -o root -g root -m 0755 50-wifi-safe /etc/NetworkManager/dispatcher.d/
sudo restorecon -v /etc/NetworkManager/dispatcher.d/50-wifi-safe # fix SELinux label on Fedora
systemctl is-enabled NetworkManager-dispatcher.service
# Opt out your home network by UUID, not by SSID
sudo mkdir -p /etc/wifi-safe
nmcli -g connection.uuid connection show "HomeWifi" | sudo tee -a /etc/wifi-safe/trusted-uuids
The restorecon line is the one people skip. If you write the script in your home directory and mv it into place, it keeps the user_home_t label, and SELinux will stop the dispatcher from executing it. install creates a new file, which usually gets the right label from the directory, but running restorecon costs nothing. If the script runs but tailscale or firewall-cmd calls fail inside it, check sudo ausearch -m avc -ts recent for denials before you start rewriting logic.
Step 5: Verify, and know where to look
nmcli connection down "CoffeeShop" && nmcli connection up "CoffeeShop"
journalctl -t wifi-safe -f # our own log lines
journalctl -u NetworkManager-dispatcher -f # whether NM ran the script at all
firewall-cmd --get-zone-of-interface=wlp0s20f3 # expect: hostile-wifi
tailscale status --json | jq '.ExitNodeStatus' # expect: non-null, Online: true
If journalctl -t wifi-safe shows nothing at all, the script didn't run, and the problem is ownership, permissions, SELinux, or the dispatcher service. If it shows lines but the state is wrong, the problem is inside the script. Splitting the logs this way cuts the debugging space in half on the first look.
Why it works
The design rests on moving trust from the network to the tunnel. Three properties make that hold up.
Deny by default, opt out by exception. Every Wi-Fi profile that isn't on a short, explicit list gets the hostile zone and the exit node. A new network can't slip through because you forgot to configure it. Forgetting is the safe outcome. The worst case of a missing trusted entry is that your home traffic takes a detour through the exit node, which is annoying and harmless.
Interface-bound policy instead of address-bound policy. Packets on tailscale0 have already been decrypted by WireGuard using a peer's key, so the interface itself carries an authentication guarantee that a source address doesn't. A --add-source=100.64.0.0/10 rule asks "does this packet claim to be from the tailnet?" An --add-interface=tailscale0 rule asks "did this packet come out of the tunnel?" Only the second question has an answer an attacker on the same Wi-Fi can't forge.
Event-driven reconciliation, idempotently. The script doesn't assume anything about previous state. It checks the profile's zone and the exit node status each time and only changes what's wrong. That's the same shape as a Kubernetes controller: observe, compare, act. It's why I'm comfortable having it fire on every up and every connectivity-change without worrying about it stacking up side effects.
The exit node closes the egress half. With --exit-node set, Tailscale installs a default route through tailscale0 and the local network only sees encrypted WireGuard UDP to the exit node (or a DERP relay). DNS goes along for the ride: with --accept-dns on, the system resolver points at Tailscale's 100.100.100.100, and non-tailnet lookups get forwarded via the exit node rather than handed to whatever resolver the coffee shop's DHCP offered. Setting --exit-node-allow-lan-access=false matters on hostile networks, because the alternative carves the local subnet back out of the tunnel and lets you talk to (and be talked to by) your neighbors.
When the exit node itself goes offline, my understanding is that the Tailscale client keeps the route pinned and traffic stops instead of silently falling back to the local network. That's fail-closed behavior, and it's the right call for this setup, but it means your exit node is now a dependency for internet access whenever you travel. I run mine on always-on hardware at home and I'd suggest the same. A second exit node in a different location is cheap insurance.
Lessons learned
There's a leak window, and you should know its size. Between Wi-Fi association and the dispatcher completing, the machine is on the network with whatever zone the profile had, and egress isn't tunneled yet. For a known profile, the zone is already pinned from last time, so it's only the egress window. For a brand-new network, the first few seconds are in the default zone. If that matters for your threat model, change the default zone itself (firewall-cmd --set-default-zone=hostile-wifi) and put your home profile in a more permissive zone explicitly. That flips the defaults at the firewall layer too, and it's what I'd do on a laptop that travels a lot.
Captive portals expire mid-session. Hotel networks commonly re-prompt after 24 hours. With the exit node already active, the portal can't intercept you, so you'll see a dead connection instead of a login page. The fix is manual: tailscale set --exit-node=, log in, and let the next connectivity-change re-enable it. I haven't found a clean way to automate the reverse transition without the script turning the exit node off whenever connectivity flickers, which would defeat the purpose.
UUIDs are better than SSIDs, but they aren't magic. I key the trusted list on the NM connection UUID so that a network called "HomeWifi" in a hotel lobby is a new profile, not a trusted one. That stops casual collisions. It doesn't stop an evil twin that clones your home SSID and knows your WPA2-PSK password, because NM will happily auto-connect that to your existing profile. If your home network uses WPA3-SAE or 802.1X, that's a much harder attack. If it's a PSK you've handed to every guest for five years, treat the trusted list as a convenience feature, not a security control.
Keep dispatcher scripts fast. The dispatcher runs scripts sequentially, so a script blocked on a hung tailscale call delays every script after it and every subsequent event, and the dispatcher will eventually time out and kill it. Everything in this script is a local socket or D-Bus call. If you're tempted to add a curl to verify your public IP from inside the dispatcher, put it in a separate systemd unit triggered by the script instead.
Silent automation needs a heartbeat. The failure mode of a broken dispatcher is identical to the failure mode of no dispatcher: nothing happens and nobody tells you. The NOT PROTECTED log line is there so a desktop notification hook or a quick journalctl -t wifi-safe -p warning check has something to grep for. If you already push laptop metrics anywhere, a "seconds since last wifi-safe success on an untrusted network" gauge is a decent alert, with the same don't-page-on-flaps discipline I wrote about in Prometheus alerting rules.
Test the permission failure on purpose. Before you trust this on a trip, chmod 0775 the script, reconnect, and confirm that journalctl -u NetworkManager-dispatcher tells you something (or doesn't). Knowing what a broken install looks like on your own machine beats discovering it at the gate.
The bigger shift for me was conceptual. Once the tailnet is the boundary, the Wi-Fi stops being something you evaluate and becomes something you route through. You don't decide whether the airport network is safe. You assume it isn't, every time, and let a fifty-line script make that assumption hold. If you're running Tailscale inside containers rather than on a laptop, the kernel TUN in unprivileged LXC post covers the same interface-binding idea from the server side, and if you want help applying this kind of zero-trust pattern across a fleet, that's the sort of work I take on through GuatuLabs.
Top comments (0)