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
@@ -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 {