ipn/ipnlocal,control/controlclient: track control-plane DisableLogTail per-instance

When Headscale sends MapResponse.Debug.DisableLogTail, the client
previously called envknob.SetNoLogsNoSupport(), permanently latching
the global no-logs-no-support flag for the process lifetime. This
meant users switching from a Headscale tailnet to a Tailscale tailnet
with Network Flow Logs could not connect without restarting tailscaled.

Replace the global envknob.SetNoLogsNoSupport() call in
handleDebugMessage with a per-instance callback (OnDisableLogTail)
that sets an atomic.Bool on LocalBackend. This flag follows the
control client lifecycle: it is cleared in startLocked() when a new
control client is created (profile switch, re-login, etc.). If the
new control server also wants logging disabled, it will re-send
DisableLogTail in its first MapResponse.

The global user-set flag (--no-logs-no-support / TS_NO_LOGS_NO_SUPPORT)
is unchanged and always takes precedence.

Also improve the error message shown when a tailnet requires logging:
differentiate between the user-explicit case (tells user to restart
without the flag) and the control-server case (tells user to restart).

Fixes #15048
This commit is contained in:
Kristoffer Dalby 2026-03-11 14:38:43 +00:00
parent da7d38f5fa
commit 6fd492b7c8
4 changed files with 60 additions and 8 deletions

View File

