tstest/natlab/vmtest, cmd/tta: add TestTaildrop

Add a vmtest that brings up two Ubuntu nodes, each behind its own
EasyNAT, joined to the tailnet. The sender pushes a small file via
"tailscale file cp" and the receiver fetches it via "tailscale file
get --wait", asserting that the filename and contents round-trip
unchanged.

To make Taildrop work in vmtest, three small pieces were needed:

The Linux/FreeBSD cloud-init now starts tailscaled with --statedir as
well as --state=mem:, so the daemon has a VarRoot to host Taildrop's
incoming-files directory. State itself remains in-memory (so nothing
persists across reboots); only the var-root scratch space is on disk.

vmtest.New grows a variadic EnvOption parameter and a SameTailnetUser
helper. When the option is passed, Start sets AllNodesSameUser=true
on the embedded testcontrol.Server. Cross-node Taildrop requires the
sender and receiver to share a Tailnet user (or have an explicit
PeerCapabilityFileSharingTarget granted between them, which we don't
plumb here), so TestTaildrop opts in. Existing tests don't.

cmd/tta gains /taildrop-send and /taildrop-recv handlers that wrap
"tailscale file cp" and "tailscale file get --wait", plus
Env.SendTaildropFile and Env.RecvTaildropFile helpers in vmtest that
drive them.

Updates #13038

Change-Id: I8f5f70f88106e6e2ee07780dd46fe00f8efcfdf1
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick 2026-04-28 17:26:10 +00:00 committed by Brad Fitzpatrick
parent 4b8e0ede6d
commit ec7b11d986
4 changed files with 222 additions and 5 deletions

View File

