From 6ca078c46ecda87fd8cb5f083f5b494ce06f3961 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Tue, 6 Aug 2024 17:33:38 -0700 Subject: [PATCH] cmd/derper: move 204 handler from package main to derphttp Updates #13038 Change-Id: I28a8284dbe49371cae0e9098205c7c5f17225b40 Signed-off-by: Brad Fitzpatrick --- cmd/derper/derper.go | 29 ++--------------------------- cmd/derper/derper_test.go | 9 +++++---- derp/derphttp/derphttp_server.go | 27 +++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 31 deletions(-) diff --git a/cmd/derper/derper.go b/cmd/derper/derper.go index 76151175c..80c9dc44f 100644 --- a/cmd/derper/derper.go +++ b/cmd/derper/derper.go @@ -237,7 +237,7 @@ func main() { tsweb.AddBrowserHeaders(w) io.WriteString(w, "User-agent: *\nDisallow: /\n") })) - mux.Handle("/generate_204", http.HandlerFunc(serveNoContent)) + mux.Handle("/generate_204", http.HandlerFunc(derphttp.ServeNoContent)) debug := tsweb.Debugger(mux) debug.KV("TLS hostname", *hostname) debug.KV("Mesh key", s.HasMeshKey()) @@ -337,7 +337,7 @@ func main() { if *httpPort > -1 { go func() { port80mux := http.NewServeMux() - port80mux.HandleFunc("/generate_204", serveNoContent) + port80mux.HandleFunc("/generate_204", derphttp.ServeNoContent) port80mux.Handle("/", certManager.HTTPHandler(tsweb.Port80Handler{Main: mux})) port80srv := &http.Server{ Addr: net.JoinHostPort(listenHost, fmt.Sprintf("%d", *httpPort)), @@ -378,31 +378,6 @@ func main() { } } -const ( - noContentChallengeHeader = "X-Tailscale-Challenge" - noContentResponseHeader = "X-Tailscale-Response" -) - -// For captive portal detection -func serveNoContent(w http.ResponseWriter, r *http.Request) { - if challenge := r.Header.Get(noContentChallengeHeader); challenge != "" { - badChar := strings.IndexFunc(challenge, func(r rune) bool { - return !isChallengeChar(r) - }) != -1 - if len(challenge) <= 64 && !badChar { - w.Header().Set(noContentResponseHeader, "response "+challenge) - } - } - w.WriteHeader(http.StatusNoContent) -} - -func isChallengeChar(c rune) bool { - // Semi-randomly chosen as a limited set of valid characters - return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || - ('0' <= c && c <= '9') || - c == '.' || c == '-' || c == '_' -} - var validProdHostname = regexp.MustCompile(`^derp([^.]*)\.tailscale\.com\.?$`) func prodAutocertHostPolicy(_ context.Context, host string) error { diff --git a/cmd/derper/derper_test.go b/cmd/derper/derper_test.go index 1af7c3abe..553a78f9f 100644 --- a/cmd/derper/derper_test.go +++ b/cmd/derper/derper_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "tailscale.com/derp/derphttp" "tailscale.com/tstest/deptest" ) @@ -76,20 +77,20 @@ func TestNoContent(t *testing.T) { t.Run(tt.name, func(t *testing.T) { req, _ := http.NewRequest("GET", "https://localhost/generate_204", nil) if tt.input != "" { - req.Header.Set(noContentChallengeHeader, tt.input) + req.Header.Set(derphttp.NoContentChallengeHeader, tt.input) } w := httptest.NewRecorder() - serveNoContent(w, req) + derphttp.ServeNoContent(w, req) resp := w.Result() if tt.want == "" { - if h, found := resp.Header[noContentResponseHeader]; found { + if h, found := resp.Header[derphttp.NoContentResponseHeader]; found { t.Errorf("got %+v; expected no response header", h) } return } - if got := resp.Header.Get(noContentResponseHeader); got != tt.want { + if got := resp.Header.Get(derphttp.NoContentResponseHeader); got != tt.want { t.Errorf("got %q; want %q", got, tt.want) } }) diff --git a/derp/derphttp/derphttp_server.go b/derp/derphttp/derphttp_server.go index d1193f383..41ce86764 100644 --- a/derp/derphttp/derphttp_server.go +++ b/derp/derphttp/derphttp_server.go @@ -18,6 +18,7 @@ import ( // following its HTTP request. const fastStartHeader = "Derp-Fast-Start" +// Handler returns an http.Handler to be mounted at /derp, serving s. func Handler(s *derp.Server) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // These are installed both here and in cmd/derper. The check here @@ -79,3 +80,29 @@ func ProbeHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "bogus probe method", http.StatusMethodNotAllowed) } } + +// ServeNoContent generates the /generate_204 response used by Tailscale's +// captive portal detection. +func ServeNoContent(w http.ResponseWriter, r *http.Request) { + if challenge := r.Header.Get(NoContentChallengeHeader); challenge != "" { + badChar := strings.IndexFunc(challenge, func(r rune) bool { + return !isChallengeChar(r) + }) != -1 + if len(challenge) <= 64 && !badChar { + w.Header().Set(NoContentResponseHeader, "response "+challenge) + } + } + w.WriteHeader(http.StatusNoContent) +} + +func isChallengeChar(c rune) bool { + // Semi-randomly chosen as a limited set of valid characters + return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || + ('0' <= c && c <= '9') || + c == '.' || c == '-' || c == '_' +} + +const ( + NoContentChallengeHeader = "X-Tailscale-Challenge" + NoContentResponseHeader = "X-Tailscale-Response" +)