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:
@@ -1,10 +1,17 @@
|
|||||||
# Headscale configuration for integration tests.
|
# Headscale configuration for integration tests.
|
||||||
# Ephemeral SQLite, embedded DERP, no OIDC.
|
# Ephemeral SQLite, embedded DERP, no OIDC.
|
||||||
|
|
||||||
server_url: http://headscale:8080
|
server_url: https://headscale:8443
|
||||||
listen_addr: 0.0.0.0:8080
|
listen_addr: 0.0.0.0:8443
|
||||||
metrics_listen_addr: 0.0.0.0:9090
|
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 protocol (auto-generates key on first start)
|
||||||
noise:
|
noise:
|
||||||
private_key_path: /var/lib/headscale/noise_private.key
|
private_key_path: /var/lib/headscale/noise_private.key
|
||||||
|
|||||||
20
sunbeam-net/tests/config/test-cert.pem
Normal file
20
sunbeam-net/tests/config/test-cert.pem
Normal 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-----
|
||||||
28
sunbeam-net/tests/config/test-key.pem
Normal file
28
sunbeam-net/tests/config/test-key.pem
Normal 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-----
|
||||||
@@ -18,11 +18,13 @@ services:
|
|||||||
image: headscale/headscale:0.23
|
image: headscale/headscale:0.23
|
||||||
command: serve
|
command: serve
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080" # control plane (TS2021 Noise + HTTP API)
|
- "8443:8443" # control plane + embedded DERP, both over TLS
|
||||||
- "3478:3478/udp" # STUN
|
- "3478:3478/udp" # STUN
|
||||||
- "9090:9090" # metrics
|
- "9090:9090" # metrics
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/headscale.yaml:/etc/headscale/config.yaml:ro
|
- ./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
|
- headscale-data:/var/lib/headscale
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "headscale", "nodes", "list"]
|
test: ["CMD", "headscale", "nodes", "list"]
|
||||||
@@ -46,11 +48,15 @@ services:
|
|||||||
TS_AUTHKEY: "${PEER_A_AUTH_KEY}"
|
TS_AUTHKEY: "${PEER_A_AUTH_KEY}"
|
||||||
TS_STATE_DIR: /var/lib/tailscale
|
TS_STATE_DIR: /var/lib/tailscale
|
||||||
TS_USERSPACE: "false"
|
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
|
# Pin the WireGuard listen port (passed to tailscaled itself) so we
|
||||||
# can publish it to the host — without this our test daemon (running
|
# can publish it to the host — without this our test daemon (running
|
||||||
# outside docker) can't reach peer-a's UDP endpoint.
|
# outside docker) can't reach peer-a's UDP endpoint.
|
||||||
TS_TAILSCALED_EXTRA_ARGS: --port=41641
|
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:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
- NET_RAW
|
- NET_RAW
|
||||||
@@ -60,6 +66,7 @@ services:
|
|||||||
- "41641:41641/udp"
|
- "41641:41641/udp"
|
||||||
volumes:
|
volumes:
|
||||||
- peer-a-state:/var/lib/tailscale
|
- 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
|
# Tailscale doesn't have a great healthcheck, but it registers fast
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "tailscale", "status", "--json"]
|
test: ["CMD", "tailscale", "status", "--json"]
|
||||||
@@ -78,7 +85,8 @@ services:
|
|||||||
TS_AUTHKEY: "${PEER_B_AUTH_KEY}"
|
TS_AUTHKEY: "${PEER_B_AUTH_KEY}"
|
||||||
TS_STATE_DIR: /var/lib/tailscale
|
TS_STATE_DIR: /var/lib/tailscale
|
||||||
TS_USERSPACE: "false"
|
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:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
- NET_RAW
|
- NET_RAW
|
||||||
@@ -86,6 +94,7 @@ services:
|
|||||||
- /dev/net/tun:/dev/net/tun
|
- /dev/net/tun:/dev/net/tun
|
||||||
volumes:
|
volumes:
|
||||||
- peer-b-state:/var/lib/tailscale
|
- peer-b-state:/var/lib/tailscale
|
||||||
|
- ./config/test-cert.pem:/etc/headscale/test-cert.pem:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "tailscale", "status", "--json"]
|
test: ["CMD", "tailscale", "status", "--json"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ async fn test_register_and_receive_netmap() {
|
|||||||
control_socket: state_dir.path().join("test.sock"),
|
control_socket: state_dir.path().join("test.sock"),
|
||||||
hostname: "sunbeam-net-test".into(),
|
hostname: "sunbeam-net-test".into(),
|
||||||
server_public_key: None,
|
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();
|
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"),
|
control_socket: state_dir.path().join("proxy.sock"),
|
||||||
hostname: "sunbeam-net-proxy-test".into(),
|
hostname: "sunbeam-net-proxy-test".into(),
|
||||||
server_public_key: None,
|
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();
|
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
|
/// 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.
|
/// proxy, and assert we get bytes back across the WireGuard tunnel.
|
||||||
///
|
///
|
||||||
/// **Currently ignored** because the docker-compose test stack runs Headscale
|
/// Requires the docker-compose stack with TUN-mode peers + TLS Headscale
|
||||||
/// over plain HTTP, but Tailscale's official client unconditionally tries to
|
/// (sunbeam-net/tests/run.sh handles the setup). The test enables
|
||||||
/// connect to DERP relays over TLS:
|
/// `derp_tls_insecure` because the test stack uses a self-signed cert.
|
||||||
///
|
|
||||||
/// 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.
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
#[ignore = "blocked on TLS DERP — see comment"]
|
|
||||||
async fn test_e2e_tcp_through_tunnel() {
|
async fn test_e2e_tcp_through_tunnel() {
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
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 coord_url = require_env("SUNBEAM_NET_TEST_COORD_URL");
|
||||||
let auth_key = require_env("SUNBEAM_NET_TEST_AUTH_KEY");
|
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")
|
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"),
|
control_socket: state_dir.path().join("e2e.sock"),
|
||||||
hostname: "sunbeam-net-e2e-test".into(),
|
hostname: "sunbeam-net-e2e-test".into(),
|
||||||
server_public_key: None,
|
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)
|
let handle = sunbeam_net::VpnDaemon::start(config)
|
||||||
@@ -252,6 +256,7 @@ async fn test_daemon_lifecycle() {
|
|||||||
control_socket: state_dir.path().join("daemon.sock"),
|
control_socket: state_dir.path().join("daemon.sock"),
|
||||||
hostname: "sunbeam-net-daemon-test".into(),
|
hostname: "sunbeam-net-daemon-test".into(),
|
||||||
server_public_key: None,
|
server_public_key: None,
|
||||||
|
derp_tls_insecure: std::env::var("SUNBEAM_NET_TEST_DERP_INSECURE").is_ok(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let handle = sunbeam_net::VpnDaemon::start(config)
|
let handle = sunbeam_net::VpnDaemon::start(config)
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ $COMPOSE exec -T peer-b tailscale ip -4 || true
|
|||||||
echo "==> Running integration tests..."
|
echo "==> Running integration tests..."
|
||||||
cd ../..
|
cd ../..
|
||||||
SUNBEAM_NET_TEST_AUTH_KEY="$CLIENT_KEY" \
|
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:]') \
|
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
|
cargo test -p sunbeam-net --features integration --test integration -- --nocapture
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user