mirror of
https://github.com/juanfont/headscale.git
synced 2026-03-21 17:09:30 +01:00
types: add option to disable taildrop, improve tests (#2955)
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user