Merge ee94447b4f259c23be023a2cd1b9a2d4fbc0fcba into b3455fa99a5e8d07133d5140017ec7c49f032a07

This commit is contained in:
Nick Khyl 2025-03-24 23:08:51 +00:00 committed by GitHub
commit d7751c715b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 494 additions and 12 deletions

View File

@ -5706,13 +5706,15 @@ func (b *LocalBackend) enterStateLockedOnEntry(newState ipn.State, unlock unlock
}
b.blockEngineUpdates(true)
fallthrough
case ipn.Stopped:
case ipn.Stopped, ipn.NoState:
// Unconfigure the engine if it has stopped (WantRunning is set to false)
// or if we've switched to a different profile and the state is unknown.
err := b.e.Reconfig(&wgcfg.Config{}, &router.Config{}, &dns.Config{})
if err != nil {
b.logf("Reconfig(down): %v", err)
}
if authURL == "" {
if newState == ipn.Stopped && authURL == "" {
systemd.Status("Stopped; run 'tailscale up' to log in")
}
case ipn.Starting, ipn.NeedsMachineAuth:
@ -5726,8 +5728,6 @@ func (b *LocalBackend) enterStateLockedOnEntry(newState ipn.State, unlock unlock
addrStrs = append(addrStrs, p.Addr().String())
}
systemd.Status("Connected; %s; %s", activeLogin, strings.Join(addrStrs, " "))
case ipn.NoState:
// Do nothing.
default:
b.logf("[unexpected] unknown newState %#v", newState)
}

View File

