Files
tailscale/cmd/tta/tta.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

664 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// The tta server is the Tailscale Test Agent.
//
// It runs on each Tailscale node being integration tested and permits the test
// harness to control the node. It connects out to the test drver (rather than
// accepting any TCP connections inbound, which might be blocked depending on
// the scenario being tested) and then the test driver turns the TCP connection
// around and sends request back.
package main
import (
"bytes"
"context"
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"time"
"tailscale.com/atomicfile"
"tailscale.com/client/local"
"tailscale.com/hostinfo"
"tailscale.com/util/mak"
"tailscale.com/util/must"
"tailscale.com/util/set"
"tailscale.com/version/distro"
)
// connContextKeyType is the type of connContextKey, which isn't of type
// `string` to avoid collisions while being used as a context key.
type connContextKeyType string
const (
// connContextKey is the key for looking up the TCP connection
// corresponding to an HTTP request coming in from testing
// infrastructure.
connContextKey connContextKeyType = "conn-context-key"
)
var (
driverAddr = flag.String("driver", "test-driver.tailscale:8008", "address of the test driver; by default we use the DNS name test-driver.tailscale which is special cased in the emulated network's DNS server")
)
func absify(cmd string) string {
if distro.Get() == distro.Gokrazy && !strings.Contains(cmd, "/") {
return "/user/" + cmd
}
return cmd
}
func serveCmd(w http.ResponseWriter, cmd string, args ...string) {
log.Printf("Got serveCmd for %q %v", cmd, args)
out, err := exec.Command(absify(cmd), args...).CombinedOutput()
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if err != nil {
w.Header().Set("Exec-Err", err.Error())
if exiterr, ok := err.(*exec.ExitError); ok {
w.Header().Set("Exec-Exit-Code", strconv.Itoa(exiterr.ExitCode()))
}
w.WriteHeader(500)
log.Printf("Err on serveCmd for %q %v, %d bytes of output: %v", cmd, args, len(out), err)
} else {
w.Header().Set("Exec-Exit-Code", "0")
log.Printf("Did serveCmd for %q %v, %d bytes of output", cmd, args, len(out))
}
w.Write(out)
}
type localClientRoundTripper struct {
lc local.Client
}
func (rt *localClientRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.RequestURI = ""
return rt.lc.DoLocalRequest(req)
}
func main() {
var logBuf logBuffer
log.SetOutput(io.MultiWriter(os.Stderr, &logBuf))
if distro.Get() == distro.Gokrazy {
if !hostinfo.IsNATLabGuestVM() {
// "Exiting immediately with status code 0 when the
// GOKRAZY_FIRST_START=1 environment variable is set means “dont
// start the program on boot”"
return
}
}
flag.Parse()
// On macOS VMs, start polling the host via vsock for an IP assignment.
// This bypasses DHCP for near-instant network configuration.
startIPAssignLoop()
debug := false
if distro.Get() == distro.Gokrazy {
cmdLine, _ := os.ReadFile("/proc/cmdline")
explicitNS := false
for s := range strings.FieldsSeq(string(cmdLine)) {
if ns, ok := strings.CutPrefix(s, "tta.nameserver="); ok {
err := atomicfile.WriteFile("/tmp/resolv.conf", []byte("nameserver "+ns+"\n"), 0644)
log.Printf("Wrote /tmp/resolv.conf: %v", err)
explicitNS = true
continue
}
if v, ok := strings.CutPrefix(s, "tta.debug="); ok {
debug, _ = strconv.ParseBool(v)
continue
}
}
if !explicitNS {
nsRx := regexp.MustCompile(`(?m)^nameserver (.*)`)
for t := time.Now(); time.Since(t) < 10*time.Second; time.Sleep(10 * time.Millisecond) {
all, _ := os.ReadFile("/etc/resolv.conf")
if nsRx.Match(all) {
break
}
}
}
}
log.Printf("Tailscale Test Agent running.")
gokRP := httputil.NewSingleHostReverseProxy(must.Get(url.Parse("http://gokrazy")))
gokRP.Transport = &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
if network != "tcp" {
return nil, errors.New("unexpected network")
}
if addr != "gokrazy:80" {
return nil, errors.New("unexpected addr")
}
var d net.Dialer
return d.DialContext(ctx, "unix", "/run/gokrazy-http.sock")
},
}
var ttaMux http.ServeMux // agent mux
var serveMux http.ServeMux
serveMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-TTA-GoKrazy") == "1" {
gokRP.ServeHTTP(w, r)
return
}
ttaMux.ServeHTTP(w, r)
})
var hs http.Server
hs.Handler = &serveMux
hs.ConnContext = func(ctx context.Context, c net.Conn) context.Context {
return context.WithValue(ctx, connContextKey, c)
}
revSt := revDialState{
needConnCh: make(chan bool, 1),
debug: debug,
}
hs.ConnState = revSt.connState
conns := make(chan net.Conn, 1)
lcRP := httputil.NewSingleHostReverseProxy(must.Get(url.Parse("http://local-tailscaled.sock")))
lcRP.Transport = new(localClientRoundTripper)
ttaMux.HandleFunc("/localapi/", func(w http.ResponseWriter, r *http.Request) {
log.Printf("Got localapi request: %v", r.URL)
t0 := time.Now()
lcRP.ServeHTTP(w, r)
log.Printf("Did localapi request in %v: %v", time.Since(t0).Round(time.Millisecond), r.URL)
})
ttaMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "TTA\n")
return
})
ttaMux.HandleFunc("/up", func(w http.ResponseWriter, r *http.Request) {
args := []string{"up", "--login-server=http://control.tailscale"}
if routes := r.URL.Query().Get("advertise-routes"); routes != "" {
args = append(args, "--advertise-routes="+routes)
}
if snat := r.URL.Query().Get("snat-subnet-routes"); snat != "" {
args = append(args, "--snat-subnet-routes="+snat)
}
if r.URL.Query().Get("accept-routes") == "true" {
args = append(args, "--accept-routes")
}
serveCmd(w, "tailscale", args...)
})
ttaMux.HandleFunc("/set", func(w http.ResponseWriter, r *http.Request) {
args := []string{"set"}
if r.URL.Query().Get("accept-routes") == "true" {
args = append(args, "--accept-routes")
}
if routes := r.URL.Query().Get("advertise-routes"); routes != "" {
args = append(args, "--advertise-routes="+routes)
}
if snat := r.URL.Query().Get("snat-subnet-routes"); snat != "" {
args = append(args, "--snat-subnet-routes="+snat)
}
serveCmd(w, "tailscale", args...)
})
ttaMux.HandleFunc("/tailscale", func(w http.ResponseWriter, r *http.Request) {
serveCmd(w, "tailscale", r.URL.Query()["arg"]...)
})
ttaMux.HandleFunc("/gokrazy-root", func(w http.ResponseWriter, r *http.Request) {
cmdLine, err := os.ReadFile("/proc/cmdline")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for s := range strings.FieldsSeq(string(cmdLine)) {
if root, ok := strings.CutPrefix(s, "root="); ok {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
io.WriteString(w, root+"\n")
return
}
}
http.Error(w, "no root= in /proc/cmdline", http.StatusInternalServerError)
})
ttaMux.HandleFunc("/ip", func(w http.ResponseWriter, r *http.Request) {
conn, ok := r.Context().Value(connContextKey).(net.Conn)
if !ok {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Write([]byte(conn.LocalAddr().String()))
})
ttaMux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
host := r.URL.Query().Get("host")
if distro.Get() == distro.Gokrazy {
// The busybox in question here is the breakglass busybox inside the
// natlab QEMU image.
serveCmd(w, "/usr/local/bin/busybox", "ping", "-c", "4", "-W", "1", host)
} else {
serveCmd(w, "ping", "-c", "4", "-W", "1", host)
}
})
ttaMux.HandleFunc("/add-route", func(w http.ResponseWriter, r *http.Request) {
prefix := r.URL.Query().Get("prefix")
via := r.URL.Query().Get("via")
if prefix == "" || via == "" {
http.Error(w, "missing prefix or via", http.StatusBadRequest)
return
}
switch runtime.GOOS {
case "linux":
serveCmd(w, "ip", "route", "add", prefix, "via", via)
default:
http.Error(w, "add-route not supported on "+runtime.GOOS, http.StatusNotImplemented)
}
})
ttaMux.HandleFunc("/start-webserver", func(w http.ResponseWriter, r *http.Request) {
port := r.URL.Query().Get("port")
name := r.URL.Query().Get("name")
if port == "" {
http.Error(w, "missing port", http.StatusBadRequest)
return
}
if name == "" {
name = "unnamed"
}
log.Printf("Starting webserver on port %s as %q", port, name)
go func() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
host, _, _ := net.SplitHostPort(r.RemoteAddr)
fmt.Fprintf(w, "Hello world I am %s from %s", name, host)
})
if err := http.ListenAndServe(":"+port, mux); err != nil {
log.Printf("webserver on :%s failed: %v", port, err)
}
}()
io.WriteString(w, "OK\n")
})
ttaMux.HandleFunc("/taildrop-send", func(w http.ResponseWriter, r *http.Request) {
to := r.URL.Query().Get("to") // peer's Tailscale IP
name := r.URL.Query().Get("name")
if to == "" || name == "" {
http.Error(w, "missing to or name", http.StatusBadRequest)
return
}
if strings.ContainsAny(name, "/\\") {
http.Error(w, "bad name", http.StatusBadRequest)
return
}
dir, err := os.MkdirTemp("", "taildrop-send-")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, name)
f, err := os.Create(path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if _, err := io.Copy(f, r.Body); err != nil {
f.Close()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := f.Close(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
serveCmd(w, "tailscale", "file", "cp", path, to+":")
})
ttaMux.HandleFunc("/taildrop-recv", func(w http.ResponseWriter, r *http.Request) {
dir, err := os.MkdirTemp("", "taildrop-recv-")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer os.RemoveAll(dir)
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, absify("tailscale"), "file", "get", "--wait", dir)
if out, err := cmd.CombinedOutput(); err != nil {
http.Error(w, fmt.Sprintf("tailscale file get: %v\n%s", err, out), http.StatusInternalServerError)
return
}
ents, err := os.ReadDir(dir)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if len(ents) != 1 {
http.Error(w, fmt.Sprintf("got %d files, want 1", len(ents)), http.StatusInternalServerError)
return
}
data, err := os.ReadFile(filepath.Join(dir, ents[0].Name()))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Taildrop-Filename", ents[0].Name())
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(data)
})
ttaMux.HandleFunc("/http-get", func(w http.ResponseWriter, r *http.Request) {
targetURL := r.URL.Query().Get("url")
if targetURL == "" {
http.Error(w, "missing url", http.StatusBadRequest)
return
}
log.Printf("HTTP GET %s", targetURL)
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", targetURL, nil)
if err != nil {
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
// in userspace networking mode).
client := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
// Try the Tailscale localapi proxy dialer first.
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
var d net.Dialer
return d.DialContext(ctx, network, addr)
}
port, _ := strconv.ParseUint(portStr, 10, 16)
var lc local.Client
conn, err := lc.UserDial(ctx, network, host, uint16(port))
if err == nil {
return conn, nil
}
log.Printf("http-get: UserDial failed, falling back to direct: %v", err)
var d net.Dialer
return d.DialContext(ctx, network, addr)
},
},
}
resp, err := client.Do(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
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)
})
ttaMux.HandleFunc("/fw", addFirewallHandler)
ttaMux.HandleFunc("/wg-server-up", func(w http.ResponseWriter, r *http.Request) {
if wgServerUp == nil {
http.Error(w, "wg-server-up not supported on this platform", http.StatusNotImplemented)
return
}
wgServerUp(w, r)
})
ttaMux.HandleFunc("/restart-tailscaled", func(w http.ResponseWriter, r *http.Request) {
if restartTailscaled == nil {
http.Error(w, "restart-tailscaled not supported on this platform", http.StatusNotImplemented)
return
}
pid, err := restartTailscaled()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "killed tailscaled pid %d (supervisor will respawn)\n", pid)
})
ttaMux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
logBuf.mu.Lock()
defer logBuf.mu.Unlock()
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write(logBuf.buf.Bytes())
})
go hs.Serve(chanListener(conns))
// For doing agent operations locally from gokrazy:
// (e.g. with "wget -O - localhost:8123/fw" or "wget -O - localhost:8123/logs"
// to get early tta logs before the port 124 connection is established)
go func() {
err := http.ListenAndServe("127.0.0.1:8123", &ttaMux)
if err != nil {
log.Fatalf("ListenAndServe: %v", err)
}
}()
revSt.runDialOutLoop(conns)
}
// dialCancels tracks cancel funcs for in-flight connect() and sleep contexts.
// resetDialCancels cancels them all so the dial loop retries immediately.
var (
dialCancelMu sync.Mutex
dialCancels set.HandleSet[context.CancelFunc]
)
// registerDialCancel adds a cancel func and returns a handle for removal.
func registerDialCancel(cancel context.CancelFunc) set.Handle {
dialCancelMu.Lock()
defer dialCancelMu.Unlock()
return dialCancels.Add(cancel)
}
// unregisterDialCancel removes a previously registered cancel func.
func unregisterDialCancel(h set.Handle) {
dialCancelMu.Lock()
defer dialCancelMu.Unlock()
delete(dialCancels, h)
}
// resetDialCancels cancels all in-flight connect and sleep contexts,
// causing the dial loop to retry immediately with the updated driver address.
func resetDialCancels() {
dialCancelMu.Lock()
defer dialCancelMu.Unlock()
for h, cancel := range dialCancels {
cancel()
delete(dialCancels, h)
}
}
func connect() (net.Conn, error) {
d := net.Dialer{
Control: bypassControlFunc,
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
h := registerDialCancel(cancel)
defer func() {
cancel()
unregisterDialCancel(h)
}()
c, err := d.DialContext(ctx, "tcp", *driverAddr)
if err != nil {
return nil, err
}
return c, nil
}
type chanListener <-chan net.Conn
func (cl chanListener) Accept() (net.Conn, error) {
c, ok := <-cl
if !ok {
return nil, errors.New("closed")
}
return c, nil
}
func (cl chanListener) Close() error {
return nil
}
func (cl chanListener) Addr() net.Addr {
return &net.TCPAddr{
IP: net.ParseIP("52.0.0.34"), // TS..DR(iver)
Port: 123,
}
}
type revDialState struct {
needConnCh chan bool
debug bool
mu sync.Mutex
newSet set.Set[net.Conn] // conns in StateNew
onNew map[net.Conn]func()
}
func (s *revDialState) connState(c net.Conn, cs http.ConnState) {
s.mu.Lock()
defer s.mu.Unlock()
oldLen := len(s.newSet)
switch cs {
case http.StateNew:
if f, ok := s.onNew[c]; ok {
f()
delete(s.onNew, c)
}
s.newSet.Make()
s.newSet.Add(c)
default:
s.newSet.Delete(c)
}
s.vlogf("ConnState: %p now %v; newSet %v=>%v", c, s, oldLen, len(s.newSet))
if len(s.newSet) < 2 {
select {
case s.needConnCh <- true:
default:
}
}
}
func (s *revDialState) waitNeedConnect() {
for {
s.mu.Lock()
need := len(s.newSet) < 2
s.mu.Unlock()
if need {
return
}
<-s.needConnCh
}
}
func (s *revDialState) vlogf(format string, arg ...any) {
if !s.debug {
return
}
log.Printf(format, arg...)
}
func (s *revDialState) runDialOutLoop(conns chan<- net.Conn) {
var lastErr string
connected := false
for {
s.vlogf("[dial-driver] waiting need connect...")
s.waitNeedConnect()
s.vlogf("[dial-driver] connecting...")
t0 := time.Now()
c, err := connect()
if err != nil {
s := err.Error()
if s != lastErr {
log.Printf("[dial-driver] connect failure: %v", s)
}
lastErr = s
sleepCtx, sleepCancel := context.WithTimeout(context.Background(), time.Second)
h := registerDialCancel(sleepCancel)
<-sleepCtx.Done()
sleepCancel()
unregisterDialCancel(h)
continue
}
if !connected {
connected = true
log.Printf("Connected to %v", *driverAddr)
}
s.vlogf("[dial-driver] connected %v => %v after %v", c.LocalAddr(), c.RemoteAddr(), time.Since(t0))
inHTTP := make(chan struct{})
s.mu.Lock()
mak.Set(&s.onNew, c, func() { close(inHTTP) })
s.mu.Unlock()
s.vlogf("[dial-driver] sending...")
conns <- c
s.vlogf("[dial-driver] sent; waiting")
select {
case <-inHTTP:
s.vlogf("[dial-driver] conn in HTTP")
case <-time.After(2 * time.Second):
s.vlogf("[dial-driver] timeout waiting for conn to be accepted into HTTP")
}
}
}
func addFirewallHandler(w http.ResponseWriter, r *http.Request) {
if addFirewall == nil {
http.Error(w, "firewall not supported", 500)
return
}
err := addFirewall()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
io.WriteString(w, "OK\n")
}
var addFirewall func() error // set by fw_linux.go
// wgServerUp brings up a userspace WireGuard "Mullvad-style" exit-node
// server on this VM. It is set by wgserver_linux.go and is nil on
// non-Linux.
var wgServerUp func(w http.ResponseWriter, r *http.Request)
// restartTailscaled sends SIGKILL to the local tailscaled process so the
// gokrazy supervisor restarts it. It is set by restart_tailscaled_linux.go
// and is nil on non-Linux.
var restartTailscaled func() (pid int, err error)
// logBuffer is a bytes.Buffer that is safe for concurrent use
// intended to capture early logs from the process, even if
// gokrazy's syslog streaming isn't working or yet working.
// It only captures the first 1MB of logs, as that's considered
// plenty for early debugging. At runtime, it's assumed that
// syslog log streaming is working.
type logBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (lb *logBuffer) Write(p []byte) (n int, err error) {
lb.mu.Lock()
defer lb.mu.Unlock()
const maxSize = 1 << 20 // more than plenty; see type comment
if lb.buf.Len() > maxSize {
return len(p), nil
}
return lb.buf.Write(p)
}