tstest/natlab/vmtest, client/web: add web client integration tests

Adds two Gokrazy-based vmtests covering the tailscaled web client at
port 5252:

* TestWebClientLocalAccess enables the web client on a single node
  and exercises the canonical owner session flow against the node's
  own Tailscale IP: an unauthenticated GET /api/auth that identifies
  the caller, a GET /api/auth/session/new that issues a
  TS-Web-Session cookie, and a final GET /api/auth that reports
  authorized=true with the cookie.

* TestWebClientRemoteAccess runs the same session flow from a peer
  node on the same tailnet against a second target node's web
  client, exercising netstack interception of incoming :5252
  traffic, cross-node WhoIs, and the same-user "owner" path. It
  then flips the test control server's AllNodesSameUser off,
  re-logs in the client under a fresh identity, and asserts that
  GET /api/auth/session/new returns 401 with body "not-owner" --
  exercising the cross-user rejection in client/web/auth.go.

To make the natlab test environment exercise the same code path
as production (check mode, where the web client posts to
/machine/webclient/init via Noise and waits on a control-issued
auth URL), this also:

* Allowlists the natlab fake control hostname "control.tailscale"
  in client/web/auth.go's controlSupportsCheckMode so the web
  client follows the check-mode branch rather than the
  no-check-mode shortcut that immediately marks new sessions
  authenticated.

* Adds /machine/webclient/{init,wait} handlers to testcontrol.
  init returns a placeholder auth ID and URL; wait returns
  Complete=true immediately, so the web client's awaitUserAuth
  resolves on its first call. Together these let the tests drive
  the full check-mode session lifecycle without a real
  browser-click loop.

To support the multi-request HTTP flows from the test harness,
this also adds:

* vmtest.Env.HTTPGetStatus, a sister of HTTPGet that returns the
  upstream status code, body, and Set-Cookie cookies (as a
  vmtest.HTTPResponse) and accepts cookies on the outgoing
  request, so tests can drive flows that depend on cookie
  continuity.

* Cookie pass-through in cmd/tta's /http-get handler: it forwards
  the Cookie request header upstream and surfaces upstream
  Set-Cookie response headers downstream. This is what lets
  HTTPGetStatus carry a session cookie across requests.

Previously the only tests of the web client were in-process
httptest-based handler tests in client/web/web_test.go; nothing
exercised the actual port 5252 listener wiring, the cross-node
auth path, cookie-driven session state transitions through the
check-mode control round-trip, or the not-owner rejection end
to end.

Updates #13038

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Idb01486a89b53ac02c6ad3358bcfcceca90dbc36
This commit is contained in:
Brad Fitzpatrick
2026-06-30 06:55:12 -07:00
committed by Brad Fitzpatrick
parent 8b5060faf5
commit 66af25733c
5 changed files with 287 additions and 1 deletions
+2 -1
View File
@@ -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
+6
View File
@@ -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)
@@ -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/<src>/to/<dst> and
// /machine/webclient/wait/<src>/to/<dst>/<id>. 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 {
+74
View File
@@ -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)
+175
View File
@@ -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://<tsip>: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)
}
}