Files
tailscale/tstest/natlab/vmtest/webclient_test.go
T
Brad FitzpatrickandBrad Fitzpatrick 66af25733c 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
2026-06-30 06:55:12 -07:00

176 lines
6.5 KiB
Go

// 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)
}
}