diff options
| author | Andrew Dunham <andrew@du.nham.ca> | 2023-07-27 10:46:48 -0400 |
|---|---|---|
| committer | Andrew Dunham <andrew@du.nham.ca> | 2023-07-27 11:11:54 -0400 |
| commit | 3a8750861ba8c680c7a4ce21e6ddb8082bb21577 (patch) | |
| tree | bea00b6d95edb213b11d78a8554670bebe3d147b | |
| parent | aa37be70cf41555ad2e47993d15c43d5c6e5912c (diff) | |
| download | tailscale-andrew/captive-portal-package.tar.xz tailscale-andrew/captive-portal-package.zip | |
cmd/derper, net/netcheck: improve captive portal checksandrew/captive-portal-package
This refactors the captive portal detection into a new package, and adds
an additional check that makes a regular HTTP request with an expected
response value. This works on the JetBlue captive portal, where the
/generate_204 endpoint works fine (including the challenge!) but a
regular HTTP request is intercepted and redirected to flyfi[.]com
Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I04e72f0641507e1b5e0da7e9bf318c7b375b2b6a
| -rw-r--r-- | cmd/derper/derper.go | 8 | ||||
| -rw-r--r-- | net/netcheck/captiveportal/captiveportal.go | 185 | ||||
| -rw-r--r-- | net/netcheck/captiveportal/captiveportal_test.go | 53 |
3 files changed, 246 insertions, 0 deletions
diff --git a/cmd/derper/derper.go b/cmd/derper/derper.go index a757745ba..a3f685e5c 100644 --- a/cmd/derper/derper.go +++ b/cmd/derper/derper.go @@ -30,6 +30,7 @@ import ( "tailscale.com/derp" "tailscale.com/derp/derphttp" "tailscale.com/metrics" + "tailscale.com/net/netcheck/captiveportal" "tailscale.com/net/stun" "tailscale.com/tsweb" "tailscale.com/types/key" @@ -208,6 +209,7 @@ func main() { io.WriteString(w, "User-agent: *\nDisallow: /\n") })) mux.Handle("/generate_204", http.HandlerFunc(serveNoContent)) + mux.Handle("/captive.txt", http.HandlerFunc(serveCaptiveTxt)) debug := tsweb.Debugger(mux) debug.KV("TLS hostname", *hostname) debug.KV("Mesh key", s.HasMeshKey()) @@ -285,6 +287,7 @@ func main() { go func() { port80mux := http.NewServeMux() port80mux.HandleFunc("/generate_204", serveNoContent) + port80mux.HandleFunc("/captive.txt", serveCaptiveTxt) port80mux.Handle("/", certManager.HTTPHandler(tsweb.Port80Handler{Main: mux})) port80srv := &http.Server{ Addr: net.JoinHostPort(listenHost, fmt.Sprintf("%d", *httpPort)), @@ -333,6 +336,11 @@ func serveNoContent(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func serveCaptiveTxt(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(captiveportal.CaptiveTxtContent)) +} + func isChallengeChar(c rune) bool { // Semi-randomly chosen as a limited set of valid characters return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || diff --git a/net/netcheck/captiveportal/captiveportal.go b/net/netcheck/captiveportal/captiveportal.go new file mode 100644 index 000000000..077dd607b --- /dev/null +++ b/net/netcheck/captiveportal/captiveportal.go @@ -0,0 +1,185 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Package captiveportal checks whether a captive portal is intercepting +// HTTP(S) traffic. +package captiveportal + +import ( + "bytes" + "context" + "fmt" + "io" + "math/rand" + "net/http" + "net/url" + "strings" + + "tailscale.com/tailcfg" + "tailscale.com/types/logger" + "tailscale.com/util/multierr" +) + +var noRedirectClient = &http.Client{ + // No redirects allowed + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + + // Remaining fields are the same as the default client. + Transport: http.DefaultClient.Transport, + Jar: http.DefaultClient.Jar, + Timeout: http.DefaultClient.Timeout, +} + +// Check reports whether or not we think the system is behind a captive portal. +func Check(ctx context.Context, logf logger.Logf, dm *tailcfg.DERPMap, preferredDERP int) (bool, error) { + defer noRedirectClient.CloseIdleConnections() + + node := pickDERPNode(dm, preferredDERP) + if strings.HasSuffix(node.HostName, tailcfg.DotInvalid) { + // Don't try to connect to invalid hostnames. This occurred in tests: + // https://github.com/tailscale/tailscale/issues/6207 + // TODO(bradfitz,andrew-d): how to actually handle this nicely? + return false, nil + } + + const numChecks = 2 + + type checkResult struct { + portal bool + err error + } + results := make(chan checkResult, numChecks) + mkResult := func(res bool, err error) checkResult { return checkResult{res, err} } + + go func() { + results <- mkResult(checkGenerate204(ctx, logf, node)) + }() + go func() { + results <- mkResult(checkDERPRedirect(ctx, logf, node)) + }() + + var ( + ret bool + errs []error + ) + for i := 0; i < numChecks; i++ { + res := <-results + if res.err != nil { + errs = append(errs, res.err) + continue + } + if res.portal { + ret = true + } + } + + // Ignore errors if we successfully detect a captive portal; just return that. + if ret { + return true, nil + } + return false, multierr.New(errs...) +} + +// checkGenerate204 checks for a captive portal by making a request to the DERP +// node's /generate_204 endpoint with a challenge and verifying that it returns +// a valid response. +func checkGenerate204(ctx context.Context, logf logger.Logf, node *tailcfg.DERPNode) (bool, error) { + req, err := http.NewRequestWithContext(ctx, "GET", "http://"+node.HostName+"/generate_204", nil) + if err != nil { + return false, err + } + + // 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_" + node.HostName + req.Header.Set("X-Tailscale-Challenge", chal) + r, err := noRedirectClient.Do(req) + if err != nil { + return false, err + } + defer r.Body.Close() + + expectedResponse := "response " + chal + validResponse := r.Header.Get("X-Tailscale-Response") == expectedResponse + + logf("[v2] checkGenerate204 url=%q status_code=%d valid_response=%v", req.URL.String(), r.StatusCode, validResponse) + return r.StatusCode != 204 || !validResponse, nil +} + +const CaptiveTxtContent = "This is an arbitrary string that we expect the DERP server to return, and use to detect certain kinds of captive portals." + +// checkDERPRedirect checks for a captive portal by checking whether a regular +// HTTP request to the DERP node is redirected; this happens on e.g. JetBlue's +// in-flight WiFi. +func checkDERPRedirect(ctx context.Context, logf logger.Logf, node *tailcfg.DERPNode) (bool, error) { + req, err := http.NewRequestWithContext(ctx, "GET", "http://"+node.HostName, nil) + if err != nil { + return false, err + } + + r, err := noRedirectClient.Do(req) + if err != nil { + return false, err + } + defer r.Body.Close() + + bodyContent, err := io.ReadAll(r.Body) + if err != nil { + return false, fmt.Errorf("reading response body: %v", err) + } + + // We expect a redirect to the DERP server, or no redirect; if we get + // redirected anywhere else, assume it's a captive portal. + location := r.Header.Get("Location") + logf("[v2] checkDERPRedirect url=%q status_code=%d location=%q", req.URL.String(), r.StatusCode, location) + + // Successful responses aren't captive portals. + if r.StatusCode == 200 { + if bytes.Equal(bodyContent, []byte(CaptiveTxtContent)) { + return false, nil + } + + // We got an unexpected response body; this is probably + // something intercepting + rewriting the page. + return true, nil + } + + // If we get a non-redirect, the DERP server may be down or something + // else is wrong, but this probably isn't a captive portal. + if r.StatusCode < 300 || r.StatusCode > 399 { + return false, fmt.Errorf("invalid status %d", r.StatusCode) + } + + // A redirect to the DERP server itself isn't a captive portal, but + // redirects elsewhere are. + uri, err := url.Parse(location) + if err != nil { + return false, fmt.Errorf("parsing Location: %w", err) + } + + return uri.Hostname() != node.HostName, nil +} + +func pickDERPNode(dm *tailcfg.DERPMap, preferredDERP int) *tailcfg.DERPNode { + // If we have a preferred DERP region with more than one node, try + // that; otherwise, pick a random one not marked as "Avoid". + if preferredDERP == 0 || dm.Regions[preferredDERP] == nil || + (preferredDERP != 0 && len(dm.Regions[preferredDERP].Nodes) == 0) { + rids := make([]int, 0, len(dm.Regions)) + for id, reg := range dm.Regions { + if reg == nil || reg.Avoid || len(reg.Nodes) == 0 { + continue + } + rids = append(rids, id) + } + if len(rids) == 0 { + return nil + } + preferredDERP = rids[rand.Intn(len(rids))] + } + + return dm.Regions[preferredDERP].Nodes[0] +} diff --git a/net/netcheck/captiveportal/captiveportal_test.go b/net/netcheck/captiveportal/captiveportal_test.go new file mode 100644 index 000000000..41a76213d --- /dev/null +++ b/net/netcheck/captiveportal/captiveportal_test.go @@ -0,0 +1,53 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package captiveportal + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + + "tailscale.com/ipn" + "tailscale.com/tailcfg" +) + +func TestCheck(t *testing.T) { + ctx := context.Background() + dm, err := prodDERPMap(ctx, http.DefaultClient) + if err != nil { + t.Fatal(err) + } + portal, err := Check(ctx, t.Logf, dm, 0) + if err != nil { + t.Fatal(err) + } + t.Logf("captive portal: %v", portal) +} + +func prodDERPMap(ctx context.Context, httpc *http.Client) (*tailcfg.DERPMap, error) { + req, err := http.NewRequestWithContext(ctx, "GET", ipn.DefaultControlURL+"/derpmap/default", nil) + if err != nil { + return nil, fmt.Errorf("create prodDERPMap request: %w", err) + } + res, err := httpc.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch prodDERPMap failed: %w", err) + } + defer res.Body.Close() + b, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("fetch prodDERPMap failed: %w", err) + } + if res.StatusCode != 200 { + return nil, fmt.Errorf("fetch prodDERPMap: %v: %s", res.Status, b) + } + var derpMap tailcfg.DERPMap + if err = json.Unmarshal(b, &derpMap); err != nil { + return nil, fmt.Errorf("fetch prodDERPMap: %w", err) + } + return &derpMap, nil +} |
