From 0cbc516fb0afa55b7d23bb6bf3bbe774c7182123 Mon Sep 17 00:00:00 2001 From: Yuri Tatishchev Date: Sun, 11 Oct 2026 12:08:01 -0700 Subject: [PATCH] WIP: router: tailscale --- modules/router/default.nix | 1 + modules/router/firewall.nix | 46 +++++++++++++++++++++++++++++++++++- modules/router/tailscale.nix | 44 ++++++++++++++++++++++++++++++++++ modules/router/vars.nix | 8 +++++++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 modules/router/tailscale.nix diff --git a/modules/router/default.nix b/modules/router/default.nix index a4160a9..43d9c0d 100644 --- a/modules/router/default.nix +++ b/modules/router/default.nix @@ -9,6 +9,7 @@ in ./opts.nix ./ifconfig.nix ./wireguard.nix + ./tailscale.nix ./firewall.nix ./dns.nix ./kea.nix diff --git a/modules/router/firewall.nix b/modules/router/firewall.nix index 27b8674..0e36aad 100644 --- a/modules/router/firewall.nix +++ b/modules/router/firewall.nix @@ -15,6 +15,11 @@ let ${ifs.lan50.name}, ${ifs.wg0.name}, } + # Tailscale is its own zone, since tailnet traffic is already filtered by tailscale ACLs + # and needs some special handling (exit node NAT with CGNAT/ULA addrs) + define ZONE_TAILSCALE_IFS = { ${ifs.ts0.name} } + define TAILSCALE_NET4 = ${ifs.ts0.net4} + define TAILSCALE_NET6 = ${ifs.ts0.net6} define OPNSENSE_NET6 = ${vars.extra.opnsense.net6} define ZONE_LAN_EXTRA_NET6 = { # TODO: reevaluate this statement @@ -80,6 +85,8 @@ in # Masquerade LAN addrs oifname $ZONE_WAN_IFS ip saddr $RFC1918 masquerade + # Masquerade tailnet addrs (tailscale exit node) + oifname $ZONE_WAN_IFS ip saddr $TAILSCALE_NET4 masquerade } ''; }; @@ -97,12 +104,28 @@ in ''; }; + # IPv6 masquerading for tailnet addrs only (tailscale exit node) + # Tailscale only assigns ULAs from $TAILSCALE_NET6, so NAT is unavoidable here + networking.nftables.tables.nat6_tailscale = { + family = "ip6"; + content = '' + ${nftIdentifiers} + chain postrouting { + type nat hook postrouting priority srcnat; policy accept; + oifname $ZONE_WAN_IFS ip6 saddr $TAILSCALE_NET6 masquerade + } + ''; + }; + networking.nftables.tables.firewall = { family = "inet"; content = '' ${nftIdentifiers} define ALLOWED_TCP_PORTS = { ssh } - define ALLOWED_UDP_PORTS = { ${toString vars.ifs.wg0.listenPort} } + define ALLOWED_UDP_PORTS = { + ${toString vars.ifs.wg0.listenPort}, + ${toString vars.ifs.ts0.listenPort}, # tailscale direct connections + } define ALLOWED_TCP_LAN_PORTS = { ssh, https } define ALLOWED_UDP_LAN_PORTS = { bootps, dhcpv6-server, domain, https } set port_forward_v6 { @@ -161,6 +184,8 @@ in # iifname $ZONE_LAN_IFS accept iifname $ZONE_LAN_IFS jump zone_lan_input ip6 saddr $ZONE_LAN_EXTRA_NET6 jump zone_lan_input + # Tailscale zone input rules + iifname $ZONE_TAILSCALE_IFS jump zone_tailscale_input # log } @@ -176,6 +201,8 @@ in # LAN zone forward rules iifname $ZONE_LAN_IFS jump zone_lan_forward ip6 saddr $ZONE_LAN_EXTRA_NET6 jump zone_lan_forward + # Tailscale zone forward rules + iifname $ZONE_TAILSCALE_IFS jump zone_tailscale_forward } chain zone_wan_input { @@ -215,6 +242,23 @@ in # Allow traffic between LANs oifname $ZONE_LAN_IFS accept + + # Allow LAN to reach tailnet (no SNAT, tailnet peers see real LAN addrs) + oifname $ZONE_TAILSCALE_IFS accept + } + + chain zone_tailscale_input { + # Tailnet access is restricted by tailscale ACLs, treat like LAN + jump zone_lan_input + } + + chain zone_tailscale_forward { + # Subnet router: allow tailnet to LANs (advertised routes) + oifname $ZONE_LAN_IFS accept + + # Exit node: allow tailnet to WAN (masqueraded in nat4 / nat6_tailscale) + oifname $ZONE_WAN_IFS ip saddr $TAILSCALE_NET4 accept + oifname $ZONE_WAN_IFS ip6 saddr $TAILSCALE_NET6 accept } chain output { diff --git a/modules/router/tailscale.nix b/modules/router/tailscale.nix new file mode 100644 index 0000000..59162ed --- /dev/null +++ b/modules/router/tailscale.nix @@ -0,0 +1,44 @@ +{ config, lib, pkgs, ... }: +let + vars = import ./vars.nix config; + ifs = vars.ifs; + ts0 = ifs.ts0; + + # Subnets advertised to the tailnet (still need to be approved in the admin console) + advertisedRoutes = [ + "${vars.p4}.0.0/16" # all LAN v4 subnets + "${vars.ulaPrefix}::/48" # all LAN ULA subnets + ]; +in +{ + # One-time setup after deploying: `sudo tailscale up` + # (state is persisted in /var/lib/tailscale) + services.tailscale = { + enable = true; + port = ts0.listenPort; + interfaceName = ts0.name; + # Enables ip forwarding (already enabled globally by networkd anyway). + # Not using "both", since that sets rp_filter to loose on all interfaces. + useRoutingFeatures = "server"; + # Firewall is managed manually in firewall.nix (networking.firewall is disabled) + openFirewall = false; + # Applied with `tailscale set` on every start, so these stay declarative + extraSetFlags = [ + # Don't let tailscale touch nftables/iptables, all rules live in firewall.nix + "--netfilter-mode=off" + # Don't SNAT tailnet -> LAN traffic, LAN hosts route back via the router anyway + # (preserves real source addrs for logging/ACLs on LAN hosts) + "--snat-subnet-routes=false" + # The router is its own DNS server, don't let tailscale mess with resolv.conf + "--accept-dns=false" + "--advertise-routes=${lib.strings.concatStringsSep "," advertisedRoutes}" + "--advertise-exit-node" + ]; + }; + + # networkd removes "foreign" ip rules when it (re)starts, which would wipe tailscale's + # policy routing rules (table 52). This is extra important since networkd is + # restarted every minute (see ifconfig.nix). + # The nixpkgs tailscale module already marks tailscale0 as unmanaged in networkd. + systemd.network.config.networkConfig.ManageForeignRoutingPolicyRules = false; +} diff --git a/modules/router/vars.nix b/modules/router/vars.nix index f4ebedb..5888538 100644 --- a/modules/router/vars.nix +++ b/modules/router/vars.nix @@ -111,6 +111,14 @@ rec { } // { listenPort = 51944; }; + # Tailscale addresses are assigned by the coordination server, + # these are the fixed ranges tailscale allocates node addrs from. + ts0 = { + name = "tailscale0"; + listenPort = 41641; + net4 = "100.64.0.0/10"; + net6 = "fd7a:115c:a1e0::/48"; + }; }; extra = {