diff --git a/control/controlclient/direct.go b/control/controlclient/direct.go index 96bd268b4..4337cc649 100644 --- a/control/controlclient/direct.go +++ b/control/controlclient/direct.go @@ -570,6 +570,11 @@ func (c *Direct) SendLiteMapUpdate(ctx context.Context) error { return c.sendMapRequest(ctx, 1, nil) } +// If we go more than pollTimeout without hearing from the server, +// end the long poll. We should be receiving a keep alive ping +// every minute. +const pollTimeout = 120 * time.Second + // cb nil means to omit peers. func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netmap.NetworkMap)) error { c.mu.Lock() @@ -694,10 +699,6 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm return nil } - // If we go more than pollTimeout without hearing from the server, - // end the long poll. We should be receiving a keep alive ping - // every minute. - const pollTimeout = 120 * time.Second timeout := time.NewTimer(pollTimeout) timeoutReset := make(chan struct{}) pollDone := make(chan struct{}) @@ -795,6 +796,11 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm } setControlAtomic(&controlUseDERPRoute, resp.Debug.DERPRoute) setControlAtomic(&controlTrimWGConfig, resp.Debug.TrimWGConfig) + if sleep := time.Duration(resp.Debug.SleepSeconds * float64(time.Second)); sleep > 0 { + if err := sleepAsRequested(ctx, c.logf, timeoutReset, sleep); err != nil { + return err + } + } } nm := sess.netmapForResponse(&resp) @@ -1181,3 +1187,34 @@ func answerPing(logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest) { logf("answerPing complete to %v (after %v)", pr.URL, d) } } + +func sleepAsRequested(ctx context.Context, logf logger.Logf, timeoutReset chan<- struct{}, d time.Duration) error { + const maxSleep = 5 * time.Minute + if d > maxSleep { + logf("sleeping for %v, capped from server-requested %v ...", maxSleep, d) + d = maxSleep + } else { + logf("sleeping for server-requested %v ...", d) + } + + ticker := time.NewTicker(pollTimeout / 2) + defer ticker.Stop() + timer := time.NewTimer(d) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + case <-ticker.C: + select { + case timeoutReset <- struct{}{}: + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + } +} diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 81419040a..1c49a19dd 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -41,7 +41,8 @@ import ( // 16: 2021-04-15: client understands Node.Online, MapResponse.OnlineChange // 17: 2021-04-18: MapResponse.Domain empty means unchanged // 18: 2021-04-19: MapResponse.Node nil means unchanged (all fields now omitempty) -const CurrentMapRequestVersion = 18 +// 19: 2021-04-21: MapResponse.Debug.SleepSeconds +const CurrentMapRequestVersion = 19 type StableID string @@ -1012,6 +1013,12 @@ type Debug struct { // GoroutineDumpURL, if non-empty, requests that the client do // a one-time dump of its active goroutines to the given URL. GoroutineDumpURL string `json:",omitempty"` + + // SleepSeconds requests that the client sleep for the + // provided number of seconds. + // The client can (and should) limit the value (such as 5 + // minutes). + SleepSeconds float64 `json:",omitempty"` } func (k MachineKey) String() string { return fmt.Sprintf("mkey:%x", k[:]) }