diff --git a/cmd/tailscale/cli/debug.go b/cmd/tailscale/cli/debug.go index 51e825803..374e7fd98 100644 --- a/cmd/tailscale/cli/debug.go +++ b/cmd/tailscale/cli/debug.go @@ -132,13 +132,23 @@ var debugCmd = &ffcli.Command{ { Name: "derp-set-homeless", Exec: localAPIAction("derp-set-homeless"), - ShortHelp: "enable DERP homeless mode (breaks reachablility)", + ShortHelp: "enable DERP homeless mode (breaks reachability)", }, { Name: "derp-unset-homeless", Exec: localAPIAction("derp-unset-homeless"), ShortHelp: "disable DERP homeless mode", }, + { + Name: "sleep-set", + Exec: localAPIAction("sleep-set"), + ShortHelp: "asks the backend to enter sleep mode", + }, + { + Name: "sleep-unset", + Exec: localAPIAction("sleep-unset"), + ShortHelp: "asks the backend to leave sleep mode and resume all features", + }, { Name: "break-tcp-conns", Exec: localAPIAction("break-tcp-conns"), diff --git a/control/controlclient/auto.go b/control/controlclient/auto.go index d0551cdab..f4d9a4f1a 100644 --- a/control/controlclient/auto.go +++ b/control/controlclient/auto.go @@ -139,6 +139,7 @@ type Auto struct { loginGoal *LoginGoal // non-nil if some login activity is desired inMapPoll bool // true once we get the first MapResponse in a stream; false when HTTP response ends state State // TODO(bradfitz): delete this, make it computed by method from other state + isSleeping bool // whether we are ZZZing authCtx context.Context // context used for auth requests mapCtx context.Context // context used for netmap and update requests @@ -200,6 +201,16 @@ func NewNoStart(opts Options) (_ *Auto, err error) { } +func (c *Auto) SetSleepMode(enabled bool) { + c.logf("setSleepMode(%v)", enabled) + c.isSleeping = enabled + c.SetPaused(enabled) +} + +func (c *Auto) IsSleeping() bool { + return c.isSleeping +} + // SetPaused controls whether HTTP activity should be paused. // // The client can be paused and unpaused repeatedly, unlike Start and Shutdown, which can only be used once. diff --git a/control/controlclient/client.go b/control/controlclient/client.go index ef5af68c6..2acd82ef8 100644 --- a/control/controlclient/client.go +++ b/control/controlclient/client.go @@ -54,6 +54,10 @@ type Client interface { // TODO: It might be better to simply shutdown the controlclient and // make a new one when it's time to unpause. SetPaused(bool) + // SetSleepMode pauses the control client and prevents anybody else + // from unpausing it until SetSleepMode(false) is called again + SetSleepMode(bool) + IsSleeping() bool // AuthCantContinue returns whether authentication is blocked. If it // is, you either need to visit the auth URL (previously sent in a // Status callback) or call the Login function appropriately. diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index b8aa769a1..3bc164124 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -53,6 +53,7 @@ import ( "tailscale.com/ipn/policy" "tailscale.com/log/sockstatlog" "tailscale.com/logpolicy" + "tailscale.com/logtail" "tailscale.com/net/dns" "tailscale.com/net/dnscache" "tailscale.com/net/dnsfallback" @@ -580,7 +581,12 @@ func (b *LocalBackend) pauseOrResumeControlClientLocked() { return } networkUp := b.prevIfState.AnyInterfaceUp() - b.cc.SetPaused((b.state == ipn.Stopped && b.netMap != nil) || (!networkUp && !testenv.InTest())) + shouldPause := (b.state == ipn.Stopped && b.netMap != nil) || (!networkUp && !testenv.InTest()) + if b.cc.IsSleeping() && shouldPause == false { + // Leave things untouched if a request to un-pause comes in while we should be sleeping. + return + } + b.cc.SetPaused(shouldPause) } // linkChange is our network monitor callback, called whenever the network changes. @@ -5327,6 +5333,17 @@ func peerCanProxyDNS(p tailcfg.NodeView) bool { return false } +func (b *LocalBackend) SetSleep(enabled bool) { + b.logf("SetSleep: enabled = %v", enabled) + b.cc.SetSleepMode(enabled) + b.MagicConn().SetHomeless(enabled) + if enabled { + logtail.Disable() + } else { + logtail.Enable() + } +} + func (b *LocalBackend) DebugRebind() error { b.MagicConn().Rebind() return nil diff --git a/ipn/ipnlocal/state_test.go b/ipn/ipnlocal/state_test.go index 403233a11..395fc750a 100644 --- a/ipn/ipnlocal/state_test.go +++ b/ipn/ipnlocal/state_test.go @@ -91,10 +91,11 @@ func (nt *notifyThrottler) drain(count int) []ipn.Notify { // in the controlclient.Client, so by controlling it, we can check that // the state machine works as expected. type mockControl struct { - tb testing.TB - logf logger.Logf - opts controlclient.Options - paused atomic.Bool + tb testing.TB + logf logger.Logf + opts controlclient.Options + paused atomic.Bool + isSleeping atomic.Bool mu sync.Mutex machineKey key.MachinePrivate @@ -236,6 +237,18 @@ func (cc *mockControl) SetPaused(paused bool) { } } +func (cc *mockControl) SetSleepMode(enabled bool) { + cc.mu.Lock() + defer cc.mu.Unlock() + cc.isSleeping.Store(enabled) +} + +func (cc *mockControl) IsSleeping() bool { + cc.mu.Lock() + defer cc.mu.Unlock() + return cc.isSleeping.Load() +} + func (cc *mockControl) AuthCantContinue() bool { cc.mu.Lock() defer cc.mu.Unlock() diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index a1b7da46f..cd5ebb811 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -110,6 +110,7 @@ var handler = map[string]localAPIHandler{ "serve-config": (*Handler).serveServeConfig, "set-dns": (*Handler).serveSetDNS, "set-expiry-sooner": (*Handler).serveSetExpirySooner, + "set-sleep": (*Handler).serveSetSleep, "tailfs/fileserver-address": (*Handler).serveTailFSFileServerAddr, "tailfs/shares": (*Handler).serveShares, "start": (*Handler).serveStart, @@ -573,6 +574,10 @@ func (h *Handler) serveDebug(w http.ResponseWriter, r *http.Request) { h.b.MagicConn().SetHomeless(true) case "derp-unset-homeless": h.b.MagicConn().SetHomeless(false) + case "sleep-set": + h.b.SetSleep(true) + case "sleep-unset": + h.b.SetSleep(false) case "rebind": err = h.b.DebugRebind() case "restun": @@ -1695,6 +1700,20 @@ func (h *Handler) serveSetExpirySooner(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "done\n") } +func (h *Handler) serveSetSleep(w http.ResponseWriter, r *http.Request) { + if !h.PermitWrite { + http.Error(w, "access denied", http.StatusForbidden) + return + } + if r.Method != "POST" { + http.Error(w, "want POST", http.StatusBadRequest) + return + } + h.b.SetSleep(r.FormValue("sleep") == "true") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(struct{}{}) +} + func (h *Handler) servePing(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if r.Method != "POST" { diff --git a/logtail/logtail.go b/logtail/logtail.go index fcaf80e41..90be1b515 100644 --- a/logtail/logtail.go +++ b/logtail/logtail.go @@ -510,6 +510,11 @@ func (l *Logger) StartFlush() { // logtailDisabled is whether logtail uploads to logcatcher are disabled. var logtailDisabled atomic.Bool +// Enable enables logtail uploads for the lifetime of the process. +func Enable() { + logtailDisabled.Store(false) +} + // Disable disables logtail uploads for the lifetime of the process. func Disable() { logtailDisabled.Store(true)