test(net): TLS-enabled docker stack and active e2e test

The docker-compose stack now serves Headscale (and its embedded DERP)
over TLS on port 8443 with a self-signed cert covering localhost,
127.0.0.1, and the docker-network hostname `headscale`. Tailscale
peers trust the cert via SSL_CERT_FILE; our test daemon uses
`derp_tls_insecure: true` (gated on the SUNBEAM_NET_TEST_DERP_INSECURE
env var) since pinning a self-signed root in tests is more trouble
than it's worth.

With TLS DERP working, the previously-ignored
`test_e2e_tcp_through_tunnel` test now passes: the daemon spawns,
registers, completes a Noise handshake over TLS, opens a TLS DERP
relay session, runs a real WireGuard handshake with peer-a (verified
via boringtun ↔ tailscale interop), and TCP-tunnels an HTTP GET
through smoltcp ↔ engine ↔ proxy ↔ test client. The 191-byte echo
response round-trips and the test asserts on its body.

- tests/config/headscale.yaml: tls_cert_path + tls_key_path, listen on
  8443, server_url=https://headscale:8443
- tests/config/test-cert.pem + test-key.pem: 365-day self-signed RSA
  cert with SAN DNS:localhost, DNS:headscale, IP:127.0.0.1
- tests/docker-compose.yml: mount certs into headscale + both peers,
  set SSL_CERT_FILE on the peers, expose 8443 instead of 8080
- tests/run.sh: switch to https://localhost:8443, set
  SUNBEAM_NET_TEST_DERP_INSECURE=1
- tests/integration.rs: drop the #[ignore] on test_e2e_tcp_through_tunnel,
  read derp_tls_insecure from env in all four test configs
This commit is contained in:
2026-04-07 15:29:03 +01:00
parent 2624a13952
commit 94fb6155f7
6 changed files with 90 additions and 20 deletions

View File

@@ -38,6 +38,7 @@ async fn test_register_and_receive_netmap() {
control_socket: state_dir.path().join("test.sock"),
hostname: "sunbeam-net-test".into(),
server_public_key: None,
derp_tls_insecure: std::env::var("SUNBEAM_NET_TEST_DERP_INSECURE").is_ok(),
};
let keys = sunbeam_net::keys::NodeKeys::load_or_generate(&config.state_dir).unwrap();
@@ -107,6 +108,7 @@ async fn test_proxy_listener_accepts() {
control_socket: state_dir.path().join("proxy.sock"),
hostname: "sunbeam-net-proxy-test".into(),
server_public_key: None,
derp_tls_insecure: std::env::var("SUNBEAM_NET_TEST_DERP_INSECURE").is_ok(),
};
let handle = sunbeam_net::VpnDaemon::start(config).await.unwrap();
@@ -132,25 +134,25 @@ async fn test_proxy_listener_accepts() {
/// End-to-end: bring up the daemon, dial peer-a's echo server through the
/// proxy, and assert we get bytes back across the WireGuard tunnel.
///
/// **Currently ignored** because the docker-compose test stack runs Headscale
/// over plain HTTP, but Tailscale's official client unconditionally tries to
/// connect to DERP relays over TLS:
///
/// derp.Recv(derp-999): connect to region 999: tls: first record does
/// not look like a TLS handshake
///
/// So peer-a can never receive WireGuard packets we forward via the relay,
/// and we have no other reachable transport from the host into the docker
/// network. Unblocking this requires either: (a) generating a self-signed
/// cert, configuring Headscale + DERP for TLS, and teaching DerpClient to
/// negotiate TLS; or (b) running the test daemon inside the same docker
/// network as peer-a so direct UDP works without relays. Tracked separately.
/// Requires the docker-compose stack with TUN-mode peers + TLS Headscale
/// (sunbeam-net/tests/run.sh handles the setup). The test enables
/// `derp_tls_insecure` because the test stack uses a self-signed cert.
#[tokio::test(flavor = "multi_thread")]
#[ignore = "blocked on TLS DERP — see comment"]
async fn test_e2e_tcp_through_tunnel() {
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// Try to install a tracing subscriber so the daemon's logs reach
// stderr when the test is run with `cargo test -- --nocapture`. Ignore
// failures (already installed by another test).
let _ = tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("sunbeam_net=trace")),
)
.with_test_writer()
.try_init();
let coord_url = require_env("SUNBEAM_NET_TEST_COORD_URL");
let auth_key = require_env("SUNBEAM_NET_TEST_AUTH_KEY");
let peer_a_ip: std::net::IpAddr = require_env("SUNBEAM_NET_TEST_PEER_A_IP")
@@ -171,6 +173,8 @@ async fn test_e2e_tcp_through_tunnel() {
control_socket: state_dir.path().join("e2e.sock"),
hostname: "sunbeam-net-e2e-test".into(),
server_public_key: None,
// Test stack uses a self-signed cert.
derp_tls_insecure: std::env::var("SUNBEAM_NET_TEST_DERP_INSECURE").is_ok(),
};
let handle = sunbeam_net::VpnDaemon::start(config)
@@ -252,6 +256,7 @@ async fn test_daemon_lifecycle() {
control_socket: state_dir.path().join("daemon.sock"),
hostname: "sunbeam-net-daemon-test".into(),
server_public_key: None,
derp_tls_insecure: std::env::var("SUNBEAM_NET_TEST_DERP_INSECURE").is_ok(),
};
let handle = sunbeam_net::VpnDaemon::start(config)