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:
committed by
Brad Fitzpatrick
parent
8b5060faf5
commit
66af25733c
+2
-1
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user