ipn{,/local},cmd/tailscale: add "sync" flag and pref to disable control map poll

For manual (human) testing, this lets the user disable control plane
map polls with "tailscale set --sync=false" (which survives restarts)
and "tailscale set --sync" to restore.

A high severity health warning is shown while this is active.

Updates #12639
Updates #17945

Change-Id: I83668fa5de3b5e5e25444df0815ec2a859153a6d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick 2025-11-17 08:06:16 -08:00 committed by Brad Fitzpatrick
parent 165a24744e
commit f1cddc6ecf
10 changed files with 136 additions and 6 deletions

View File

@ -63,6 +63,7 @@ type setArgsT struct {
reportPosture bool reportPosture bool
snat bool snat bool
statefulFiltering bool statefulFiltering bool
sync bool
netfilterMode string netfilterMode string
relayServerPort string relayServerPort string
} }
@ -85,6 +86,7 @@ func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet {
setf.BoolVar(&setArgs.updateApply, "auto-update", false, "automatically update to the latest available version") setf.BoolVar(&setArgs.updateApply, "auto-update", false, "automatically update to the latest available version")
setf.BoolVar(&setArgs.reportPosture, "report-posture", false, "allow management plane to gather device posture information") setf.BoolVar(&setArgs.reportPosture, "report-posture", false, "allow management plane to gather device posture information")
setf.BoolVar(&setArgs.runWebClient, "webclient", false, "expose the web interface for managing this node over Tailscale at port 5252") setf.BoolVar(&setArgs.runWebClient, "webclient", false, "expose the web interface for managing this node over Tailscale at port 5252")
setf.BoolVar(&setArgs.sync, "sync", false, hidden+"actively sync configuration from the control plane (set to false only for network failure testing)")
setf.StringVar(&setArgs.relayServerPort, "relay-server-port", "", "UDP port number (0 will pick a random unused port) for the relay server to bind to, on all interfaces, or empty string to disable relay server functionality") setf.StringVar(&setArgs.relayServerPort, "relay-server-port", "", "UDP port number (0 will pick a random unused port) for the relay server to bind to, on all interfaces, or empty string to disable relay server functionality")
ffcomplete.Flag(setf, "exit-node", func(args []string) ([]string, ffcomplete.ShellCompDirective, error) { ffcomplete.Flag(setf, "exit-node", func(args []string) ([]string, ffcomplete.ShellCompDirective, error) {
@ -149,6 +151,7 @@ func runSet(ctx context.Context, args []string) (retErr error) {
OperatorUser: setArgs.opUser, OperatorUser: setArgs.opUser,
NoSNAT: !setArgs.snat, NoSNAT: !setArgs.snat,
ForceDaemon: setArgs.forceDaemon, ForceDaemon: setArgs.forceDaemon,
Sync: opt.NewBool(setArgs.sync),
AutoUpdate: ipn.AutoUpdatePrefs{ AutoUpdate: ipn.AutoUpdatePrefs{
Check: setArgs.updateCheck, Check: setArgs.updateCheck,
Apply: opt.NewBool(setArgs.updateApply), Apply: opt.NewBool(setArgs.updateApply),

View File

@ -890,6 +890,7 @@ func init() {
addPrefFlagMapping("advertise-connector", "AppConnector") addPrefFlagMapping("advertise-connector", "AppConnector")
addPrefFlagMapping("report-posture", "PostureChecking") addPrefFlagMapping("report-posture", "PostureChecking")
addPrefFlagMapping("relay-server-port", "RelayServerPort") addPrefFlagMapping("relay-server-port", "RelayServerPort")
addPrefFlagMapping("sync", "Sync")
} }
func addPrefFlagMapping(flagName string, prefNames ...string) { func addPrefFlagMapping(flagName string, prefNames ...string) {
@ -925,7 +926,7 @@ func updateMaskedPrefsFromUpOrSetFlag(mp *ipn.MaskedPrefs, flagName string) {
if prefs, ok := prefsOfFlag[flagName]; ok { if prefs, ok := prefsOfFlag[flagName]; ok {
for _, pref := range prefs { for _, pref := range prefs {
f := reflect.ValueOf(mp).Elem() f := reflect.ValueOf(mp).Elem()
for _, name := range strings.Split(pref, ".") { for name := range strings.SplitSeq(pref, ".") {
f = f.FieldByName(name + "Set") f = f.FieldByName(name + "Set")
} }
f.SetBool(true) f.SetBool(true)

View File

@ -90,6 +90,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
Egg bool Egg bool
AdvertiseRoutes []netip.Prefix AdvertiseRoutes []netip.Prefix
AdvertiseServices []string AdvertiseServices []string
Sync opt.Bool
NoSNAT bool NoSNAT bool
NoStatefulFiltering opt.Bool NoStatefulFiltering opt.Bool
NetfilterMode preftype.NetfilterMode NetfilterMode preftype.NetfilterMode

View File

@ -363,6 +363,12 @@ func (v PrefsView) AdvertiseServices() views.Slice[string] {
return views.SliceOf(v.ж.AdvertiseServices) return views.SliceOf(v.ж.AdvertiseServices)
} }
// Sync is whether this node should sync its configuration from
// the control plane. If unset, this defaults to true.
// This exists primarily for testing, to verify that netmap caching
// and offline operation work correctly.
func (v PrefsView) Sync() opt.Bool { return v.ж.Sync }
// NoSNAT specifies whether to source NAT traffic going to // NoSNAT specifies whether to source NAT traffic going to
// destinations in AdvertiseRoutes. The default is to apply source // destinations in AdvertiseRoutes. The default is to apply source
// NAT, which makes the traffic appear to come from the router // NAT, which makes the traffic appear to come from the router
@ -482,6 +488,7 @@ var _PrefsViewNeedsRegeneration = Prefs(struct {
Egg bool Egg bool
AdvertiseRoutes []netip.Prefix AdvertiseRoutes []netip.Prefix
AdvertiseServices []string AdvertiseServices []string
Sync opt.Bool
NoSNAT bool NoSNAT bool
NoStatefulFiltering opt.Bool NoStatefulFiltering opt.Bool
NetfilterMode preftype.NetfilterMode NetfilterMode preftype.NetfilterMode

View File

@ -870,6 +870,7 @@ func (b *LocalBackend) initPrefsFromConfig(conf *conffile.Config) error {
if err := b.pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil { if err := b.pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil {
return err return err
} }
b.updateWarnSync(p.View())
b.setStaticEndpointsFromConfigLocked(conf) b.setStaticEndpointsFromConfigLocked(conf)
b.conf = conf b.conf = conf
return nil return nil
@ -931,7 +932,12 @@ func (b *LocalBackend) pauseOrResumeControlClientLocked() {
return return
} }
networkUp := b.prevIfState.AnyInterfaceUp() networkUp := b.prevIfState.AnyInterfaceUp()
b.cc.SetPaused((b.state == ipn.Stopped && b.NetMap() != nil) || (!networkUp && !testenv.InTest() && !assumeNetworkUpdateForTest())) pauseForNetwork := (b.state == ipn.Stopped && b.NetMap() != nil) || (!networkUp && !testenv.InTest() && !assumeNetworkUpdateForTest())
prefs := b.pm.CurrentPrefs()
pauseForSyncPref := prefs.Valid() && prefs.Sync().EqualBool(false)
b.cc.SetPaused(pauseForNetwork || pauseForSyncPref)
} }
// DisconnectControl shuts down control client. This can be run before node shutdown to force control to consider this ndoe // DisconnectControl shuts down control client. This can be run before node shutdown to force control to consider this ndoe
@ -2519,6 +2525,7 @@ func (b *LocalBackend) startLocked(opts ipn.Options) error {
logf("serverMode=%v", inServerMode) logf("serverMode=%v", inServerMode)
} }
b.applyPrefsToHostinfoLocked(hostinfo, prefs) b.applyPrefsToHostinfoLocked(hostinfo, prefs)
b.updateWarnSync(prefs)
persistv := prefs.Persist().AsStruct() persistv := prefs.Persist().AsStruct()
if persistv == nil { if persistv == nil {
@ -2570,6 +2577,7 @@ func (b *LocalBackend) startLocked(opts ipn.Options) error {
ControlKnobs: b.sys.ControlKnobs(), ControlKnobs: b.sys.ControlKnobs(),
Shutdown: ccShutdown, Shutdown: ccShutdown,
Bus: b.sys.Bus.Get(), Bus: b.sys.Bus.Get(),
StartPaused: prefs.Sync().EqualBool(false),
// Don't warn about broken Linux IP forwarding when // Don't warn about broken Linux IP forwarding when
// netstack is being used. // netstack is being used.
@ -4658,6 +4666,9 @@ func (b *LocalBackend) setPrefsLocked(newp *ipn.Prefs) ipn.PrefsView {
b.resetAlwaysOnOverrideLocked() b.resetAlwaysOnOverrideLocked()
} }
b.pauseOrResumeControlClientLocked() // for prefs.Sync changes
b.updateWarnSync(prefs)
if oldp.ShieldsUp() != newp.ShieldsUp || hostInfoChanged { if oldp.ShieldsUp() != newp.ShieldsUp || hostInfoChanged {
b.doSetHostinfoFilterServicesLocked() b.doSetHostinfoFilterServicesLocked()
} }
@ -6665,6 +6676,13 @@ func (b *LocalBackend) sshServerOrInit() (_ SSHServer, err error) {
return b.sshServer, nil return b.sshServer, nil
} }
var warnSyncDisabled = health.Register(&health.Warnable{
Code: "sync-disabled",
Title: "Tailscale Sync is Disabled",
Severity: health.SeverityHigh,
Text: health.StaticMessage("Tailscale control plane syncing is disabled; run `tailscale set --sync` to restore"),
})
var warnSSHSELinuxWarnable = health.Register(&health.Warnable{ var warnSSHSELinuxWarnable = health.Register(&health.Warnable{
Code: "ssh-unavailable-selinux-enabled", Code: "ssh-unavailable-selinux-enabled",
Title: "Tailscale SSH and SELinux", Title: "Tailscale SSH and SELinux",
@ -6680,6 +6698,14 @@ func (b *LocalBackend) updateSELinuxHealthWarning() {
} }
} }
func (b *LocalBackend) updateWarnSync(prefs ipn.PrefsView) {
if prefs.Sync().EqualBool(false) {
b.health.SetUnhealthy(warnSyncDisabled, nil)
} else {
b.health.SetHealthy(warnSyncDisabled)
}
}
func (b *LocalBackend) handleSSHConn(c net.Conn) (err error) { func (b *LocalBackend) handleSSHConn(c net.Conn) (err error) {
s, err := b.sshServerOrInit() s, err := b.sshServerOrInit()
if err != nil { if err != nil {

View File

@ -1129,10 +1129,12 @@ func TestProfileStateChangeCallback(t *testing.T) {
} }
gotChanges := make([]stateChange, 0, len(tt.wantChanges)) gotChanges := make([]stateChange, 0, len(tt.wantChanges))
pm.StateChangeHook = func(profile ipn.LoginProfileView, prefs ipn.PrefsView, sameNode bool) { pm.StateChangeHook = func(profile ipn.LoginProfileView, prefView ipn.PrefsView, sameNode bool) {
prefs := prefView.AsStruct()
prefs.Sync = prefs.Sync.Normalized()
gotChanges = append(gotChanges, stateChange{ gotChanges = append(gotChanges, stateChange{
Profile: profile.AsStruct(), Profile: profile.AsStruct(),
Prefs: prefs.AsStruct(), Prefs: prefs,
SameNode: sameNode, SameNode: sameNode,
}) })
} }

View File

@ -207,6 +207,12 @@ type Prefs struct {
// control server. // control server.
AdvertiseServices []string AdvertiseServices []string
// Sync is whether this node should sync its configuration from
// the control plane. If unset, this defaults to true.
// This exists primarily for testing, to verify that netmap caching
// and offline operation work correctly.
Sync opt.Bool
// NoSNAT specifies whether to source NAT traffic going to // NoSNAT specifies whether to source NAT traffic going to
// destinations in AdvertiseRoutes. The default is to apply source // destinations in AdvertiseRoutes. The default is to apply source
// NAT, which makes the traffic appear to come from the router // NAT, which makes the traffic appear to come from the router
@ -364,12 +370,13 @@ type MaskedPrefs struct {
EggSet bool `json:",omitempty"` EggSet bool `json:",omitempty"`
AdvertiseRoutesSet bool `json:",omitempty"` AdvertiseRoutesSet bool `json:",omitempty"`
AdvertiseServicesSet bool `json:",omitempty"` AdvertiseServicesSet bool `json:",omitempty"`
SyncSet bool `json:",omitzero"`
NoSNATSet bool `json:",omitempty"` NoSNATSet bool `json:",omitempty"`
NoStatefulFilteringSet bool `json:",omitempty"` NoStatefulFilteringSet bool `json:",omitempty"`
NetfilterModeSet bool `json:",omitempty"` NetfilterModeSet bool `json:",omitempty"`
OperatorUserSet bool `json:",omitempty"` OperatorUserSet bool `json:",omitempty"`
ProfileNameSet bool `json:",omitempty"` ProfileNameSet bool `json:",omitempty"`
AutoUpdateSet AutoUpdatePrefsMask `json:",omitempty"` AutoUpdateSet AutoUpdatePrefsMask `json:",omitzero"`
AppConnectorSet bool `json:",omitempty"` AppConnectorSet bool `json:",omitempty"`
PostureCheckingSet bool `json:",omitempty"` PostureCheckingSet bool `json:",omitempty"`
NetfilterKindSet bool `json:",omitempty"` NetfilterKindSet bool `json:",omitempty"`
@ -547,6 +554,9 @@ func (p *Prefs) pretty(goos string) string {
if p.LoggedOut { if p.LoggedOut {
sb.WriteString("loggedout=true ") sb.WriteString("loggedout=true ")
} }
if p.Sync.EqualBool(false) {
sb.WriteString("sync=false ")
}
if p.ForceDaemon { if p.ForceDaemon {
sb.WriteString("server=true ") sb.WriteString("server=true ")
} }
@ -653,6 +663,7 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
p.ExitNodeAllowLANAccess == p2.ExitNodeAllowLANAccess && p.ExitNodeAllowLANAccess == p2.ExitNodeAllowLANAccess &&
p.CorpDNS == p2.CorpDNS && p.CorpDNS == p2.CorpDNS &&
p.RunSSH == p2.RunSSH && p.RunSSH == p2.RunSSH &&
p.Sync.Normalized() == p2.Sync.Normalized() &&
p.RunWebClient == p2.RunWebClient && p.RunWebClient == p2.RunWebClient &&
p.WantRunning == p2.WantRunning && p.WantRunning == p2.WantRunning &&
p.LoggedOut == p2.LoggedOut && p.LoggedOut == p2.LoggedOut &&
@ -956,10 +967,15 @@ func PrefsFromBytes(b []byte, base *Prefs) error {
if len(b) == 0 { if len(b) == 0 {
return nil return nil
} }
return json.Unmarshal(b, base) return json.Unmarshal(b, base)
} }
func (p *Prefs) normalizeOptBools() {
if p.Sync == opt.ExplicitlyUnset {
p.Sync = ""
}
}
var jsonEscapedZero = []byte(`\u0000`) var jsonEscapedZero = []byte(`\u0000`)
// LoadPrefsWindows loads a legacy relaynode config file into Prefs with // LoadPrefsWindows loads a legacy relaynode config file into Prefs with

View File

@ -57,6 +57,7 @@ func TestPrefsEqual(t *testing.T) {
"Egg", "Egg",
"AdvertiseRoutes", "AdvertiseRoutes",
"AdvertiseServices", "AdvertiseServices",
"Sync",
"NoSNAT", "NoSNAT",
"NoStatefulFiltering", "NoStatefulFiltering",
"NetfilterMode", "NetfilterMode",
@ -404,6 +405,7 @@ func checkPrefs(t *testing.T, p Prefs) {
if err != nil { if err != nil {
t.Fatalf("PrefsFromBytes(p2) failed: bytes=%q; err=%v\n", p2.ToBytes(), err) t.Fatalf("PrefsFromBytes(p2) failed: bytes=%q; err=%v\n", p2.ToBytes(), err)
} }
p2b.normalizeOptBools()
p2p := p2.Pretty() p2p := p2.Pretty()
p2bp := p2b.Pretty() p2bp := p2b.Pretty()
t.Logf("\np2p: %#v\np2bp: %#v\n", p2p, p2bp) t.Logf("\np2p: %#v\np2bp: %#v\n", p2p, p2bp)
@ -419,6 +421,42 @@ func checkPrefs(t *testing.T, p Prefs) {
} }
} }
// PrefsFromBytes documents that it preserves fields unset in the JSON.
// This verifies that stays true.
func TestPrefsFromBytesPreservesOldValues(t *testing.T) {
tests := []struct {
name string
old Prefs
json []byte
want Prefs
}{
{
name: "preserve-control-url",
old: Prefs{ControlURL: "https://foo"},
json: []byte(`{"RouteAll": true}`),
want: Prefs{ControlURL: "https://foo", RouteAll: true},
},
{
name: "opt.Bool", // test that we don't normalize it early
old: Prefs{Sync: "unset"},
json: []byte(`{}`),
want: Prefs{Sync: "unset"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
old := tt.old // shallow
err := PrefsFromBytes(tt.json, &old)
if err != nil {
t.Fatalf("PrefsFromBytes failed: %v", err)
}
if !old.Equals(&tt.want) {
t.Fatalf("got %+v; want %+v", old, tt.want)
}
})
}
}
func TestBasicPrefs(t *testing.T) { func TestBasicPrefs(t *testing.T) {
tstest.PanicOnLog() tstest.PanicOnLog()
@ -591,6 +629,11 @@ func TestPrefsPretty(t *testing.T) {
"linux", "linux",
`Prefs{ra=false dns=false want=false routes=[] nf=off update=off Persist=nil}`, `Prefs{ra=false dns=false want=false routes=[] nf=off update=off Persist=nil}`,
}, },
{
Prefs{Sync: "false"},
"linux",
"Prefs{ra=false dns=false want=false sync=false routes=[] nf=off update=off Persist=nil}",
},
} }
for i, tt := range tests { for i, tt := range tests {
got := tt.p.pretty(tt.os) got := tt.p.pretty(tt.os)

View File

@ -83,6 +83,17 @@ func (b *Bool) Scan(src any) error {
} }
} }
// Normalized returns the normalized form of b, mapping "unset" to ""
// and leaving other values unchanged.
func (b Bool) Normalized() Bool {
switch b {
case ExplicitlyUnset:
return Empty
default:
return b
}
}
// EqualBool reports whether b is equal to v. // EqualBool reports whether b is equal to v.
// If b is empty or not a valid bool, it reports false. // If b is empty or not a valid bool, it reports false.
func (b Bool) EqualBool(v bool) bool { func (b Bool) EqualBool(v bool) bool {

View File

@ -106,6 +106,8 @@ func TestBoolEqualBool(t *testing.T) {
}{ }{
{"", true, false}, {"", true, false},
{"", false, false}, {"", false, false},
{"unset", true, false},
{"unset", false, false},
{"sdflk;", true, false}, {"sdflk;", true, false},
{"sldkf;", false, false}, {"sldkf;", false, false},
{"true", true, true}, {"true", true, true},
@ -122,6 +124,24 @@ func TestBoolEqualBool(t *testing.T) {
} }
} }
func TestBoolNormalized(t *testing.T) {
tests := []struct {
in Bool
want Bool
}{
{"", ""},
{"true", "true"},
{"false", "false"},
{"unset", ""},
{"foo", "foo"},
}
for _, tt := range tests {
if got := tt.in.Normalized(); got != tt.want {
t.Errorf("(%q).Normalized() = %q; want %q", string(tt.in), string(got), string(tt.want))
}
}
}
func TestUnmarshalAlloc(t *testing.T) { func TestUnmarshalAlloc(t *testing.T) {
b := json.Unmarshaler(new(Bool)) b := json.Unmarshaler(new(Bool))
n := testing.AllocsPerRun(10, func() { b.UnmarshalJSON(trueBytes) }) n := testing.AllocsPerRun(10, func() { b.UnmarshalJSON(trueBytes) })