@ -82,7 +82,8 @@ type Direct struct {
debugFlags []string
skipIPForwardingCheck bool
pinger Pinger
popBrowser func(url string) // or nil
popBrowser func(url string) // or nil
onDisableLogTail func() // or nil; called when control sends DisableLogTail
polc policyclient.Client // always non-nil
c2nHandler http.Handler // or nil
panicOnUse bool // if true, panic if client is used (for testing)
@ -180,6 +181,13 @@ type Options struct {
// attempted. It is used to allow the client to clean up any resources or complete any
// tasks that are dependent on a live client.
Shutdown func()
// OnDisableLogTail, if non-nil, is called when the control plane
// requests logging to be disabled via MapResponse.Debug.DisableLogTail.
// This is primarily used by Headscale. The callback allows the caller
// (e.g., LocalBackend) to track this state per-instance rather than
// relying on global state.
OnDisableLogTail func()
}
// ControlDialPlanner is the interface optionally supplied when creating a
@ -323,6 +331,7 @@ func NewDirect(opts Options) (*Direct, error) {
pinger: opts.Pinger,
polc: cmp.Or(opts.PolicyClient, policyclient.Client(policyclient.NoPolicyClient{})),
popBrowser: opts.PopBrowserURL,
onDisableLogTail: opts.OnDisableLogTail,
c2nHandler: opts.C2NHandler,
dialer: opts.Dialer,
dnsCache: dnsCache,
@ -1252,7 +1261,9 @@ func (c *Direct) handleDebugMessage(ctx context.Context, debug *tailcfg.Debug) e
}
if buildfeatures.HasLogTail && debug.DisableLogTail {
logtail.Disable()
envknob.SetNoLogsNoSupport()
if c.onDisableLogTail != nil {
c.onDisableLogTail()
}
}
if sleep := time.Duration(debug.SleepSeconds * float64(time.Second)); sleep > 0 {
if err := sleepAsRequested(ctx, c.logf, sleep, c.clock); err != nil {

View File

@ -54,6 +54,7 @@ import (
"tailscale.com/ipn/ipnstate"
"tailscale.com/log/sockstatlog"
"tailscale.com/logpolicy"
"tailscale.com/logtail"
"tailscale.com/net/dns"
"tailscale.com/net/dnscache"
"tailscale.com/net/dnsfallback"
@ -214,7 +215,14 @@ type LocalBackend struct {
// Tailscale on port 5252.
exposeRemoteWebClientAtomicBool atomic.Bool // TODO(nickkhyl): move to nodeBackend
shutdownCalled bool // if Shutdown has been called
debugSink packet.CaptureSink
// noLogsFromControl tracks whether the current control plane has
// requested logging to be disabled via MapResponse.Debug.DisableLogTail.
// This is primarily set by Headscale. It follows the control client's
// lifecycle: it is cleared when switching profiles or control servers.
// The global envknob.NoLogsNoSupport (user-set via CLI/env) always
// takes precedence over this per-instance flag.
noLogsFromControl atomic.Bool
debugSink packet.CaptureSink
sockstatLogger *sockstatlog.Logger
// getTCPHandlerForFunnelFlow returns a handler for an incoming TCP flow for
@ -1780,8 +1788,18 @@ func (b *LocalBackend) setControlClientStatusLocked(c controlclient.Client, st c
// Now complete the lock-free parts of what we started while locked.
if st.NetMap != nil {
if envknob.NoLogsNoSupport() && st.NetMap.HasCap(tailcfg.CapabilityDataPlaneAuditLogs) {
msg := "tailnet requires logging to be enabled. Remove --no-logs-no-support from tailscaled command line."
if b.NoLogsNoSupport() && st.NetMap.HasCap(tailcfg.CapabilityDataPlaneAuditLogs) {
var msg string
if envknob.NoLogsNoSupport() {
msg = "This tailnet requires logging to be enabled. " +
"Tailscale is running with logging explicitly disabled " +
"(via --no-logs-no-support or TS_NO_LOGS_NO_SUPPORT). " +
"Restart Tailscale without this setting to connect to this tailnet."
} else {
msg = "This tailnet requires logging to be enabled. " +
"Logging was disabled by the control server. " +
"Restart Tailscale to connect to this tailnet."
}
b.health.SetLocalLogConfigHealth(errors.New(msg))
// Get the current prefs again, since we unlocked above.
prefs := b.pm.CurrentPrefs().AsStruct()
@ -2607,6 +2625,16 @@ func (b *LocalBackend) startLocked(opts ipn.Options) error {
c2nHandler = http.HandlerFunc(b.handleC2N)
}
// If no-logs-no-support was set by the previous control plane
// (e.g., Headscale's DisableLogTail), clear it before connecting to
// the new control server. If the new control plane also wants logging
// disabled, it will send DisableLogTail in its first MapResponse.
// The global user-set flag (envknob) always takes precedence.
if b.noLogsFromControl.Swap(false) && !envknob.NoLogsNoSupport() {
logtail.Enable()
b.logf("re-enabled logtail; was disabled by previous control server")
}
// TODO(apenwarr): The only way to change the ServerURL is to
// re-run b.Start, because this is the only place we create a
// new controlclient. EditPrefs allows you to overwrite ServerURL,
@ -2625,6 +2653,7 @@ func (b *LocalBackend) startLocked(opts ipn.Options) error {
PolicyClient: b.sys.PolicyClientOrDefault(),
Pinger: b,
PopBrowserURL: b.tellClientToBrowseToURL,
OnDisableLogTail: func() { b.noLogsFromControl.Store(true) },
Dialer: b.Dialer(),
Observer: b,
C2NHandler: c2nHandler,
@ -4206,6 +4235,14 @@ func (b *LocalBackend) isDefaultServerLocked() bool {
return prefs.ControlURLOrDefault(b.polc) == ipn.DefaultControlURL
}
// NoLogsNoSupport reports whether logging is effectively disabled for this
// LocalBackend instance, either by the user (CLI flag or environment variable)
// or by the current control plane (via MapResponse.Debug.DisableLogTail).
// The global user-set flag always takes precedence.
func (b *LocalBackend) NoLogsNoSupport() bool {
return envknob.NoLogsNoSupport() || b.noLogsFromControl.Load()
}
func (b *LocalBackend) checkExitNodePrefsLocked(p *ipn.Prefs) error {
tryingToUseExitNode := p.ExitNodeIP.IsValid() || p.ExitNodeID != ""
if !tryingToUseExitNode {
@ -5636,6 +5673,7 @@ func (b *LocalBackend) applyPrefsToHostinfoLocked(hi *tailcfg.Hostinfo, prefs ip
hi.RequestTags = prefs.AdvertiseTags().AsSlice()
hi.ShieldsUp = prefs.ShieldsUp()
hi.AllowsUpdate = buildfeatures.HasClientUpdate && (envknob.AllowsRemoteUpdate() || prefs.AutoUpdate().Apply.EqualBool(true))
hi.NoLogsNoSupport = b.NoLogsNoSupport()
if buildfeatures.HasAdvertiseRoutes {
b.metrics.advertisedRoutes.Set(float64(tsaddr.WithoutExitRoute(prefs.AdvertiseRoutes()).Len()))

View File

@ -398,7 +398,7 @@ func (h *Handler) serveBugReport(w http.ResponseWriter, r *http.Request) {
logMarker := func() string {
return fmt.Sprintf("BUG-%v-%v-%v", h.backendLogID, h.clock.Now().UTC().Format("20060102150405Z"), rands.HexString(16))
}
if envknob.NoLogsNoSupport() {
if h.b.NoLogsNoSupport() {
logMarker = func() string { return "BUG-NO-LOGS-NO-SUPPORT-this-node-has-had-its-logging-disabled" }
}

View File

@ -2326,8 +2326,11 @@ type Debug struct {
// state machine.
SleepSeconds float64 `json:",omitempty"`
// DisableLogTail disables the logtail package. Once disabled it can't be
// re-enabled for the lifetime of the process.
// DisableLogTail disables the logtail package. When set by the control
// plane, logging is automatically re-enabled when switching to a
// different control server. When set by the user (via CLI flag or
// environment variable), it cannot be re-enabled for the lifetime of
// the process.
//
// This is primarily used by Headscale.
DisableLogTail bool `json:",omitempty"`