mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-25 11:05:45 +00:00
3b93fd9c44
We see tons of logs of the form: 2024/11/15 19:57:29 netcheck: [v2] 76 available captive portal detection endpoints: [Endpoint{URL="http://192.73.240.161/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.240.121/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.240.132/generate_204", StatusCode=204, ExpectedContent="", 11:58SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://209.177.158.246/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://209.177.158.15/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://199.38.182.118/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.243.135/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.243.229/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.243.141/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://45.159.97.144/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://45.159.97.61/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://45.159.97.233/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://45.159.98.196/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://45.159.98.253/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://45.159.98.145/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://68.183.90.120/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://209.177.156.94/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.248.83/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://209.177.156.197/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://199.38.181.104/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://209.177.145.120/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://199.38.181.93/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://199.38.181.103/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://102.67.165.90/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://102.67.165.185/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://102.67.165.36/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://176.58.90.147/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://176.58.90.207/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://176.58.90.104/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://162.248.221.199/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://162.248.221.215/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://162.248.221.248/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://185.34.3.232/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://185.34.3.207/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://185.34.3.75/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://208.83.234.151/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://208.83.233.233/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://208.72.155.133/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://185.40.234.219/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://185.40.234.113/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://185.40.234.77/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://43.245.48.220/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://43.245.48.50/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://43.245.48.250/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.252.65/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.252.134/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://208.111.34.178/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://43.245.49.105/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://43.245.49.83/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://43.245.49.144/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://176.58.92.144/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://176.58.88.183/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://176.58.92.254/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://148.163.220.129/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://148.163.220.134/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://148.163.220.210/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.242.187/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.242.28/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.242.204/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://176.58.93.248/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://176.58.93.147/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://176.58.93.154/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://192.73.244.245/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://208.111.40.12/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://208.111.40.216/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://103.6.84.152/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://205.147.105.30/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://205.147.105.78/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://102.67.167.245/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://102.67.167.37/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://102.67.167.188/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://103.84.155.178/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://103.84.155.188/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://103.84.155.46/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=true, Provider=DERPMapOther} Endpoint{URL="http://controlplane.tailscale.com/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=false, Provider=Tailscale} Endpoint{URL="http://login.tailscale.com/generate_204", StatusCode=204, ExpectedContent="", SupportsTailscaleChallenge=false, Provider=Tailscale}] That can be much shorter. Also add a fast exit path to the concurrency on match. Doing 5 all at once is still pretty gratuitous, though. Updates #1634 Fixes #13019 Change-Id: Icdbb16572fca4477b0ee9882683a3ac6eb08e2f2 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
233 lines
8.4 KiB
Go
233 lines
8.4 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
// Package captivedetection provides a way to detect if the system is connected to a network that has
|
|
// a captive portal. It does this by making HTTP requests to known captive portal detection endpoints
|
|
// and checking if the HTTP responses indicate that a captive portal might be present.
|
|
package captivedetection
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"net/http"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"tailscale.com/net/netmon"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/logger"
|
|
)
|
|
|
|
// Detector checks whether the system is behind a captive portal.
|
|
type Detector struct {
|
|
|
|
// httpClient is the HTTP client that is used for captive portal detection. It is configured
|
|
// to not follow redirects, have a short timeout and no keep-alive.
|
|
httpClient *http.Client
|
|
// currIfIndex is the index of the interface that is currently being used by the httpClient.
|
|
currIfIndex int
|
|
// mu guards currIfIndex.
|
|
mu sync.Mutex
|
|
// logf is the logger used for logging messages. If it is nil, log.Printf is used.
|
|
logf logger.Logf
|
|
}
|
|
|
|
// NewDetector creates a new Detector instance for captive portal detection.
|
|
func NewDetector(logf logger.Logf) *Detector {
|
|
d := &Detector{logf: logf}
|
|
d.httpClient = &http.Client{
|
|
// No redirects allowed
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
Transport: &http.Transport{
|
|
DialContext: d.dialContext,
|
|
DisableKeepAlives: true,
|
|
},
|
|
Timeout: Timeout,
|
|
}
|
|
return d
|
|
}
|
|
|
|
// Timeout is the timeout for captive portal detection requests. Because the captive portal intercepting our requests
|
|
// is usually located on the LAN, this is a relatively short timeout.
|
|
const Timeout = 3 * time.Second
|
|
|
|
// Detect is the entry point to the API. It attempts to detect if the system is behind a captive portal
|
|
// by making HTTP requests to known captive portal detection Endpoints. If any of the requests return a response code
|
|
// or body that looks like a captive portal, Detect returns true. It returns false in all other cases, including when any
|
|
// error occurs during a detection attempt.
|
|
//
|
|
// This function might take a while to return, as it will attempt to detect a captive portal on all available interfaces
|
|
// by performing multiple HTTP requests. It should be called in a separate goroutine if you want to avoid blocking.
|
|
func (d *Detector) Detect(ctx context.Context, netMon *netmon.Monitor, derpMap *tailcfg.DERPMap, preferredDERPRegionID int) (found bool) {
|
|
return d.detectCaptivePortalWithGOOS(ctx, netMon, derpMap, preferredDERPRegionID, runtime.GOOS)
|
|
}
|
|
|
|
func (d *Detector) detectCaptivePortalWithGOOS(ctx context.Context, netMon *netmon.Monitor, derpMap *tailcfg.DERPMap, preferredDERPRegionID int, goos string) (found bool) {
|
|
ifState := netMon.InterfaceState()
|
|
if !ifState.AnyInterfaceUp() {
|
|
d.logf("[v2] DetectCaptivePortal: no interfaces up, returning false")
|
|
return false
|
|
}
|
|
|
|
endpoints := availableEndpoints(derpMap, preferredDERPRegionID, d.logf, goos)
|
|
|
|
// Here we try detecting a captive portal using *all* available interfaces on the system
|
|
// that have a IPv4 address. We consider to have found a captive portal when any interface
|
|
// reports one may exists. This is necessary because most systems have multiple interfaces,
|
|
// and most importantly on macOS no default route interface is set until the user has accepted
|
|
// the captive portal alert thrown by the system. If no default route interface is known,
|
|
// we need to try with anything that might remotely resemble a Wi-Fi interface.
|
|
for ifName, i := range ifState.Interface {
|
|
if !i.IsUp() || i.IsLoopback() || interfaceNameDoesNotNeedCaptiveDetection(ifName, goos) {
|
|
continue
|
|
}
|
|
addrs, err := i.Addrs()
|
|
if err != nil {
|
|
d.logf("[v1] DetectCaptivePortal: failed to get addresses for interface %s: %v", ifName, err)
|
|
continue
|
|
}
|
|
if len(addrs) == 0 {
|
|
continue
|
|
}
|
|
d.logf("[v2] attempting to do captive portal detection on interface %s", ifName)
|
|
res := d.detectOnInterface(ctx, i.Index, endpoints)
|
|
if res {
|
|
d.logf("DetectCaptivePortal(found=true,ifName=%s)", ifName)
|
|
return true
|
|
}
|
|
}
|
|
|
|
d.logf("DetectCaptivePortal(found=false)")
|
|
return false
|
|
}
|
|
|
|
// interfaceNameDoesNotNeedCaptiveDetection returns true if an interface does not require captive portal detection
|
|
// based on its name. This is useful to avoid making unnecessary HTTP requests on interfaces that are known to not
|
|
// require it. We also avoid making requests on the interface prefixes "pdp" and "rmnet", which are cellular data
|
|
// interfaces on iOS and Android, respectively, and would be needlessly battery-draining.
|
|
func interfaceNameDoesNotNeedCaptiveDetection(ifName string, goos string) bool {
|
|
ifName = strings.ToLower(ifName)
|
|
excludedPrefixes := []string{"tailscale", "tun", "tap", "docker", "kube", "wg", "ipsec"}
|
|
if goos == "windows" {
|
|
excludedPrefixes = append(excludedPrefixes, "loopback", "tunnel", "ppp", "isatap", "teredo", "6to4")
|
|
} else if goos == "darwin" || goos == "ios" {
|
|
excludedPrefixes = append(excludedPrefixes, "pdp", "awdl", "bridge", "ap", "utun", "tap", "llw", "anpi", "lo", "stf", "gif", "xhc", "pktap")
|
|
} else if goos == "android" {
|
|
excludedPrefixes = append(excludedPrefixes, "rmnet", "p2p", "dummy", "sit")
|
|
}
|
|
for _, prefix := range excludedPrefixes {
|
|
if strings.HasPrefix(ifName, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// detectOnInterface reports whether or not we think the system is behind a
|
|
// captive portal, detected by making a request to a URL that we know should
|
|
// return a "204 No Content" response and checking if that's what we get.
|
|
//
|
|
// The boolean return is whether we think we have a captive portal.
|
|
func (d *Detector) detectOnInterface(ctx context.Context, ifIndex int, endpoints []Endpoint) bool {
|
|
defer d.httpClient.CloseIdleConnections()
|
|
|
|
use := min(len(endpoints), 5)
|
|
endpoints = endpoints[:use]
|
|
d.logf("[v2] %d available captive portal detection endpoints; trying %v", len(endpoints), use)
|
|
|
|
// We try to detect the captive portal more quickly by making requests to multiple endpoints concurrently.
|
|
var wg sync.WaitGroup
|
|
resultCh := make(chan bool, len(endpoints))
|
|
|
|
// Once any goroutine detects a captive portal, we shut down the others.
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
for _, e := range endpoints {
|
|
wg.Add(1)
|
|
go func(endpoint Endpoint) {
|
|
defer wg.Done()
|
|
found, err := d.verifyCaptivePortalEndpoint(ctx, endpoint, ifIndex)
|
|
if err != nil {
|
|
if ctx.Err() == nil {
|
|
d.logf("[v1] checkCaptivePortalEndpoint failed with endpoint %v: %v", endpoint, err)
|
|
}
|
|
return
|
|
}
|
|
if found {
|
|
cancel() // one match is good enough
|
|
resultCh <- true
|
|
}
|
|
}(e)
|
|
}
|
|
|
|
go func() {
|
|
wg.Wait()
|
|
close(resultCh)
|
|
}()
|
|
|
|
for result := range resultCh {
|
|
if result {
|
|
// If any of the endpoints seems to be a captive portal, we consider the system to be behind one.
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// verifyCaptivePortalEndpoint checks if the given Endpoint is a captive portal by making an HTTP request to the
|
|
// given Endpoint URL using the interface with index ifIndex, and checking if the response looks like a captive portal.
|
|
func (d *Detector) verifyCaptivePortalEndpoint(ctx context.Context, e Endpoint, ifIndex int) (found bool, err error) {
|
|
ctx, cancel := context.WithTimeout(ctx, Timeout)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", e.URL.String(), nil)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// Attach the Tailscale challenge header if the endpoint supports it. Not all captive portal detection endpoints
|
|
// support this, so we only attach it if the endpoint does.
|
|
if e.SupportsTailscaleChallenge {
|
|
// Note: the set of valid characters in a challenge and the total
|
|
// length is limited; see isChallengeChar in cmd/derper for more
|
|
// details.
|
|
chal := "ts_" + e.URL.Host
|
|
req.Header.Set("X-Tailscale-Challenge", chal)
|
|
}
|
|
|
|
d.mu.Lock()
|
|
d.currIfIndex = ifIndex
|
|
d.mu.Unlock()
|
|
|
|
// Make the actual request, and check if the response looks like a captive portal or not.
|
|
r, err := d.httpClient.Do(req)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return e.responseLooksLikeCaptive(r, d.logf), nil
|
|
}
|
|
|
|
func (d *Detector) dialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
ifIndex := d.currIfIndex
|
|
|
|
dl := &net.Dialer{
|
|
Timeout: Timeout,
|
|
Control: func(network, address string, c syscall.RawConn) error {
|
|
return setSocketInterfaceIndex(c, ifIndex, d.logf)
|
|
},
|
|
}
|
|
|
|
return dl.DialContext(ctx, network, addr)
|
|
}
|