From 3d07b5d6e121a489e7d4f3e9d20f12f58bd981f3 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Fri, 10 Jul 2026 02:33:34 +0000 Subject: [PATCH] wgengine,cmd/tailscaled,feature/bird: move BIRD integration to ./feature The BIRD (BGP) integration previously lived half in cmd/tailscaled (which created a chirp client via a build-tag-gated file on some platforms) and half in wgengine (which carried the client in its Config and toggled the "tailscale" protocol as the node gained or lost primary subnet router duty). Move it all to a new feature/bird package, installed on the engine via the new wgengine.HookNewBird hook, like other feature/* packages. wgengine.Config.BIRDClient (and the wgengine.BIRDClient interface) are replaced by a BIRDSocket path from which the engine constructs the feature's Bird handle at startup. The subnet router overlap detection and protocol toggling move into feature/bird, preserving the previous ordering: state is recomputed before Reconfig's ErrNoChanges early return and applied after the router is configured. tailscaled keeps BIRD support by default on the platforms that previously had it (linux, darwin, freebsd, openbsd) via feature/condregister. Also, add an integration test, as this feature lacked much test coverage previously. Updates #12614 Change-Id: I7866a50779e454c87933b358735f7dcd9e2b126f Signed-off-by: Brad Fitzpatrick --- cmd/tailscaled/depaware.txt | 3 +- cmd/tailscaled/deps_test.go | 14 ++ cmd/tailscaled/tailscaled.go | 15 +- cmd/tailscaled/tailscaled_bird.go | 17 -- feature/bird/bird.go | 107 +++++++++ feature/bird/bird_test.go | 205 ++++++++++++++++++ feature/condregister/maybe_bird.go | 8 + tsnet/tsnet_test.go | 2 + .../tailscaled_deps_test_darwin.go | 1 - .../tailscaled_deps_test_freebsd.go | 1 - .../integration/tailscaled_deps_test_linux.go | 1 - .../tailscaled_deps_test_openbsd.go | 1 - wgengine/bird_hooks.go | 41 ++++ wgengine/userspace.go | 91 +++----- 14 files changed, 419 insertions(+), 88 deletions(-) delete mode 100644 cmd/tailscaled/tailscaled_bird.go create mode 100644 feature/bird/bird.go create mode 100644 feature/bird/bird_test.go create mode 100644 feature/condregister/maybe_bird.go create mode 100644 wgengine/bird_hooks.go diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index da6262032..f4f8985b0 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -258,7 +258,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com from tailscale.com/version tailscale.com/appc from tailscale.com/ipn/ipnlocal+ 💣 tailscale.com/atomicfile from tailscale.com/ipn+ - LD tailscale.com/chirp from tailscale.com/cmd/tailscaled + LD tailscale.com/chirp from tailscale.com/feature/bird tailscale.com/client/local from tailscale.com/client/web+ tailscale.com/client/tailscale/apitype from tailscale.com/client/local+ tailscale.com/client/web from tailscale.com/ipn/ipnlocal @@ -292,6 +292,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/feature/ace from tailscale.com/feature/condregister tailscale.com/feature/acme from tailscale.com/feature/condregister tailscale.com/feature/appconnectors from tailscale.com/feature/condregister + LD tailscale.com/feature/bird from tailscale.com/feature/condregister tailscale.com/feature/buildfeatures from tailscale.com/wgengine/magicsock+ tailscale.com/feature/c2n from tailscale.com/feature/condregister tailscale.com/feature/capture from tailscale.com/feature/condregister diff --git a/cmd/tailscaled/deps_test.go b/cmd/tailscaled/deps_test.go index 9f17ea5d6..ed8a83aad 100644 --- a/cmd/tailscaled/deps_test.go +++ b/cmd/tailscaled/deps_test.go @@ -137,6 +137,20 @@ func TestOmitCaptivePortal(t *testing.T) { }.Check(t) } +func TestOmitBird(t *testing.T) { + deptest.DepChecker{ + GOOS: "linux", + GOARCH: "amd64", + Tags: "ts_omit_bird,ts_include_cli", + OnDep: func(dep string) { + switch dep { + case "tailscale.com/chirp", "tailscale.com/feature/bird": + t.Errorf("unexpected dep with ts_omit_bird: %q", dep) + } + }, + }.Check(t) +} + func TestOmitAuth(t *testing.T) { deptest.DepChecker{ GOOS: "linux", diff --git a/cmd/tailscaled/tailscaled.go b/cmd/tailscaled/tailscaled.go index c1408bc6a..41128564a 100644 --- a/cmd/tailscaled/tailscaled.go +++ b/cmd/tailscaled/tailscaled.go @@ -142,9 +142,8 @@ var args struct { } var ( - installSystemDaemon func([]string) error // non-nil on some platforms - uninstallSystemDaemon func([]string) error // non-nil on some platforms - createBIRDClient func(string) (wgengine.BIRDClient, error) // non-nil on some platforms + installSystemDaemon func([]string) error // non-nil on some platforms + uninstallSystemDaemon func([]string) error // non-nil on some platforms ) // Note - we use function pointers for subcommands so that subcommands like @@ -281,7 +280,7 @@ store state on filesystem.`) log.Fatalf("--socket is required") } - if buildfeatures.HasBird && args.birdSocketPath != "" && createBIRDClient == nil { + if buildfeatures.HasBird && args.birdSocketPath != "" && !wgengine.HookNewBird.IsSet() { log.SetFlags(0) log.Fatalf("--bird-socket is not supported on %s", runtime.GOOS) } @@ -804,12 +803,8 @@ func tryEngine(logf logger.Logf, sys *tsd.System, name string) (onlyNetstack boo netstackSubnetRouter := onlyNetstack // but mutated later on some platforms netns.SetEnabled(!onlyNetstack) - if args.birdSocketPath != "" && createBIRDClient != nil { - log.Printf("Connecting to BIRD at %s ...", args.birdSocketPath) - conf.BIRDClient, err = createBIRDClient(args.birdSocketPath) - if err != nil { - return false, fmt.Errorf("createBIRDClient: %w", err) - } + if buildfeatures.HasBird && args.birdSocketPath != "" { + conf.BIRDSocket = args.birdSocketPath } if onlyNetstack { if runtime.GOOS == "linux" && distro.Get() == distro.Synology { diff --git a/cmd/tailscaled/tailscaled_bird.go b/cmd/tailscaled/tailscaled_bird.go deleted file mode 100644 index c1c32d2bb..000000000 --- a/cmd/tailscaled/tailscaled_bird.go +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Tailscale Inc & contributors -// SPDX-License-Identifier: BSD-3-Clause - -//go:build go1.19 && (linux || darwin || freebsd || openbsd) && !ts_omit_bird - -package main - -import ( - "tailscale.com/chirp" - "tailscale.com/wgengine" -) - -func init() { - createBIRDClient = func(ctlSocket string) (wgengine.BIRDClient, error) { - return chirp.New(ctlSocket) - } -} diff --git a/feature/bird/bird.go b/feature/bird/bird.go new file mode 100644 index 000000000..13b5fb065 --- /dev/null +++ b/feature/bird/bird.go @@ -0,0 +1,107 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Package bird integrates Tailscale with the BIRD Internet Routing +// Daemon: it enables the "tailscale" protocol in BIRD while this node +// is a primary subnet router and disables it otherwise. The BIRD +// client implementation lives in tailscale.com/chirp. +package bird + +import ( + "net/netip" + + "tailscale.com/chirp" + "tailscale.com/feature" + "tailscale.com/tailcfg" + "tailscale.com/types/logger" + "tailscale.com/types/views" + "tailscale.com/wgengine" +) + +func init() { + feature.Register("bird") + wgengine.HookNewBird.Set(newBird) +} + +// protocolName is the name of the BIRD protocol that Tailscale enables +// while this node is a primary subnet router. +const protocolName = "tailscale" + +// bird implements [wgengine.Bird] on top of [chirp.BIRDClient], +// tracking the primary subnet router state across engine +// reconfigurations. One bird exists per engine. +type bird struct { + logf logger.Logf + client *chirp.BIRDClient + + // The fields below are only accessed from Reconfig and + // ReconfigDone, which the engine serializes under its internal + // lock. + + // isSubnetRouter is whether the last Reconfig found this node to + // be a primary subnet router. + isSubnetRouter bool + // lastIsSubnetRouter is the primary subnet router state last + // successfully applied to BIRD. ReconfigDone compares it against + // isSubnetRouter to decide whether to toggle the protocol. + lastIsSubnetRouter bool +} + +func newBird(logf logger.Logf, socketPath string) (wgengine.Bird, error) { + logf("wgengine: connecting to BIRD at %s ...", socketPath) + client, err := chirp.New(socketPath) + if err != nil { + return nil, err + } + // Disable the protocol at start time; ReconfigDone enables it only + // once this node becomes a primary subnet router. + if err := client.DisableProtocol(protocolName); err != nil { + return nil, err + } + return &bird{logf: logf, client: client}, nil +} + +func (b *bird) Reconfig(self tailcfg.NodeView) (changed bool) { + b.isSubnetRouter = false + if self.Valid() { + b.isSubnetRouter = hasOverlap(self.PrimaryRoutes(), self.Hostinfo().RoutableIPs()) + b.logf("[v1] Reconfig: hasOverlap(%v, %v) = %v; isSubnetRouter=%v lastIsSubnetRouter=%v", + self.PrimaryRoutes(), self.Hostinfo().RoutableIPs(), + b.isSubnetRouter, b.isSubnetRouter, b.lastIsSubnetRouter) + } + return b.isSubnetRouter != b.lastIsSubnetRouter +} + +func (b *bird) ReconfigDone() { + if b.isSubnetRouter == b.lastIsSubnetRouter { + return + } + b.logf("wgengine: Reconfig: configuring BIRD") + var err error + if b.isSubnetRouter { + err = b.client.EnableProtocol(protocolName) + } else { + err = b.client.DisableProtocol(protocolName) + } + if err != nil { + // Log but don't fail here; a later Reconfig will retry. + b.logf("wgengine: error configuring BIRD: %v", err) + return + } + b.lastIsSubnetRouter = b.isSubnetRouter +} + +func (b *bird) Close() { + b.client.DisableProtocol(protocolName) + b.client.Close() +} + +// hasOverlap reports whether any prefix in rips is also present in aips. +func hasOverlap(aips, rips views.Slice[netip.Prefix]) bool { + for _, aip := range aips.All() { + if views.SliceContains(rips, aip) { + return true + } + } + return false +} diff --git a/feature/bird/bird_test.go b/feature/bird/bird_test.go new file mode 100644 index 000000000..a5fe66e57 --- /dev/null +++ b/feature/bird/bird_test.go @@ -0,0 +1,205 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package bird + +import ( + "bufio" + "errors" + "fmt" + "net" + "net/netip" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "tailscale.com/feature" + "tailscale.com/tailcfg" + "tailscale.com/wgengine" +) + +// fakeBIRD is a fake BIRD server listening on a unix socket. It speaks +// enough of the BIRD CLI wire protocol to satisfy chirp and records the +// commands it receives so tests can assert on them. +type fakeBIRD struct { + ln net.Listener + sock string + + mu sync.Mutex + calls []string // commands received, e.g. "enable tailscale" + enabled bool // whether the "tailscale" protocol is enabled + failNext bool // whether to reply to the next command with a runtime error +} + +func newFakeBIRD(t *testing.T) *fakeBIRD { + t.Helper() + sock := filepath.Join(t.TempDir(), "bird.sock") + ln, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + fb := &fakeBIRD{ln: ln, sock: sock} + t.Cleanup(func() { ln.Close() }) + go fb.listen() + return fb +} + +func (fb *fakeBIRD) listen() { + for { + c, err := fb.ln.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) { + return + } + panic(err) + } + go fb.handle(c) + } +} + +func (fb *fakeBIRD) handle(c net.Conn) { + fmt.Fprintln(c, "0001 BIRD 2.0.8 ready.") + sc := bufio.NewScanner(c) + for sc.Scan() { + cmd := sc.Text() + var proto string + var wantEnabled bool + switch { + case strings.HasPrefix(cmd, "enable "): + proto, wantEnabled = strings.TrimPrefix(cmd, "enable "), true + case strings.HasPrefix(cmd, "disable "): + proto, wantEnabled = strings.TrimPrefix(cmd, "disable "), false + } + + fb.mu.Lock() + fb.calls = append(fb.calls, cmd) + fail := fb.failNext + fb.failNext = false + switch { + case proto != protocolName: + fmt.Fprintln(c, "9001 syntax error, unexpected CF_SYM_UNDEFINED, expecting CF_SYM_KNOWN or TEXT or ALL") + case fail: + fmt.Fprintln(c, "8001 fake runtime error") + case wantEnabled == fb.enabled: + fmt.Fprintf(c, "0010-%s: already %s\n0000 \n", proto, verb(wantEnabled)) + default: + fb.enabled = wantEnabled + fmt.Fprintf(c, "0011-%s: %s\n0000 \n", proto, verb(wantEnabled)) + } + fb.mu.Unlock() + } + if err := sc.Err(); err != nil && !errors.Is(err, net.ErrClosed) { + panic(err) + } +} + +func verb(enabled bool) string { + if enabled { + return "enabled" + } + return "disabled" +} + +// takeCalls returns the commands received since the last call and +// resets the record. It is safe to call once the chirp request that +// caused them has returned, as chirp waits for each response. +func (fb *fakeBIRD) takeCalls() []string { + fb.mu.Lock() + defer fb.mu.Unlock() + calls := fb.calls + fb.calls = nil + return calls +} + +func (fb *fakeBIRD) checkCalls(t *testing.T, want ...string) { + t.Helper() + if diff := cmp.Diff(fb.takeCalls(), want, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("BIRD calls mismatch (-got +want):\n%s", diff) + } +} + +func node(primaryRoutes, routableIPs []netip.Prefix) tailcfg.NodeView { + return (&tailcfg.Node{ + PrimaryRoutes: primaryRoutes, + Hostinfo: (&tailcfg.Hostinfo{RoutableIPs: routableIPs}).View(), + }).View() +} + +func TestBird(t *testing.T) { + if !feature.IsRegistered("bird") { + t.Fatal("bird feature not registered") + } + + fb := newFakeBIRD(t) + newBird, ok := wgengine.HookNewBird.GetOk() + if !ok { + t.Fatal("HookNewBird not set") + } + b, err := newBird(t.Logf, fb.sock) + if err != nil { + t.Fatal(err) + } + // Construction disables the protocol. + fb.checkCalls(t, "disable tailscale") + + pfx := netip.MustParsePrefix + subnetRouter := node( + []netip.Prefix{pfx("192.168.1.0/24")}, + []netip.Prefix{pfx("192.168.1.0/24"), pfx("10.0.0.0/8")}, + ) + notSubnetRouter := node( + []netip.Prefix{pfx("192.168.2.0/24")}, + []netip.Prefix{pfx("192.168.1.0/24")}, + ) + + // An invalid self node is not a subnet router; no state change, no calls. + if changed := b.Reconfig(tailcfg.NodeView{}); changed { + t.Error("Reconfig(invalid) reported change") + } + b.ReconfigDone() + fb.checkCalls(t) + + // Becoming a primary subnet router enables the protocol. + if changed := b.Reconfig(subnetRouter); !changed { + t.Error("Reconfig(subnetRouter) reported no change") + } + b.ReconfigDone() + fb.checkCalls(t, "enable tailscale") + + // Reconfig with the same state is a no-op. + if changed := b.Reconfig(subnetRouter); changed { + t.Error("Reconfig(subnetRouter) again reported change") + } + b.ReconfigDone() + fb.checkCalls(t) + + // A BIRD failure leaves the state unapplied, so the next Reconfig + // still reports a change and retries. + fb.mu.Lock() + fb.failNext = true + fb.mu.Unlock() + if changed := b.Reconfig(notSubnetRouter); !changed { + t.Error("Reconfig(notSubnetRouter) reported no change") + } + b.ReconfigDone() + fb.checkCalls(t, "disable tailscale") + if changed := b.Reconfig(notSubnetRouter); !changed { + t.Error("Reconfig(notSubnetRouter) retry reported no change") + } + b.ReconfigDone() + fb.checkCalls(t, "disable tailscale") + + // And becoming a subnet router again enables it again. + if changed := b.Reconfig(subnetRouter); !changed { + t.Error("Reconfig(subnetRouter) reported no change") + } + b.ReconfigDone() + fb.checkCalls(t, "enable tailscale") + + // Close disables the protocol on the way out. + b.Close() + fb.checkCalls(t, "disable tailscale") +} diff --git a/feature/condregister/maybe_bird.go b/feature/condregister/maybe_bird.go new file mode 100644 index 000000000..b2cae98b0 --- /dev/null +++ b/feature/condregister/maybe_bird.go @@ -0,0 +1,8 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_bird && (linux || darwin || freebsd || openbsd) + +package condregister + +import _ "tailscale.com/feature/bird" diff --git a/tsnet/tsnet_test.go b/tsnet/tsnet_test.go index 6767430f1..906008b06 100644 --- a/tsnet/tsnet_test.go +++ b/tsnet/tsnet_test.go @@ -3409,6 +3409,8 @@ func TestDeps(t *testing.T) { BadDeps: map[string]string{ "golang.org/x/crypto/ssh": "tsnet should not depend on SSH", "golang.org/x/crypto/ssh/internal/bcrypt_pbkdf": "tsnet should not depend on SSH", + "tailscale.com/chirp": "tsnet should not depend on BIRD integration", + "tailscale.com/feature/bird": "tsnet should not depend on BIRD integration", "tailscale.com/feature/clientupdate": "tsnet should not depend on feature/clientupdate", "tailscale.com/feature/remoteconfig": "tsnet should not depend on feature/remoteconfig", "tailscale.com/feature/syspolicy": "tsnet should not depend on syspolicy", diff --git a/tstest/integration/tailscaled_deps_test_darwin.go b/tstest/integration/tailscaled_deps_test_darwin.go index 3132739f6..9a19b3997 100644 --- a/tstest/integration/tailscaled_deps_test_darwin.go +++ b/tstest/integration/tailscaled_deps_test_darwin.go @@ -10,7 +10,6 @@ import ( // Otherwise cmd/go never sees that we depend on these packages' // transitive deps when we run "go install tailscaled" in a child // process and can cache a prior success when a dependency changes. - _ "tailscale.com/chirp" _ "tailscale.com/client/local" _ "tailscale.com/cmd/tailscaled/childproc" _ "tailscale.com/control/controlclient" diff --git a/tstest/integration/tailscaled_deps_test_freebsd.go b/tstest/integration/tailscaled_deps_test_freebsd.go index 3132739f6..9a19b3997 100644 --- a/tstest/integration/tailscaled_deps_test_freebsd.go +++ b/tstest/integration/tailscaled_deps_test_freebsd.go @@ -10,7 +10,6 @@ import ( // Otherwise cmd/go never sees that we depend on these packages' // transitive deps when we run "go install tailscaled" in a child // process and can cache a prior success when a dependency changes. - _ "tailscale.com/chirp" _ "tailscale.com/client/local" _ "tailscale.com/cmd/tailscaled/childproc" _ "tailscale.com/control/controlclient" diff --git a/tstest/integration/tailscaled_deps_test_linux.go b/tstest/integration/tailscaled_deps_test_linux.go index 3132739f6..9a19b3997 100644 --- a/tstest/integration/tailscaled_deps_test_linux.go +++ b/tstest/integration/tailscaled_deps_test_linux.go @@ -10,7 +10,6 @@ import ( // Otherwise cmd/go never sees that we depend on these packages' // transitive deps when we run "go install tailscaled" in a child // process and can cache a prior success when a dependency changes. - _ "tailscale.com/chirp" _ "tailscale.com/client/local" _ "tailscale.com/cmd/tailscaled/childproc" _ "tailscale.com/control/controlclient" diff --git a/tstest/integration/tailscaled_deps_test_openbsd.go b/tstest/integration/tailscaled_deps_test_openbsd.go index 3132739f6..9a19b3997 100644 --- a/tstest/integration/tailscaled_deps_test_openbsd.go +++ b/tstest/integration/tailscaled_deps_test_openbsd.go @@ -10,7 +10,6 @@ import ( // Otherwise cmd/go never sees that we depend on these packages' // transitive deps when we run "go install tailscaled" in a child // process and can cache a prior success when a dependency changes. - _ "tailscale.com/chirp" _ "tailscale.com/client/local" _ "tailscale.com/cmd/tailscaled/childproc" _ "tailscale.com/control/controlclient" diff --git a/wgengine/bird_hooks.go b/wgengine/bird_hooks.go new file mode 100644 index 000000000..f388ee792 --- /dev/null +++ b/wgengine/bird_hooks.go @@ -0,0 +1,41 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package wgengine + +import ( + "tailscale.com/feature" + "tailscale.com/tailcfg" + "tailscale.com/types/logger" +) + +// Bird is the engine's handle on the BIRD Internet Routing Daemon +// integration, implemented by the feature/bird package. It enables the +// "tailscale" protocol in BIRD while this node is a primary subnet +// router and disables it otherwise. +// +// Reconfig and ReconfigDone are only called from [Engine.Reconfig], +// serialized by the engine's internal lock. Close is called from +// [Engine.Close]. +type Bird interface { + // Reconfig recomputes from the given self node whether this node is + // a primary subnet router. It is called near the top of every + // [Engine.Reconfig], before its ErrNoChanges early return. It + // reports whether the primary subnet router state changed, which + // suppresses the engine's ErrNoChanges early return. + Reconfig(self tailcfg.NodeView) (changed bool) + + // ReconfigDone is called at the end of [Engine.Reconfig], after the + // router is configured, and applies any protocol state change + // computed by the preceding Reconfig call. + ReconfigDone() + + // Close disables the protocol and closes the connection to BIRD. + Close() +} + +// HookNewBird is set by the feature/bird package to construct each +// engine's [Bird], connecting to the BIRD unix socket at socketPath. +// If unset, BIRD integration is not linked into the binary and +// [Config.BIRDSocket] must not be set. +var HookNewBird feature.Hook[func(logf logger.Logf, socketPath string) (Bird, error)] diff --git a/wgengine/userspace.go b/wgengine/userspace.go index f5da3dcea..258f79c40 100644 --- a/wgengine/userspace.go +++ b/wgengine/userspace.go @@ -85,9 +85,13 @@ type userspaceEngine struct { netMon *netmon.Monitor health *health.Tracker netMonOwned bool // whether we created netMon (and thus need to close it) - birdClient BIRDClient // or nil controlKnobs *controlknobs.Knobs // or nil + // bird is the BIRD integration handle constructed via + // [HookNewBird], or nil if [Config.BIRDSocket] was empty or the + // feature/bird package is not linked into the binary. + bird Bird + testMaybeReconfigHook func() // for tests; if non-nil, fires if maybeReconfigWireguardLocked called // isLocalAddr reports the whether an IP is assigned to the local @@ -112,11 +116,10 @@ type userspaceEngine struct { // no longer install per-config lookup closures. peerConfigFn atomic.Pointer[func(key.NodePublic) (allowedIPs []netip.Prefix, ok bool)] - lastCfgFull wgcfg.Config - lastRouter *router.Config - lastDNSConfig dns.ConfigView // or invalid if none - lastIsSubnetRouter bool // was the node a primary subnet router in the last run. - reconfigureVPN func() error // or nil + lastCfgFull wgcfg.Config + lastRouter *router.Config + lastDNSConfig dns.ConfigView // or invalid if none + reconfigureVPN func() error // or nil // lastAppliedDisableTUNUDPGRO and lastAppliedDisableTUNTCPGRO cache the // controlknobs values that were last applied to the TUN device. They are @@ -162,13 +165,6 @@ type userspaceEngine struct { // Lock ordering: magicsock.Conn.mu, wgLock, then mu. } -// BIRDClient handles communication with the BIRD Internet Routing Daemon. -type BIRDClient interface { - EnableProtocol(proto string) error - DisableProtocol(proto string) error - Close() error -} - // Config is the engine configuration. type Config struct { // Tun is the device used by the Engine to exchange packets with @@ -228,9 +224,10 @@ type Config struct { // Used in "fake" mode for development. RespondToPing bool - // BIRDClient, if non-nil, will be used to configure BIRD whenever - // this node is a primary subnet router. - BIRDClient BIRDClient + // BIRDSocket, if non-empty, is the path of the BIRD unix socket to + // configure whenever this node is a primary subnet router. It + // requires the feature/bird package to be linked in. + BIRDSocket string // SetSubsystem, if non-nil, is called for each new subsystem created, just before a successful return. SetSubsystem func(any) @@ -369,17 +366,21 @@ func NewUserspaceEngine(logf logger.Logf, conf Config) (_ Engine, reterr error) router: rtr, dialer: conf.Dialer, confListenPort: conf.ListenPort, - birdClient: conf.BIRDClient, controlKnobs: conf.ControlKnobs, reconfigureVPN: conf.ReconfigureVPN, health: conf.HealthTracker, } - if e.birdClient != nil { - // Disable the protocol at start time. - if err := e.birdClient.DisableProtocol("tailscale"); err != nil { + if buildfeatures.HasBird && conf.BIRDSocket != "" { + newBird, ok := HookNewBird.GetOk() + if !ok { + return nil, errors.New("wgengine: Config.BIRDSocket set but the feature/bird package is not linked in") + } + bird, err := newBird(logf, conf.BIRDSocket) + if err != nil { return nil, err } + e.bird = bird } e.isLocalAddr.Store(ipset.FalseContainsIPFunc()) e.isDNSIPOverTailscale.Store(ipset.FalseContainsIPFunc()) @@ -827,17 +828,6 @@ func peerWireGuardStateFromDevice(state device.PeerSessionState) PeerWireGuardSt } } -// hasOverlap checks if there is a IPPrefix which is common amongst the two -// provided slices. -func hasOverlap(aips, rips views.Slice[netip.Prefix]) bool { - for _, aip := range aips.All() { - if views.SliceContains(rips, aip) { - return true - } - } - return false -} - // ResetAndStop resets the engine to a clean state (like calling Reconfig // with all pointers to zero values) and returns the resulting status. // @@ -877,14 +867,14 @@ func (e *userspaceEngine) Reconfig(cfg *wgcfg.Config, routerCfg *router.Config, peerMTUEnable := e.magicConn.ShouldPMTUD() - isSubnetRouter := false - if buildfeatures.HasBird && e.birdClient != nil && self.Valid() { - isSubnetRouter = hasOverlap(self.PrimaryRoutes(), self.Hostinfo().RoutableIPs()) - e.logf("[v1] Reconfig: hasOverlap(%v, %v) = %v; isSubnetRouter=%v lastIsSubnetRouter=%v", - self.PrimaryRoutes(), self.Hostinfo().RoutableIPs(), - isSubnetRouter, isSubnetRouter, e.lastIsSubnetRouter) + // Let the BIRD integration recompute whether this node is a + // primary subnet router, before the early return below so that a + // change in that state alone still reaches the protocol toggle in + // ReconfigDone at the end. + birdChanged := false + if e.bird != nil { + birdChanged = e.bird.Reconfig(self) } - isSubnetRouterChanged := buildfeatures.HasAdvertiseRoutes && isSubnetRouter != e.lastIsSubnetRouter engineChanged := !e.lastCfgFull.Equal(cfg) routerChanged := checkchange.Update(&e.lastRouter, routerCfg) @@ -905,7 +895,7 @@ func (e *userspaceEngine) Reconfig(cfg *wgcfg.Config, routerCfg *router.Config, netlogChanged = e.netlogger.Reconfig(routerCfg, routerChanged) } - if !engineChanged && !routerChanged && !dnsChanged && !listenPortChanged && !isSubnetRouterChanged && !peerMTUChanged && !netlogChanged { + if !engineChanged && !routerChanged && !dnsChanged && !listenPortChanged && !birdChanged && !peerMTUChanged && !netlogChanged { return ErrNoChanges } @@ -990,20 +980,10 @@ func (e *userspaceEngine) Reconfig(cfg *wgcfg.Config, routerCfg *router.Config, e.netlogger.ReconfigDone() } - if buildfeatures.HasBird && isSubnetRouterChanged && e.birdClient != nil { - e.logf("wgengine: Reconfig: configuring BIRD") - var err error - if isSubnetRouter { - err = e.birdClient.EnableProtocol("tailscale") - } else { - err = e.birdClient.DisableProtocol("tailscale") - } - if err != nil { - // Log but don't fail here. - e.logf("wgengine: error configuring BIRD: %v", err) - } else { - e.lastIsSubnetRouter = isSubnetRouter - } + // Let the BIRD integration apply any protocol state change now, + // after the router is configured. + if e.bird != nil { + e.bird.ReconfigDone() } e.logf("[v1] wgengine: Reconfig done") @@ -1174,9 +1154,8 @@ func (e *userspaceEngine) Close() { e.router.Close() e.wgdev.Close() e.tundev.Close() - if e.birdClient != nil { - e.birdClient.DisableProtocol("tailscale") - e.birdClient.Close() + if e.bird != nil { + e.bird.Close() } close(e.waitCh)