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
+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)