feature/captiveportal: move captive portal code out of ipnlocal, netcheck

Captive portal detection was half-migrated: it had a build tag and
buildfeatures constant, but its code still lived in build-tag-gated
files in ipn/ipnlocal and net/netcheck, with its per-backend state
(context, cancel func, signaling channel) as fields on LocalBackend.

Move it under feature/captiveportal. The health-driven detection loop
becomes an ipnext.Extension holding its own state: it starts and
stops the loop from the BackendStateChange hook and subscribes to
health.Change events on the eventbus itself, removing the captive
portal hooks and special cases from LocalBackend entirely. The DERP
map now comes from a new ipnext.NodeBackend.DERPMap method, and the
preferred DERP region from magicsock's last netcheck report (the
same underlying source as the previously used Hostinfo.NetInfo).

The netcheck probe hook is now exported with a signature free of
netcheck internals, and its implementation moves to the small
feature/captiveportal/netcheckhook package, which installs the hook
as an import side effect. That package stays free of tsd/wgengine
dependencies so the tailscale CLI can keep probing for captive
portals in "tailscale netcheck" without linking the daemon-side
extension. The net/captivedetection library itself is unchanged and
stays put; after this change it is only linked when something pulls
in netcheckhook or the feature extension.

tailscaled links the feature by default via condregister as before,
but tsnet no longer does (shrinking tsnet, k8s-operator, and tsidp);
tsnet users who want it can blank-import the feature package, and
tsnet's dep test now locks that in.

