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.
This commit is contained in:
2026-04-07 15:28:44 +01:00
parent e934eb45dc
commit 2624a13952
14 changed files with 389 additions and 78 deletions

4
Cargo.lock generated
View File

@@ -4407,15 +4407,19 @@ dependencies = [
"ipnet",
"pretty_assertions",
"rand 0.8.5",
"rustls-pemfile",
"serde",
"serde_json",
"smoltcp",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-rustls",
"tokio-test",
"tokio-util",
"tracing",
"tracing-subscriber",
"webpki-roots 1.0.6",
"x25519-dalek",
"zstd",
]

View File

@@ -187,6 +187,8 @@ async fn run_daemon_foreground() -> Result<()> {
control_socket: state_dir.join("daemon.sock"),
hostname,
server_public_key: None,
// Production deployments use proper TLS — leave verification on.
derp_tls_insecure: false,
};
step(&format!("Connecting to {}", ctx.vpn_url));

View File

@@ -28,6 +28,9 @@ base64 = "0.22"
tracing = "0.1"
thiserror = "2"
ipnet = "2"
tokio-rustls = "0.26.4"
webpki-roots = "1.0.6"
rustls-pemfile = "2.2.0"
[features]
integration = []
@@ -36,3 +39,4 @@ integration = []
tokio-test = "0.4"
pretty_assertions = "1"
tempfile = "3"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }

View File

@@ -31,4 +31,8 @@ pub struct VpnConfig {
/// The coordination server's Noise public key (32 bytes).
/// If `None`, it will be fetched from the server's `/key` endpoint.
pub server_public_key: Option<[u8; 32]>,
/// Skip TLS certificate verification when connecting to DERP relays
/// over `https://`. Only set this for test stacks with self-signed
/// certs — in production, leave it `false`.
pub derp_tls_insecure: bool,
}

View File

