mirror of
https://github.com/tailscale/tailscale.git
synced 2026-07-20 21:23:07 +08:00
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 <bradfitz@tailscale.com>
206 lines
5.2 KiB
Go
206 lines
5.2 KiB
Go
// 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")
|
|
}
|