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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user