I have a home server that already acts as a Tailscale exit node (call it exit node A) for my main tailnet. I wanted the same physical box to also offer a second, completely separate network: a WiFi access point where any device that joins gets internet as if it were sitting at a different location, by routing through a different Tailscale exit node (exit node B).
The catch is that you can’t just run two tailscaled instances side by side and
expect them to coexist. This post walks through why, the network-namespace
architecture that makes it work, and a fair amount of routing and WiFi-calling
debugging along the way.
What this is
home-server is a Tailscale exit node (exit node A) for the main tailnet.
This setup adds a second, fully isolated Tailscale identity on the same
physical box that routes traffic through a different exit node (exit node
B) instead of A’s own internet connection. A WiFi access point is bridged into
that second identity, so any device that joins the AP gets internet access as if
it were physically at exit node B’s location, extending B’s network presence to
wherever this server sits.
Both identities run side by side without interfering with each other or with A’s normal exit-node service for the rest of the tailnet.
Why a second instance can’t just share routing with the primary
Tailscale programs Linux policy routing (ip rule plus a dedicated route table,
normally table 52) to implement exit-node redirection. Two tailscaled
processes in the same network namespace fight over the same table and rules:
whichever started more recently silently overwrites the other’s routes for all
tailnet peer IPs. I hit this directly during setup. Bringing up a second
instance in the main namespace caused SSH (which travels over the primary’s
tailscale0) to go dark, because the second instance clobbered table 52’s
routes to every peer, including the primary’s own address.
The fix is to run the second tailscaled inside its own network namespace
(apnet). Namespaces get their own private routing tables and rules, so the two
instances never see each other’s state.
Architecture
┌───────────────────────────────────────────────┐
│ home-server │
│ │
phone/laptop │ wlan-ap (AP, hostapd) │
joins wifi ───────┼─▶ 10.55.10.1/24 ── dnsmasq DHCP .50-.150 │
"extended-net-B" │ │ │
│ │ ip rule: from 10.55.10.0/24 → table 200
│ ▼ │
│ veth-host 10.200.0.1/30 ── (main netns) │
│ │ │
│ ══════╪══════════ netns boundary ═══════════ │
│ ▼ │
│ veth-ns 10.200.0.2/30 (netns: apnet) │
│ │ │
│ │ ip rule pref 100: to 10.55.10.0/24 → │
│ │ lookup main (else exit-node's │
│ │ table-52 catch-all eats it) │
│ ▼ │
│ tailscaled (2nd instance, state in │
│ /var/lib/tailscale-apnet/) │
│ tailscale0 100.x.x.b │
│ exit-node = exit node B │
└───────────────────────────────────────────────┘
Primary tailscaled (unchanged): main netns, state in
/var/lib/tailscale/, tailscale0 100.x.x.a, advertises as
exit node A for the rest of the tailnet. Totally separate from the above.
Traffic path for a device on the AP: device → hostapd/AP → host routes
10.55.10.0/24 traffic into the apnet netns via the veth pair → inside the
netns, Tailscale’s exit-node routing sends it out through tailscale0 to exit
node B → return traffic un-NATs back to the device’s AP IP, and a dedicated ip rule (priority 100) makes sure it routes back out the veth instead of looping
back into the tunnel.
Components and where they live
| Component | Purpose | Location |
|---|---|---|
hostapd | Runs the WiFi AP | /etc/hostapd/hostapd.conf, service hostapd.service |
dnsmasq (dedicated instance) | DHCP for AP clients | /etc/dnsmasq.d/ap-extended-net-b.conf, service dnsmasq-ap.service |
| NetworkManager override | Stops NM from managing the AP’s WiFi adapter | /etc/NetworkManager/conf.d/99-unmanage-ap-wifi.conf |
| Static IP for AP interface | Gateway address for AP subnet | /etc/systemd/network/10-ap-wifi.network (via systemd-networkd) |
| netns + veth + routing setup | Wires the AP subnet into the isolated namespace | /usr/local/sbin/setup-apnet-netns.sh, run by apnet-netns-setup.service (oneshot, boots before the 2nd tailscaled) |
| Second Tailscale instance | Joins tailnet as a second node, sets exit node | tailscaled-apnet.service, state in /var/lib/tailscale-apnet/, socket /run/tailscale/tailscaled-apnet.sock |
| DNS inside the namespace | Public resolvers (the netns can’t reach the host’s systemd-resolved stub) | /etc/netns/apnet/resolv.conf |
| rtl8188eus driver (DKMS) | Gives the WiFi adapter AP-mode support (stock rtl8xxxu doesn’t support AP mode on this chipset) | DKMS module 8188eu/5.3.9, blacklist at /etc/modprobe.d/blacklist-rtl8xxxu.conf |
All of the above services are systemctl enabled, so they come back up
automatically on reboot, in the right order (apnet-netns-setup →
tailscaled-apnet).
Control-socket caveat: both
tailscaledunits originally declared the sameRuntimeDirectory=tailscale(/run/tailscale). systemd recreates that directory on each unit’s start/stop, so the apnet instance, which starts after the primary on every boot, silently deleted the primary’s/run/tailscale/tailscaled.sock. Exit node A kept working (the socket is the local control API, not the data path), but the primary node was unmanageable via thetailscaleCLI until restarted. Fixed withRuntimeDirectoryPreserve=yesdrop-ins on both units. See “Primary node unmanageable /tailscaled.sockmissing” under Troubleshooting.
Current configuration
- WiFi adapter: TP-Link TL-WN722N v2/v3 (Realtek RTL8188EUS chipset). This
chipset’s in-kernel
rtl8xxxudriver does not support AP mode, confirmed viaiw phy<N> infoshowing onlymanaged/monitor. I built and installed the out-of-treertl8188eusdriver via DKMS (fromgithub.com/aircrack-ng/rtl8188eus) and blacklistedrtl8xxxufor this to take effect. 2.4GHz only (this card has no 5GHz radio). - SSID:
extended-net-B - Password: set your own in
hostapd.conf - Channel: 6 (2.4GHz)
- Regulatory domain: US
- AP subnet:
10.55.10.0/24, gateway10.55.10.1 - DHCP range:
10.55.10.50–10.55.10.150 - Exit node B: a remote desktop on a fixed broadband connection in a different country, on a separate tailnet account, switched to from a phone hotspot to rule out phone-hotspot instability as the cause of WiFi-calling flapping. Earlier, two different phones were used for testing.
- veth point-to-point link:
10.200.0.1/30(host) ↔10.200.0.2/30(namespace) - Host-side policy routing table for AP subnet: table
200 - Second Tailscale node name in tailnet: a dedicated apnet node name
(
100.x.x.b) - MTU:
1280end-to-end (AP WiFi interface, both veth ends, and advertised to AP clients via DHCP option 26). See “MTU and WiFi calling” below for why.
MTU and WiFi calling
WiFi calling uses IPsec (IKEv2/ESP, typically NAT-T-encapsulated over UDP), which
is intolerant of fragmentation and notoriously prone to being silently broken by
PMTUD black holes through VPN/tunnel hops (intermediate routers dropping the ICMP
“fragmentation needed” packets that would otherwise tell the sender to shrink its
packets). Rather than rely on PMTUD working correctly through this path (host →
AP → veth → netns → WireGuard hop → exit node B’s uplink), MTU is fixed at 1280
before packets ever reach the Tailscale-encapsulated hop:
- The AP’s WiFi interface (
wlan-ap) has its link MTU set to 1280 via/etc/systemd/network/10-ap-wifi.network([Link] MTUBytes=1280). dnsmasqadvertises the same value to clients via DHCP option 26 (dhcp-option=26,1280in/etc/dnsmasq.d/ap-extended-net-b.conf), so a connecting phone’s own IP stack natively fragments its IPsec/ESP payloads to fit before transmitting. It never has to discover the limit via ICMP.- Both ends of the veth pair (
veth-host/veth-ns) are also set to 1280 MTU in/usr/local/sbin/setup-apnet-netns.sh(theAP_MTUvariable), so the forwarding path inside the host can’t itself become the bottleneck. - Tailscale’s own
tailscale0interface already defaults to MTU 1280, so the entire chain is now consistently 1280 end-to-end with no fragmentation possible at any hop.
To change this value, edit AP_MTU in setup-apnet-netns.sh, the MTUBytes=
line in 10-ap-wifi.network, and the dhcp-option=26,... line in the dnsmasq
config, then run sudo /usr/local/sbin/setup-apnet-netns.sh, sudo systemctl restart systemd-networkd, and sudo systemctl restart dnsmasq-ap.
How to change things
Change SSID or password
sudo nano /etc/hostapd/hostapd.conf
# edit `ssid=` and/or `wpa_passphrase=`
sudo systemctl restart hostapd
Existing connected clients will be dropped and need to reconnect with the new password.
Change the AP subnet (e.g. away from 10.55.10.0/24)
Three places need to agree. They are not cross-referenced automatically:
/etc/systemd/network/10-ap-wifi.network: changeAddress=(the.1host address in the new subnet)/etc/dnsmasq.d/ap-extended-net-b.conf: changedhcp-range=and thedhcp-option=3,...gateway to match/usr/local/sbin/setup-apnet-netns.sh: change every occurrence of10.55.10.0/24(the host-sideip rule), and on the live system, also re-add the netns-sideip rule ... to <new-subnet> lookup main pref 100to match (skipping this silently breaks return traffic again)
Then:
sudo systemctl restart systemd-networkd # picks up new static IP
sudo systemctl restart dnsmasq-ap
sudo /usr/local/sbin/setup-apnet-netns.sh # reapplies routing with new subnet
Caution: restarting systemd-networkd has previously triggered Tailscale to
lose and restore its own ip rules (it self-heals, but if the second
tailscaled instance happens to be misconfigured at the same time, this can
cascade into a full outage like I hit during initial setup). Prefer making this
change at a time when you can verify SSH access immediately afterward, and have a
LAN-IP fallback handy just in case.
Change exit node B
This is the lightest-weight change in the whole setup:
sudo ip netns exec apnet tailscale --socket=/run/tailscale/tailscaled-apnet.sock \
up --accept-dns=false --exit-node=<new-node-name-or-IP> \
--exit-node-allow-lan-access=false --hostname=<apnet-node-name> --accept-routes
That’s it. No netns, veth, hostapd, or dnsmasq changes needed. Swapping exit node B is a single command since all the plumbing (NAT, the AP, the isolation) is keyed off the namespace, not the specific exit node.
Setting up a dedicated device as exit node B
Using a phone hotspot as exit node B is fine for testing, but a dedicated always-on device is much more stable. Steps:
- Get the new device onto the same tailnet (
tailscale upon it, logging into the same account/tailnet). - On that device, advertise it as an exit node:
sudo tailscale up --advertise-exit-node(Linux), or enable the equivalent toggle in the Tailscale app for other OSes. - If your tailnet has ACLs / exit-node approval requirements, approve it in the Tailscale admin console (Machines → that device → enable exit node).
- On
home-server, run the one command from “Change exit node B” above, pointing--exit-node=at the new device’s tailnet name. - Verify:
sudo ip netns exec apnet tailscale --socket=/run/tailscale/tailscaled-apnet.sock statusshould showactive; exit nodenext to the new device. Then check a connected AP client actually gets that device’s public IP (e.g. visitifconfig.mefrom a phone onextended-net-B).
No changes are needed to hostapd, dnsmasq, the netns, veth, or any routing rule. They’re all already keyed to “whatever the apnet instance’s current exit node is,” not to a specific device.
Troubleshooting
AP clients get an IP but no internet
Most likely the host-side NAT/routing chain is broken somewhere. Check in order:
# 1. Does the host know to route the AP subnet into the namespace?
ip rule show | grep 10.55.10.0/24 # expect: ... lookup 200
ip route show table 200 # expect: default via 10.200.0.2 dev veth-host
# 2. Is the namespace's own exit-node routing not swallowing return traffic?
sudo ip netns exec apnet ip rule show # expect a "pref 100 ... to 10.55.10.0/24 lookup main" ABOVE the "lookup 52" line
# 3. Is the 2nd tailscaled instance actually up with an exit node set?
sudo ip netns exec apnet tailscale --socket=/run/tailscale/tailscaled-apnet.sock status
# expect the exit node line to say "active; exit node"
# 4. Is NAT happening at both hops?
sudo iptables -t nat -L POSTROUTING -n -v | grep -E "10.200.0.0/30|tailscale0"
sudo ip netns exec apnet iptables -t nat -L POSTROUTING -n -v
If step 2’s rule is missing, you’ll see symptoms like: DNS/TCP requests visibly
leave the phone (tcpdump -i wlan-ap), reach the namespace’s tailscale0 and
get answered, but the answer never comes back out the AP interface. This is a
routing loop where exit-node’s catch-all default route in table 52 grabs the
return traffic before it gets a chance to go out via the veth back to the host. I
hit exactly this during initial setup. The fix was the pref 100 rule from step
2.
Whole-server SSH/Tailscale outage after touching this config
This happened once during setup, root-caused to two compounding factors:
- A second
tailscaledrunning in the main network namespace (not yet isolated) overwrote the primary’s routes in table 52, including routes to other tailnet peers. This is why I moved the second instance into its own netns. systemd-networkd(enabled for the AP’s static IP) flushesip rules on its own startup/reload. Tailscale detects this and restores its rules automatically, but combined with #1 being active at the same time, the primary’s table 52 ended up incompletely reprogrammed.
If this happens again:
- Don’t panic-reboot. First try
sudo systemctl restart tailscaled(the primary one, not-apnet). This forces a full clean reprogram of its routing table and resolved it last time. - Fallback access: a direct LAN IP (not via Tailscale) works as long as your SSH
key is in
~/.ssh/authorized_keysthere, useful precisely when the Tailscale path itself is the thing that’s broken.
Primary node unmanageable / tailscaled.sock missing (shared RuntimeDirectory)
Symptom: tailscale status (or any tailscale CLI command) on the primary
node fails with failed to connect to local tailscaled ... /run/tailscale/tailscaled.sock: connect: no such file or directory, even though
systemctl status tailscaled shows it active (running) and exit node A is
still serving traffic normally. (Only /run/tailscale/tailscaled-apnet.sock is
present in the directory.)
Root cause: tailscaled.service and tailscaled-apnet.service both declared
RuntimeDirectory=tailscale, i.e. they shared /run/tailscale. systemd does
not reference-count shared runtime directories. It removes and recreates the
directory whenever either unit starts or stops, destroying the other daemon’s
socket. Because the apnet unit waits on network-online.target plus
apnet-netns-setup, it starts after the primary on every boot and wiped the
primary’s tailscaled.sock each time. The breakage is silent: the running daemon
keeps its in-kernel routes and its control-plane connection, so exit node A and
all routing keep working. Only local CLI management of the primary is lost.
Fix: RuntimeDirectoryPreserve=yes drop-ins on both units, so neither wipes
/run/tailscale on start/stop. Each unit’s ExecStopPost ... --cleanup only
removes its own socket, so they no longer interfere.
# drop-in files (identical contents):
# /etc/systemd/system/tailscaled.service.d/10-preserve-runtime-dir.conf
# /etc/systemd/system/tailscaled-apnet.service.d/10-preserve-runtime-dir.conf
# [Service]
# RuntimeDirectoryPreserve=yes
sudo systemctl daemon-reload # neutralizes the bug for all future start/stop, incl. reboot
# then restart the primary ONCE to recreate its now-missing socket. This briefly
# drops exit node A and any Tailscale-path SSH, so run it from the LAN fallback:
sudo systemctl restart tailscaled
ls -la /run/tailscale # verify BOTH tailscaled.sock and tailscaled-apnet.sock coexist
WiFi calling registers then drops repeatedly (“emergency calls only”)
Diagnosed via a 4-point IKE/IPsec packet capture (UDP 500/4500) plus phone-side
adb logcat -b radio,main while toggling WiFi calling. Findings:
- The IKEv2 handshake to the carrier’s ePDG completes normally at the network
level.
IKE_SA_INITandIKE_AUTHround-trips both get clean responses, no packet loss, no NAT port instability. This rules out the AP/veth/netns/Tailscale chain as the cause. - Phone-side logs (
RegiMgr: notifyImsRegistration: isVoWiFiRegistered) show the registration actually succeeds, but then flaps: registered → deregistered ~50-60s later → re-registered ~5-10s after that, repeating. If you check the WFC toggle during a “deregistered” window it looks permanently broken, but it’s cycling. - Most likely cause: a phone used as the exit node has a much less stable connection than a dedicated device. Its own mobile/hotspot NAT mapping or signal can blip, which breaks the AP client’s existing IPsec session and forces IMS to re-register. This is expected to improve once exit node B is a dedicated always-on device.
- A vendor-internal
wfcErrorCodeis cached in the WFC settings UI during drops, not a standard 3GPP IKE notify code, no public decode table. Not pursued further since the flapping pattern already explains the symptom.
WiFi calling with Airplane Mode on (to suppress cellular registration)
Tested as an attempt to force the SIM off the cellular/roaming network entirely
and rely solely on VoWiFi over extended-net-B. Result: does not work for this
SIM/carrier, and fails differently depending on timing:
- First attempt: the IKE handshake to the ePDG never completed.
IKE_AUTHrequest sent, no response within 10s (ERIS_NO_IKE_AUTH_RESPONSE), then a retry’sIKE_SA_INITitself got no response at all (ERIS_NO_IKE_SA_INIT_RESPONSE). - Second attempt: the handshake got further.
IKE_SA_INITcompletes,IKE_AUTHexchanges the EAP-AKA challenge/response, but the ePDG explicitly rejects it every time:parsed IKE_AUTH response ... [ EAP/SUCC N(AUTH_FAILED) ]→AUTHENTICATION_FAILED. This repeats on a ~13-15s retry cycle, which is why the WFC toggle visibly “turns itself off” about 10 seconds after being enabled. It’s retrying and getting rejected, not hanging. - Conclusion: this looks like a hard authentication rejection by the carrier’s network, not a transport/MTU/routing issue (the handshake reaches the EAP-AKA step cleanly). Most likely explanation: the carrier’s AAA/ePDG requires the SIM to have an active cellular registration to validate the EAP-AKA exchange for VoWiFi, as an anti-fraud check. With Airplane Mode on there’s no cellular attach, so every authentication attempt is denied regardless of the WiFi/Tailscale path’s health.
- Takeaway: don’t rely on Airplane Mode + WiFi calling as a way to avoid the cellular-roaming/IP-location mismatch on this SIM. It doesn’t get far enough to be useful.
WiFi calling rejected with INTERNAL_ADDRESS_FAILURE (dedicated desktop exit node)
Re-tested after switching exit node B from a phone to a desktop on a fixed
broadband connection, specifically to rule out the phone-hotspot-instability
cause of the flapping above. Result: a different, more specific failure,
diagnosed via phone-side adb logcat -b radio,main plus a fresh 4-point
IKE/IPsec packet capture.
- Phone-side:
IKE_SA_INITsucceeds,IKE_AUTHcompletes the full EAP-AKA exchange cleanly (EAP/SUCC, authentication itself succeeds, unlike the Airplane Mode case above), but the finalAUTHmessage gets rejected withN(INT_ADDR_FAIL)(INTERNAL_ADDRESS_FAILURE), a standard IKEv2 notify meaning the ePDG would not assign an internal tunnel IP. This repeats on a tight ~2-3s retry cycle. - The phone alternated between two different ePDG IPs in the same carrier range, both rejected identically, ruling out one flaky ePDG server.
- The 4-point packet capture (AP WiFi, veth-host, veth-ns, tailscale0 in the
netns) showed fully clean, symmetric traffic at every hop: same packet count
and sequence end-to-end, no drops, no asymmetry. The IKE exchange completes and
the carrier’s own
INFORMATIONALteardown follows immediately after the rejection. This is a deliberate rejection by the carrier, not a network/path failure on my side. - Conclusion: this looks like a carrier-side policy rejection (most likely IP-reputation/geofencing-related, given it’s tied to the new exit node’s location/IP rather than a transport issue), not something fixable in this architecture. It’s a different failure mode than the phone-hotspot flapping: that one was registration succeeding then degrading due to a flaky exit node; this one is the carrier outright refusing to complete the tunnel setup from this particular exit node’s IP, consistently, across multiple of their own ePDG servers.
- Decision: stop pursuing WiFi calling through this AP for now. The carrier appears to actively reject VoWiFi from this IP/location rather than just being unreliable about it.
Checking AP client connections
cat /var/lib/misc/dnsmasq.leases # see who's connected + their IP
sudo systemctl status hostapd dnsmasq-ap tailscaled-apnet apnet-netns-setup
Known limitations
- The WiFi adapter is 2.4GHz-only (no 5GHz radio on this hardware).
- A desktop exit node avoids the offline/battery issues of phone-based exit nodes, but WiFi calling specifically does not work through it. See “WiFi calling rejected with INTERNAL_ADDRESS_FAILURE” above.
- DNS for AP clients is currently hardcoded to
1.1.1.1/8.8.8.8rather than the netns’s tailnet DNS. This was a deliberate simplification, since the netns can’t reach the host’ssystemd-resolvedstub resolver (127.0.0.53 only listens in the host’s own network namespace).
Control-plane ACL
The tailnet’s exit-node access is gated by a Tailscale ACL. Roughly (emails redacted):
{
"tagOwners": {
"tag:admin": ["you@example.com"],
"tag:trusted-devices": ["you@example.com"],
"tag:guest-devices": ["you@example.com"],
"tag:exit-nodes": ["you@example.com"]
},
"grants": [
// Admin devices: full access to everything, including exit-node egress
{
"src": ["tag:admin"],
"dst": ["*", "autogroup:internet"],
"ip": ["*"]
},
// Trusted devices: only SSH + media server on the box, plus exit-node egress
{
"src": ["tag:trusted-devices"],
"dst": ["media-server", "autogroup:internet"],
"ip": ["tcp:22", "tcp:8080"]
},
// Guest devices: public internet only (any exit node egress)
{
"src": ["tag:guest-devices"],
"dst": ["autogroup:internet"],
"ip": ["*"]
}
],
"autoApprovers": {"exitNode": ["tag:exit-nodes"]}
}
The takeaway from the whole project: once the second tailscaled lives in its
own network namespace, everything else (the AP, NAT, the routing rules) is keyed
to the namespace rather than to any specific exit node, so swapping where your
“second location” lives becomes a one-line command.