mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-05 04:06:35 +02:00
wgengine/router/osrouter: add native FreeBSD routing, no-snat support
And add site-to-site natlab vmtests for Linux + FreeBSD. Fixes #5573 Updates #18897 Change-Id: I9629f46fdd99137f0ae96431487d3a7410e2a4cc Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
parent
0e8ae9d60c
commit
c02a32029e
@ -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)")
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 <nodename>" on all requests.
|
||||
// The webserver responds with "Hello world I am <nodename> from <sourceIP>" 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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
12
wgengine/router/osrouter/router_darwin.go
Normal file
12
wgengine/router/osrouter/router_darwin.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user