diff --git a/cmd/tailscale/cli/set.go b/cmd/tailscale/cli/set.go index 6fd4b09ad..7896ebe09 100644 --- a/cmd/tailscale/cli/set.go +++ b/cmd/tailscale/cli/set.go @@ -115,6 +115,8 @@ func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet { setf.BoolVar(&setArgs.snat, "snat-subnet-routes", true, "source NAT traffic to local routes advertised with --advertise-routes") setf.BoolVar(&setArgs.statefulFiltering, "stateful-filtering", false, "apply stateful filtering to forwarded packets (subnet routers, exit nodes, and so on)") setf.StringVar(&setArgs.netfilterMode, "netfilter-mode", defaultNetfilterMode(), "netfilter mode (one of on, nodivert, off)") + case "freebsd": + setf.BoolVar(&setArgs.snat, "snat-subnet-routes", true, "source NAT traffic to local routes advertised with --advertise-routes") case "windows": setf.BoolVar(&setArgs.forceDaemon, "unattended", false, "run in \"Unattended Mode\" where Tailscale keeps running even after the current GUI user logs out (Windows-only)") } diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index 586df07bb..a9eec6aa0 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -128,6 +128,8 @@ func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet { upf.BoolVar(&upArgs.snat, "snat-subnet-routes", true, "source NAT traffic to local routes advertised with --advertise-routes") upf.BoolVar(&upArgs.statefulFiltering, "stateful-filtering", false, "apply stateful filtering to forwarded packets (subnet routers, exit nodes, and so on)") upf.StringVar(&upArgs.netfilterMode, "netfilter-mode", defaultNetfilterMode(), "netfilter mode (one of on, nodivert, off)") + case "freebsd": + upf.BoolVar(&upArgs.snat, "snat-subnet-routes", true, "source NAT traffic to local routes advertised with --advertise-routes") case "windows": upf.BoolVar(&upArgs.forceDaemon, "unattended", false, "run in \"Unattended Mode\" where Tailscale keeps running even after the current GUI user logs out (Windows-only)") } @@ -355,14 +357,15 @@ func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goo prefs.AppConnector.Advertise = upArgs.advertiseConnector prefs.PostureChecking = upArgs.postureChecking - if goos == "linux" { + if goos == "linux" || goos == "freebsd" { prefs.NoSNAT = !upArgs.snat // We want to make sure user is aware setting --snat-subnet-routes=false with --advertise-exit-node would break exitnode, // but we won't prevent them from doing it since there are current dependencies on that combination. (as of 2026-03-25) if prefs.NoSNAT && prefs.AdvertisesExitNode() { warnf("--snat-subnet-routes=false is set with --advertise-exit-node; internet traffic through this exit node may not work as expected") } - + } + if goos == "linux" { // Backfills for NoStatefulFiltering occur when loading a profile; just set it explicitly here. prefs.NoStatefulFiltering.Set(!upArgs.statefulFiltering) v, warning, err := netfilterModeFromFlag(upArgs.netfilterMode) @@ -1106,8 +1109,10 @@ func applyImplicitPrefs(prefs, oldPrefs *ipn.Prefs, env upCheckEnv) { func flagAppliesToOS(flag, goos string) bool { switch flag { - case "netfilter-mode", "snat-subnet-routes", "stateful-filtering": + case "netfilter-mode", "stateful-filtering": return goos == "linux" + case "snat-subnet-routes": + return goos == "linux" || goos == "freebsd" case "unattended": return goos == "windows" } diff --git a/cmd/tailscaled/tailscaled.go b/cmd/tailscaled/tailscaled.go index fe18731ae..9847f0a9b 100644 --- a/cmd/tailscaled/tailscaled.go +++ b/cmd/tailscaled/tailscaled.go @@ -729,9 +729,9 @@ func handleSubnetsInNetstack() bool { return true } switch runtime.GOOS { - case "windows", "darwin", "freebsd", "openbsd", "solaris", "illumos": + case "windows", "darwin", "openbsd", "solaris", "illumos": // Enable on Windows and tailscaled-on-macOS (this doesn't - // affect the GUI clients), and on FreeBSD. + // affect the GUI clients). return true } return false diff --git a/cmd/tta/tta.go b/cmd/tta/tta.go index cf5dc4162..f66443dd8 100644 --- a/cmd/tta/tta.go +++ b/cmd/tta/tta.go @@ -202,6 +202,9 @@ func main() { if routes := r.URL.Query().Get("advertise-routes"); routes != "" { args = append(args, "--advertise-routes="+routes) } + if snat := r.URL.Query().Get("snat-subnet-routes"); snat != "" { + args = append(args, "--snat-subnet-routes="+snat) + } serveCmd(w, "tailscale", args...) }) ttaMux.HandleFunc("/ip", func(w http.ResponseWriter, r *http.Request) { @@ -236,7 +239,8 @@ func main() { go func() { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintf(w, "Hello world I am %s", name) + host, _, _ := net.SplitHostPort(r.RemoteAddr) + fmt.Fprintf(w, "Hello world I am %s from %s", name, host) }) if err := http.ListenAndServe(":"+port, mux); err != nil { log.Printf("webserver on :%s failed: %v", port, err) diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go index 4e9d46bda..19a6c4dd6 100644 --- a/ipn/ipn_view.go +++ b/ipn/ipn_view.go @@ -378,7 +378,7 @@ func (v PrefsView) Sync() opt.Bool { return v.ж.Sync } // network to route Tailscale traffic back to the subnet relay // machine. // -// Linux-only. +// Linux and FreeBSD only. func (v PrefsView) NoSNAT() bool { return v.ж.NoSNAT } // NoStatefulFiltering specifies whether to apply stateful filtering when diff --git a/ipn/prefs.go b/ipn/prefs.go index 9125df2c1..c10ba533b 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -222,7 +222,7 @@ type Prefs struct { // network to route Tailscale traffic back to the subnet relay // machine. // - // Linux-only. + // Linux and FreeBSD only. NoSNAT bool // NoStatefulFiltering specifies whether to apply stateful filtering when diff --git a/tstest/natlab/vmtest/cloudinit.go b/tstest/natlab/vmtest/cloudinit.go index 334863f9c..b9c4b61cf 100644 --- a/tstest/natlab/vmtest/cloudinit.go +++ b/tstest/natlab/vmtest/cloudinit.go @@ -162,9 +162,6 @@ func (e *Env) generateFreeBSDUserData(n *Node) string { ud.WriteString(" - \"chmod +x /usr/local/bin/tailscaled /usr/local/bin/tailscale /usr/local/bin/tta\"\n") // Enable IP forwarding for subnet routers. - // This is currently a noop as of 2026-04-08 because FreeBSD uses - // gvisor netstack for subnet routing until - // https://github.com/tailscale/tailscale/issues/5573 etc are fixed. if n.advertiseRoutes != "" { ud.WriteString(" - \"sysctl net.inet.ip.forwarding=1\"\n") ud.WriteString(" - \"sysctl net.inet6.ip6.forwarding=1\"\n") diff --git a/tstest/natlab/vmtest/vmtest.go b/tstest/natlab/vmtest/vmtest.go index e6c89467f..7459a74f5 100644 --- a/tstest/natlab/vmtest/vmtest.go +++ b/tstest/natlab/vmtest/vmtest.go @@ -27,6 +27,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "testing" "time" @@ -95,14 +96,15 @@ type Node struct { name string num int // assigned during AddNode - os OSImage - nets []*vnet.Network - vnetNode *vnet.Node // primary vnet node (set during Start) - agent *vnet.NodeAgentClient - joinTailnet bool - advertiseRoutes string - webServerPort int - sshPort int // host port for SSH debug access (cloud VMs only) + os OSImage + nets []*vnet.Network + vnetNode *vnet.Node // primary vnet node (set during Start) + agent *vnet.NodeAgentClient + joinTailnet bool + advertiseRoutes string + snatSubnetRoutes *bool // nil means default (true) + webServerPort int + sshPort int // host port for SSH debug access (cloud VMs only) } // AddNode creates a new VM node. The name is used for identification and as the @@ -130,6 +132,9 @@ func (e *Env) AddNode(name string, opts ...any) *Node { vnetOpts = append(vnetOpts, vnet.DontJoinTailnet) case nodeOptAdvertiseRoutes: n.advertiseRoutes = string(o) + case nodeOptSNATSubnetRoutes: + v := bool(o) + n.snatSubnetRoutes = &v case nodeOptWebServer: n.webServerPort = int(o) default: @@ -154,6 +159,7 @@ func (n *Node) LanIP(net *vnet.Network) netip.Addr { type nodeOptOS OSImage type nodeOptNoTailscale struct{} type nodeOptAdvertiseRoutes string +type nodeOptSNATSubnetRoutes bool type nodeOptWebServer int // OS returns a NodeOption that sets the node's operating system image. @@ -168,8 +174,14 @@ func AdvertiseRoutes(routes string) nodeOptAdvertiseRoutes { return nodeOptAdvertiseRoutes(routes) } +// SNATSubnetRoutes returns a NodeOption that sets whether the node should +// source NAT traffic to advertised subnet routes. The default is true. +// Setting this to false preserves original source IPs, which is needed +// for site-to-site configurations. +func SNATSubnetRoutes(v bool) nodeOptSNATSubnetRoutes { return nodeOptSNATSubnetRoutes(v) } + // WebServer returns a NodeOption that starts a webserver on the given port. -// The webserver responds with "Hello world I am " on all requests. +// The webserver responds with "Hello world I am from " on all requests. func WebServer(port int) nodeOptWebServer { return nodeOptWebServer(port) } // Start initializes the virtual network, builds/downloads images, compiles @@ -332,6 +344,13 @@ func (e *Env) tailscaleUp(ctx context.Context, n *Node) error { if n.advertiseRoutes != "" { url += "&advertise-routes=" + n.advertiseRoutes } + if n.snatSubnetRoutes != nil { + if *n.snatSubnetRoutes { + url += "&snat-subnet-routes=true" + } else { + url += "&snat-subnet-routes=false" + } + } req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return err @@ -575,7 +594,11 @@ func (e *Env) HTTPGet(from *Node, targetURL string) string { return "" } -// ensureGokrazy finds or builds the gokrazy base image and kernel. +var buildGokrazy sync.Once + +// ensureGokrazy builds the gokrazy base image (once per test process) and +// locates the kernel. The build is fast (~4s) so we always rebuild to ensure +// the baked-in binaries (tta, tailscale, tailscaled) match the current source. func (e *Env) ensureGokrazy(ctx context.Context) error { if e.gokrazyBase != "" { return nil // already found @@ -586,21 +609,23 @@ func (e *Env) ensureGokrazy(ctx context.Context) error { return err } - e.gokrazyBase = filepath.Join(modRoot, "gokrazy/natlabapp.qcow2") - if _, err := os.Stat(e.gokrazyBase); err != nil { - if !os.IsNotExist(err) { - return err - } + var buildErr error + buildGokrazy.Do(func() { e.t.Logf("building gokrazy natlab image...") cmd := exec.CommandContext(ctx, "make", "natlab") cmd.Dir = filepath.Join(modRoot, "gokrazy") cmd.Stderr = os.Stderr cmd.Stdout = os.Stdout if err := cmd.Run(); err != nil { - return fmt.Errorf("make natlab: %w", err) + buildErr = fmt.Errorf("make natlab: %w", err) } + }) + if buildErr != nil { + return buildErr } + e.gokrazyBase = filepath.Join(modRoot, "gokrazy/natlabapp.qcow2") + kernel, err := findKernelPath(filepath.Join(modRoot, "go.mod")) if err != nil { return fmt.Errorf("finding kernel: %w", err) diff --git a/tstest/natlab/vmtest/vmtest_test.go b/tstest/natlab/vmtest/vmtest_test.go index 91c8359f1..650565846 100644 --- a/tstest/natlab/vmtest/vmtest_test.go +++ b/tstest/natlab/vmtest/vmtest_test.go @@ -45,3 +45,93 @@ func testSubnetRouterForOS(t testing.TB, srOS vmtest.OSImage) { t.Fatalf("got %q", body) } } + +func TestSiteToSite(t *testing.T) { + testSiteToSite(t, vmtest.Ubuntu2404) +} + +func TestSiteToSiteFreeBSD(t *testing.T) { + testSiteToSite(t, vmtest.FreeBSD150) +} + +// testSiteToSite runs a site-to-site subnet routing test with +// --snat-subnet-routes=false, verifying that original source IPs are preserved +// across Tailscale subnet routes. +// +// Topology: +// +// Site A: backend-a (10.1.0.0/24) ← → sr-a (WAN + LAN-A) +// Site B: backend-b (10.2.0.0/24) ← → sr-b (WAN + LAN-B) +// +// Both subnet routers are on Tailscale with --snat-subnet-routes=false. +// The test sends HTTP from backend-a to backend-b through the subnet routers +// and verifies that backend-b sees backend-a's LAN IP (not the subnet router's). +func testSiteToSite(t *testing.T, srOS vmtest.OSImage) { + env := vmtest.New(t) + + // WAN networks for each site (each behind NAT). + wanA := env.AddNetwork("2.1.1.1", "192.168.1.1/24", vnet.EasyNAT) + wanB := env.AddNetwork("3.1.1.1", "192.168.2.1/24", vnet.EasyNAT) + + // Internal LAN for each site. + lanA := env.AddNetwork("10.1.0.1/24") + lanB := env.AddNetwork("10.2.0.1/24") + + // Subnet routers: each on its WAN + LAN, advertising the local LAN, + // with SNAT disabled to preserve source IPs. + srA := env.AddNode("sr-a", wanA, lanA, + vmtest.OS(srOS), + vmtest.AdvertiseRoutes("10.1.0.0/24"), + vmtest.SNATSubnetRoutes(false)) + srB := env.AddNode("sr-b", wanB, lanB, + vmtest.OS(srOS), + vmtest.AdvertiseRoutes("10.2.0.0/24"), + vmtest.SNATSubnetRoutes(false)) + + // Backend servers on each site's LAN (not on Tailscale). + // Use Ubuntu so we can SSH in to add static routes. + backendA := env.AddNode("backend-a", lanA, + vmtest.OS(vmtest.Ubuntu2404), + vmtest.DontJoinTailnet(), + vmtest.WebServer(8080)) + backendB := env.AddNode("backend-b", lanB, + vmtest.OS(vmtest.Ubuntu2404), + vmtest.DontJoinTailnet(), + vmtest.WebServer(8080)) + + env.Start() + env.ApproveRoutes(srA, "10.1.0.0/24") + env.ApproveRoutes(srB, "10.2.0.0/24") + + // Add static routes on the backends so that traffic to the remote site's + // subnet goes through the local subnet router. This mirrors how a real + // site-to-site deployment is configured. + srALanIP := srA.LanIP(lanA) + srBLanIP := srB.LanIP(lanB) + t.Logf("sr-a LAN IP: %s, sr-b LAN IP: %s", srALanIP, srBLanIP) + t.Logf("backend-a LAN IP: %s, backend-b LAN IP: %s", backendA.LanIP(lanA), backendB.LanIP(lanB)) + + if out, err := env.SSHExec(backendA, fmt.Sprintf("ip route add 10.2.0.0/24 via %s", srALanIP)); err != nil { + t.Fatalf("adding route on backend-a: %v\n%s", err, out) + } + if out, err := env.SSHExec(backendB, fmt.Sprintf("ip route add 10.1.0.0/24 via %s", srBLanIP)); err != nil { + t.Fatalf("adding route on backend-b: %v\n%s", err, out) + } + + // Make an HTTP request from backend-a to backend-b through the subnet routers. + // TTA's /http-get falls back to direct dial on non-Tailscale nodes. + backendBIP := backendB.LanIP(lanB) + body := env.HTTPGet(backendA, fmt.Sprintf("http://%s:8080/", backendBIP)) + t.Logf("response: %s", body) + + if !strings.Contains(body, "Hello world I am backend-b") { + t.Fatalf("expected response from backend-b, got %q", body) + } + + // Verify the source IP was preserved. With --snat-subnet-routes=false, + // backend-b should see backend-a's LAN IP as the source, not sr-b's LAN IP. + backendAIP := backendA.LanIP(lanA).String() + if !strings.Contains(body, "from "+backendAIP) { + t.Fatalf("source IP not preserved: expected %q in response, got %q", backendAIP, body) + } +} diff --git a/wgengine/router/osrouter/router_darwin.go b/wgengine/router/osrouter/router_darwin.go new file mode 100644 index 000000000..0fd5dcc25 --- /dev/null +++ b/wgengine/router/osrouter/router_darwin.go @@ -0,0 +1,12 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package osrouter + +import "tailscale.com/wgengine/router" + +func init() { + router.HookNewUserspaceRouter.Set(func(opts router.NewOpts) (router.Router, error) { + return newUserspaceBSDRouter(opts.Logf, opts.Tun, opts.NetMon, opts.Health) + }) +} diff --git a/wgengine/router/osrouter/router_freebsd.go b/wgengine/router/osrouter/router_freebsd.go index c1e1a389b..6e73c5697 100644 --- a/wgengine/router/osrouter/router_freebsd.go +++ b/wgengine/router/osrouter/router_freebsd.go @@ -4,24 +4,255 @@ package osrouter import ( + "fmt" + "os/exec" + "strings" + + "github.com/tailscale/wireguard-go/tun" + "tailscale.com/health" "tailscale.com/net/netmon" "tailscale.com/types/logger" "tailscale.com/wgengine/router" ) func init() { + router.HookNewUserspaceRouter.Set(func(opts router.NewOpts) (router.Router, error) { + return newFreeBSDRouter(opts.Logf, opts.Tun, opts.NetMon, opts.Health) + }) router.HookCleanUp.Set(func(logf logger.Logf, netMon *netmon.Monitor, ifName string) { cleanUp(logf, ifName) }) } +// freebsdRouter extends the shared BSD userspace router with FreeBSD-specific +// IP forwarding and PF-based NAT for native subnet routing. +type freebsdRouter struct { + *userspaceBSDRouter + snatSubnetRoutes bool +} + +func newFreeBSDRouter(logf logger.Logf, tundev tun.Device, netMon *netmon.Monitor, health *health.Tracker) (router.Router, error) { + bsd, err := newUserspaceBSDRouter(logf, tundev, netMon, health) + if err != nil { + return nil, err + } + return &freebsdRouter{userspaceBSDRouter: bsd}, nil +} + +func (r *freebsdRouter) Set(cfg *router.Config) (reterr error) { + if cfg == nil { + cfg = &shutdownConfig + } + + setErr := func(err error) { + if reterr == nil { + reterr = err + } + } + + // Base address and route management. + if err := r.userspaceBSDRouter.Set(cfg); err != nil { + setErr(err) + } + + // Enable IP forwarding when advertising subnet routes. + if len(cfg.SubnetRoutes) > 0 { + r.enableIPForwarding() + } + + // Manage PF NAT rules for subnet routing. + switch { + case cfg.SNATSubnetRoutes == r.snatSubnetRoutes: + // No change needed. + case cfg.SNATSubnetRoutes: + if err := r.addPFNATRules(); err != nil { + r.logf("adding PF NAT rules: %v", err) + setErr(err) + } + default: + if err := r.delPFNATRules(); err != nil { + r.logf("removing PF NAT rules: %v", err) + setErr(err) + } + } + r.snatSubnetRoutes = cfg.SNATSubnetRoutes + + return reterr +} + +func (r *freebsdRouter) enableIPForwarding() { + for _, kv := range []string{ + "net.inet.ip.forwarding=1", + "net.inet6.ip6.forwarding=1", + } { + if out, err := cmd("sysctl", kv).CombinedOutput(); err != nil { + r.logf("warning: sysctl %s: %v (%s)", kv, err, strings.TrimSpace(string(out))) + } + } +} + +// pfAnchorName is the PF anchor used for all Tailscale NAT/filter rules. +// Using an anchor keeps our rules isolated from the user's existing PF +// configuration; we only ever flush or modify rules inside this anchor. +const pfAnchorName = "tailscale" + +// addPFNATRules configures PF to masquerade traffic from Tailscale addresses +// leaving via non-Tailscale interfaces. This is the FreeBSD equivalent of the +// Linux iptables MASQUERADE rule used for subnet routing. +// +// Rules are loaded into the "tailscale" PF anchor so that any pre-existing +// user rules in the main ruleset are left untouched. +func (r *freebsdRouter) addPFNATRules() error { + // Ensure the PF kernel module is loaded. + cmd("kldload", "pf").CombinedOutput() // may already be loaded + + // Enable PF (idempotent; returns error if already enabled). + cmd("pfctl", "-e").CombinedOutput() + + // Ensure the main ruleset references our anchor so PF evaluates it. + // We add both a nat-anchor (for NAT rules) and an anchor (for filter + // rules, currently just "pass" to avoid blocking) if not already present. + if err := ensurePFAnchorRef(); err != nil { + return fmt.Errorf("ensuring PF anchor reference: %w", err) + } + + // Load rules into the tailscale anchor. + // Traffic from Tailscale CGNAT (100.64.0.0/10) or ULA (fd7a:115c:a1e0::/48) + // addresses exiting any non-Tailscale interface is source-NATed to the + // outgoing interface's address so that return traffic routes back through + // this node. + rules := fmt.Sprintf( + "nat on ! %s inet from 100.64.0.0/10 to any -> (self)\n"+ + "nat on ! %s inet6 from fd7a:115c:a1e0::/48 to any -> (self)\n", + r.tunname, r.tunname, + ) + + pfctl := exec.Command("pfctl", "-a", pfAnchorName, "-f", "-") + pfctl.Stdin = strings.NewReader(rules) + out, err := pfctl.CombinedOutput() + if err != nil { + return fmt.Errorf("pfctl -a %s -f: %v (%s)", pfAnchorName, err, strings.TrimSpace(string(out))) + } + return nil +} + +// getPFMainRuleset reads the current main PF filter and NAT rules. +func getPFMainRuleset() (filterRules, natRules string) { + if out, err := cmd("pfctl", "-s", "rules").CombinedOutput(); err == nil { + filterRules = string(out) + } + if out, err := cmd("pfctl", "-s", "nat").CombinedOutput(); err == nil { + natRules = string(out) + } + return +} + +// loadPFMainRuleset replaces the main PF ruleset with the given combined +// NAT + filter rules. +func loadPFMainRuleset(rules string) error { + pfctl := exec.Command("pfctl", "-f", "-") + pfctl.Stdin = strings.NewReader(rules) + out, err := pfctl.CombinedOutput() + if err != nil { + return fmt.Errorf("pfctl -f: %v (%s)", err, strings.TrimSpace(string(out))) + } + return nil +} + +// ensurePFAnchorRef makes sure the main PF ruleset contains nat-anchor and +// anchor references for "tailscale". Without these, PF won't evaluate our +// anchor even if it has rules loaded. +// +// We read the current main ruleset and prepend the references only if they're +// not already present, then reload the combined ruleset. +func ensurePFAnchorRef() error { + filterRules, natRules := getPFMainRuleset() + + var additions string + natAnchorRef := fmt.Sprintf("nat-anchor \"%s\"", pfAnchorName) + anchorRef := fmt.Sprintf("anchor \"%s\"", pfAnchorName) + + if !strings.Contains(natRules, natAnchorRef) { + additions += natAnchorRef + "\n" + } + if !strings.Contains(filterRules, anchorRef) { + additions += anchorRef + "\n" + } + if additions == "" { + return nil // already present + } + + // Prepend our anchor references so they're evaluated, then include + // all existing rules so we don't disrupt the user's configuration. + return loadPFMainRuleset(additions + natRules + filterRules) +} + +// removePFAnchorRef removes the nat-anchor and anchor references for +// "tailscale" from the main PF ruleset via read-modify-write, leaving +// all other rules intact. +func removePFAnchorRef() error { + filterRules, natRules := getPFMainRuleset() + + natAnchorRef := fmt.Sprintf("nat-anchor \"%s\"", pfAnchorName) + anchorRef := fmt.Sprintf("anchor \"%s\"", pfAnchorName) + + newNat := removeLines(natRules, natAnchorRef) + newFilter := removeLines(filterRules, anchorRef) + + if newNat == natRules && newFilter == filterRules { + return nil // nothing to remove + } + + return loadPFMainRuleset(newNat + newFilter) +} + +// removeLines removes all lines from s that contain substr. +func removeLines(s, substr string) string { + var b strings.Builder + for line := range strings.SplitSeq(s, "\n") { + if strings.Contains(line, substr) { + continue + } + b.WriteString(line) + b.WriteByte('\n') + } + return b.String() +} + +// delPFNATRules flushes rules inside the tailscale PF anchor and removes +// the anchor references from the main ruleset. +func (r *freebsdRouter) delPFNATRules() error { + // Flush rules inside the anchor. + if out, err := cmd("pfctl", "-a", pfAnchorName, "-F", "all").CombinedOutput(); err != nil { + return fmt.Errorf("pfctl -a %s -F all: %v (%s)", pfAnchorName, err, strings.TrimSpace(string(out))) + } + // Remove the anchor references from the main ruleset. + if err := removePFAnchorRef(); err != nil { + return fmt.Errorf("removing PF anchor reference: %w", err) + } + return nil +} + +func (r *freebsdRouter) Close() error { + cleanUp(r.logf, r.tunname) + return nil +} + func cleanUp(logf logger.Logf, interfaceName string) { + // Flush only the tailscale PF anchor, leaving user rules intact. + if out, err := cmd("pfctl", "-a", pfAnchorName, "-F", "all").CombinedOutput(); err != nil { + logf("pfctl flush anchor %s: %v (%s)", pfAnchorName, err, strings.TrimSpace(string(out))) + } + // Remove the anchor references from the main ruleset. + if err := removePFAnchorRef(); err != nil { + logf("removing PF anchor ref: %v", err) + } + // If the interface was left behind, ifconfig down will not remove it. // In fact, this will leave a system in a tainted state where starting tailscaled // will result in "interface tailscale0 already exists" // until the defunct interface is ifconfig-destroyed. - ifup := []string{"ifconfig", interfaceName, "destroy"} - if out, err := cmd(ifup...).CombinedOutput(); err != nil { + if out, err := cmd("ifconfig", interfaceName, "destroy").CombinedOutput(); err != nil { logf("ifconfig destroy: %v\n%s", err, out) } } diff --git a/wgengine/router/osrouter/router_userspace_bsd.go b/wgengine/router/osrouter/router_userspace_bsd.go index 272594d7c..88eda4cbf 100644 --- a/wgengine/router/osrouter/router_userspace_bsd.go +++ b/wgengine/router/osrouter/router_userspace_bsd.go @@ -22,12 +22,6 @@ import ( "tailscale.com/wgengine/router" ) -func init() { - router.HookNewUserspaceRouter.Set(func(opts router.NewOpts) (router.Router, error) { - return newUserspaceBSDRouter(opts.Logf, opts.Tun, opts.NetMon, opts.Health) - }) -} - type userspaceBSDRouter struct { logf logger.Logf netMon *netmon.Monitor @@ -37,7 +31,7 @@ type userspaceBSDRouter struct { routes map[netip.Prefix]bool } -func newUserspaceBSDRouter(logf logger.Logf, tundev tun.Device, netMon *netmon.Monitor, health *health.Tracker) (router.Router, error) { +func newUserspaceBSDRouter(logf logger.Logf, tundev tun.Device, netMon *netmon.Monitor, health *health.Tracker) (*userspaceBSDRouter, error) { tunname, err := tundev.Name() if err != nil { return nil, err diff --git a/wgengine/router/router.go b/wgengine/router/router.go index 6868acb43..7e11469b1 100644 --- a/wgengine/router/router.go +++ b/wgengine/router/router.go @@ -131,8 +131,11 @@ type Config struct { // flow logging and is otherwise ignored. SubnetRoutes []netip.Prefix + // SNATSubnetRoutes enables SNAT for traffic to local subnets. + // Implemented on Linux (iptables/nftables) and FreeBSD (PF). + SNATSubnetRoutes bool + // Linux-only things below, ignored on other platforms. - SNATSubnetRoutes bool // SNAT traffic to local subnets StatefulFiltering bool // Apply stateful filtering to inbound connections NetfilterMode preftype.NetfilterMode // how much to manage netfilter rules NetfilterKind string // what kind of netfilter to use ("nftables", "iptables", or "" to auto-detect)