diff --git a/client/web/auth.go b/client/web/auth.go index 916f24782..1281695a9 100644 --- a/client/web/auth.go +++ b/client/web/auth.go @@ -199,7 +199,8 @@ func (s *Server) controlSupportsCheckMode(ctx context.Context) bool { if err != nil { return true } - return strings.HasSuffix(controlURL.Host, ".tailscale.com") + return strings.HasSuffix(controlURL.Host, ".tailscale.com") || + controlURL.Host == "control.tailscale" // for natlab tests } // awaitUserAuth blocks until the given session auth has been completed diff --git a/cmd/tta/tta.go b/cmd/tta/tta.go index bbc6dbcda..5d9122667 100644 --- a/cmd/tta/tta.go +++ b/cmd/tta/tta.go @@ -366,6 +366,9 @@ func main() { http.Error(w, err.Error(), http.StatusInternalServerError) return } + if cookie := r.Header.Get("Cookie"); cookie != "" { + req.Header.Set("Cookie", cookie) + } // Use Tailscale's SOCKS5 proxy if available, so traffic to Tailscale // subnet routes goes through the WireGuard tunnel instead of the // host network stack (which may not have the routes, especially @@ -397,6 +400,9 @@ func main() { return } defer resp.Body.Close() + for _, sc := range resp.Header.Values("Set-Cookie") { + w.Header().Add("Set-Cookie", sc) + } w.Header().Set("X-Upstream-Status", strconv.Itoa(resp.StatusCode)) w.WriteHeader(resp.StatusCode) io.Copy(w, resp.Body) diff --git a/tstest/integration/testcontrol/testcontrol.go b/tstest/integration/testcontrol/testcontrol.go index 4e0775003..0ad335ae3 100644 --- a/tstest/integration/testcontrol/testcontrol.go +++ b/tstest/integration/testcontrol/testcontrol.go @@ -380,6 +380,7 @@ func (s *Server) initMux() { }) s.mux.HandleFunc("/key", s.serveKey) s.mux.HandleFunc("/machine/tka/", s.serveTKA) + s.mux.HandleFunc("/machine/webclient/", s.serveWebClient) s.mux.HandleFunc("/machine/", s.serveMachine) s.mux.HandleFunc("/ts2021", s.serveNoiseUpgrade) s.mux.HandleFunc("/c2n/", s.serveC2N) @@ -519,6 +520,35 @@ func (s *Server) serveMachine(w http.ResponseWriter, r *http.Request) { } } +// serveWebClient handles the Noise-protected web client auth flow endpoints +// posted to /machine/webclient/init//to/ and +// /machine/webclient/wait//to//. It is the test-control +// counterpart to client/web's check-mode session creation: it returns a +// placeholder auth URL for init, and immediately Complete=true for wait, so +// tests can drive the full check-mode session lifecycle without a real +// browser-click loop. +func (s *Server) serveWebClient(w http.ResponseWriter, r *http.Request) { + if r.Method != httpm.POST { + http.Error(w, "POST required", http.StatusMethodNotAllowed) + return + } + var resp tailcfg.WebClientAuthResponse + switch { + case strings.HasPrefix(r.URL.Path, "/machine/webclient/init/"): + resp.ID = "testcontrol-webclient-auth" + resp.URL = "https://control.tailscale/test-web-auth" + case strings.HasPrefix(r.URL.Path, "/machine/webclient/wait/"): + resp.Complete = true + default: + s.serveUnhandled(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Printf("testcontrol: encoding web client response: %v", err) + } +} + func (s *Server) serveSetDNS(w http.ResponseWriter, r *http.Request, mkey key.MachinePublic) { var req tailcfg.SetDNSRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { diff --git a/tstest/natlab/vmtest/vmtest.go b/tstest/natlab/vmtest/vmtest.go index ced9d9443..d479c15ff 100644 --- a/tstest/natlab/vmtest/vmtest.go +++ b/tstest/natlab/vmtest/vmtest.go @@ -1440,6 +1440,80 @@ func (e *Env) HTTPGet(from *Node, targetURL string) string { return "" } +// HTTPResponse is the result of a successful [Env.HTTPGetStatus] call. +type HTTPResponse struct { + // Status is the upstream HTTP status code. + Status int + // Body is the upstream response body. + Body string + // SetCookies is the parsed list of cookies the upstream set, if any. + SetCookies []*http.Cookie +} + +// HTTPGetStatus is like [Env.HTTPGet] but returns the upstream HTTP status +// code, body, and any Set-Cookie response cookies, so callers can assert on +// rejection responses (e.g. 401, 403) and drive multi-request flows that need +// cookie continuity (e.g. session-based authentication). +// +// Any sendCookies are sent on the upstream request via the Cookie header. +// +// The request is proxied through TTA's /http-get handler, which dials via +// Tailscale's UserDial; this works on any OS the test agent runs on. +// +// Like [Env.HTTPGet], HTTPGetStatus retries up to 3 times on TTA-level +// connection failures (502 / 503 from TTA when it cannot reach upstream). +// Upstream responses (including 4xx) are returned to the caller without retry. +func (e *Env) HTTPGetStatus(from *Node, targetURL string, sendCookies ...*http.Cookie) (*HTTPResponse, error) { + cookieHeader := "" + if len(sendCookies) > 0 { + parts := make([]string, 0, len(sendCookies)) + for _, c := range sendCookies { + parts = append(parts, c.Name+"="+c.Value) + } + cookieHeader = strings.Join(parts, "; ") + } + + var lastErr error + for attempt := range 3 { + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + reqURL := "http://unused/http-get?url=" + url.QueryEscape(targetURL) + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + cancel() + return nil, err + } + if cookieHeader != "" { + req.Header.Set("Cookie", cookieHeader) + } + res, err := from.agent.HTTPClient.Do(req) + cancel() + if err != nil { + e.logVerbosef("HTTPGetStatus attempt %d from %s: %v", attempt+1, from.name, err) + lastErr = err + time.Sleep(2 * time.Second) + continue + } + b, _ := io.ReadAll(res.Body) + res.Body.Close() + // A bare 502/503 with no X-Upstream-Status means TTA itself couldn't + // reach upstream; retry. If the header is set, the upstream really + // did answer with that status and we should pass it through. + if (res.StatusCode == http.StatusBadGateway || res.StatusCode == http.StatusServiceUnavailable) && + res.Header.Get("X-Upstream-Status") == "" { + e.logVerbosef("HTTPGetStatus attempt %d from %s: TTA %d: %s", attempt+1, from.name, res.StatusCode, string(b)) + lastErr = fmt.Errorf("TTA %d: %s", res.StatusCode, strings.TrimSpace(string(b))) + time.Sleep(2 * time.Second) + continue + } + return &HTTPResponse{ + Status: res.StatusCode, + Body: string(b), + SetCookies: res.Cookies(), + }, nil + } + return nil, fmt.Errorf("HTTPGetStatus from %s to %s: gave up: %w", from.name, targetURL, lastErr) +} + // Tailscale runs the tailscale CLI on the given node via TTA. func (e *Env) Tailscale(n *Node, args ...string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) diff --git a/tstest/natlab/vmtest/webclient_test.go b/tstest/natlab/vmtest/webclient_test.go new file mode 100644 index 000000000..bb6811da5 --- /dev/null +++ b/tstest/natlab/vmtest/webclient_test.go @@ -0,0 +1,175 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package vmtest_test + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "tailscale.com/tstest/natlab/vmtest" +) + +// TestWebClientLocalAccess verifies that, after enabling the web client on a +// single node, the node's own Tailscale IP responds on port 5252 and that a +// same-node session can be created and used to access the management UI as +// the owner. +func TestWebClientLocalAccess(t *testing.T) { + env := vmtest.New(t) + node := easy(env) + env.Start() + + enableWebClient(t, env, node) + assertOwnerSessionFlow(t, env, node, webClientBaseURL(t, env, node), viewerName(t, env, node)) +} + +// TestWebClientRemoteAccess verifies that a peer node on the same tailnet can +// create a session on a target's web client and then use it to access the +// management UI as the owner, and that after re-logging-in under a different +// user the target rejects new session attempts with 401 "not-owner". +// +// This exercises: +// - netstack interception of incoming :5252 traffic, gated by +// ShouldExposeRemoteWebClient (ipn/ipnlocal/netstack.go) +// - cross-node WhoIs identifying the caller (client/web/web.go) +// - cookie issuance + the same-user "owner" path through getSession + +// authorizeRequest (client/web/auth.go) +// - the not-owner rejection path (client/web/auth.go) +func TestWebClientRemoteAccess(t *testing.T) { + env := vmtest.New(t, vmtest.SameTailnetUser(), vmtest.AllOnline()) + target := easy(env) + client := easy(env) + env.Start() + + enableWebClient(t, env, target) + baseURL := webClientBaseURL(t, env, target) + + assertOwnerSessionFlow(t, env, client, baseURL, viewerName(t, env, client)) + + // Re-log-in the client under a fresh identity that is no longer the + // target's owner, and assert /api/auth/session/new is rejected. + env.ControlServer().AllNodesSameUser = false + env.Relogin(client) + assertSessionRejectedNotOwner(t, env, client, baseURL) +} + +// enableWebClient turns on the management web client on n via "tailscale set +// --webclient", fataling the test on error. +func enableWebClient(t *testing.T, env *vmtest.Env, n *vmtest.Node) { + t.Helper() + if out, err := env.Tailscale(n, "set", "--webclient"); err != nil { + t.Fatalf("tailscale set --webclient on %s: %v\n%s", n.Name(), err, out) + } +} + +// webClientBaseURL returns the http://:5252 base URL for n's management +// web client. +func webClientBaseURL(t *testing.T, env *vmtest.Env, n *vmtest.Node) string { + t.Helper() + st := env.Status(n) + if st.Self == nil || len(st.Self.TailscaleIPs) == 0 { + t.Fatalf("%s has no Tailscale IPs; status=%+v", n.Name(), st) + } + return fmt.Sprintf("http://%s:5252", st.Self.TailscaleIPs[0]) +} + +// viewerName returns the DNS-name form (no trailing dot) that the web client +// uses in viewerIdentity.nodeName for a request from n. +func viewerName(t *testing.T, env *vmtest.Env, n *vmtest.Node) string { + t.Helper() + st := env.Status(n) + if st.Self == nil { + t.Fatalf("%s has no Self status", n.Name()) + } + return strings.TrimSuffix(st.Self.DNSName, ".") +} + +// assertOwnerSessionFlow exercises the canonical owner flow against the +// management web client at baseURL, calling from `from`: +// +// 1. GET /api/auth without a cookie: the server is reachable, identifies the +// caller as expectViewer, and reports authorized=false (no session yet). +// 2. GET /api/auth/session/new: the web client posts to +// /machine/webclient/init on the test control server via Noise; control +// returns a placeholder auth URL; the response sets a TS-Web-Session +// cookie with PendingAuth=true. +// 3. GET /api/auth with the cookie: awaitUserAuth posts to +// /machine/webclient/wait on the test control server, which returns +// Complete=true; the session is marked Authenticated and the response +// reports authorized=true. +// +// This exercises the check-mode path in client/web/auth.go (the +// controlSupportsCheckMode branch), which fires for the natlab test control +// server's hostname (control.tailscale). +// +// Use this for both same-node (self-as-owner) and cross-node-same-user +// (peer-as-owner) paths: the assertions are identical. +func assertOwnerSessionFlow(t *testing.T, env *vmtest.Env, from *vmtest.Node, baseURL, expectViewer string) { + t.Helper() + + res, err := env.HTTPGetStatus(from, baseURL+"/api/auth") + if err != nil { + t.Fatalf("GET /api/auth: %v", err) + } + if res.Status != 200 { + t.Fatalf("GET /api/auth: status = %d, want 200; body=%s", res.Status, res.Body) + } + if !strings.Contains(res.Body, `"serverMode":"manage"`) { + t.Errorf("/api/auth response missing serverMode=manage: %s", res.Body) + } + if expectViewer != "" && !strings.Contains(res.Body, fmt.Sprintf(`"nodeName":%q`, expectViewer)) { + t.Errorf("/api/auth viewerIdentity does not name %q: %s", expectViewer, res.Body) + } + if strings.Contains(res.Body, `"authorized":true`) { + t.Errorf("unauthenticated /api/auth should not report authorized=true: %s", res.Body) + } + + res, err = env.HTTPGetStatus(from, baseURL+"/api/auth/session/new") + if err != nil { + t.Fatalf("GET /api/auth/session/new: %v", err) + } + if res.Status != 200 { + t.Fatalf("GET /api/auth/session/new: status = %d, want 200; body=%s", res.Status, res.Body) + } + var cookie *http.Cookie + for _, c := range res.SetCookies { + if c.Name == "TS-Web-Session" { + cookie = c + break + } + } + if cookie == nil { + t.Fatalf("/api/auth/session/new did not set a TS-Web-Session cookie; got %v", res.SetCookies) + } + + res, err = env.HTTPGetStatus(from, baseURL+"/api/auth", cookie) + if err != nil { + t.Fatalf("GET /api/auth (authed): %v", err) + } + if res.Status != 200 { + t.Fatalf("GET /api/auth (authed): status = %d, want 200; body=%s", res.Status, res.Body) + } + if !strings.Contains(res.Body, `"authorized":true`) { + t.Errorf("authenticated /api/auth should report authorized=true: %s", res.Body) + } +} + +// assertSessionRejectedNotOwner asserts that /api/auth/session/new from `from` +// against baseURL returns 401 with body "not-owner" -- the rejection path in +// client/web/auth.go's getSession for a source node whose user is not the +// web client owner. +func assertSessionRejectedNotOwner(t *testing.T, env *vmtest.Env, from *vmtest.Node, baseURL string) { + t.Helper() + res, err := env.HTTPGetStatus(from, baseURL+"/api/auth/session/new") + if err != nil { + t.Fatalf("GET /api/auth/session/new from non-owner: %v", err) + } + if res.Status != 401 { + t.Errorf("GET /api/auth/session/new from non-owner: status = %d, want 401; body=%s", res.Status, res.Body) + } + if !strings.Contains(res.Body, "not-owner") { + t.Errorf("non-owner response body does not contain \"not-owner\": %s", res.Body) + } +}