@@ -30,48 +30,89 @@ impl ControlClient {
/// 4. Run `h2::client::handshake` over the encrypted stream
/// 5. Spawn the h2 connection driver task
pub async fn connect(config: &VpnConfig, keys: &NodeKeys) -> crate::Result<Self> {
// Parse host:port from the coordination URL.
let addr = parse_coordination_addr(&config.coordination_url)?;
let use_tls = config.coordination_url.starts_with("https://");
let host = addr.split(':').next().unwrap_or(&addr).to_string();
tracing::debug!("connecting to coordination server at {addr}");
tracing::debug!("connecting to coordination server at {addr} (tls={use_tls})");
let mut tcp = TcpStream::connect(&addr)
.await
.map_err(|e| crate::Error::Control(format!("tcp connect to {addr}: {e}")))?;
let tls_mode = if config.derp_tls_insecure {
crate::tls::TlsMode::InsecureSkipVerify
} else {
crate::tls::TlsMode::Verify
};
// Resolve the server's Noise public key.
let server_public = match config.server_public_key {
Some(key) => key,
None => fetch_server_key(&addr).await?,
None => fetch_server_key(&config.coordination_url, &addr, tls_mode).await?,
};
// Noise IK handshake (controlbase protocol).
let server_pub_key = x25519_dalek::PublicKey::from(server_public);
let result = noise::handshake::perform_handshake(
&mut tcp,
&keys.node_private,
&keys.node_public,
&server_pub_key,
)
.await?;
// Wrap in NoiseStream for transparent encryption.
// Pass leftover bytes from the handshake TCP buffer.
let mut noise_stream =
noise::stream::NoiseStream::new(tcp, result.tx_cipher, result.rx_cipher, result.leftover);
let tcp = TcpStream::connect(&addr)
.await
.map_err(|e| crate::Error::Control(format!("tcp connect to {addr}: {e}")))?;
// Consume the early payload (EarlyNoise) before h2 starts.
// Headscale sends this immediately after the handshake.
let early = noise_stream.consume_early_payload().await
// Run the handshake either directly on the TCP stream (HTTP coordination)
// or on top of a TLS-wrapped stream (HTTPS coordination), then hand the
// resulting NoiseStream to h2.
if use_tls {
let mut tls = crate::tls::tls_wrap(tcp, &host, tls_mode).await?;
let result = noise::handshake::perform_handshake(
&mut tls,
&host,
&keys.node_private,
&keys.node_public,
&server_pub_key,
)
.await?;
let noise_stream = noise::stream::NoiseStream::new(
tls,
result.tx_cipher,
result.rx_cipher,
result.leftover,
);
Self::finish_h2_handshake(noise_stream).await
} else {
let mut tcp = tcp;
let result = noise::handshake::perform_handshake(
&mut tcp,
&host,
&keys.node_private,
&keys.node_public,
&server_pub_key,
)
.await?;
let noise_stream = noise::stream::NoiseStream::new(
tcp,
result.tx_cipher,
result.rx_cipher,
result.leftover,
);
Self::finish_h2_handshake(noise_stream).await
}
}
/// Common tail for `connect`: consume the early Noise payload, run the
/// h2 client handshake on top of the encrypted stream, and spawn the
/// connection driver. Generic over the underlying transport so it
/// works for both plain TCP and TLS-wrapped connections.
async fn finish_h2_handshake<S>(
mut noise_stream: noise::stream::NoiseStream<S>,
) -> crate::Result<Self>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let early = noise_stream
.consume_early_payload()
.await
.map_err(|e| crate::Error::Control(format!("early payload: {e}")))?;
tracing::debug!("early payload consumed ({} bytes)", early.len());
// h2 client handshake over the encrypted stream.
let (sender, connection) = h2::client::handshake(noise_stream)
.await
.map_err(|e| crate::Error::Control(format!("h2 handshake: {e}")))?;
// Spawn the connection driver — it must run for the lifetime of the client.
let conn_task = tokio::spawn(async move {
if let Err(e) = connection.await {
tracing::error!("h2 connection error: {e}");
@@ -226,25 +267,45 @@ fn parse_coordination_addr(url: &str) -> crate::Result<String> {
///
/// Headscale/Tailscale returns JSON: `{"publicKey":"mkey:<hex>","legacyPublicKey":"mkey:..."}`.
/// We parse the `publicKey` field and strip the `mkey:` prefix.
async fn fetch_server_key(addr: &str) -> crate::Result<[u8; 32]> {
let mut tcp = TcpStream::connect(addr)
.await
.map_err(|e| crate::Error::Control(format!("connect to /key: {e}")))?;
async fn fetch_server_key(
coordination_url: &str,
addr: &str,
tls_mode: crate::tls::TlsMode,
) -> crate::Result<[u8; 32]> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let use_tls = coordination_url.starts_with("https://");
let host = addr.split(':').next().unwrap_or(addr);
let request = format!(
"GET /key?v=69 HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n"
);
tcp.write_all(request.as_bytes())
.await
.map_err(|e| crate::Error::Control(format!("write /key request: {e}")))?;
let mut buf = Vec::with_capacity(4096);
tcp.read_to_end(&mut buf)
.await
.map_err(|e| crate::Error::Control(format!("read /key response: {e}")))?;
let buf = if use_tls {
let tcp = TcpStream::connect(addr)
.await
.map_err(|e| crate::Error::Control(format!("connect to /key: {e}")))?;
let mut tls = crate::tls::tls_wrap(tcp, host, tls_mode).await?;
tls.write_all(request.as_bytes())
.await
.map_err(|e| crate::Error::Control(format!("write /key request: {e}")))?;
let mut buf = Vec::with_capacity(4096);
tls.read_to_end(&mut buf)
.await
.map_err(|e| crate::Error::Control(format!("read /key response: {e}")))?;
buf
} else {
let mut tcp = TcpStream::connect(addr)
.await
.map_err(|e| crate::Error::Control(format!("connect to /key: {e}")))?;
tcp.write_all(request.as_bytes())
.await
.map_err(|e| crate::Error::Control(format!("write /key request: {e}")))?;
let mut buf = Vec::with_capacity(4096);
tcp.read_to_end(&mut buf)
.await
.map_err(|e| crate::Error::Control(format!("read /key response: {e}")))?;
buf
};
let response = String::from_utf8_lossy(&buf);

View File

@@ -9,7 +9,7 @@ use crate::config::VpnConfig;
use crate::control::MapUpdate;
use crate::daemon::ipc::IpcServer;
use crate::daemon::state::{DaemonHandle, DaemonStatus};
use crate::derp::client::DerpClient;
use crate::derp::client::{DerpClient, DerpTlsMode};
use crate::proto::types::DerpMap;
use crate::proxy::engine::{EngineCommand, NetworkEngine};
use crate::wg::tunnel::{DecapAction, WgTunnel};
@@ -234,16 +234,35 @@ async fn run_session(
}
};
// The DERP endpoint we connect to is either pulled from the netmap's
// DerpMap (real Tailscale-style deployments), or — for embedded relays
// where the netmap returns a useless `host: ""`, port: 0` — derived
// from the coordination URL. In both cases, prefix the URL with the
// same scheme as the coordination URL so HTTPS coordination implies
// HTTPS DERP. The DerpClient strips the scheme back off internally.
let coord_scheme = if config.coordination_url.starts_with("https://") {
"https"
} else {
"http"
};
let derp_endpoint = derp_map
.as_ref()
.and_then(pick_derp_node)
.filter(|(host, port)| !host.is_empty() && *port != 0)
.or_else(|| coordination_host_port(&config.coordination_url));
.map(|(h, p)| format!("{coord_scheme}://{h}:{p}"))
.or_else(|| {
coordination_host_port(&config.coordination_url)
.map(|(h, p)| format!("{coord_scheme}://{h}:{p}"))
});
let _derp_task = if let Some((host, port)) = derp_endpoint {
let url = format!("{host}:{port}");
tracing::info!("connecting to DERP relay at {url}");
match DerpClient::connect(&url, keys).await {
let _derp_task = if let Some(url) = derp_endpoint {
let tls_mode = if config.derp_tls_insecure {
DerpTlsMode::InsecureSkipVerify
} else {
DerpTlsMode::Verify
};
tracing::info!("connecting to DERP relay at {url} (tls_mode={tls_mode:?})");
match DerpClient::connect_with_tls(&url, keys, tls_mode).await {
Ok(client) => {
tracing::info!("DERP relay connected: {url}");
let derp_cancel = cancel.clone();
@@ -731,6 +750,7 @@ mod tests {
control_socket: dir.path().join("test.sock"),
hostname: "test-node".to_string(),
server_public_key: Some([0xaa; 32]),
derp_tls_insecure: false,
};
let handle = VpnDaemon::start(config).await.unwrap();

View File

@@ -2,19 +2,28 @@ use bytes::{BufMut, BytesMut};
use futures::{SinkExt, StreamExt};
#[cfg(test)]
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio_util::codec::Framed;
use super::framing::*;
use crate::error::Error;
/// A trait alias for the underlying transport — either a plain TcpStream
/// (`derp://host` / `http://host`) or a TLS-wrapped one (`https://host`).
trait DerpTransport: AsyncRead + AsyncWrite + Unpin + Send {}
impl<T: AsyncRead + AsyncWrite + Unpin + Send> DerpTransport for T {}
/// Client for a single DERP relay server.
pub struct DerpClient {
inner: Framed<TcpStream, DerpFrameCodec>,
inner: Framed<Box<dyn DerpTransport>, DerpFrameCodec>,
server_public: [u8; 32],
}
/// Re-export of the shared TLS mode under the DERP-specific name so
/// existing call sites keep working.
pub use crate::tls::TlsMode as DerpTlsMode;
impl std::fmt::Debug for DerpClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DerpClient")
@@ -27,30 +36,55 @@ fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
// TLS helpers live in `crate::tls` and are shared with the control client.
impl DerpClient {
/// Connect to a DERP server, perform HTTP upgrade and NaCl handshake.
///
/// `url` should be like `http://host:port` or `host:port`.
/// `url` accepts `http://host:port`, `https://host:port`, or bare
/// `host:port` (treated as plain HTTP). HTTPS uses standard webpki
/// certificate verification.
pub async fn connect(
url: &str,
node_keys: &crate::keys::NodeKeys,
) -> crate::Result<Self> {
Self::connect_with_tls(url, node_keys, DerpTlsMode::Verify).await
}
/// Like [`connect`], but lets the caller pick a TLS verification
/// mode. Use this with `DerpTlsMode::InsecureSkipVerify` against test
/// servers with self-signed certs.
pub async fn connect_with_tls(
url: &str,
node_keys: &crate::keys::NodeKeys,
tls_mode: DerpTlsMode,
) -> crate::Result<Self> {
use crypto_box::aead::{Aead, AeadCore, OsRng};
use crypto_box::{PublicKey, SalsaBox, SecretKey};
// Parse host:port from url
let addr = url
.strip_prefix("http://")
.or_else(|| url.strip_prefix("https://"))
.unwrap_or(url);
// Parse the URL into (scheme, addr).
let (use_tls, addr) = if let Some(rest) = url.strip_prefix("https://") {
(true, rest)
} else if let Some(rest) = url.strip_prefix("http://") {
(false, rest)
} else {
(false, url)
};
// TCP connect
let mut stream = TcpStream::connect(addr).await.map_err(|e| {
let tcp = TcpStream::connect(addr).await.map_err(|e| {
Error::Derp(format!("failed to connect to DERP server {addr}: {e}"))
})?;
// HTTP upgrade request
// Optionally wrap in TLS.
let host = addr.split(':').next().unwrap_or(addr);
let mut stream: Box<dyn DerpTransport> = if use_tls {
Box::new(crate::tls::tls_wrap(tcp, host, tls_mode).await?)
} else {
Box::new(tcp)
};
// HTTP upgrade request
let upgrade_req = format!(
"GET /derp HTTP/1.1\r\n\
Host: {host}\r\n\

View File

@@ -13,6 +13,9 @@ pub enum Error {
#[error("DERP relay error: {0}")]
Derp(String),
#[error("TLS error: {0}")]
Tls(String),
#[error("authentication failed: {0}")]
Auth(String),

View File

@@ -8,6 +8,7 @@ pub mod error;
pub mod keys;
pub mod noise;
pub mod proxy;
pub mod tls;
pub mod wg;
pub(crate) mod proto;

View File

@@ -124,13 +124,22 @@ fn protocol_version_prologue(version: u16) -> Vec<u8> {
/// Perform the TS2021 HTTP upgrade and controlbase Noise IK handshake.
///
/// Generic over the underlying stream so it can be called with either a
/// plain TcpStream (`http://...`) or a TLS-wrapped one (`https://...`).
/// `host_header` is what we put in the HTTP `Host:` header — typically
/// the hostname portion of the coordination URL.
///
/// Returns a [`HandshakeResult`] containing the transport ciphers.
pub(crate) async fn perform_handshake(
stream: &mut TcpStream,
pub(crate) async fn perform_handshake<S>(
stream: &mut S,
host_header: &str,
machine_private: &StaticSecret,
machine_public: &PublicKey,
server_public: &PublicKey,
) -> crate::Result<HandshakeResult> {
) -> crate::Result<HandshakeResult>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let mut s = SymmetricState::initialize();
// Prologue
@@ -165,13 +174,9 @@ pub(crate) async fn perform_handshake(
let init_b64 = base64::engine::general_purpose::STANDARD.encode(&init);
// Send HTTP upgrade
let peer = stream
.peer_addr()
.map(|a| a.to_string())
.unwrap_or_else(|_| "localhost".into());
let upgrade_req = format!(
"POST /ts2021 HTTP/1.1\r\n\
Host: {peer}\r\n\
Host: {host_header}\r\n\
Upgrade: tailscale-control-protocol\r\n\
Connection: upgrade\r\n\
X-Tailscale-Handshake: {init_b64}\r\n\
@@ -404,6 +409,7 @@ mod tests {
let mut client_stream = TcpStream::connect(addr).await.unwrap();
let result = perform_handshake(
&mut client_stream,
"localhost",
&machine_private,
&machine_public,
&server_public,

View File

@@ -21,8 +21,11 @@ const MAX_PLAINTEXT_CHUNK: usize = MAX_FRAME_SIZE - TAG_SIZE;
///
/// Implements `AsyncRead + AsyncWrite` so it can be used transparently by `h2`
/// and other async I/O consumers.
pub struct NoiseStream {
inner: Framed<TcpStream, NoiseFrameCodec>,
///
/// Generic over the underlying stream so it can wrap either a plain
/// TcpStream or a TLS-wrapped one (`tokio_rustls::client::TlsStream`).
pub struct NoiseStream<S = TcpStream> {
inner: Framed<S, NoiseFrameCodec>,
tx_cipher: ChaCha20Poly1305,
rx_cipher: ChaCha20Poly1305,
read_nonce: AtomicU64,
@@ -35,11 +38,11 @@ pub struct NoiseStream {
}
// SAFETY: ChaCha20Poly1305 is Send, all other fields are Send.
unsafe impl Send for NoiseStream {}
unsafe impl<S: Send> Send for NoiseStream<S> {}
impl Unpin for NoiseStream {}
impl<S: Unpin> Unpin for NoiseStream<S> {}
impl std::fmt::Debug for NoiseStream {
impl<S> std::fmt::Debug for NoiseStream<S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NoiseStream")
.field("read_nonce", &self.read_nonce.load(Ordering::Relaxed))
@@ -55,12 +58,15 @@ fn build_nonce(counter: u64) -> Nonce {
Nonce::from(nonce)
}
impl NoiseStream {
/// Wrap a TCP stream with controlbase encryption using the given transport ciphers.
/// `leftover` contains any bytes already read from TCP that belong to the first
/// Noise transport record (from the handshake buffer overflow).
impl<S> NoiseStream<S>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
/// Wrap a stream with controlbase encryption using the given transport ciphers.
/// `leftover` contains any bytes already read from the underlying stream that
/// belong to the first Noise transport record (from the handshake buffer overflow).
pub fn new(
stream: TcpStream,
stream: S,
tx_cipher: ChaCha20Poly1305,
rx_cipher: ChaCha20Poly1305,
leftover: Vec<u8>,
@@ -194,7 +200,10 @@ impl NoiseStream {
}
}
impl AsyncRead for NoiseStream {
impl<S> AsyncRead for NoiseStream<S>
where
S: AsyncRead + AsyncWrite + Unpin,
{
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
@@ -237,7 +246,10 @@ impl AsyncRead for NoiseStream {
}
}
impl AsyncWrite for NoiseStream {
impl<S> AsyncWrite for NoiseStream<S>
where
S: AsyncRead + AsyncWrite + Unpin,
{
fn poll_write(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,

View File

@@ -222,25 +222,34 @@ impl NetworkEngine {
}
}
// smoltcp → local: read from smoltcp socket
// smoltcp → local: read from smoltcp socket. Errors here mean the
// socket has closed cleanly (FIN received and drained); the
// SynSent/Listen/etc transient states return Ok(0) instead.
let mut tmp = [0u8; 8192];
match vnet.tcp_recv(conn.handle, &mut tmp) {
Ok(n) if n > 0 => {
tracing::trace!("bridge: smoltcp → buf {n} bytes");
conn.remote_buf.extend_from_slice(&tmp[..n]);
}
Err(_) => {
Ok(_) => {}
Err(e) => {
tracing::debug!("bridge: smoltcp recv ended: {e}");
conn.remote_done = true;
}
_ => {}
}
// Write buffered smoltcp data to local TCP
if !conn.remote_buf.is_empty() {
match conn.local.try_write(&conn.remote_buf) {
Ok(n) if n > 0 => {
tracing::trace!("bridge: buf → local {n} bytes");
conn.remote_buf.drain(..n);
}
_ => {}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(e) => {
tracing::debug!("bridge: local write error: {e}");
}
}
}
@@ -249,7 +258,12 @@ impl NetworkEngine {
conn.remote_done = true;
}
// Done when both sides are finished
conn.local_read_done && conn.remote_done && conn.local_buf.is_empty() && conn.remote_buf.is_empty()
// Done when the remote side has finished AND we've flushed all of
// its data to the local socket. We don't wait for the local side
// to half-close its write, because most clients (curl, kubectl,
// browsers) keep the write side open until they see EOF on the
// read side. Returning true here drops the local TcpStream, which
// closes the connection from our end and lets the client read EOF.
conn.remote_done && conn.remote_buf.is_empty()
}
}

119
sunbeam-net/src/tls.rs Normal file
View File

@@ -0,0 +1,119 @@
//! 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,
]
}
}

View File

@@ -177,17 +177,44 @@ impl VirtualNetwork {
}
/// Read data from a TCP socket.
///
/// Returns `Ok(n)` with the number of bytes read (possibly 0 if no
/// data is available right now). Returns `Err(...)` only when the
/// socket has actually finished receiving (FIN seen, drained) — not
/// merely when the socket is in a transient state like SynSent.
pub fn tcp_recv(
&mut self,
handle: TcpSocketHandle,
buf: &mut [u8],
) -> crate::Result<usize> {
let socket = self.sockets.get_mut::<tcp::Socket>(handle.0);
// Not ready to receive yet (SynSent, Listen, Closed) — return 0,
// don't propagate as a fatal error. The poll loop will retry.
if !socket.may_recv() {
// If recv side is fully closed (Finished), tell the caller so
// they stop polling.
if socket.state() == tcp::State::Closed
|| socket.state() == tcp::State::CloseWait
|| socket.state() == tcp::State::TimeWait
|| socket.state() == tcp::State::Closing
|| socket.state() == tcp::State::LastAck
{
// Recv side may still have buffered data; try one drain.
if socket.recv_queue() > 0 {
return socket
.recv_slice(buf)
.map_err(|e| crate::Error::WireGuard(format!("TCP recv: {e:?}")));
}
return Err(crate::Error::WireGuard("TCP recv: closed".into()));
}
return Ok(0);
}
socket
.recv_slice(buf)
.map_err(|e| crate::Error::WireGuard(format!("TCP recv: {e:?}")))
}
/// Write data to a TCP socket.
pub fn tcp_send(
&mut self,