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

@@ -1,10 +1,17 @@
# Headscale configuration for integration tests.
# Ephemeral SQLite, embedded DERP, no OIDC.
server_url: http://headscale:8080
listen_addr: 0.0.0.0:8080
server_url: https://headscale:8443
listen_addr: 0.0.0.0:8443
metrics_listen_addr: 0.0.0.0:9090
# Self-signed cert covering localhost, 127.0.0.1, and the docker-network
# hostname `headscale`. Generated by tests/run.sh on first run; the
# integration tests connect with derp_tls_insecure: true so they don't
# need to trust this CA.
tls_cert_path: /etc/headscale/test-cert.pem
tls_key_path: /etc/headscale/test-key.pem
# Noise protocol (auto-generates key on first start)
noise:
private_key_path: /var/lib/headscale/noise_private.key

View File

@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDMDCCAhigAwIBAgIUY3x22aarkJ14Mbb8tXK4uoUdmpIwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDQwNzE0MDU0MFoXDTI3MDQw
NzE0MDU0MFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAzSUL/WcwxwVjRTECaNd9DBMdJAcHa26c5wtANsghvwmh
LZP3aLzBNtCdgZ1VxN+2W8j5w2HBIJ3V2aRWL6I3ZcZ7u2gJltjyJuqxtlTsDrsI
/j6jNo69c6/IO4X9pgVyHnNk55LQg7mmbw+EOmeyDKfF70UF45xHB+FX5Eb2uoRl
hCSdt2pIYw/4OYCSSx68Jb/HLtMtSvMOJua+AiB647Yypij9uD4amBx0+gAPCSYq
CIfL/pVDr1OqfdKZaP0L7ZG/99wVRjk9QQRLiiio6Ob+FhIlB4XTlsFIl2WENjrq
izMxKWEXd8T/DNo+yqoSNPFQGPur35pHGd/ZZpkOgQIDAQABo3oweDAdBgNVHQ4E
FgQUsGoAfRBgHjVVKChl2Mz9xESmvPQwHwYDVR0jBBgwFoAUsGoAfRBgHjVVKChl
2Mz9xESmvPQwDwYDVR0TAQH/BAUwAwEB/zAlBgNVHREEHjAcgglsb2NhbGhvc3SC
CWhlYWRzY2FsZYcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAhMKo1oBO1ywUBjJo
xVU8fgeDmETZwakZTzwpkqqXTdnnzsjAfzHIXAgs+gnaGMqQpYlyMY0na8KBGJ56
qsuSf9twQ2V/aWMhlSmB0buEqg2TwTIepHZoLPU0pH2Z6ilvEjaphKLHdGCjBLMZ
/xPk7mXzW4+gHVRsYrkWIUqX6gPP3Pn4OlOOxbTG8naIiM6d10/0dV8KawqoLCrb
yVUGOZ7RbW9mZ4X+Wl7zZLZEnLiQf3O8vbCVr7fygTGBl4cw8M8nsetYvJTExoed
FwYRFbtHYJEjb+R8csVJDCoN6O7A1xiGzZfn8eKJ7OsYTmtRZ3jPPUsOH+aM0yIa
yKRSuA==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDNJQv9ZzDHBWNF
MQJo130MEx0kBwdrbpznC0A2yCG/CaEtk/dovME20J2BnVXE37ZbyPnDYcEgndXZ
pFYvojdlxnu7aAmW2PIm6rG2VOwOuwj+PqM2jr1zr8g7hf2mBXIec2TnktCDuaZv
D4Q6Z7IMp8XvRQXjnEcH4VfkRva6hGWEJJ23akhjD/g5gJJLHrwlv8cu0y1K8w4m
5r4CIHrjtjKmKP24PhqYHHT6AA8JJioIh8v+lUOvU6p90plo/Qvtkb/33BVGOT1B
BEuKKKjo5v4WEiUHhdOWwUiXZYQ2OuqLMzEpYRd3xP8M2j7KqhI08VAY+6vfmkcZ
39lmmQ6BAgMBAAECggEADKD7uHlaSN49irxmJBMj+RLSJ+4g2E3CbfzE0rqGTU7n
87jYscNw95FnKNwJNCn7fXIFYjBJ5dqhmBjkT2FinKrX4iUY9gbb/WZUU1+t+ogs
GQ69GHY4Fn+bSYLJpydNq6855oGkwX8zzkF7x+arUNkhN8YdgzITM1p9gSmXNcsl
egzveQNmhBF9w+JMpot5bY/KXaoTTBi9R3ZLob21KfT+DTWUvi8PTrzHq4gQ9pX0
AQuYd0wcfOwl44k123ZeLP8YA1r7sIVqorjkU0f+zpv9W5Xoe7YPbqZV2jRbF8WK
wBNDTvLaW7F9mk6HhcF4kcjfepv5F+y+Bh4oStwCCwKBgQDzmGGLGVrjy5KfdVyM
TVPkzmLTEls0DsmWieHH9+2JXExrQBZXusurPlUFs5zTFl4rD0Ug2BdPFmEtsn8c
qq/xXQL1W5c0b+UgynG0QcZBRJiQ11TTVgISIZiPL4V8LVJg8PvbzoDwNhPzZuYT
ZR1FQaisEpSf3L20DpWDB0dWLwKBgQDXl2g9Gi6Dv7/7j+IgJM7PBtPmeMOs07dr
Oi/hgrS111qMuVB8cYO588XzC+alJLEt1Wc/krSqUusjkWtSD3mDDFZBJLFQ7Rny
Yp1wN3QyntShntqAxIw3sD+RLJjZrKvX9YYV66oamEhMSadGkW2Pq6Y3fYGe6SRX
uYUhzPpqTwKBgBwfRXOXk8SkpeK+29Zevwa1RPd1MQ5Lfr5gYK6DUur+utvO5EVw
jT7RzWMBH2PHO0vhUWu/RsGcpc9uwfn/QpyszkChOE2XdW5ZsNLMnSS/1JU0JtjX
HxoUwtYU+GYjnVUPvSPdLUmOFLOO95TZoY1zTRPAeWQTSdtVq7Ea0AOFAoGAPF1K
dIFWMNGJwbghynpD5be1sTxzHXsSSlW6flwImTm5QtnIbW+jQHe/HzRf2jGR4pF0
HVrId0BMUmMvN4TZsxXLOOY7N7uLnlB6YKdGQ74xLye5aoCd+iHBSra//YLZgthe
ONkJgfTNmX6t9ZZWpPmcysC7gHErGdz6J+Kq4wUCgYEAufqI3u268wzIfN9Zigdw
vK5A/pnN1uUyIVA9YjHHuw8a7PZIQhdPTvBIVLMOoACNZpSH/FGnBNFQEcnC5u+4
yWaZQmk90cTVJBotxHKAuAuIeKazBYwoeXrXvID+0sbPz9rLlmLkQlWKHfFYjuEr
SOvveQdZfHIhzbFvtA/fIvE=
-----END PRIVATE KEY-----

