integration: add TestTailscaleRustAxum for tailscale-rs

Add an integration test that runs the tailscale-rs axum example
against headscale end-to-end. The test provisions one headscale
instance, one Go tailscale probe client (tsic), and one
tailscale-rs node (tsric) on the same tailnet, then verifies:

  - the tailscale-rs node registers and is assigned both an IPv4
    and an IPv6 address
  - the Go probe sees the tailscale-rs node as a peer in its status
  - GET /index.html and GET /assets/index.css from the axum server
    return the expected content over the tailnet
  - three sequential POST /count calls return distinct, incrementing
    counter values, proving netstack state is maintained across
    multiple TCP connections

This is the first integration test that exercises a non-Go
Tailscale client against headscale, giving end-to-end coverage of
the control protocol for alternate implementations.
This commit is contained in:
Kristoffer Dalby 2026-04-28 08:52:46 +00:00
parent ea968e2f5d
commit 775bc3a715

271
integration/tsric_test.go Normal file
View File

@ -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")
}