Files
cli/sunbeam-net/src/tls.rs
Sienna Meridian Satterwhite 2624a13952 feat(net): TLS support for HTTPS coordination URLs and DERP relays
Production Headscale terminates TLS for both the control plane (via the
TS2021 HTTP CONNECT upgrade endpoint) and the embedded DERP relay.
Without TLS, the daemon could only talk to plain-HTTP test stacks.

- New crate::tls module: shared TlsMode (Verify | InsecureSkipVerify)
  + tls_wrap helper. webpki roots in Verify mode; an explicit
  ServerCertVerifier that accepts any cert in InsecureSkipVerify
  (test-only).
- Cargo.toml: add tokio-rustls, webpki-roots, rustls-pemfile.
- noise/handshake: perform_handshake is now generic over the underlying
  stream and takes an explicit `host_header` argument instead of using
  `peer_addr`. Lets callers pass either a TcpStream or a TLS-wrapped
  stream.
- noise/stream: NoiseStream<S> is generic over the underlying transport
  with `S = TcpStream` as the default. The AsyncRead+AsyncWrite impls
  forward to whatever S provides.
- control/client: ControlClient::connect detects `https://` in
  coordination_url and TLS-wraps the TCP stream before the Noise
  handshake. fetch_server_key now also TLS-wraps when needed. Both
  honor the new derp_tls_insecure config flag (which is misnamed but
  controls all TLS verification, not just DERP).
- derp/client: DerpClient::connect_with_tls accepts a TlsMode and uses
  the shared tls::tls_wrap helper instead of duplicating it. The
  client struct's inner Framed is now generic over a Box<dyn
  DerpTransport> so it can hold either a plain or TLS-wrapped stream.
- daemon/lifecycle: derive the DERP URL scheme from coordination_url
  (https → https) and pass derp_tls_insecure through.
- config.rs: new `derp_tls_insecure: bool` field on VpnConfig.
- src/vpn_cmds.rs: pass `derp_tls_insecure: false` for production.

Two bug fixes found while wiring this up:

- proxy/engine: bridge_connection used to set remote_done on any
  smoltcp recv error, including the transient InvalidState that
  smoltcp returns while a TCP socket is still in SynSent. That meant
  the engine gave up on the connection before the WG handshake even
  finished. Distinguish "not ready yet" (returns Ok(0)) from
  "actually closed" (returns Err) inside tcp_recv, and only mark
  remote_done on the latter.
- proxy/engine: the connection's "done" condition required
  local_read_done, but most clients (curl, kubectl) keep their write
  side open until they read EOF. The engine never closed its local
  TCP, so clients sat in read_to_end forever. Drop the connection as
  soon as the remote side has finished and we've drained its buffer
  to the local socket — the local TcpStream drop closes the socket
  and the client sees EOF.
2026-04-07 15:28:44 +01:00

120 lines
4.3 KiB
Rust

//! Shared TLS helpers used by both the control client (TS2021 over
//! HTTPS) and the DERP relay client.
//!
//! Both clients accept either plain TCP (`http://...`) or TLS-wrapped
//! TCP (`https://...`). For production they verify against the system's
//! webpki roots; for tests against self-signed certs they accept any
//! cert via [`TlsMode::InsecureSkipVerify`].
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio_rustls::TlsConnector;
use tokio_rustls::client::TlsStream;
use tokio_rustls::rustls::ClientConfig;
use tokio_rustls::rustls::pki_types::ServerName;
use crate::error::Error;
/// TLS verification mode shared by all clients in this crate.
#[derive(Debug, Clone, Copy, Default)]
pub enum TlsMode {
/// Standard verification using the system's webpki roots. Use this in
/// production.
#[default]
Verify,
/// Skip certificate verification. Only use this against a known test
/// server with a self-signed cert.
InsecureSkipVerify,
}
/// Wrap a TcpStream in a TLS connection. Honors `TlsMode`.
pub async fn tls_wrap(
tcp: TcpStream,
server_name: &str,
mode: TlsMode,
) -> crate::Result<TlsStream<TcpStream>> {
let config = match mode {
TlsMode::Verify => {
let mut roots = tokio_rustls::rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth()
}
TlsMode::InsecureSkipVerify => ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertVerification))
.with_no_client_auth(),
};
let connector = TlsConnector::from(Arc::new(config));
let dns_name = ServerName::try_from(server_name.to_string())
.map_err(|e| Error::Tls(format!("invalid TLS server name {server_name}: {e}")))?;
connector
.connect(dns_name, tcp)
.await
.map_err(|e| Error::Tls(format!("TLS handshake failed: {e}")))
}
/// Cert verifier that accepts every server certificate. Used when
/// `TlsMode::InsecureSkipVerify` is set.
#[derive(Debug)]
struct NoCertVerification;
impl tokio_rustls::rustls::client::danger::ServerCertVerifier for NoCertVerification {
fn verify_server_cert(
&self,
_end_entity: &tokio_rustls::rustls::pki_types::CertificateDer<'_>,
_intermediates: &[tokio_rustls::rustls::pki_types::CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: tokio_rustls::rustls::pki_types::UnixTime,
) -> std::result::Result<
tokio_rustls::rustls::client::danger::ServerCertVerified,
tokio_rustls::rustls::Error,
> {
Ok(tokio_rustls::rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &tokio_rustls::rustls::pki_types::CertificateDer<'_>,
_dss: &tokio_rustls::rustls::DigitallySignedStruct,
) -> std::result::Result<
tokio_rustls::rustls::client::danger::HandshakeSignatureValid,
tokio_rustls::rustls::Error,
> {
Ok(tokio_rustls::rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &tokio_rustls::rustls::pki_types::CertificateDer<'_>,
_dss: &tokio_rustls::rustls::DigitallySignedStruct,
) -> std::result::Result<
tokio_rustls::rustls::client::danger::HandshakeSignatureValid,
tokio_rustls::rustls::Error,
> {
Ok(tokio_rustls::rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<tokio_rustls::rustls::SignatureScheme> {
use tokio_rustls::rustls::SignatureScheme;
vec![
SignatureScheme::RSA_PKCS1_SHA256,
SignatureScheme::RSA_PKCS1_SHA384,
SignatureScheme::RSA_PKCS1_SHA512,
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::ECDSA_NISTP384_SHA384,
SignatureScheme::ECDSA_NISTP521_SHA512,
SignatureScheme::RSA_PSS_SHA256,
SignatureScheme::RSA_PSS_SHA384,
SignatureScheme::RSA_PSS_SHA512,
SignatureScheme::ED25519,
]
}
}