mirror of
https://github.com/juanfont/headscale.git
synced 2026-05-05 03:56:10 +02:00
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:
parent
ea968e2f5d
commit
775bc3a715
271
integration/tsric_test.go
Normal file
271
integration/tsric_test.go
Normal 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")
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user