captivedetection.go 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. // Copyright (c) Tailscale Inc & AUTHORS
  2. // SPDX-License-Identifier: BSD-3-Clause
  3. // Package captivedetection provides a way to detect if the system is connected to a network that has
  4. // a captive portal. It does this by making HTTP requests to known captive portal detection endpoints
  5. // and checking if the HTTP responses indicate that a captive portal might be present.
  6. package captivedetection
  7. import (
  8. "context"
  9. "net"
  10. "net/http"
  11. "runtime"
  12. "strconv"
  13. "strings"
  14. "sync"
  15. "syscall"
  16. "time"
  17. "tailscale.com/net/netmon"
  18. "tailscale.com/syncs"
  19. "tailscale.com/tailcfg"
  20. "tailscale.com/types/logger"
  21. )
  22. // Detector checks whether the system is behind a captive portal.
  23. type Detector struct {
  24. clock func() time.Time
  25. // httpClient is the HTTP client that is used for captive portal detection. It is configured
  26. // to not follow redirects, have a short timeout and no keep-alive.
  27. httpClient *http.Client
  28. // currIfIndex is the index of the interface that is currently being used by the httpClient.
  29. currIfIndex int
  30. // mu guards currIfIndex.
  31. mu syncs.Mutex
  32. // logf is the logger used for logging messages. If it is nil, log.Printf is used.
  33. logf logger.Logf
  34. }
  35. // NewDetector creates a new Detector instance for captive portal detection.
  36. func NewDetector(logf logger.Logf) *Detector {
  37. d := &Detector{logf: logf}
  38. d.httpClient = &http.Client{
  39. // No redirects allowed
  40. CheckRedirect: func(req *http.Request, via []*http.Request) error {
  41. return http.ErrUseLastResponse
  42. },
  43. Transport: &http.Transport{
  44. DialContext: d.dialContext,
  45. DisableKeepAlives: true,
  46. },
  47. Timeout: Timeout,
  48. }
  49. return d
  50. }
  51. func (d *Detector) Now() time.Time {
  52. if d.clock != nil {
  53. return d.clock()
  54. }
  55. return time.Now()
  56. }
  57. // Timeout is the timeout for captive portal detection requests. Because the captive portal intercepting our requests
  58. // is usually located on the LAN, this is a relatively short timeout.
  59. const Timeout = 3 * time.Second
  60. // Detect is the entry point to the API. It attempts to detect if the system is behind a captive portal
  61. // by making HTTP requests to known captive portal detection Endpoints. If any of the requests return a response code
  62. // or body that looks like a captive portal, Detect returns true. It returns false in all other cases, including when any
  63. // error occurs during a detection attempt.
  64. //
  65. // This function might take a while to return, as it will attempt to detect a captive portal on all available interfaces
  66. // by performing multiple HTTP requests. It should be called in a separate goroutine if you want to avoid blocking.
  67. func (d *Detector) Detect(ctx context.Context, netMon *netmon.Monitor, derpMap *tailcfg.DERPMap, preferredDERPRegionID int) (found bool) {
  68. return d.detectCaptivePortalWithGOOS(ctx, netMon, derpMap, preferredDERPRegionID, runtime.GOOS)
  69. }
  70. func (d *Detector) detectCaptivePortalWithGOOS(ctx context.Context, netMon *netmon.Monitor, derpMap *tailcfg.DERPMap, preferredDERPRegionID int, goos string) (found bool) {
  71. ifState := netMon.InterfaceState()
  72. if !ifState.AnyInterfaceUp() {
  73. d.logf("[v2] DetectCaptivePortal: no interfaces up, returning false")
  74. return false
  75. }
  76. endpoints := availableEndpoints(derpMap, preferredDERPRegionID, d.logf, goos)
  77. // Here we try detecting a captive portal using *all* available interfaces on the system
  78. // that have a IPv4 address. We consider to have found a captive portal when any interface
  79. // reports one may exists. This is necessary because most systems have multiple interfaces,
  80. // and most importantly on macOS no default route interface is set until the user has accepted
  81. // the captive portal alert thrown by the system. If no default route interface is known,
  82. // we need to try with anything that might remotely resemble a Wi-Fi interface.
  83. for ifName, i := range ifState.Interface {
  84. if !i.IsUp() || i.IsLoopback() || interfaceNameDoesNotNeedCaptiveDetection(ifName, goos) {
  85. continue
  86. }
  87. addrs, err := i.Addrs()
  88. if err != nil {
  89. d.logf("[v1] DetectCaptivePortal: failed to get addresses for interface %s: %v", ifName, err)
  90. continue
  91. }
  92. if len(addrs) == 0 {
  93. continue
  94. }
  95. d.logf("[v2] attempting to do captive portal detection on interface %s", ifName)
  96. res := d.detectOnInterface(ctx, i.Index, endpoints)
  97. if res {
  98. d.logf("DetectCaptivePortal(found=true,ifName=%s)", ifName)
  99. return true
  100. }
  101. }
  102. d.logf("DetectCaptivePortal(found=false)")
  103. return false
  104. }
  105. // interfaceNameDoesNotNeedCaptiveDetection returns true if an interface does not require captive portal detection
  106. // based on its name. This is useful to avoid making unnecessary HTTP requests on interfaces that are known to not
  107. // require it. We also avoid making requests on the interface prefixes "pdp" and "rmnet", which are cellular data
  108. // interfaces on iOS and Android, respectively, and would be needlessly battery-draining.
  109. func interfaceNameDoesNotNeedCaptiveDetection(ifName string, goos string) bool {
  110. ifName = strings.ToLower(ifName)
  111. excludedPrefixes := []string{"tailscale", "tun", "tap", "docker", "kube", "wg", "ipsec"}
  112. if goos == "windows" {
  113. excludedPrefixes = append(excludedPrefixes, "loopback", "tunnel", "ppp", "isatap", "teredo", "6to4")
  114. } else if goos == "darwin" || goos == "ios" {
  115. excludedPrefixes = append(excludedPrefixes, "pdp", "awdl", "bridge", "ap", "utun", "tap", "llw", "anpi", "lo", "stf", "gif", "xhc", "pktap")
  116. } else if goos == "android" {
  117. excludedPrefixes = append(excludedPrefixes, "rmnet", "p2p", "dummy", "sit")
  118. }
  119. for _, prefix := range excludedPrefixes {
  120. if strings.HasPrefix(ifName, prefix) {
  121. return true
  122. }
  123. }
  124. return false
  125. }
  126. // detectOnInterface reports whether or not we think the system is behind a
  127. // captive portal, detected by making a request to a URL that we know should
  128. // return a "204 No Content" response and checking if that's what we get.
  129. //
  130. // The boolean return is whether we think we have a captive portal.
  131. func (d *Detector) detectOnInterface(ctx context.Context, ifIndex int, endpoints []Endpoint) bool {
  132. defer d.httpClient.CloseIdleConnections()
  133. use := min(len(endpoints), 5)
  134. endpoints = endpoints[:use]
  135. d.logf("[v2] %d available captive portal detection endpoints; trying %v", len(endpoints), use)
  136. // We try to detect the captive portal more quickly by making requests to multiple endpoints concurrently.
  137. var wg sync.WaitGroup
  138. resultCh := make(chan bool, len(endpoints))
  139. // Once any goroutine detects a captive portal, we shut down the others.
  140. ctx, cancel := context.WithCancel(ctx)
  141. defer cancel()
  142. for _, e := range endpoints {
  143. wg.Add(1)
  144. go func(endpoint Endpoint) {
  145. defer wg.Done()
  146. found, err := d.verifyCaptivePortalEndpoint(ctx, endpoint, ifIndex)
  147. if err != nil {
  148. if ctx.Err() == nil {
  149. d.logf("[v1] checkCaptivePortalEndpoint failed with endpoint %v: %v", endpoint, err)
  150. }
  151. return
  152. }
  153. if found {
  154. cancel() // one match is good enough
  155. resultCh <- true
  156. }
  157. }(e)
  158. }
  159. go func() {
  160. wg.Wait()
  161. close(resultCh)
  162. }()
  163. for result := range resultCh {
  164. if result {
  165. // If any of the endpoints seems to be a captive portal, we consider the system to be behind one.
  166. return true
  167. }
  168. }
  169. return false
  170. }
  171. // verifyCaptivePortalEndpoint checks if the given Endpoint is a captive portal by making an HTTP request to the
  172. // given Endpoint URL using the interface with index ifIndex, and checking if the response looks like a captive portal.
  173. func (d *Detector) verifyCaptivePortalEndpoint(ctx context.Context, e Endpoint, ifIndex int) (found bool, err error) {
  174. ctx, cancel := context.WithTimeout(ctx, Timeout)
  175. defer cancel()
  176. u := *e.URL
  177. v := u.Query()
  178. v.Add("t", strconv.Itoa(int(d.Now().Unix())))
  179. u.RawQuery = v.Encode()
  180. req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
  181. if err != nil {
  182. return false, err
  183. }
  184. req.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate, no-transform, max-age=0")
  185. // Attach the Tailscale challenge header if the endpoint supports it. Not all captive portal detection endpoints
  186. // support this, so we only attach it if the endpoint does.
  187. if e.SupportsTailscaleChallenge {
  188. // Note: the set of valid characters in a challenge and the total
  189. // length is limited; see isChallengeChar in cmd/derper for more
  190. // details.
  191. chal := "ts_" + e.URL.Host
  192. req.Header.Set("X-Tailscale-Challenge", chal)
  193. }
  194. d.mu.Lock()
  195. d.currIfIndex = ifIndex
  196. d.mu.Unlock()
  197. // Make the actual request, and check if the response looks like a captive portal or not.
  198. r, err := d.httpClient.Do(req)
  199. if err != nil {
  200. return false, err
  201. }
  202. return e.responseLooksLikeCaptive(r, d.logf), nil
  203. }
  204. func (d *Detector) dialContext(ctx context.Context, network, addr string) (net.Conn, error) {
  205. d.mu.Lock()
  206. defer d.mu.Unlock()
  207. ifIndex := d.currIfIndex
  208. dl := &net.Dialer{
  209. Timeout: Timeout,
  210. Control: func(network, address string, c syscall.RawConn) error {
  211. return setSocketInterfaceIndex(c, ifIndex, d.logf)
  212. },
  213. }
  214. return dl.DialContext(ctx, network, addr)
  215. }