diff --git a/integration/tsric_test.go b/integration/tsric_test.go new file mode 100644 index 00000000..52510615 --- /dev/null +++ b/integration/tsric_test.go @@ -0,0 +1,271 @@ +package integration + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/juanfont/headscale/integration/hsic" + "github.com/juanfont/headscale/integration/tsic" + "github.com/juanfont/headscale/integration/tsric" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestTailscaleRustAxum tests that the tailscale-rs axum example can join a +// headscale network and serve HTTP to other peers on the tailnet. +// +// Architecture: +// +// headscale (control) <--- tsic (probe client) --curl--> tsric (axum server) +// +// The test: +// 1. Creates a headscale environment with one regular Tailscale client (tsic) +// 2. Creates a tailscale-rs container running the axum example (tsric) +// 3. Verifies the tsric node registers with headscale +// 4. Uses the tsic client to curl the axum web server through the tailnet +func TestTailscaleRustAxum(t *testing.T) { + IntegrationSkip(t) + + // Set up a scenario with one user and one regular Tailscale client. + // The regular client acts as a "probe" to verify the tsric node + // is reachable on the tailnet. + spec := ScenarioSpec{ + NodesPerUser: 1, + Users: []string{"user1"}, //nolint:goconst // consistent with other integration tests + } + + scenario, err := NewScenario(spec) + + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithTestName("tailscalers"), + // The embedded DERP server uses a self-signed cert that + // tailscale-rs cannot validate without a custom CA bundle, so + // we route DERP through Tailscale's public relays. + hsic.WithPublicDERP(), + // TODO: drop WithoutTLS once tailscale-rs lets us inject the + // headscale CA into its trust chain; until then the control + // plane has to be plain HTTP for the Rust client to register. + hsic.WithoutTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + // Get the headscale instance and probe client + headscale, err := scenario.Headscale() + require.NoError(t, err) + + allClients, err := scenario.ListTailscaleClients() + requireNoErrListClients(t, err) + require.Len(t, allClients, 1, "expected exactly 1 probe client") + + probeClient := allClients[0] + + // Create auth key for the tailscale-rs node + users, err := headscale.ListUsers() + require.NoError(t, err) + require.NotEmpty(t, users, "expected at least one user") + + var userID uint64 + + for _, u := range users { + if u.GetName() == "user1" { //nolint:goconst + userID = u.GetId() + + break + } + } + + require.NotZero(t, userID, "user1 not found") + + pak, err := headscale.CreateAuthKey(userID, false, true) + require.NoError(t, err) + + // Determine the network and headscale connection details + networks := scenario.Networks() + require.NotEmpty(t, networks) + + network := networks[0] + headscaleIP := headscale.GetIPInNetwork(network) + headscaleHostname := headscale.GetHostname() + headscaleEndpoint := headscale.GetEndpoint() + + t.Logf("Headscale endpoint: %s (hostname: %s, IP: %s)", + headscaleEndpoint, headscaleHostname, headscaleIP) + + // Create the tailscale-rs container + tsrsOpts := []tsric.Option{ + tsric.WithNetwork(network), + tsric.WithHeadscaleURL(headscaleEndpoint), + tsric.WithAuthKey(pak.GetKey()), + tsric.WithExtraHosts([]string{headscaleHostname + ":" + headscaleIP}), + } + + cert := headscale.GetCert() + if len(cert) > 0 { + tsrsOpts = append(tsrsOpts, tsric.WithCACert(cert)) + } + + t.Log("Creating tailscale-rs container (first build may take several minutes)...") + + tsrs, err := tsric.New(scenario.Pool(), tsrsOpts...) + require.NoError(t, err, "failed to create tailscale-rs container") + + defer func() { + _, _, err := tsrs.Shutdown() + if err != nil { + t.Logf("error shutting down tailscale-rs container: %s", err) + } + }() + + // Wait for the tailscale-rs node to appear in headscale's node list. + // Verify it gets both IPv4 and IPv6 addresses and has the expected hostname. + var ( + rustNodeIPv4 string + rustNodeIPv6 string + rustNodeName string + ) + + t.Log("Waiting for tailscale-rs node to register with headscale...") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes() + assert.NoError(c, err) + + // Expect 2 nodes: 1 tsic probe + 1 tsric + assert.GreaterOrEqual(c, len(nodes), 2, + "expected at least 2 nodes (1 probe + 1 tailscale-rs)") + + // Find the tailscale-rs node by hostname prefix + for _, n := range nodes { + if strings.HasPrefix(n.GetGivenName(), "tsrs-") { + addrs := n.GetIpAddresses() + if len(addrs) > 0 { + rustNodeIPv4 = addrs[0] + } + + if len(addrs) > 1 { + rustNodeIPv6 = addrs[1] + } + + rustNodeName = n.GetGivenName() + } + } + + assert.NotEmpty(c, rustNodeIPv4, "tailscale-rs node should have an IPv4 address") + }, 120*time.Second, 2*time.Second, "tailscale-rs node should register with headscale") + + require.NotEmpty(t, rustNodeIPv4, "failed to find tailscale-rs node IP") + + t.Logf("tailscale-rs node %q registered with IPv4=%s IPv6=%s", + rustNodeName, rustNodeIPv4, rustNodeIPv6) + + // Verify IPv6 was allocated. The axum example only listens on IPv4, + // so we can't curl via IPv6, but headscale should still assign both. + assert.NotEmpty(t, rustNodeIPv6, + "headscale should assign both IPv4 and IPv6 to the tailscale-rs node") + + // Verify the hostname propagated correctly from the config + assert.True(t, strings.HasPrefix(rustNodeName, "tsrs-"), + "tailscale-rs node name should start with tsrs- prefix") + + // Verify the probe client sees the tailscale-rs node as a peer + t.Log("Verifying probe client sees tailscale-rs as a peer...") + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + status, err := probeClient.Status() + assert.NoError(c, err) + + found := false + + for _, peerKey := range status.Peers() { + peer := status.Peer[peerKey] + if strings.HasPrefix(peer.HostName, "tsrs-") { + found = true + } + } + + assert.True(c, found, "probe client should see tsrs node as a peer") + }, 30*time.Second, 2*time.Second, "probe should see tailscale-rs peer in status") + + // Test 1: GET /index.html — verify the axum web server serves content + axumURL := fmt.Sprintf("http://%s/index.html", rustNodeIPv4) + + t.Logf("Verifying axum web server is reachable at %s via probe client...", axumURL) + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + result, err := probeClient.Curl(axumURL) + assert.NoError(c, err, "curl to axum server failed") + assert.Contains(c, result, "tailscale-rs", + "expected index.html to contain 'tailscale-rs'") + }, 120*time.Second, 2*time.Second, "axum /index.html should be reachable from probe client") + + t.Log("axum web server is serving content through the tailnet") + + // Test 2: GET /assets/index.css — verify static asset serving works + cssURL := fmt.Sprintf("http://%s/assets/index.css", rustNodeIPv4) + + t.Logf("Verifying static asset at %s...", cssURL) + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + result, err := probeClient.Curl(cssURL) + assert.NoError(c, err, "curl to CSS asset failed") + assert.Contains(c, result, "font-family", + "expected CSS file to contain 'font-family'") + }, 10*time.Second, 1*time.Second, "axum should serve static CSS assets") + + // Test 3: Sequential POST /count — verify the counter increments correctly. + // This exercises multiple TCP connections and proves the netstack maintains + // state across requests. + countURL := fmt.Sprintf("http://%s/count", rustNodeIPv4) + + t.Logf("Verifying /count POST endpoint increments at %s...", countURL) + + // First POST establishes connectivity and gets the initial counter value + assert.EventuallyWithT(t, func(c *assert.CollectT) { + stdout, _, err := probeClient.Execute([]string{ + "curl", "--silent", + "--connect-timeout", "3", + "--max-time", "5", + "-X", "POST", + countURL, + }) + assert.NoError(c, err, "curl POST to /count failed") + assert.Contains(c, stdout, `"count"`, + "expected /count response to contain 'count'") + }, 30*time.Second, 2*time.Second, "axum /count POST should work") + + // Fire several more POSTs and verify the counter advances. + // The axum handler returns {"count": N} where N is the pre-increment value. + // After the initial EventuallyWithT loop we don't know the exact counter, + // but two back-to-back POSTs should return consecutive values. + t.Log("Verifying counter increments across multiple requests...") + + var firstCount, secondCount string + + stdout1, _, err := probeClient.Execute([]string{ + "curl", "--silent", "--max-time", "5", "-X", "POST", countURL, + }) + require.NoError(t, err, "first sequential POST failed") + + firstCount = stdout1 + + stdout2, _, err := probeClient.Execute([]string{ + "curl", "--silent", "--max-time", "5", "-X", "POST", countURL, + }) + require.NoError(t, err, "second sequential POST failed") + + secondCount = stdout2 + + t.Logf("Counter responses: first=%s second=%s", firstCount, secondCount) + + // Verify they're different (counter is incrementing) + require.NotEqual(t, firstCount, secondCount, + "counter should increment between sequential POST requests") + + t.Log("TestTailscaleRustAxum: all checks passed") +}