Updates #12614

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I3f1d09f9dc03e18f9a648ab5e42d16fa540b3fa9
This commit is contained in:
Brad Fitzpatrick
2026-07-14 20:22:23 -04:00
committed by Brad Fitzpatrick
parent 72ca0cae4b
commit bb4f458207
15 changed files with 414 additions and 337 deletions
+4
View File
@@ -531,4 +531,8 @@ type NodeBackend interface {
// node that the portlist service collection is desirable, should it
// choose to report them.
CollectServices() bool
// DERPMap returns the current DERP map from the current netmap,
// or nil if there is no netmap.
DERPMap() *tailcfg.DERPMap
}
-186
View File
@@ -1,186 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_captiveportal
package ipnlocal
import (
"context"
"time"
"tailscale.com/health"
"tailscale.com/net/captivedetection"
"tailscale.com/util/clientmetric"
)
func init() {
hookCaptivePortalHealthChange.Set(captivePortalHealthChange)
hookCheckCaptivePortalLoop.Set(checkCaptivePortalLoop)
}
var metricCaptivePortalDetected = clientmetric.NewCounter("captiveportal_detected")
// captivePortalDetectionInterval is the duration to wait in an unhealthy state with connectivity broken
// before running captive portal detection.
const captivePortalDetectionInterval = 2 * time.Second
func captivePortalHealthChange(b *LocalBackend, state *health.State) {
isConnectivityImpacted := false
for _, w := range state.Warnings {
// Ignore the captive portal warnable itself.
if w.ImpactsConnectivity && w.WarnableCode != captivePortalWarnable.Code {
isConnectivityImpacted = true
break
}
}
// captiveCtx can be changed, and is protected with 'mu'; grab that
// before we start our select, below.
//
// It is guaranteed to be non-nil.
b.mu.Lock()
ctx := b.captiveCtx
b.mu.Unlock()
// If the context is canceled, we don't need to do anything.
if ctx.Err() != nil {
return
}
if isConnectivityImpacted {
b.logf("health: connectivity impacted; triggering captive portal detection")
// Ensure that we select on captiveCtx so that we can time out
// triggering captive portal detection if the backend is shutdown.
select {
case b.needsCaptiveDetection <- true:
case <-ctx.Done():
}
} else {
// If connectivity is not impacted, we know for sure we're not behind a captive portal,
// so drop any warning, and signal that we don't need captive portal detection.
b.health.SetHealthy(captivePortalWarnable)
select {
case b.needsCaptiveDetection <- false:
case <-ctx.Done():
}
}
}
// captivePortalWarnable is a Warnable which is set to an unhealthy state when a captive portal is detected.
var captivePortalWarnable = health.Register(&health.Warnable{
Code: "captive-portal-detected",
Title: "Captive portal detected",
// High severity, because captive portals block all traffic and require user intervention.
Severity: health.SeverityHigh,
Text: health.StaticMessage("This network requires you to log in using your web browser."),
ImpactsConnectivity: true,
})
func checkCaptivePortalLoop(b *LocalBackend, ctx context.Context) {
var tmr *time.Timer
maybeStartTimer := func() {
// If there's an existing timer, nothing to do; just continue
// waiting for it to expire. Otherwise, create a new timer.
if tmr == nil {
tmr = time.NewTimer(captivePortalDetectionInterval)
}
}
maybeStopTimer := func() {
if tmr == nil {
return
}
if !tmr.Stop() {
<-tmr.C
}
tmr = nil
}
for {
if ctx.Err() != nil {
maybeStopTimer()
return
}
// First, see if we have a signal on our "healthy" channel, which
// takes priority over an existing timer. Because a select is
// nondeterministic, we explicitly check this channel before
// entering the main select below, so that we're guaranteed to
// stop the timer before starting captive portal detection.
select {
case needsCaptiveDetection := <-b.needsCaptiveDetection:
if needsCaptiveDetection {
maybeStartTimer()
} else {
maybeStopTimer()
}
default:
}
var timerChan <-chan time.Time
if tmr != nil {
timerChan = tmr.C
}
select {
case <-ctx.Done():
// All done; stop the timer and then exit.
maybeStopTimer()
return
case <-timerChan:
// Kick off captive portal check
b.performCaptiveDetection()
// nil out timer to force recreation
tmr = nil
case needsCaptiveDetection := <-b.needsCaptiveDetection:
if needsCaptiveDetection {
maybeStartTimer()
} else {
// Healthy; cancel any existing timer
maybeStopTimer()
}
}
}
}
// shouldRunCaptivePortalDetection reports whether captive portal detection
// should be run. It is enabled by default, but can be disabled via a control
// knob. It is also only run when the user explicitly wants the backend to be
// running.
func (b *LocalBackend) shouldRunCaptivePortalDetection() bool {
b.mu.Lock()
defer b.mu.Unlock()
return !b.ControlKnobs().DisableCaptivePortalDetection.Load() && b.pm.prefs.WantRunning()
}
// performCaptiveDetection checks if captive portal detection is enabled via controlknob. If so, it runs
// the detection and updates the Warnable accordingly.
func (b *LocalBackend) performCaptiveDetection() {
if !b.shouldRunCaptivePortalDetection() {
return
}
d := captivedetection.NewDetector(b.logf)
b.mu.Lock() // for b.hostinfo
cn := b.currentNode()
dm := cn.DERPMap()
preferredDERP := 0
if b.hostinfo != nil {
if b.hostinfo.NetInfo != nil {
preferredDERP = b.hostinfo.NetInfo.PreferredDERP
}
}
ctx := b.ctx
netMon := b.NetMon()
b.mu.Unlock()
found := d.Detect(ctx, netMon, dm, preferredDERP)
if found {
if !b.health.IsUnhealthy(captivePortalWarnable) {
metricCaptivePortalDetected.Add(1)
}
b.health.SetUnhealthy(captivePortalWarnable, health.Args{})
} else {
b.health.SetHealthy(captivePortalWarnable)
}
}
+18 -77
View File
@@ -433,21 +433,6 @@ type LocalBackend struct {
// refreshAutoExitNode indicates if the exit node should be recomputed when the next netcheck report is available.
refreshAutoExitNode bool // guarded by mu
// captiveCtx and captiveCancel are used to control captive portal
// detection. They are protected by 'mu' and can be changed during the
// lifetime of a LocalBackend.
//
// captiveCtx will always be non-nil, though it might be a canceled
// context. captiveCancel is non-nil if checkCaptivePortalLoop is
// running, and is set to nil after being canceled.
captiveCtx context.Context
captiveCancel context.CancelFunc
// needsCaptiveDetection is a channel that is used to signal either
// that captive portal detection is required (sending true) or that the
// backend is healthy and captive portal detection is not required
// (sending false).
needsCaptiveDetection chan bool
// overrideAlwaysOn is whether [pkey.AlwaysOn] is overridden by the user
// and should have no impact on the WantRunning state until the policy changes,
// or the user re-connects manually, switches to a different profile, etc.
@@ -583,11 +568,6 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
ctx, cancel := context.WithCancelCause(context.Background())
clock := tstime.StdClock{}
// Until we transition to a Running state, use a canceled context for
// our captive portal detection.
captiveCtx, captiveCancel := context.WithCancel(ctx)
captiveCancel()
m := metrics{
advertisedRoutes: sys.UserMetricsRegistry().NewGauge(
"tailscaled_advertised_routes", "Number of advertised network routes (e.g. by a subnet router)"),
@@ -606,27 +586,24 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
}
b := &LocalBackend{
ctx: ctx,
ctxCancel: cancel,
logf: logf,
keyLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now),
statsLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now),
sys: sys,
polc: sys.PolicyClientOrDefault(),
health: sys.HealthTracker.Get(),
metrics: m,
e: e,
dialer: dialer,
store: store,
pm: pm,
backendLogID: logID,
state: ipn.NoState,
em: newExpiryManager(logf, sys.Bus.Get()),
loginFlags: loginFlags,
clock: clock,
captiveCtx: captiveCtx,
captiveCancel: nil, // so that we start checkCaptivePortalLoop when Running
needsCaptiveDetection: make(chan bool),
ctx: ctx,
ctxCancel: cancel,
logf: logf,
keyLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now),
statsLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now),
sys: sys,
polc: sys.PolicyClientOrDefault(),
health: sys.HealthTracker.Get(),
metrics: m,
e: e,
dialer: dialer,
store: store,
pm: pm,
backendLogID: logID,
state: ipn.NoState,
em: newExpiryManager(logf, sys.Bus.Get()),
loginFlags: loginFlags,
clock: clock,
}
sys.NoiseRoundTripper.Set(noiseRoundTripper{b})
@@ -1241,12 +1218,6 @@ func (b *LocalBackend) linkChange(delta *netmon.ChangeDelta) {
}
}
// Captive portal detection hooks.
var (
hookCaptivePortalHealthChange feature.Hook[func(*LocalBackend, *health.State)]
hookCheckCaptivePortalLoop feature.Hook[func(*LocalBackend, context.Context)]
)
func (b *LocalBackend) onHealthChange(change health.Change) {
if !buildfeatures.HasHealth {
return
@@ -1274,10 +1245,6 @@ func (b *LocalBackend) onHealthChange(change health.Change) {
b.cc.SetIPForwardingBroken(broken)
}
b.mu.Unlock()
if f, ok := hookCaptivePortalHealthChange.GetOk(); ok {
f(b, state)
}
}
// GetOrSetCaptureSink returns the current packet capture sink, creating it
@@ -1343,11 +1310,6 @@ func (b *LocalBackend) Shutdown() {
}
b.shutdownCalled = true
if buildfeatures.HasCaptivePortal && b.captiveCancel != nil {
b.logf("canceling captive portal context")
b.captiveCancel()
}
b.shutdownCertRefreshLoopLocked()
b.stopReconnectTimerLocked()
@@ -6779,17 +6741,6 @@ func (b *LocalBackend) enterStateLocked(newState ipn.State) {
b.resetAuthURLLocked()
}
// Start a captive portal detection loop if none has been
// started. Create a new context if none is present, since it
// can be shut down if we transition away from Running.
if buildfeatures.HasCaptivePortal {
if b.captiveCancel == nil {
captiveCtx, captiveCancel := context.WithCancel(b.ctx)
b.captiveCtx, b.captiveCancel = captiveCtx, captiveCancel
b.goTracker.Go(func() { hookCheckCaptivePortalLoop.Get()(b, captiveCtx) })
}
}
// (Re)evaluate the background TLS cert refresh loop. It runs
// only while we're Running and ServeConfig has at least one
// HTTPS Web entry, so idle nodes don't hold a timer open.
@@ -6798,16 +6749,6 @@ func (b *LocalBackend) enterStateLocked(newState ipn.State) {
// Transitioning away from running.
b.closePeerAPIListenersLocked()
// Stop any existing captive portal detection loop.
if buildfeatures.HasCaptivePortal && b.captiveCancel != nil {
b.captiveCancel()
b.captiveCancel = nil
// NOTE: don't set captiveCtx to nil here, to ensure
// that we always have a (canceled) context to wait on
// in onHealthChange.
}
b.updateCertRefreshLoopLocked()
}
b.pauseOrResumeControlClientLocked()