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
-55
View File
@@ -1,55 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_captiveportal
package netcheck
import (
"context"
"time"
"tailscale.com/net/captivedetection"
"tailscale.com/tailcfg"
)
func init() {
hookStartCaptivePortalDetection.Set(startCaptivePortalDetection)
}
func startCaptivePortalDetection(ctx context.Context, rs *reportState, dm *tailcfg.DERPMap, preferredDERP int) (done <-chan struct{}, stop func()) {
c := rs.c
// NOTE(andrew): we can't simply add this goroutine to the
// `NewWaitGroupChan` below, since we don't wait for that
// waitgroup to finish when exiting this function and thus get
// a data race.
ch := make(chan struct{})
tmr := time.AfterFunc(c.captivePortalDelay(), func() {
defer close(ch)
d := captivedetection.NewDetector(c.logf)
found := d.Detect(ctx, c.NetMon, dm, preferredDERP)
rs.report.CaptivePortal.Set(found)
})
stop = func() {
// Don't cancel our captive portal check if we're
// explicitly doing a verbose netcheck.
if c.Verbose {
return
}
if tmr.Stop() {
// Stopped successfully; need to close the
// signal channel ourselves.
close(ch)
return
}
// Did not stop; do nothing and it'll finish by itself
// and close the signal channel.
}
return ch, stop
}
+15 -13
View File
@@ -233,8 +233,7 @@ type Client struct {
ForcePreferredDERP int
// For tests
testEnoughRegions int
testCaptivePortalDelay time.Duration
testEnoughRegions int
mu syncs.Mutex // guards following
nextFull bool // do a full region scan, even if last != nil
@@ -257,14 +256,6 @@ func (c *Client) enoughRegions() int {
return 3
}
func (c *Client) captivePortalDelay() time.Duration {
if c.testCaptivePortalDelay > 0 {
return c.testCaptivePortalDelay
}
// Chosen semi-arbitrarily
return 200 * time.Millisecond
}
func (c *Client) logf(format string, a ...any) {
if c.Logf != nil {
c.Logf(format, a...)
@@ -788,7 +779,15 @@ func (c *Client) SetForcePreferredDERP(region int) {
c.ForcePreferredDERP = region
}
var hookStartCaptivePortalDetection feature.Hook[func(ctx context.Context, rs *reportState, dm *tailcfg.DERPMap, preferredDERP int) (<-chan struct{}, func())]
// HookStartCaptivePortalDetection, if set, is called by GetReport to
// asynchronously start captive portal detection during a full (non-incremental)
// netcheck. It is set at init time by the optional
// tailscale.com/feature/captiveportal/netcheckhook package.
//
// The returned done channel is closed when detection has finished (and
// setCaptivePortal has been called with the result, if it ran); the returned
// stop function cancels a detection that has not yet started.
var HookStartCaptivePortalDetection feature.Hook[func(ctx context.Context, c *Client, dm *tailcfg.DERPMap, preferredDERP int, setCaptivePortal func(bool)) (done <-chan struct{}, stop func())]
// GetReport gets a report. The 'opts' argument is optional and can be nil.
// Callers are discouraged from passing a ctx with an arbitrary deadline as this
@@ -915,8 +914,11 @@ func (c *Client) GetReport(ctx context.Context, dm *tailcfg.DERPMap, opts *GetRe
captivePortalDone := syncs.ClosedChan()
captivePortalStop := func() {}
if buildfeatures.HasCaptivePortal && !rs.incremental && !onlySTUN {
start := hookStartCaptivePortalDetection.Get()
captivePortalDone, captivePortalStop = start(ctx, rs, dm, preferredDERP)
if start, ok := HookStartCaptivePortalDetection.GetOk(); ok {
captivePortalDone, captivePortalStop = start(ctx, c, dm, preferredDERP, func(found bool) {
rs.report.CaptivePortal.Set(found)
})
}
}
wg := syncs.NewWaitGroupChan()