diff --git a/sunbeam-net/tests/config/headscale.yaml b/sunbeam-net/tests/config/headscale.yaml new file mode 100644 index 00000000..1ce59961 --- /dev/null +++ b/sunbeam-net/tests/config/headscale.yaml @@ -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 diff --git a/sunbeam-net/tests/docker-compose.yml b/sunbeam-net/tests/docker-compose.yml new file mode 100644 index 00000000..2bd381f1 --- /dev/null +++ b/sunbeam-net/tests/docker-compose.yml @@ -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= \ +# 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: diff --git a/sunbeam-net/tests/integration.rs b/sunbeam-net/tests/integration.rs new file mode 100644 index 00000000..203777d5 --- /dev/null +++ b/sunbeam-net/tests/integration.rs @@ -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"); +} diff --git a/sunbeam-net/tests/run.sh b/sunbeam-net/tests/run.sh new file mode 100755 index 00000000..1c426dec --- /dev/null +++ b/sunbeam-net/tests/run.sh @@ -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."