From 642073f4b87ccd1767ebe601fd18e0ef3d026b7b Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 12 Dec 2025 11:35:16 +0100 Subject: [PATCH] types: add option to disable taildrop, improve tests (#2955) --- CHANGELOG.md | 1 + config-example.yaml | 12 +- hscontrol/mapper/tail_test.go | 3 +- hscontrol/types/config.go | 9 + hscontrol/types/node.go | 9 +- integration/general_test.go | 425 +++++++++++++++++++++++++++------- 6 files changed, 365 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aae7589..f14b449f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ release. PeerChangedPatch responses instead of full map updates, reducing bandwidth and improving performance - Tags can now be tagOwner of other tags [#2930](https://github.com/juanfont/headscale/pull/2930) +- Add `taildrop.enabled` configuration option to enable/disable Taildrop file sharing [#2955](https://github.com/juanfont/headscale/pull/2955) ## 0.27.2 (2025-xx-xx) diff --git a/config-example.yaml b/config-example.yaml index 7a60529e..887e2ea9 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -295,8 +295,7 @@ dns: # Split DNS (see https://tailscale.com/kb/1054/dns/), # a map of domains and which DNS server to use for each. - split: - {} + split: {} # foo.bar.com: # - 1.1.1.1 # darp.headscale.net: @@ -408,6 +407,15 @@ logtail: # default static port 41641. This option is intended as a workaround for some buggy # firewall devices. See https://tailscale.com/kb/1181/firewalls/ for more information. randomize_client_port: false + +# Taildrop configuration +# Taildrop is the file sharing feature of Tailscale, allowing nodes to send files to each other. +# https://tailscale.com/kb/1106/taildrop/ +taildrop: + # Enable or disable Taildrop for all nodes. + # When enabled, nodes can send files to other nodes owned by the same user. + # Tagged devices and cross-user transfers are not permitted by Tailscale clients. + enabled: true # Advanced performance tuning parameters. # The defaults are carefully chosen and should rarely need adjustment. # Only modify these if you have identified a specific performance issue. diff --git a/hscontrol/mapper/tail_test.go b/hscontrol/mapper/tail_test.go index 73922387..5b7030de 100644 --- a/hscontrol/mapper/tail_test.go +++ b/hscontrol/mapper/tail_test.go @@ -205,6 +205,7 @@ func TestTailNode(t *testing.T) { BaseDomain: tt.baseDomain, TailcfgDNSConfig: tt.dnsConfig, RandomizeClientPort: false, + Taildrop: types.TaildropConfig{Enabled: true}, } _ = primary.SetRoutes(tt.node.ID, tt.node.SubnetRoutes()...) @@ -272,7 +273,7 @@ func TestNodeExpiry(t *testing.T) { func(id types.NodeID) []netip.Prefix { return []netip.Prefix{} }, - &types.Config{}, + &types.Config{Taildrop: types.TaildropConfig{Enabled: true}}, ) if err != nil { t.Fatalf("nodeExpiry() error = %v", err) diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index dfc3498d..13621c0a 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -94,6 +94,7 @@ type Config struct { LogTail LogTailConfig RandomizeClientPort bool + Taildrop TaildropConfig CLI CLIConfig @@ -211,6 +212,10 @@ type LogTailConfig struct { Enabled bool } +type TaildropConfig struct { + Enabled bool +} + type CLIConfig struct { Address string APIKey string @@ -382,6 +387,7 @@ func LoadConfig(path string, isFile bool) error { viper.SetDefault("logtail.enabled", false) viper.SetDefault("randomize_client_port", false) + viper.SetDefault("taildrop.enabled", true) viper.SetDefault("ephemeral_node_inactivity_timeout", "120s") @@ -1048,6 +1054,9 @@ func LoadServerConfig() (*Config, error) { LogTail: logTailConfig, RandomizeClientPort: randomizeClientPort, + Taildrop: TaildropConfig{ + Enabled: viper.GetBool("taildrop.enabled"), + }, Policy: policyConfig(), diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index 95117a57..a92382c4 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -1028,14 +1028,17 @@ func (nv NodeView) TailNode( tsaddr.SortPrefixes(allowedIPs) capMap := tailcfg.NodeCapMap{ - tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{}, - tailcfg.CapabilityAdmin: []tailcfg.RawMessage{}, - tailcfg.CapabilitySSH: []tailcfg.RawMessage{}, + tailcfg.CapabilityAdmin: []tailcfg.RawMessage{}, + tailcfg.CapabilitySSH: []tailcfg.RawMessage{}, } if cfg.RandomizeClientPort { capMap[tailcfg.NodeAttrRandomizeClientPort] = []tailcfg.RawMessage{} } + if cfg.Taildrop.Enabled { + capMap[tailcfg.CapabilityFileSharing] = []tailcfg.RawMessage{} + } + tNode := tailcfg.Node{ //nolint:gosec // G115: NodeID values are within int64 range ID: tailcfg.NodeID(nv.ID()), diff --git a/integration/general_test.go b/integration/general_test.go index c68768f7..f44a0f03 100644 --- a/integration/general_test.go +++ b/integration/general_test.go @@ -14,6 +14,7 @@ import ( "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/integration/hsic" + "github.com/juanfont/headscale/integration/integrationutil" "github.com/juanfont/headscale/integration/tsic" "github.com/rs/zerolog/log" "github.com/samber/lo" @@ -366,12 +367,18 @@ func TestPingAllByHostname(t *testing.T) { // This might mean we approach setup slightly wrong, but for now, ignore // the linter // nolint:tparallel +// TestTaildrop tests the Taildrop file sharing functionality across multiple scenarios: +// 1. Same-user transfers: Nodes owned by the same user can send files to each other +// 2. Cross-user transfers: Nodes owned by different users cannot send files to each other +// 3. Tagged device transfers: Tagged devices cannot send nor receive files +// +// Each user gets len(MustTestVersions) nodes to ensure compatibility across all supported versions. func TestTaildrop(t *testing.T) { IntegrationSkip(t) spec := ScenarioSpec{ - NodesPerUser: len(MustTestVersions), - Users: []string{"user1"}, + NodesPerUser: 0, // We'll create nodes manually to control tags + Users: []string{"user1", "user2"}, } scenario, err := NewScenario(spec) @@ -385,16 +392,99 @@ func TestTaildrop(t *testing.T) { ) requireNoErrHeadscaleEnv(t, err) + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + networks := scenario.Networks() + require.NotEmpty(t, networks, "scenario should have at least one network") + network := networks[0] + + // Create untagged nodes for user1 using all test versions + user1Key, err := scenario.CreatePreAuthKey(userMap["user1"].GetId(), true, false) + require.NoError(t, err) + + var user1Clients []TailscaleClient + for i, version := range MustTestVersions { + t.Logf("Creating user1 client %d with version %s", i, version) + client, err := scenario.CreateTailscaleNode( + version, + tsic.WithNetwork(network), + ) + require.NoError(t, err) + + err = client.Login(headscale.GetEndpoint(), user1Key.GetKey()) + require.NoError(t, err) + + err = client.WaitForRunning(integrationutil.PeerSyncTimeout()) + require.NoError(t, err) + + user1Clients = append(user1Clients, client) + scenario.GetOrCreateUser("user1").Clients[client.Hostname()] = client + } + + // Create untagged nodes for user2 using all test versions + user2Key, err := scenario.CreatePreAuthKey(userMap["user2"].GetId(), true, false) + require.NoError(t, err) + + var user2Clients []TailscaleClient + for i, version := range MustTestVersions { + t.Logf("Creating user2 client %d with version %s", i, version) + client, err := scenario.CreateTailscaleNode( + version, + tsic.WithNetwork(network), + ) + require.NoError(t, err) + + err = client.Login(headscale.GetEndpoint(), user2Key.GetKey()) + require.NoError(t, err) + + err = client.WaitForRunning(integrationutil.PeerSyncTimeout()) + require.NoError(t, err) + + user2Clients = append(user2Clients, client) + scenario.GetOrCreateUser("user2").Clients[client.Hostname()] = client + } + + // Create a tagged device (tags-as-identity: tags come from PreAuthKey) + // Use "head" version to test latest behavior + taggedKey, err := scenario.CreatePreAuthKeyWithTags(userMap["user1"].GetId(), true, false, []string{"tag:server"}) + require.NoError(t, err) + + taggedClient, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(network), + ) + require.NoError(t, err) + + err = taggedClient.Login(headscale.GetEndpoint(), taggedKey.GetKey()) + require.NoError(t, err) + + err = taggedClient.WaitForRunning(integrationutil.PeerSyncTimeout()) + require.NoError(t, err) + + // Add tagged client to user1 for tracking (though it's tagged, not user-owned) + scenario.GetOrCreateUser("user1").Clients[taggedClient.Hostname()] = taggedClient + allClients, err := scenario.ListTailscaleClients() requireNoErrListClients(t, err) + // Expected: len(MustTestVersions) for user1 + len(MustTestVersions) for user2 + 1 tagged + expectedClientCount := len(MustTestVersions)*2 + 1 + require.Len(t, allClients, expectedClientCount, + "should have %d clients: %d user1 + %d user2 + 1 tagged", + expectedClientCount, len(MustTestVersions), len(MustTestVersions)) + err = scenario.WaitForTailscaleSync() requireNoErrSync(t, err) - // This will essentially fetch and cache all the FQDNs + // Cache FQDNs _, err = scenario.ListTailscaleClientsFQDNs() requireNoErrListFQDN(t, err) + // Install curl on all clients for _, client := range allClients { if !strings.Contains(client.Hostname(), "head") { command := []string{"apk", "add", "curl"} @@ -403,110 +493,269 @@ func TestTaildrop(t *testing.T) { t.Fatalf("failed to install curl on %s, err: %s", client.Hostname(), err) } } + } + + // Helper to get FileTargets for a client. + getFileTargets := func(client TailscaleClient) ([]apitype.FileTarget, error) { curlCommand := []string{ "curl", "--unix-socket", "/var/run/tailscale/tailscaled.sock", "http://local-tailscaled.sock/localapi/v0/file-targets", } + result, _, err := client.Execute(curlCommand) + if err != nil { + return nil, err + } + + var fts []apitype.FileTarget + if err := json.Unmarshal([]byte(result), &fts); err != nil { + return nil, fmt.Errorf("failed to parse file-targets response: %w (response: %s)", err, result) + } + + return fts, nil + } + + // Helper to check if a client is in the FileTargets list + isInFileTargets := func(fts []apitype.FileTarget, targetHostname string) bool { + for _, ft := range fts { + if strings.Contains(ft.Node.Name, targetHostname) { + return true + } + } + return false + } + + // Test 1: Verify user1 nodes can see each other in FileTargets but not user2 nodes or tagged node + t.Run("FileTargets-user1", func(t *testing.T) { + for _, client := range user1Clients { + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + fts, err := getFileTargets(client) + assert.NoError(ct, err) + + // Should see the other user1 clients + for _, peer := range user1Clients { + if peer.Hostname() == client.Hostname() { + continue + } + assert.True(ct, isInFileTargets(fts, peer.Hostname()), + "user1 client %s should see user1 peer %s in FileTargets", client.Hostname(), peer.Hostname()) + } + + // Should NOT see user2 clients + for _, peer := range user2Clients { + assert.False(ct, isInFileTargets(fts, peer.Hostname()), + "user1 client %s should NOT see user2 peer %s in FileTargets", client.Hostname(), peer.Hostname()) + } + + // Should NOT see tagged client + assert.False(ct, isInFileTargets(fts, taggedClient.Hostname()), + "user1 client %s should NOT see tagged client %s in FileTargets", client.Hostname(), taggedClient.Hostname()) + }, 10*time.Second, 1*time.Second) + } + }) + + // Test 2: Verify user2 nodes can see each other in FileTargets but not user1 nodes or tagged node + t.Run("FileTargets-user2", func(t *testing.T) { + for _, client := range user2Clients { + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + fts, err := getFileTargets(client) + assert.NoError(ct, err) + + // Should see the other user2 clients + for _, peer := range user2Clients { + if peer.Hostname() == client.Hostname() { + continue + } + assert.True(ct, isInFileTargets(fts, peer.Hostname()), + "user2 client %s should see user2 peer %s in FileTargets", client.Hostname(), peer.Hostname()) + } + + // Should NOT see user1 clients + for _, peer := range user1Clients { + assert.False(ct, isInFileTargets(fts, peer.Hostname()), + "user2 client %s should NOT see user1 peer %s in FileTargets", client.Hostname(), peer.Hostname()) + } + + // Should NOT see tagged client + assert.False(ct, isInFileTargets(fts, taggedClient.Hostname()), + "user2 client %s should NOT see tagged client %s in FileTargets", client.Hostname(), taggedClient.Hostname()) + }, 10*time.Second, 1*time.Second) + } + }) + + // Test 3: Verify tagged device has no FileTargets (empty list) + t.Run("FileTargets-tagged", func(t *testing.T) { assert.EventuallyWithT(t, func(ct *assert.CollectT) { - result, _, err := client.Execute(curlCommand) + fts, err := getFileTargets(taggedClient) assert.NoError(ct, err) - - var fts []apitype.FileTarget - err = json.Unmarshal([]byte(result), &fts) - assert.NoError(ct, err) - - if len(fts) != len(allClients)-1 { - ftStr := fmt.Sprintf("FileTargets for %s:\n", client.Hostname()) - for _, ft := range fts { - ftStr += fmt.Sprintf("\t%s\n", ft.Node.Name) - } - assert.Failf(ct, "client %s does not have all its peers as FileTargets", - "got %d, want: %d\n%s", - len(fts), - len(allClients)-1, - ftStr, - ) - } + assert.Empty(ct, fts, "tagged client %s should have no FileTargets", taggedClient.Hostname()) }, 10*time.Second, 1*time.Second) - } + }) - for _, client := range allClients { - command := []string{"touch", fmt.Sprintf("/tmp/file_from_%s", client.Hostname())} + // Test 4: Same-user file transfer works (user1 -> user1) for all version combinations + t.Run("SameUserTransfer", func(t *testing.T) { + for _, sender := range user1Clients { + // Create file on sender + filename := fmt.Sprintf("file_from_%s", sender.Hostname()) + command := []string{"touch", fmt.Sprintf("/tmp/%s", filename)} + _, _, err := sender.Execute(command) + require.NoError(t, err, "failed to create taildrop file on %s", sender.Hostname()) - if _, _, err := client.Execute(command); err != nil { - t.Fatalf("failed to create taildrop file on %s, err: %s", client.Hostname(), err) - } + for _, receiver := range user1Clients { + if sender.Hostname() == receiver.Hostname() { + continue + } - for _, peer := range allClients { - if client.Hostname() == peer.Hostname() { - continue + receiverFQDN, _ := receiver.FQDN() + + t.Run(fmt.Sprintf("%s->%s", sender.Hostname(), receiver.Hostname()), func(t *testing.T) { + sendCommand := []string{ + "tailscale", "file", "cp", + fmt.Sprintf("/tmp/%s", filename), + fmt.Sprintf("%s:", receiverFQDN), + } + + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + t.Logf("Sending file from %s to %s", sender.Hostname(), receiver.Hostname()) + _, _, err := sender.Execute(sendCommand) + assert.NoError(ct, err) + }, 10*time.Second, 1*time.Second) + }) } + } - // It is safe to ignore this error as we handled it when caching it - peerFQDN, _ := peer.FQDN() + // Receive files on all user1 clients + for _, client := range user1Clients { + getCommand := []string{"tailscale", "file", "get", "/tmp/"} + _, _, err := client.Execute(getCommand) + require.NoError(t, err, "failed to get taildrop file on %s", client.Hostname()) - t.Run(fmt.Sprintf("%s-%s", client.Hostname(), peer.Hostname()), func(t *testing.T) { - command := []string{ - "tailscale", "file", "cp", - fmt.Sprintf("/tmp/file_from_%s", client.Hostname()), - fmt.Sprintf("%s:", peerFQDN), + // Verify files from all other user1 clients exist + for _, peer := range user1Clients { + if client.Hostname() == peer.Hostname() { + continue } - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - t.Logf( - "Sending file from %s to %s\n", - client.Hostname(), - peer.Hostname(), - ) - _, _, err := client.Execute(command) - assert.NoError(ct, err) - }, 10*time.Second, 1*time.Second) - }) - } - } - - for _, client := range allClients { - command := []string{ - "tailscale", "file", - "get", - "/tmp/", - } - if _, _, err := client.Execute(command); err != nil { - t.Fatalf("failed to get taildrop file on %s, err: %s", client.Hostname(), err) - } - - for _, peer := range allClients { - if client.Hostname() == peer.Hostname() { - continue + t.Run(fmt.Sprintf("verify-%s-received-from-%s", client.Hostname(), peer.Hostname()), func(t *testing.T) { + lsCommand := []string{"ls", fmt.Sprintf("/tmp/file_from_%s", peer.Hostname())} + result, _, err := client.Execute(lsCommand) + require.NoErrorf(t, err, "failed to ls taildrop file from %s", peer.Hostname()) + assert.Equal(t, fmt.Sprintf("/tmp/file_from_%s\n", peer.Hostname()), result) + }) } - - t.Run(fmt.Sprintf("%s-%s", client.Hostname(), peer.Hostname()), func(t *testing.T) { - command := []string{ - "ls", - fmt.Sprintf("/tmp/file_from_%s", peer.Hostname()), - } - log.Printf( - "Checking file in %s from %s\n", - client.Hostname(), - peer.Hostname(), - ) - - result, _, err := client.Execute(command) - require.NoErrorf(t, err, "failed to execute command to ls taildrop") - - log.Printf("Result for %s: %s\n", peer.Hostname(), result) - if fmt.Sprintf("/tmp/file_from_%s\n", peer.Hostname()) != result { - t.Fatalf( - "taildrop result is not correct %s, wanted %s", - result, - fmt.Sprintf("/tmp/file_from_%s\n", peer.Hostname()), - ) - } - }) } - } + }) + + // Test 5: Cross-user file transfer fails (user1 -> user2) + t.Run("CrossUserTransferBlocked", func(t *testing.T) { + sender := user1Clients[0] + receiver := user2Clients[0] + + // Create file on sender + filename := fmt.Sprintf("cross_user_file_from_%s", sender.Hostname()) + command := []string{"touch", fmt.Sprintf("/tmp/%s", filename)} + _, _, err := sender.Execute(command) + require.NoError(t, err, "failed to create taildrop file on %s", sender.Hostname()) + + // Attempt to send file - this should fail + receiverFQDN, _ := receiver.FQDN() + sendCommand := []string{ + "tailscale", "file", "cp", + fmt.Sprintf("/tmp/%s", filename), + fmt.Sprintf("%s:", receiverFQDN), + } + + t.Logf("Attempting cross-user file send from %s to %s (should fail)", sender.Hostname(), receiver.Hostname()) + _, stderr, err := sender.Execute(sendCommand) + + // The file transfer should fail because user2 is not in user1's FileTargets + // Either the command errors, or it silently fails (check stderr for error message) + if err != nil { + t.Logf("Cross-user transfer correctly failed with error: %v", err) + } else if strings.Contains(stderr, "not a valid peer") || strings.Contains(stderr, "unknown target") { + t.Logf("Cross-user transfer correctly rejected: %s", stderr) + } else { + // Even if command succeeded, verify the file was NOT received + getCommand := []string{"tailscale", "file", "get", "/tmp/"} + receiver.Execute(getCommand) + + lsCommand := []string{"ls", fmt.Sprintf("/tmp/%s", filename)} + _, _, lsErr := receiver.Execute(lsCommand) + assert.Error(t, lsErr, "Cross-user file should NOT have been received") + } + }) + + // Test 6: Tagged device cannot send files + t.Run("TaggedCannotSend", func(t *testing.T) { + // Create file on tagged client + filename := fmt.Sprintf("file_from_tagged_%s", taggedClient.Hostname()) + command := []string{"touch", fmt.Sprintf("/tmp/%s", filename)} + _, _, err := taggedClient.Execute(command) + require.NoError(t, err, "failed to create taildrop file on tagged client") + + // Attempt to send to user1 client - should fail because tagged client has no FileTargets + receiver := user1Clients[0] + receiverFQDN, _ := receiver.FQDN() + sendCommand := []string{ + "tailscale", "file", "cp", + fmt.Sprintf("/tmp/%s", filename), + fmt.Sprintf("%s:", receiverFQDN), + } + + t.Logf("Attempting tagged->user file send from %s to %s (should fail)", taggedClient.Hostname(), receiver.Hostname()) + _, stderr, err := taggedClient.Execute(sendCommand) + + if err != nil { + t.Logf("Tagged client send correctly failed with error: %v", err) + } else if strings.Contains(stderr, "not a valid peer") || strings.Contains(stderr, "unknown target") || strings.Contains(stderr, "no matches for") { + t.Logf("Tagged client send correctly rejected: %s", stderr) + } else { + // Verify file was NOT received + getCommand := []string{"tailscale", "file", "get", "/tmp/"} + receiver.Execute(getCommand) + + lsCommand := []string{"ls", fmt.Sprintf("/tmp/%s", filename)} + _, _, lsErr := receiver.Execute(lsCommand) + assert.Error(t, lsErr, "Tagged client's file should NOT have been received") + } + }) + + // Test 7: Tagged device cannot receive files (user1 tries to send to tagged) + t.Run("TaggedCannotReceive", func(t *testing.T) { + sender := user1Clients[0] + + // Create file on sender + filename := fmt.Sprintf("file_to_tagged_from_%s", sender.Hostname()) + command := []string{"touch", fmt.Sprintf("/tmp/%s", filename)} + _, _, err := sender.Execute(command) + require.NoError(t, err, "failed to create taildrop file on %s", sender.Hostname()) + + // Attempt to send to tagged client - should fail because tagged is not in user1's FileTargets + taggedFQDN, _ := taggedClient.FQDN() + sendCommand := []string{ + "tailscale", "file", "cp", + fmt.Sprintf("/tmp/%s", filename), + fmt.Sprintf("%s:", taggedFQDN), + } + + t.Logf("Attempting user->tagged file send from %s to %s (should fail)", sender.Hostname(), taggedClient.Hostname()) + _, stderr, err := sender.Execute(sendCommand) + + if err != nil { + t.Logf("Send to tagged client correctly failed with error: %v", err) + } else if strings.Contains(stderr, "not a valid peer") || strings.Contains(stderr, "unknown target") || strings.Contains(stderr, "no matches for") { + t.Logf("Send to tagged client correctly rejected: %s", stderr) + } else { + // Verify file was NOT received by tagged client + getCommand := []string{"tailscale", "file", "get", "/tmp/"} + taggedClient.Execute(getCommand) + + lsCommand := []string{"ls", fmt.Sprintf("/tmp/%s", filename)} + _, _, lsErr := taggedClient.Execute(lsCommand) + assert.Error(t, lsErr, "File to tagged client should NOT have been received") + } + }) } func TestUpdateHostnameFromClient(t *testing.T) {