mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-04 19:56:35 +02:00
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:
parent
4b8e0ede6d
commit
ec7b11d986
@ -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 == "" {
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
//
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user