View File

@@ -18,11 +18,13 @@ services:
image: headscale/headscale:0.23
command: serve
ports:
- "8080:8080" # control plane (TS2021 Noise + HTTP API)
- "8443:8443" # control plane + embedded DERP, both over TLS
- "3478:3478/udp" # STUN
- "9090:9090" # metrics
volumes:
- ./config/headscale.yaml:/etc/headscale/config.yaml:ro
- ./config/test-cert.pem:/etc/headscale/test-cert.pem:ro
- ./config/test-key.pem:/etc/headscale/test-key.pem:ro
- headscale-data:/var/lib/headscale
healthcheck:
test: ["CMD", "headscale", "nodes", "list"]
@@ -46,11 +48,15 @@ services:
TS_AUTHKEY: "${PEER_A_AUTH_KEY}"
TS_STATE_DIR: /var/lib/tailscale
TS_USERSPACE: "false"
TS_EXTRA_ARGS: --login-server=http://headscale:8080
TS_EXTRA_ARGS: --login-server=https://headscale:8443
# Pin the WireGuard listen port (passed to tailscaled itself) so we
# can publish it to the host — without this our test daemon (running
# outside docker) can't reach peer-a's UDP endpoint.
TS_TAILSCALED_EXTRA_ARGS: --port=41641
# Trust the self-signed test cert so the tailscale Go client can
# verify HTTPS connections to headscale + the embedded DERP. Go
# honors SSL_CERT_FILE for net/http TLS verification.
SSL_CERT_FILE: /etc/headscale/test-cert.pem
cap_add:
- NET_ADMIN
- NET_RAW
@@ -60,6 +66,7 @@ services:
- "41641:41641/udp"
volumes:
- peer-a-state:/var/lib/tailscale
- ./config/test-cert.pem:/etc/headscale/test-cert.pem:ro
# Tailscale doesn't have a great healthcheck, but it registers fast
healthcheck:
test: ["CMD", "tailscale", "status", "--json"]
@@ -78,7 +85,8 @@ services:
TS_AUTHKEY: "${PEER_B_AUTH_KEY}"
TS_STATE_DIR: /var/lib/tailscale
TS_USERSPACE: "false"
TS_EXTRA_ARGS: --login-server=http://headscale:8080
TS_EXTRA_ARGS: --login-server=https://headscale:8443
SSL_CERT_FILE: /etc/headscale/test-cert.pem
cap_add:
- NET_ADMIN
- NET_RAW
@@ -86,6 +94,7 @@ services:
- /dev/net/tun:/dev/net/tun
volumes:
- peer-b-state:/var/lib/tailscale
- ./config/test-cert.pem:/etc/headscale/test-cert.pem:ro
healthcheck:
test: ["CMD", "tailscale", "status", "--json"]
interval: 5s

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)

View File

@@ -59,7 +59,8 @@ $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_COORD_URL="https://localhost:8443" \
SUNBEAM_NET_TEST_DERP_INSECURE=1 \
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