@ -24,6 +24,7 @@ import (
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
@ -263,6 +264,72 @@ func main() {
}()
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 == "" {

View File

@ -120,8 +120,11 @@ func (e *Env) generateLinuxUserData(n *Node) string {
ud.WriteString(" - [\"sysctl\", \"-w\", \"net.ipv6.conf.all.forwarding=1\"]\n")
}
// Start tailscaled in the background.
ud.WriteString(" - [\"/bin/sh\", \"-c\", \"/usr/local/bin/tailscaled --state=mem: &\"]\n")
// Start tailscaled in the background. --statedir provides a VarRoot so
// features like Taildrop (which needs a place to stash incoming files)
// have a directory to work with.
ud.WriteString(" - [\"mkdir\", \"-p\", \"/var/lib/tailscale\"]\n")
ud.WriteString(" - [\"/bin/sh\", \"-c\", \"/usr/local/bin/tailscaled --state=mem: --statedir=/var/lib/tailscale &\"]\n")
ud.WriteString(" - [\"sleep\", \"2\"]\n")
// Start tta (Tailscale Test Agent).
@ -173,7 +176,9 @@ func (e *Env) generateFreeBSDUserData(n *Node) string {
// Start tailscaled and tta in the background.
// Set PATH to include /usr/local/bin so that tta can find "tailscale"
// (TTA uses exec.Command("tailscale", ...) without a full path).
ud.WriteString(" - \"export PATH=/usr/local/bin:$PATH && /usr/local/bin/tailscaled --state=mem: &\"\n")
// --statedir provides a VarRoot so features like Taildrop have a directory.
ud.WriteString(" - \"mkdir -p /var/lib/tailscale\"\n")
ud.WriteString(" - \"export PATH=/usr/local/bin:$PATH && /usr/local/bin/tailscaled --state=mem: --statedir=/var/lib/tailscale &\"\n")
ud.WriteString(" - \"sleep 2\"\n")
// Start tta (Tailscale Test Agent).

View File

@ -16,6 +16,7 @@
package vmtest
import (
"bytes"
"context"
"encoding/base64"
"flag"
@ -76,6 +77,8 @@ type Env struct {
qemuProcs []*exec.Cmd // launched QEMU processes
sameTailnetUser bool // all nodes register as the same Tailnet user
// Web UI support.
ctx context.Context // cancelled when test ends
eventBus *EventBus
@ -233,8 +236,10 @@ func (e *Env) nodeNameByNum(num int) string {
return fmt.Sprintf("node%d", num)
}
// New creates a new test environment. It skips the test if --run-vm-tests is not set.
func New(t testing.TB) *Env {
// New creates a new test environment. It skips the test if --run-vm-tests is
// not set. opts may contain [EnvOption] values returned by helpers like
// [SameTailnetUser].
func New(t testing.TB, opts ...EnvOption) *Env {
if !*runVMTests {
t.Skip("skipping VM test; set --run-vm-tests to run")
}
@ -248,6 +253,9 @@ func New(t testing.TB) *Env {
testStatus: newTestStatus(),
nodeStatus: make(map[string]*NodeStatus),
}
for _, o := range opts {
o.applyTo(e)
}
t.Cleanup(func() {
e.testStatus.finish(t.Failed())
e.eventBus.Publish(VMEvent{
@ -259,6 +267,23 @@ func New(t testing.TB) *Env {
return e
}
// EnvOption configures an [Env] in [New].
type EnvOption interface {
applyTo(*Env)
}
type envOptFunc func(*Env)
func (f envOptFunc) applyTo(e *Env) { f(e) }
// SameTailnetUser returns an [EnvOption] that makes every node register with
// the test control server as the same Tailnet user. This is needed for
// cross-node features that require a same-user relationship — Taildrop, for
// example.
func SameTailnetUser() EnvOption {
return envOptFunc(func(e *Env) { e.sameTailnetUser = true })
}
// AddNetwork creates a new virtual network. Arguments follow the same pattern as
// vnet.Config.AddNetwork (string IPs, NAT types, NetworkService values).
func (e *Env) AddNetwork(opts ...any) *vnet.Network {
@ -544,6 +569,10 @@ func (e *Env) Start() {
}
})
if e.sameTailnetUser {
e.server.ControlServer().AllNodesSameUser = true
}
// Register compiled binaries with the file server VIP.
// Binaries are registered at <goos>_<goarch>/<name> (e.g. "linux_amd64/tta").
for _, p := range needPlatform.Slice() {
@ -1094,6 +1123,69 @@ func (e *Env) HTTPGet(from *Node, targetURL string) string {
return ""
}
// SendTaildropFile sends a file via Taildrop from one node to another.
// The to node must be on the tailnet. It fatals on error.
func (e *Env) SendTaildropFile(from, to *Node, name string, content []byte) {
e.t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
st, err := to.agent.Status(ctx)
if err != nil {
e.t.Fatalf("SendTaildropFile: status for %s: %v", to.name, err)
}
if len(st.Self.TailscaleIPs) == 0 {
e.t.Fatalf("SendTaildropFile: %s has no Tailscale IPs", to.name)
}
target := st.Self.TailscaleIPs[0].String()
reqURL := fmt.Sprintf("http://unused/taildrop-send?to=%s&name=%s", target, name)
req, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewReader(content))
if err != nil {
e.t.Fatalf("SendTaildropFile: %v", err)
}
res, err := from.agent.HTTPClient.Do(req)
if err != nil {
e.t.Fatalf("SendTaildropFile(%s -> %s): %v", from.name, to.name, err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if res.StatusCode != 200 {
e.t.Fatalf("SendTaildropFile(%s -> %s): %s: %s", from.name, to.name, res.Status, body)
}
if msg := strings.TrimSpace(string(body)); msg != "" {
e.t.Logf("[%s] %s", from.name, msg)
}
e.t.Logf("[%s] sent Taildrop %q (%d bytes) to %s", from.name, name, len(content), to.name)
}
// RecvTaildropFile waits for an incoming Taildrop file on the node and
// returns the filename and contents. The provided context bounds the wait;
// in addition, RecvTaildropFile imposes its own 90s upper bound. It fatals
// on error or timeout.
func (e *Env) RecvTaildropFile(ctx context.Context, n *Node) (name string, content []byte) {
e.t.Helper()
ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", "http://unused/taildrop-recv", nil)
if err != nil {
e.t.Fatalf("RecvTaildropFile: %v", err)
}
res, err := n.agent.HTTPClient.Do(req)
if err != nil {
e.t.Fatalf("RecvTaildropFile(%s): %v", n.name, err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if res.StatusCode != 200 {
e.t.Fatalf("RecvTaildropFile(%s): %s: %s", n.name, res.Status, body)
}
name = res.Header.Get("Taildrop-Filename")
e.t.Logf("[%s] received Taildrop %q (%d bytes)", n.name, name, len(body))
return name, body
}
var buildGokrazy sync.Once
// ensureGokrazy builds the gokrazy base image (once per test process) and

View File

@ -4,6 +4,7 @@
package vmtest_test
import (
"bytes"
"fmt"
"net/netip"
"strings"
@ -363,6 +364,58 @@ func TestSubnetRouterAndExitNode(t *testing.T) {
}
}
// TestTaildrop verifies that one Ubuntu node can send a file to another
// Ubuntu node via Taildrop, and the receiver gets the same content.
//
// Topology: two Ubuntu nodes, each behind its own EasyNAT, both joined to the
// tailnet. The sender runs `tailscale file cp` to push to the receiver's
// Tailscale IP; the receiver then runs `tailscale file get --wait` to fetch
// it.
func TestTaildrop(t *testing.T) {
env := vmtest.New(t, vmtest.SameTailnetUser())
senderNet := env.AddNetwork("1.0.0.1", "192.168.1.1/24", vnet.EasyNAT)
receiverNet := env.AddNetwork("2.0.0.1", "192.168.2.1/24", vnet.EasyNAT)
sender := env.AddNode("sender", senderNet,
vmtest.OS(vmtest.Ubuntu2404))
receiver := env.AddNode("receiver", receiverNet,
vmtest.OS(vmtest.Ubuntu2404))
// Declare test-specific steps for the web UI.
sendStep := env.AddStep("Taildrop send (sender -> receiver)")
recvStep := env.AddStep("Taildrop receive (on receiver)")
verifyStep := env.AddStep("Verify received name and contents")
env.Start()
const filename = "hello.txt"
want := []byte("hello world this is a Taildrop test\n")
sendStep.Begin()
env.SendTaildropFile(sender, receiver, filename, want)
sendStep.End(nil)
recvStep.Begin()
gotName, gotContent := env.RecvTaildropFile(t.Context(), receiver)
recvStep.End(nil)
verifyStep.Begin()
if gotName != filename {
err := fmt.Errorf("received name = %q; want %q", gotName, filename)
verifyStep.End(err)
t.Error(err)
return
}
if !bytes.Equal(gotContent, want) {
err := fmt.Errorf("received content = %q; want %q", gotContent, want)
verifyStep.End(err)
t.Error(err)
return
}
verifyStep.End(nil)
}
// TestExitNode verifies that switching the client's exit node setting between
// off, exit1, and exit2 correctly routes the client's internet traffic.
//