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:
committed by
Brad Fitzpatrick
parent
4b8e0ede6d
commit
ec7b11d986
@@ -24,6 +24,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -263,6 +264,72 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
io.WriteString(w, "OK\n")
|
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) {
|
ttaMux.HandleFunc("/http-get", func(w http.ResponseWriter, r *http.Request) {
|
||||||
targetURL := r.URL.Query().Get("url")
|
targetURL := r.URL.Query().Get("url")
|
||||||
if targetURL == "" {
|
if targetURL == "" {
|
||||||
|
|||||||
@@ -120,8 +120,11 @@ func (e *Env) generateLinuxUserData(n *Node) string {
|
|||||||
ud.WriteString(" - [\"sysctl\", \"-w\", \"net.ipv6.conf.all.forwarding=1\"]\n")
|
ud.WriteString(" - [\"sysctl\", \"-w\", \"net.ipv6.conf.all.forwarding=1\"]\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start tailscaled in the background.
|
// Start tailscaled in the background. --statedir provides a VarRoot so
|
||||||
ud.WriteString(" - [\"/bin/sh\", \"-c\", \"/usr/local/bin/tailscaled --state=mem: &\"]\n")
|
// 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")
|
ud.WriteString(" - [\"sleep\", \"2\"]\n")
|
||||||
|
|
||||||
// Start tta (Tailscale Test Agent).
|
// Start tta (Tailscale Test Agent).
|
||||||
@@ -173,7 +176,9 @@ func (e *Env) generateFreeBSDUserData(n *Node) string {
|
|||||||
// Start tailscaled and tta in the background.
|
// Start tailscaled and tta in the background.
|
||||||
// Set PATH to include /usr/local/bin so that tta can find "tailscale"
|
// Set PATH to include /usr/local/bin so that tta can find "tailscale"
|
||||||
// (TTA uses exec.Command("tailscale", ...) without a full path).
|
// (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")
|
ud.WriteString(" - \"sleep 2\"\n")
|
||||||
|
|
||||||
// Start tta (Tailscale Test Agent).
|
// Start tta (Tailscale Test Agent).
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
package vmtest
|
package vmtest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"flag"
|
"flag"
|
||||||
@@ -76,6 +77,8 @@ type Env struct {
|
|||||||
|
|
||||||
qemuProcs []*exec.Cmd // launched QEMU processes
|
qemuProcs []*exec.Cmd // launched QEMU processes
|
||||||
|
|
||||||
|
sameTailnetUser bool // all nodes register as the same Tailnet user
|
||||||
|
|
||||||
// Web UI support.
|
// Web UI support.
|
||||||
ctx context.Context // cancelled when test ends
|
ctx context.Context // cancelled when test ends
|
||||||
eventBus *EventBus
|
eventBus *EventBus
|
||||||
@@ -233,8 +236,10 @@ func (e *Env) nodeNameByNum(num int) string {
|
|||||||
return fmt.Sprintf("node%d", num)
|
return fmt.Sprintf("node%d", num)
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new test environment. It skips the test if --run-vm-tests is not set.
|
// New creates a new test environment. It skips the test if --run-vm-tests is
|
||||||
func New(t testing.TB) *Env {
|
// not set. opts may contain [EnvOption] values returned by helpers like
|
||||||
|
// [SameTailnetUser].
|
||||||
|
func New(t testing.TB, opts ...EnvOption) *Env {
|
||||||
if !*runVMTests {
|
if !*runVMTests {
|
||||||
t.Skip("skipping VM test; set --run-vm-tests to run")
|
t.Skip("skipping VM test; set --run-vm-tests to run")
|
||||||
}
|
}
|
||||||
@@ -248,6 +253,9 @@ func New(t testing.TB) *Env {
|
|||||||
testStatus: newTestStatus(),
|
testStatus: newTestStatus(),
|
||||||
nodeStatus: make(map[string]*NodeStatus),
|
nodeStatus: make(map[string]*NodeStatus),
|
||||||
}
|
}
|
||||||
|
for _, o := range opts {
|
||||||
|
o.applyTo(e)
|
||||||
|
}
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
e.testStatus.finish(t.Failed())
|
e.testStatus.finish(t.Failed())
|
||||||
e.eventBus.Publish(VMEvent{
|
e.eventBus.Publish(VMEvent{
|
||||||
@@ -259,6 +267,23 @@ func New(t testing.TB) *Env {
|
|||||||
return e
|
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
|
// AddNetwork creates a new virtual network. Arguments follow the same pattern as
|
||||||
// vnet.Config.AddNetwork (string IPs, NAT types, NetworkService values).
|
// vnet.Config.AddNetwork (string IPs, NAT types, NetworkService values).
|
||||||
func (e *Env) AddNetwork(opts ...any) *vnet.Network {
|
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.
|
// Register compiled binaries with the file server VIP.
|
||||||
// Binaries are registered at <goos>_<goarch>/<name> (e.g. "linux_amd64/tta").
|
// Binaries are registered at <goos>_<goarch>/<name> (e.g. "linux_amd64/tta").
|
||||||
for _, p := range needPlatform.Slice() {
|
for _, p := range needPlatform.Slice() {
|
||||||
@@ -1094,6 +1123,69 @@ func (e *Env) HTTPGet(from *Node, targetURL string) string {
|
|||||||
return ""
|
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
|
var buildGokrazy sync.Once
|
||||||
|
|
||||||
// ensureGokrazy builds the gokrazy base image (once per test process) and
|
// ensureGokrazy builds the gokrazy base image (once per test process) and
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package vmtest_test
|
package vmtest_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"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
|
// TestExitNode verifies that switching the client's exit node setting between
|
||||||
// off, exit1, and exit2 correctly routes the client's internet traffic.
|
// off, exit1, and exit2 correctly routes the client's internet traffic.
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user