@ -4407,19 +4407,27 @@ func TestNotificationTargetMatch(t *testing.T) {
type newTestControlFn func(tb testing.TB, opts controlclient.Options) controlclient.Client
func newLocalBackendWithTestControl(t *testing.T, enableLogging bool, newControl newTestControlFn) *LocalBackend {
return newLocalBackendWithSysAndTestControl(t, enableLogging, new(tsd.System), newControl)
}
func newLocalBackendWithSysAndTestControl(t *testing.T, enableLogging bool, sys *tsd.System, newControl newTestControlFn) *LocalBackend {
logf := logger.Discard
if enableLogging {
logf = tstest.WhileTestRunningLogger(t)
}
sys := new(tsd.System)
store := new(mem.Store)
sys.Set(store)
e, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker(), sys.UserMetricsRegistry())
if err != nil {
t.Fatalf("NewFakeUserspaceEngine: %v", err)
if _, hasStore := sys.StateStore.GetOK(); !hasStore {
store := new(mem.Store)
sys.Set(store)
}
if _, hasEngine := sys.Engine.GetOK(); !hasEngine {
e, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker(), sys.UserMetricsRegistry())
if err != nil {
t.Fatalf("NewFakeUserspaceEngine: %v", err)
}
t.Cleanup(e.Close)
sys.Set(e)
}
t.Cleanup(e.Close)
sys.Set(e)
b, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0)
if err != nil {

View File

@ -5,26 +5,46 @@ package ipnlocal
import (
"context"
"errors"
"net/netip"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
qt "github.com/frankban/quicktest"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"tailscale.com/control/controlclient"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/dns"
"tailscale.com/net/netmon"
"tailscale.com/net/packet"
"tailscale.com/net/tsdial"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
"tailscale.com/tstest"
"tailscale.com/types/dnstype"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/logid"
"tailscale.com/types/netmap"
"tailscale.com/types/persist"
"tailscale.com/types/preftype"
"tailscale.com/util/dnsname"
"tailscale.com/util/mak"
"tailscale.com/util/must"
"tailscale.com/wgengine"
"tailscale.com/wgengine/filter"
"tailscale.com/wgengine/magicsock"
"tailscale.com/wgengine/router"
"tailscale.com/wgengine/wgcfg"
"tailscale.com/wgengine/wgint"
)
// notifyThrottler receives notifications from an ipn.Backend, blocking
@ -170,6 +190,14 @@ func (cc *mockControl) send(err error, url string, loginFinished bool, nm *netma
}
}
func (cc *mockControl) authenticated(nm *netmap.NetworkMap) {
if selfUser, ok := nm.UserProfiles[nm.SelfNode.User()]; ok {
cc.persist.UserProfile = *selfUser.AsStruct()
}
cc.persist.NodeID = nm.SelfNode.StableID()
cc.send(nil, "", true, nm)
}
// called records that a particular function name was called.
func (cc *mockControl) called(s string) {
cc.mu.Lock()
@ -1076,3 +1104,449 @@ func TestWGEngineStatusRace(t *testing.T) {
wg.Wait()
wantState(ipn.Running)
}
// TestEngineReconfigOnStateChange verifies that wgengine is properly reconfigured
// when the LocalBackend's state changes, such as when the user logs in, switches
// profiles, or disconnects from Tailscale.
func TestEngineReconfigOnStateChange(t *testing.T) {
enableLogging := false
connect := &ipn.MaskedPrefs{Prefs: ipn.Prefs{WantRunning: true}, WantRunningSet: true}
disconnect := &ipn.MaskedPrefs{Prefs: ipn.Prefs{WantRunning: false}, WantRunningSet: true}
node1 := testNetmapForNode(1, "node-1", []netip.Prefix{netip.MustParsePrefix("100.64.1.1/32")})
node2 := testNetmapForNode(2, "node-2", []netip.Prefix{netip.MustParsePrefix("100.64.1.2/32")})
routesWithQuad100 := func(extra ...netip.Prefix) []netip.Prefix {
return append(extra, netip.MustParsePrefix("100.100.100.100/32"))
}
hostsFor := func(nm *netmap.NetworkMap) map[dnsname.FQDN][]netip.Addr {
var hosts map[dnsname.FQDN][]netip.Addr
appendNode := func(n tailcfg.NodeView) {
addrs := make([]netip.Addr, 0, n.Addresses().Len())
for _, addr := range n.Addresses().All() {
addrs = append(addrs, addr.Addr())
}
mak.Set(&hosts, must.Get(dnsname.ToFQDN(n.Name())), addrs)
}
if nm != nil && nm.SelfNode.Valid() {
appendNode(nm.SelfNode)
}
for _, n := range nm.Peers {
appendNode(n)
}
return hosts
}
tests := []struct {
name string
steps func(*testing.T, *LocalBackend, func() *mockControl)
wantState ipn.State
wantCfg *wgcfg.Config
wantRouterCfg *router.Config
wantDNSCfg *dns.Config
}{
{
name: "Initial",
// The configs are nil until the the LocalBackend is started.
wantState: ipn.NoState,
wantCfg: nil,
wantRouterCfg: nil,
wantDNSCfg: nil,
},
{
name: "Start",
steps: func(t *testing.T, lb *LocalBackend, _ func() *mockControl) {
mustDo(t)(lb.Start(ipn.Options{}))
},
// Once started, all configs must be reset and have their zero values.
wantState: ipn.NeedsLogin,
wantCfg: &wgcfg.Config{},
wantRouterCfg: &router.Config{},
wantDNSCfg: &dns.Config{},
},
{
name: "Start/Connect",
steps: func(t *testing.T, lb *LocalBackend, _ func() *mockControl) {
mustDo(t)(lb.Start(ipn.Options{}))
mustDo2(t)(lb.EditPrefs(connect))
},
// Same if WantRunning is true, but the auth is not completed yet.
wantState: ipn.NeedsLogin,
wantCfg: &wgcfg.Config{},
wantRouterCfg: &router.Config{},
wantDNSCfg: &dns.Config{},
},
{
name: "Start/Connect/Login",
steps: func(t *testing.T, lb *LocalBackend, cc func() *mockControl) {
mustDo(t)(lb.Start(ipn.Options{}))
mustDo2(t)(lb.EditPrefs(connect))
cc().authenticated(node1)
},
// After the auth is completed, the configs must be updated to reflect the node's netmap.
wantState: ipn.Starting,
wantCfg: &wgcfg.Config{
Name: "tailscale",
NodeID: node1.SelfNode.StableID(),
Peers: []wgcfg.Peer{},
Addresses: node1.SelfNode.Addresses().AsSlice(),
},
wantRouterCfg: &router.Config{
SNATSubnetRoutes: true,
NetfilterMode: preftype.NetfilterOn,
LocalAddrs: node1.SelfNode.Addresses().AsSlice(),
Routes: routesWithQuad100(),
},
wantDNSCfg: &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: hostsFor(node1),
},
},
{
name: "Start/Connect/Login/Disconnect",
steps: func(t *testing.T, lb *LocalBackend, cc func() *mockControl) {
mustDo(t)(lb.Start(ipn.Options{}))
mustDo2(t)(lb.EditPrefs(connect))
cc().authenticated(node1)
mustDo2(t)(lb.EditPrefs(disconnect))
},
// After disconnecting, all configs must be reset and have their zero values.
wantState: ipn.Stopped,
wantCfg: &wgcfg.Config{},
wantRouterCfg: &router.Config{},
wantDNSCfg: &dns.Config{},
},
{
name: "Start/Connect/Login/NewProfile",
steps: func(t *testing.T, lb *LocalBackend, cc func() *mockControl) {
mustDo(t)(lb.Start(ipn.Options{}))
mustDo2(t)(lb.EditPrefs(connect))
cc().authenticated(node1)
mustDo(t)(lb.NewProfile())
},
// After switching to a new, empty profile, all configs should be reset
// and have their zero values until the auth is completed.
wantState: ipn.NeedsLogin,
wantCfg: &wgcfg.Config{},
wantRouterCfg: &router.Config{},
wantDNSCfg: &dns.Config{},
},
{
name: "Start/Connect/Login/NewProfile/Login",
steps: func(t *testing.T, lb *LocalBackend, cc func() *mockControl) {
mustDo(t)(lb.Start(ipn.Options{}))
mustDo2(t)(lb.EditPrefs(connect))
cc().authenticated(node1)
mustDo(t)(lb.NewProfile())
mustDo2(t)(lb.EditPrefs(connect))
cc().authenticated(node2)
},
// Once the auth is completed, the configs must be updated to reflect the node's netmap.
wantState: ipn.Starting,
wantCfg: &wgcfg.Config{
Name: "tailscale",
NodeID: node2.SelfNode.StableID(),
Peers: []wgcfg.Peer{},
Addresses: node2.SelfNode.Addresses().AsSlice(),
},
wantRouterCfg: &router.Config{
SNATSubnetRoutes: true,
NetfilterMode: preftype.NetfilterOn,
LocalAddrs: node2.SelfNode.Addresses().AsSlice(),
Routes: routesWithQuad100(),
},
wantDNSCfg: &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: hostsFor(node2),
},
},
{
name: "Start/Connect/Login/SwitchProfile",
steps: func(t *testing.T, lb *LocalBackend, cc func() *mockControl) {
mustDo(t)(lb.Start(ipn.Options{}))
mustDo2(t)(lb.EditPrefs(connect))
cc().authenticated(node1)
profileID := lb.CurrentProfile().ID()
mustDo(t)(lb.NewProfile())
cc().authenticated(node2)
mustDo(t)(lb.SwitchProfile(profileID))
},
// After switching to an existing profile, all configs must be reset
// and have their zero values until the (non-interactive) login is completed.
wantState: ipn.NoState,
wantCfg: &wgcfg.Config{},
wantRouterCfg: &router.Config{},
wantDNSCfg: &dns.Config{},
},
{
name: "Start/Connect/Login/SwitchProfile/NonInteractiveLogin",
steps: func(t *testing.T, lb *LocalBackend, cc func() *mockControl) {
mustDo(t)(lb.Start(ipn.Options{}))
mustDo2(t)(lb.EditPrefs(connect))
cc().authenticated(node1)
profileID := lb.CurrentProfile().ID()
mustDo(t)(lb.NewProfile())
cc().authenticated(node2)
mustDo(t)(lb.SwitchProfile(profileID))
cc().authenticated(node1) // complete the login
},
// After switching profiles and completing the auth, the configs
// must be updated to reflect the node's netmap.
wantState: ipn.Starting,
wantCfg: &wgcfg.Config{
Name: "tailscale",
NodeID: node1.SelfNode.StableID(),
Peers: []wgcfg.Peer{},
Addresses: node1.SelfNode.Addresses().AsSlice(),
},
wantRouterCfg: &router.Config{
SNATSubnetRoutes: true,
NetfilterMode: preftype.NetfilterOn,
LocalAddrs: node1.SelfNode.Addresses().AsSlice(),
Routes: routesWithQuad100(),
},
wantDNSCfg: &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: hostsFor(node1),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lb, engine, cc := newLocalBackendWithMockEngineAndControl(t, enableLogging)
if tt.steps != nil {
tt.steps(t, lb, cc)
}
if gotState := lb.State(); gotState != tt.wantState {
t.Errorf("State: got %v; want %v", gotState, tt.wantState)
}
opts := []cmp.Option{
cmpopts.EquateComparable(key.NodePublic{}, netip.Addr{}, netip.Prefix{}),
}
if diff := cmp.Diff(tt.wantCfg, engine.Config(), opts...); diff != "" {
t.Errorf("wgcfg.Config(+got -want): %v", diff)
}
if diff := cmp.Diff(tt.wantRouterCfg, engine.RouterConfig(), opts...); diff != "" {
t.Errorf("router.Config(+got -want): %v", diff)
}
if diff := cmp.Diff(tt.wantDNSCfg, engine.DNSConfig(), opts...); diff != "" {
t.Errorf("dns.Config(+got -want): %v", diff)
}
})
}
}
func testNetmapForNode(userID tailcfg.UserID, name string, addresses []netip.Prefix) *netmap.NetworkMap {
const (
domain = "example.com"
magicDNSSuffix = ".test.ts.net"
)
user := &tailcfg.UserProfile{
ID: userID,
DisplayName: name,
LoginName: strings.Join([]string{name, domain}, "@"),
}
self := &tailcfg.Node{
ID: tailcfg.NodeID(1000 + userID),
StableID: tailcfg.StableNodeID("stable-" + name),
User: user.ID,
Name: name + magicDNSSuffix,
Addresses: addresses,
MachineAuthorized: true,
}
return &netmap.NetworkMap{
SelfNode: self.View(),
Name: self.Name,
Domain: domain,
UserProfiles: map[tailcfg.UserID]tailcfg.UserProfileView{
user.ID: user.View(),
},
}
}
func mustDo(t *testing.T) func(error) {
t.Helper()
return func(err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
}
func mustDo2(t *testing.T) func(any, error) {
t.Helper()
return func(_ any, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
}
func newLocalBackendWithMockEngineAndControl(t *testing.T, enableLogging bool) (*LocalBackend, *mockEngine, func() *mockControl) {
t.Helper()
logf := logger.Discard
if enableLogging {
logf = tstest.WhileTestRunningLogger(t)
}
dialer := &tsdial.Dialer{Logf: logf}
dialer.SetNetMon(netmon.NewStatic())
sys := &tsd.System{}
sys.Set(dialer)
sys.Set(dialer.NetMon())
magicConn, err := magicsock.NewConn(magicsock.Options{
Logf: logf,
NetMon: dialer.NetMon(),
Metrics: sys.UserMetricsRegistry(),
HealthTracker: sys.HealthTracker(),
DisablePortMapper: true,
})
if err != nil {
t.Fatalf("NewConn failed: %v", err)
}
magicConn.SetNetworkUp(dialer.NetMon().InterfaceState().AnyInterfaceUp())
sys.Set(magicConn)
engine := newMockEngine()
sys.Set(engine)
t.Cleanup(func() {
engine.Close()
<-engine.Done()
})
lb := newLocalBackendWithSysAndTestControl(t, enableLogging, sys, func(tb testing.TB, opts controlclient.Options) controlclient.Client {
return newClient(tb, opts)
})
return lb, engine, func() *mockControl { return lb.cc.(*mockControl) }
}
var _ wgengine.Engine = (*mockEngine)(nil)
// mockEngine implements [wgengine.Engine].
type mockEngine struct {
done chan struct{} // closed when Close is called
mu sync.Mutex // protects all following fields
closed bool
cfg *wgcfg.Config
routerCfg *router.Config
dnsCfg *dns.Config
filter, jailedFilter *filter.Filter
statusCb wgengine.StatusCallback
}
func newMockEngine() *mockEngine {
return &mockEngine{
done: make(chan struct{}),
}
}
func (e *mockEngine) Reconfig(cfg *wgcfg.Config, routerCfg *router.Config, dnsCfg *dns.Config) error {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return errors.New("engine closed")
}
e.cfg = cfg
e.routerCfg = routerCfg
e.dnsCfg = dnsCfg
return nil
}
func (e *mockEngine) Config() *wgcfg.Config {
e.mu.Lock()
defer e.mu.Unlock()
return e.cfg
}
func (e *mockEngine) RouterConfig() *router.Config {
e.mu.Lock()
defer e.mu.Unlock()
return e.routerCfg
}
func (e *mockEngine) DNSConfig() *dns.Config {
e.mu.Lock()
defer e.mu.Unlock()
return e.dnsCfg
}
func (e *mockEngine) PeerForIP(netip.Addr) (_ wgengine.PeerForIP, ok bool) {
return wgengine.PeerForIP{}, false
}
func (e *mockEngine) GetFilter() *filter.Filter {
e.mu.Lock()
defer e.mu.Unlock()
return e.filter
}
func (e *mockEngine) SetFilter(f *filter.Filter) {
e.mu.Lock()
e.filter = f
e.mu.Unlock()
}
func (e *mockEngine) GetJailedFilter() *filter.Filter {
e.mu.Lock()
defer e.mu.Unlock()
return e.jailedFilter
}
func (e *mockEngine) SetJailedFilter(f *filter.Filter) {
e.mu.Lock()
e.jailedFilter = f
e.mu.Unlock()
}
func (e *mockEngine) SetStatusCallback(cb wgengine.StatusCallback) {
e.mu.Lock()
e.statusCb = cb
e.mu.Unlock()
}
func (e *mockEngine) RequestStatus() {
e.mu.Lock()
cb := e.statusCb
e.mu.Unlock()
if cb != nil {
cb(&wgengine.Status{AsOf: time.Now()}, nil)
}
}
func (e *mockEngine) PeerByKey(key.NodePublic) (_ wgint.Peer, ok bool) {
return wgint.Peer{}, false
}
func (e *mockEngine) SetNetworkMap(*netmap.NetworkMap) {}
func (e *mockEngine) UpdateStatus(*ipnstate.StatusBuilder) {}
func (e *mockEngine) Ping(ip netip.Addr, pingType tailcfg.PingType, size int, cb func(*ipnstate.PingResult)) {
cb(&ipnstate.PingResult{IP: ip.String(), Err: "not implemented"})
}
func (e *mockEngine) InstallCaptureHook(packet.CaptureCallback) {}
func (e *mockEngine) Close() {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return
}
e.closed = true
close(e.done)
}
func (e *mockEngine) Done() <-chan struct{} {
return e.done
}