mirror of
https://github.com/tailscale/tailscale.git
synced 2026-07-20 21:23:07 +08:00
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:
parent
72ca0cae4b
commit
bb4f458207
@ -784,7 +784,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/metrics from tailscale.com/tsweb+
|
||||
tailscale.com/net/bakedroots from tailscale.com/net/tlsdial+
|
||||
💣 tailscale.com/net/batching from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/net/captivedetection from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/dns from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/dns/publicdns from tailscale.com/net/dns+
|
||||
tailscale.com/net/dns/resolvconffile from tailscale.com/cmd/k8s-operator+
|
||||
|
||||
10
cmd/tailscale/cli/maybe_captiveportal.go
Normal file
10
cmd/tailscale/cli/maybe_captiveportal.go
Normal file
@ -0,0 +1,10 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_captiveportal
|
||||
|
||||
package cli
|
||||
|
||||
// Import the netcheck captive portal hook package so that the netcheck
|
||||
// command also probes for captive portals during its report.
|
||||
import _ "tailscale.com/feature/captiveportal/netcheckhook"
|
||||
@ -198,6 +198,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/feature from tailscale.com/tsweb+
|
||||
L tailscale.com/feature/awsparamstore from tailscale.com/feature/condregister/awsparamstore
|
||||
tailscale.com/feature/buildfeatures from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/feature/captiveportal/netcheckhook from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/feature/capture/dissector from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/feature/condregister/awsparamstore from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/feature/condregister/identityfederation from tailscale.com/cmd/tailscale/cli
|
||||
@ -222,11 +223,11 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/metrics from tailscale.com/tsweb+
|
||||
tailscale.com/net/ace from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/net/bakedroots from tailscale.com/net/tlsdial
|
||||
tailscale.com/net/captivedetection from tailscale.com/net/netcheck
|
||||
tailscale.com/net/captivedetection from tailscale.com/feature/captiveportal/netcheckhook
|
||||
tailscale.com/net/dnscache from tailscale.com/control/controlhttp+
|
||||
tailscale.com/net/dnsfallback from tailscale.com/control/controlhttp+
|
||||
tailscale.com/net/netaddr from tailscale.com/ipn+
|
||||
tailscale.com/net/netcheck from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/net/netcheck from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/net/neterror from tailscale.com/net/netcheck+
|
||||
tailscale.com/net/netknob from tailscale.com/net/netns+
|
||||
💣 tailscale.com/net/netmon from tailscale.com/cmd/tailscale/cli+
|
||||
|
||||
@ -295,6 +295,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
LD tailscale.com/feature/bird from tailscale.com/feature/condregister
|
||||
tailscale.com/feature/buildfeatures from tailscale.com/wgengine/magicsock+
|
||||
tailscale.com/feature/c2n from tailscale.com/feature/condregister
|
||||
tailscale.com/feature/captiveportal from tailscale.com/feature/condregister
|
||||
tailscale.com/feature/captiveportal/netcheckhook from tailscale.com/feature/captiveportal
|
||||
tailscale.com/feature/capture from tailscale.com/feature/condregister
|
||||
tailscale.com/feature/clientupdate from tailscale.com/feature/condregister
|
||||
tailscale.com/feature/condlite/expvar from tailscale.com/wgengine/magicsock
|
||||
@ -358,7 +360,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/net/ace from tailscale.com/feature/ace
|
||||
tailscale.com/net/bakedroots from tailscale.com/net/tlsdial+
|
||||
💣 tailscale.com/net/batching from tailscale.com/wgengine/magicsock+
|
||||
tailscale.com/net/captivedetection from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/captivedetection from tailscale.com/feature/captiveportal+
|
||||
tailscale.com/net/dns from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/net/dns/publicdns from tailscale.com/net/dns+
|
||||
tailscale.com/net/dns/resolvconffile from tailscale.com/net/dns+
|
||||
|
||||
@ -183,7 +183,6 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
||||
tailscale.com/metrics from tailscale.com/tsweb+
|
||||
tailscale.com/net/bakedroots from tailscale.com/net/tlsdial+
|
||||
💣 tailscale.com/net/batching from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/net/captivedetection from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/dns from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/dns/publicdns from tailscale.com/net/dns+
|
||||
tailscale.com/net/dns/resolvconffile from tailscale.com/net/dns+
|
||||
|
||||
285
feature/captiveportal/captiveportal.go
Normal file
285
feature/captiveportal/captiveportal.go
Normal file
@ -0,0 +1,285 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package captiveportal provides optional captive portal detection,
|
||||
// warning the user via the health tracker when their network requires
|
||||
// a browser login before traffic can flow.
|
||||
package captiveportal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"tailscale.com/feature"
|
||||
_ "tailscale.com/feature/captiveportal/netcheckhook" // install the netcheck probe hook too
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnext"
|
||||
"tailscale.com/net/captivedetection"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/eventbus"
|
||||
)
|
||||
|
||||
const featureName = "captiveportal"
|
||||
|
||||
func init() {
|
||||
feature.Register(featureName)
|
||||
ipnext.RegisterExtension(featureName, newExtension)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// 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,
|
||||
})
|
||||
|
||||
// Extension is the captive portal detection extension.
|
||||
// There is one per [ipnext.Host] (and hence per LocalBackend).
|
||||
type Extension struct {
|
||||
logf logger.Logf
|
||||
sb ipnext.SafeBackend
|
||||
host ipnext.Host // from Init
|
||||
health *health.Tracker
|
||||
ec *eventbus.Client
|
||||
|
||||
mu syncs.Mutex
|
||||
// captiveCtx and captiveCancel are used to control captive portal
|
||||
// detection. They are protected by 'mu' and can be changed during the
|
||||
// lifetime of the extension.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
func newExtension(logf logger.Logf, sb ipnext.SafeBackend) (ipnext.Extension, error) {
|
||||
// Initialize the context used to control the captive portal detection
|
||||
// goroutine in a canceled state, so that it is always non-nil and safe
|
||||
// to wait on even before the loop has ever started.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
return &Extension{
|
||||
logf: logf,
|
||||
sb: sb,
|
||||
health: sb.Sys().HealthTracker.Get(),
|
||||
captiveCtx: ctx,
|
||||
needsCaptiveDetection: make(chan bool),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *Extension) Name() string { return featureName }
|
||||
|
||||
func (e *Extension) Init(h ipnext.Host) error {
|
||||
e.host = h
|
||||
h.Hooks().BackendStateChange.Add(e.onBackendStateChange)
|
||||
e.ec = e.sb.Sys().Bus.Get().Client("captiveportal")
|
||||
eventbus.SubscribeFunc(e.ec, e.onHealthChange)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Extension) Shutdown() error {
|
||||
e.mu.Lock()
|
||||
if e.captiveCancel != nil {
|
||||
e.captiveCancel()
|
||||
e.captiveCancel = nil
|
||||
}
|
||||
e.mu.Unlock()
|
||||
if e.ec != nil {
|
||||
e.ec.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// onBackendStateChange starts or stops the captive portal detection loop as
|
||||
// the backend enters or leaves the Running state. It is called with
|
||||
// LocalBackend's mutex held, so it must not call back into LocalBackend.
|
||||
func (e *Extension) onBackendStateChange(st ipn.State) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if st == ipn.Running {
|
||||
// Start a captive portal detection loop if none has been
|
||||
// started.
|
||||
if e.captiveCancel == nil {
|
||||
e.captiveCtx, e.captiveCancel = context.WithCancel(context.Background())
|
||||
go e.checkCaptivePortalLoop(e.captiveCtx)
|
||||
}
|
||||
} else if e.captiveCancel != nil {
|
||||
// Transitioning away from Running; stop any existing captive
|
||||
// portal detection loop.
|
||||
e.captiveCancel()
|
||||
e.captiveCancel = nil
|
||||
|
||||
// NOTE: don't set captiveCtx to nil here, to ensure that we
|
||||
// always have a (canceled) context to wait on in
|
||||
// onHealthChange.
|
||||
}
|
||||
}
|
||||
|
||||
// onHealthChange is called (via the eventbus) whenever the node's health
|
||||
// state changes. If connectivity appears to be impacted, it signals the
|
||||
// detection loop to check for a captive portal.
|
||||
func (e *Extension) onHealthChange(health.Change) {
|
||||
state := e.health.CurrentState()
|
||||
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.
|
||||
e.mu.Lock()
|
||||
ctx := e.captiveCtx
|
||||
e.mu.Unlock()
|
||||
|
||||
// If the context is canceled, we don't need to do anything.
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if isConnectivityImpacted {
|
||||
e.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 loop is shut down.
|
||||
select {
|
||||
case e.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.
|
||||
e.health.SetHealthy(captivePortalWarnable)
|
||||
select {
|
||||
case e.needsCaptiveDetection <- false:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Extension) checkCaptivePortalLoop(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 := <-e.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
|
||||
e.performCaptiveDetection(ctx)
|
||||
// nil out the timer and its channel to force recreation
|
||||
tmr, timerChan = nil, nil
|
||||
case needsCaptiveDetection := <-e.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 (e *Extension) shouldRunCaptivePortalDetection() bool {
|
||||
return !e.sb.Sys().ControlKnobs().DisableCaptivePortalDetection.Load() &&
|
||||
e.host.Profiles().CurrentPrefs().WantRunning()
|
||||
}
|
||||
|
||||
// performCaptiveDetection checks if captive portal detection is enabled via controlknob. If so, it runs
|
||||
// the detection and updates the Warnable accordingly.
|
||||
func (e *Extension) performCaptiveDetection(ctx context.Context) {
|
||||
if !e.shouldRunCaptivePortalDetection() {
|
||||
return
|
||||
}
|
||||
|
||||
d := captivedetection.NewDetector(e.logf)
|
||||
dm := e.host.NodeBackend().DERPMap()
|
||||
preferredDERP := 0
|
||||
if mc, ok := e.sb.Sys().MagicSock.GetOK(); ok {
|
||||
if report := mc.GetLastNetcheckReport(ctx); report != nil {
|
||||
preferredDERP = report.PreferredDERP
|
||||
}
|
||||
}
|
||||
netMon := e.sb.Sys().NetMon.Get()
|
||||
found := d.Detect(ctx, netMon, dm, preferredDERP)
|
||||
if found {
|
||||
if !e.health.IsUnhealthy(captivePortalWarnable) {
|
||||
metricCaptivePortalDetected.Add(1)
|
||||
}
|
||||
e.health.SetUnhealthy(captivePortalWarnable, health.Args{})
|
||||
} else {
|
||||
e.health.SetHealthy(captivePortalWarnable)
|
||||
}
|
||||
}
|
||||
67
feature/captiveportal/netcheckhook/netcheckhook.go
Normal file
67
feature/captiveportal/netcheckhook/netcheckhook.go
Normal file
@ -0,0 +1,67 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package netcheckhook makes netcheck probe for captive portals during
|
||||
// full reports. It does so as a side effect of being imported, by
|
||||
// installing a netcheck hook from init.
|
||||
package netcheckhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"tailscale.com/net/captivedetection"
|
||||
"tailscale.com/net/netcheck"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
func init() {
|
||||
netcheck.HookStartCaptivePortalDetection.Set(startCaptivePortalDetection)
|
||||
}
|
||||
|
||||
// captivePortalDelay is the duration to wait after starting a netcheck before
|
||||
// also probing for a captive portal, to let UDP STUN finish first and avoid
|
||||
// the probe if it's unnecessary. Chosen semi-arbitrarily.
|
||||
const captivePortalDelay = 200 * time.Millisecond
|
||||
|
||||
func startCaptivePortalDetection(ctx context.Context, c *netcheck.Client, dm *tailcfg.DERPMap, preferredDERP int, setCaptivePortal func(bool)) (done <-chan struct{}, stop func()) {
|
||||
logf := c.Logf
|
||||
if logf == nil {
|
||||
logf = log.Printf
|
||||
}
|
||||
|
||||
// This goroutine can't be tracked by the wait group that
|
||||
// netcheck.GetReport uses for its probes, since GetReport doesn't
|
||||
// wait for that group to finish before returning and we'd get a
|
||||
// data race. Instead, completion is signaled by closing ch, which
|
||||
// GetReport receives as the done channel.
|
||||
ch := make(chan struct{})
|
||||
|
||||
tmr := time.AfterFunc(captivePortalDelay, func() {
|
||||
defer close(ch)
|
||||
d := captivedetection.NewDetector(logf)
|
||||
found := d.Detect(ctx, c.NetMon, dm, preferredDERP)
|
||||
setCaptivePortal(found)
|
||||
})
|
||||
|
||||
if c.Verbose {
|
||||
// Don't cancel our captive portal check if we're
|
||||
// explicitly doing a verbose netcheck.
|
||||
return ch, func() {}
|
||||
}
|
||||
|
||||
stop = func() {
|
||||
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
|
||||
}
|
||||
8
feature/condregister/maybe_captiveportal.go
Normal file
8
feature/condregister/maybe_captiveportal.go
Normal file
@ -0,0 +1,8 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_captiveportal
|
||||
|
||||
package condregister
|
||||
|
||||
import _ "tailscale.com/feature/captiveportal"
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -179,7 +179,6 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
|
||||
tailscale.com/metrics from tailscale.com/tsweb+
|
||||
tailscale.com/net/bakedroots from tailscale.com/net/tlsdial+
|
||||
💣 tailscale.com/net/batching from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/net/captivedetection from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/dns from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/dns/publicdns from tailscale.com/net/dns+
|
||||
tailscale.com/net/dns/resolvconffile from tailscale.com/net/dns+
|
||||
|
||||
@ -3411,6 +3411,7 @@ func TestDeps(t *testing.T) {
|
||||
"golang.org/x/crypto/ssh/internal/bcrypt_pbkdf": "tsnet should not depend on SSH",
|
||||
"tailscale.com/chirp": "tsnet should not depend on BIRD integration",
|
||||
"tailscale.com/feature/bird": "tsnet should not depend on BIRD integration",
|
||||
"tailscale.com/feature/captiveportal": "tsnet apps don't need captive portal detection; import it explicitly if desired",
|
||||
"tailscale.com/feature/clientupdate": "tsnet should not depend on feature/clientupdate",
|
||||
"tailscale.com/feature/remoteconfig": "tsnet should not depend on feature/remoteconfig",
|
||||
"tailscale.com/feature/syspolicy": "tsnet should not depend on syspolicy",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user