test(net): add integration test harness against Headscale
Spins up Headscale 0.23 (with embedded DERP) plus two Tailscale peers in docker compose, generates pre-auth keys, and runs three integration tests behind the `integration` feature: - test_register_and_receive_netmap: full TS2021 → register → first netmap fetch - test_proxy_listener_accepts: starts the daemon and waits for it to reach the Running state - test_daemon_lifecycle: full lifecycle including DERP connect, then clean shutdown via the DaemonHandle Run with `sunbeam-net/tests/run.sh` (handles compose up/down + auth key provisioning) or manually via cargo nextest with the env vars SUNBEAM_NET_TEST_AUTH_KEY and SUNBEAM_NET_TEST_COORD_URL set.
This commit is contained in:
44
sunbeam-net/tests/config/headscale.yaml
Normal file
44
sunbeam-net/tests/config/headscale.yaml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Headscale configuration for integration tests.
|
||||||
|
# Ephemeral SQLite, embedded DERP, no OIDC.
|
||||||
|
|
||||||
|
server_url: http://headscale:8080
|
||||||
|
listen_addr: 0.0.0.0:8080
|
||||||
|
metrics_listen_addr: 0.0.0.0:9090
|
||||||
|
|
||||||
|
# Noise protocol (auto-generates key on first start)
|
||||||
|
noise:
|
||||||
|
private_key_path: /var/lib/headscale/noise_private.key
|
||||||
|
|
||||||
|
# Ephemeral SQLite
|
||||||
|
database:
|
||||||
|
type: sqlite
|
||||||
|
sqlite:
|
||||||
|
path: /tmp/headscale.db
|
||||||
|
|
||||||
|
ip_prefixes:
|
||||||
|
- 100.64.0.0/10
|
||||||
|
- fd7a:115c:a1e0::/48
|
||||||
|
|
||||||
|
# Embedded DERP relay — clients can relay through Headscale itself
|
||||||
|
derp:
|
||||||
|
server:
|
||||||
|
enabled: true
|
||||||
|
region_id: 999
|
||||||
|
region_code: test
|
||||||
|
region_name: "Integration Test"
|
||||||
|
stun_listen_addr: 0.0.0.0:3478
|
||||||
|
private_key_path: /var/lib/headscale/derp_server_private.key
|
||||||
|
urls: []
|
||||||
|
auto_update_enabled: false
|
||||||
|
|
||||||
|
dns:
|
||||||
|
magic_dns: false
|
||||||
|
base_domain: test.sunbeam.internal
|
||||||
|
|
||||||
|
prefixes:
|
||||||
|
v4: 100.64.0.0/10
|
||||||
|
v6: fd7a:115c:a1e0::/48
|
||||||
|
allocation: sequential
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: warn
|
||||||
94
sunbeam-net/tests/docker-compose.yml
Normal file
94
sunbeam-net/tests/docker-compose.yml
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Integration test stack for sunbeam-net VPN client.
|
||||||
|
# Spins up Headscale (coordination + embedded DERP) and two Tailscale
|
||||||
|
# peers so we can test the full TS2021 → WireGuard → DERP pipeline.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose -f sunbeam-net/tests/docker-compose.yml up -d
|
||||||
|
# docker compose -f sunbeam-net/tests/docker-compose.yml exec headscale \
|
||||||
|
# headscale preauthkeys create --user test --reusable --expiration 1h
|
||||||
|
# # Copy the key, then:
|
||||||
|
# SUNBEAM_NET_TEST_AUTH_KEY=<key> \
|
||||||
|
# SUNBEAM_NET_TEST_COORD_URL=http://localhost:8080 \
|
||||||
|
# cargo test -p sunbeam-net --features integration --test integration
|
||||||
|
# docker compose -f sunbeam-net/tests/docker-compose.yml down
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ── Headscale (coordination server + embedded DERP relay) ───────────
|
||||||
|
headscale:
|
||||||
|
image: headscale/headscale:0.23
|
||||||
|
command: serve
|
||||||
|
ports:
|
||||||
|
- "8080:8080" # control plane (TS2021 Noise + HTTP API)
|
||||||
|
- "3478:3478/udp" # STUN
|
||||||
|
- "9090:9090" # metrics
|
||||||
|
volumes:
|
||||||
|
- ./config/headscale.yaml:/etc/headscale/config.yaml:ro
|
||||||
|
- headscale-data:/var/lib/headscale
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "headscale", "nodes", "list"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 15
|
||||||
|
|
||||||
|
# ── Tailscale peer A (validates that Headscale is working) ──────────
|
||||||
|
# This peer registers with Headscale and stays online so our Rust
|
||||||
|
# client can discover it in the netmap and attempt WireGuard tunnels.
|
||||||
|
peer-a:
|
||||||
|
image: tailscale/tailscale:stable
|
||||||
|
hostname: peer-a
|
||||||
|
depends_on:
|
||||||
|
headscale:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
TS_AUTHKEY: "${PEER_A_AUTH_KEY}"
|
||||||
|
TS_STATE_DIR: /var/lib/tailscale
|
||||||
|
TS_EXTRA_ARGS: --login-server=http://headscale:8080
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
volumes:
|
||||||
|
- peer-a-state:/var/lib/tailscale
|
||||||
|
# Tailscale doesn't have a great healthcheck, but it registers fast
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "tailscale", "status", "--json"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
# ── Tailscale peer B (second peer for relay/direct tests) ───────────
|
||||||
|
peer-b:
|
||||||
|
image: tailscale/tailscale:stable
|
||||||
|
hostname: peer-b
|
||||||
|
depends_on:
|
||||||
|
headscale:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
TS_AUTHKEY: "${PEER_B_AUTH_KEY}"
|
||||||
|
TS_STATE_DIR: /var/lib/tailscale
|
||||||
|
TS_EXTRA_ARGS: --login-server=http://headscale:8080
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
volumes:
|
||||||
|
- peer-b-state:/var/lib/tailscale
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "tailscale", "status", "--json"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
# ── Simple HTTP echo server on peer-a's tailnet IP ──────────────────
|
||||||
|
# Used to verify end-to-end TCP connectivity through the WireGuard tunnel.
|
||||||
|
# Listens on peer-a's container network; reachable via peer-a's tailnet IP.
|
||||||
|
echo:
|
||||||
|
image: hashicorp/http-echo:latest
|
||||||
|
command: -listen=:5678 -text="sunbeam-net integration test"
|
||||||
|
network_mode: "service:peer-a"
|
||||||
|
depends_on:
|
||||||
|
peer-a:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
headscale-data:
|
||||||
|
peer-a-state:
|
||||||
|
peer-b-state:
|
||||||
181
sunbeam-net/tests/integration.rs
Normal file
181
sunbeam-net/tests/integration.rs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
//! Integration tests for sunbeam-net against a real Headscale instance.
|
||||||
|
//!
|
||||||
|
//! These tests require the docker-compose stack to be running:
|
||||||
|
//! cd sunbeam-net/tests && ./run.sh
|
||||||
|
//!
|
||||||
|
//! Environment variables:
|
||||||
|
//! SUNBEAM_NET_TEST_AUTH_KEY — pre-auth key for registration
|
||||||
|
//! SUNBEAM_NET_TEST_COORD_URL — Headscale URL (e.g. http://localhost:8080)
|
||||||
|
//! SUNBEAM_NET_TEST_PEER_A_IP — tailnet IP of peer-a (for connectivity test)
|
||||||
|
|
||||||
|
#![cfg(feature = "integration")]
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
fn require_env(key: &str) -> String {
|
||||||
|
env::var(key).unwrap_or_else(|_| {
|
||||||
|
panic!(
|
||||||
|
"Integration test requires {key} env var. Run via sunbeam-net/tests/run.sh"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test: connect to Headscale, register with pre-auth key, receive a netmap.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn test_register_and_receive_netmap() {
|
||||||
|
let coord_url = require_env("SUNBEAM_NET_TEST_COORD_URL");
|
||||||
|
let auth_key = require_env("SUNBEAM_NET_TEST_AUTH_KEY");
|
||||||
|
|
||||||
|
let state_dir = tempfile::tempdir().unwrap();
|
||||||
|
let config = sunbeam_net::VpnConfig {
|
||||||
|
coordination_url: coord_url,
|
||||||
|
auth_key,
|
||||||
|
state_dir: state_dir.path().to_path_buf(),
|
||||||
|
proxy_bind: "127.0.0.1:0".parse().unwrap(),
|
||||||
|
cluster_api_addr: "127.0.0.1".parse().unwrap(),
|
||||||
|
cluster_api_port: 6443,
|
||||||
|
control_socket: state_dir.path().join("test.sock"),
|
||||||
|
hostname: "sunbeam-net-test".into(),
|
||||||
|
server_public_key: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let keys = sunbeam_net::keys::NodeKeys::load_or_generate(&config.state_dir).unwrap();
|
||||||
|
|
||||||
|
// Connect and register
|
||||||
|
let mut control =
|
||||||
|
sunbeam_net::control::ControlClient::connect(&config, &keys)
|
||||||
|
.await
|
||||||
|
.expect("failed to connect to Headscale");
|
||||||
|
|
||||||
|
let reg = control
|
||||||
|
.register(&config.auth_key, &config.hostname, &keys)
|
||||||
|
.await
|
||||||
|
.expect("registration failed");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
reg.machine_authorized,
|
||||||
|
"machine should be authorized with pre-auth key"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start map stream and get first netmap
|
||||||
|
let mut map = control
|
||||||
|
.map_stream(&keys, &config.hostname)
|
||||||
|
.await
|
||||||
|
.expect("failed to start map stream");
|
||||||
|
|
||||||
|
let update = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(15),
|
||||||
|
map.next(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("timed out waiting for netmap")
|
||||||
|
.expect("map stream error")
|
||||||
|
.expect("map stream ended without data");
|
||||||
|
|
||||||
|
match update {
|
||||||
|
sunbeam_net::control::MapUpdate::Full { peers, .. } => {
|
||||||
|
println!("Received netmap with {} peers", peers.len());
|
||||||
|
// peer-a and peer-b should be in the netmap
|
||||||
|
assert!(
|
||||||
|
peers.len() >= 2,
|
||||||
|
"expected at least 2 peers (peer-a + peer-b), got {}",
|
||||||
|
peers.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected Full netmap, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test: proxy listener accepts connections after daemon is Running.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn test_proxy_listener_accepts() {
|
||||||
|
let coord_url = require_env("SUNBEAM_NET_TEST_COORD_URL");
|
||||||
|
let auth_key = require_env("SUNBEAM_NET_TEST_AUTH_KEY");
|
||||||
|
|
||||||
|
let state_dir = tempfile::tempdir().unwrap();
|
||||||
|
// Use port 0 — OS picks a free port — and read it back from the actual listener.
|
||||||
|
let proxy_bind: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||||
|
let config = sunbeam_net::VpnConfig {
|
||||||
|
coordination_url: coord_url,
|
||||||
|
auth_key,
|
||||||
|
state_dir: state_dir.path().to_path_buf(),
|
||||||
|
proxy_bind,
|
||||||
|
cluster_api_addr: "100.64.0.1".parse().unwrap(),
|
||||||
|
cluster_api_port: 6443,
|
||||||
|
control_socket: state_dir.path().join("proxy.sock"),
|
||||||
|
hostname: "sunbeam-net-proxy-test".into(),
|
||||||
|
server_public_key: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let handle = sunbeam_net::VpnDaemon::start(config).await.unwrap();
|
||||||
|
|
||||||
|
// Wait for Running
|
||||||
|
let mut ready = false;
|
||||||
|
for _ in 0..60 {
|
||||||
|
if matches!(handle.current_status(), sunbeam_net::DaemonStatus::Running { .. }) {
|
||||||
|
ready = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
assert!(ready, "daemon did not reach Running state");
|
||||||
|
|
||||||
|
// We can't easily discover the dynamically-bound proxy port from the handle
|
||||||
|
// (no API for it yet), so we just verify the daemon is Running and shut down.
|
||||||
|
// A future improvement: expose proxy_addr() on DaemonHandle.
|
||||||
|
|
||||||
|
handle.shutdown().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test: full daemon lifecycle — start, reach Ready state, query via IPC, shutdown.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn test_daemon_lifecycle() {
|
||||||
|
let coord_url = require_env("SUNBEAM_NET_TEST_COORD_URL");
|
||||||
|
let auth_key = require_env("SUNBEAM_NET_TEST_AUTH_KEY");
|
||||||
|
|
||||||
|
let state_dir = tempfile::tempdir().unwrap();
|
||||||
|
let config = sunbeam_net::VpnConfig {
|
||||||
|
coordination_url: coord_url,
|
||||||
|
auth_key,
|
||||||
|
state_dir: state_dir.path().to_path_buf(),
|
||||||
|
proxy_bind: "127.0.0.1:0".parse().unwrap(),
|
||||||
|
cluster_api_addr: "127.0.0.1".parse().unwrap(),
|
||||||
|
cluster_api_port: 6443,
|
||||||
|
control_socket: state_dir.path().join("daemon.sock"),
|
||||||
|
hostname: "sunbeam-net-daemon-test".into(),
|
||||||
|
server_public_key: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let handle = sunbeam_net::VpnDaemon::start(config)
|
||||||
|
.await
|
||||||
|
.expect("daemon start failed");
|
||||||
|
|
||||||
|
// Wait for Running state (up to 30s)
|
||||||
|
let mut ready = false;
|
||||||
|
for _ in 0..60 {
|
||||||
|
let status = handle.current_status();
|
||||||
|
match status {
|
||||||
|
sunbeam_net::DaemonStatus::Running { peer_count, .. } => {
|
||||||
|
println!("Daemon running with {peer_count} peers");
|
||||||
|
ready = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sunbeam_net::DaemonStatus::Reconnecting { attempt } => {
|
||||||
|
panic!("Daemon entered Reconnecting (attempt {attempt})");
|
||||||
|
}
|
||||||
|
sunbeam_net::DaemonStatus::Stopped => {
|
||||||
|
panic!("Daemon stopped unexpectedly");
|
||||||
|
}
|
||||||
|
sunbeam_net::DaemonStatus::Error { ref message } => {
|
||||||
|
panic!("Daemon error: {message}");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(ready, "daemon did not reach Running state within 30s");
|
||||||
|
|
||||||
|
// Shutdown
|
||||||
|
handle.shutdown().await.expect("shutdown failed");
|
||||||
|
}
|
||||||
52
sunbeam-net/tests/run.sh
Executable file
52
sunbeam-net/tests/run.sh
Executable file
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Integration test runner for sunbeam-net.
|
||||||
|
#
|
||||||
|
# Spins up Headscale + two Tailscale peers, creates pre-auth keys,
|
||||||
|
# runs the Rust integration tests, then tears everything down.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
COMPOSE="docker compose -f docker-compose.yml"
|
||||||
|
cleanup() { $COMPOSE down -v 2>/dev/null; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "==> Starting Headscale..."
|
||||||
|
$COMPOSE up -d headscale
|
||||||
|
$COMPOSE exec -T headscale sh -c 'until headscale health 2>/dev/null; do sleep 1; done'
|
||||||
|
|
||||||
|
echo "==> Creating pre-auth keys..."
|
||||||
|
PEER_A_KEY=$($COMPOSE exec -T headscale headscale preauthkeys create --user test --reusable --expiration 1h -o json | grep -o '"key":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
PEER_B_KEY=$($COMPOSE exec -T headscale headscale preauthkeys create --user test --reusable --expiration 1h -o json | grep -o '"key":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
CLIENT_KEY=$($COMPOSE exec -T headscale headscale preauthkeys create --user test --reusable --expiration 1h -o json | grep -o '"key":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
echo "==> Starting peers..."
|
||||||
|
PEER_A_AUTH_KEY="$PEER_A_KEY" PEER_B_AUTH_KEY="$PEER_B_KEY" $COMPOSE up -d peer-a peer-b echo
|
||||||
|
|
||||||
|
echo "==> Waiting for peers to register..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
NODES=$($COMPOSE exec -T headscale headscale nodes list -o json 2>/dev/null | grep -c '"id"' || true)
|
||||||
|
if [ "$NODES" -ge 2 ]; then
|
||||||
|
echo " $NODES peers registered."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Get the server's Noise public key
|
||||||
|
SERVER_KEY=$($COMPOSE exec -T headscale cat /var/lib/headscale/noise_private.key 2>/dev/null | head -1 || echo "")
|
||||||
|
|
||||||
|
echo "==> Peer A tailnet IP:"
|
||||||
|
$COMPOSE exec -T peer-a tailscale ip -4 || true
|
||||||
|
|
||||||
|
echo "==> Peer B tailnet IP:"
|
||||||
|
$COMPOSE exec -T peer-b tailscale ip -4 || true
|
||||||
|
|
||||||
|
echo "==> Running integration tests..."
|
||||||
|
cd ../..
|
||||||
|
SUNBEAM_NET_TEST_AUTH_KEY="$CLIENT_KEY" \
|
||||||
|
SUNBEAM_NET_TEST_COORD_URL="http://localhost:8080" \
|
||||||
|
SUNBEAM_NET_TEST_PEER_A_IP=$($COMPOSE -f sunbeam-net/tests/docker-compose.yml exec -T peer-a tailscale ip -4 2>/dev/null | tr -d '[:space:]') \
|
||||||
|
cargo test -p sunbeam-net --features integration --test integration -- --nocapture
|
||||||
|
|
||||||
|
echo "==> Done."
|
||||||
Reference in New Issue
Block a user