diff --git a/Cargo.lock b/Cargo.lock index 481bf81a..c8f4d379 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/src/vpn_cmds.rs b/src/vpn_cmds.rs index cc70bf58..3a2e9bff 100644 --- a/src/vpn_cmds.rs +++ b/src/vpn_cmds.rs @@ -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)); diff --git a/sunbeam-net/Cargo.toml b/sunbeam-net/Cargo.toml index 8e02fafe..cc45b1fa 100644 --- a/sunbeam-net/Cargo.toml +++ b/sunbeam-net/Cargo.toml @@ -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"] } diff --git a/sunbeam-net/src/config.rs b/sunbeam-net/src/config.rs index 44b2bf97..e9fd0373 100644 --- a/sunbeam-net/src/config.rs +++ b/sunbeam-net/src/config.rs @@ -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, } diff --git a/sunbeam-net/src/control/client.rs b/sunbeam-net/src/control/client.rs index ad151d91..224a94f3 100644 --- a/sunbeam-net/src/control/client.rs +++ b/sunbeam-net/src/control/client.rs @@ -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 { - // 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( + mut noise_stream: noise::stream::NoiseStream, + ) -> crate::Result + 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 { /// /// Headscale/Tailscale returns JSON: `{"publicKey":"mkey:","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); diff --git a/sunbeam-net/src/daemon/lifecycle.rs b/sunbeam-net/src/daemon/lifecycle.rs index fb2aee3f..546258a5 100644 --- a/sunbeam-net/src/daemon/lifecycle.rs +++ b/sunbeam-net/src/daemon/lifecycle.rs @@ -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(); diff --git a/sunbeam-net/src/derp/client.rs b/sunbeam-net/src/derp/client.rs index 27eaec4e..b4fb9dcf 100644 --- a/sunbeam-net/src/derp/client.rs +++ b/sunbeam-net/src/derp/client.rs @@ -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 DerpTransport for T {} + /// Client for a single DERP relay server. pub struct DerpClient { - inner: Framed, + inner: Framed, 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::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 { 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 = 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\ diff --git a/sunbeam-net/src/error.rs b/sunbeam-net/src/error.rs index 155effa1..859705bf 100644 --- a/sunbeam-net/src/error.rs +++ b/sunbeam-net/src/error.rs @@ -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), diff --git a/sunbeam-net/src/lib.rs b/sunbeam-net/src/lib.rs index 0a9d666c..1cc257a5 100644 --- a/sunbeam-net/src/lib.rs +++ b/sunbeam-net/src/lib.rs @@ -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; diff --git a/sunbeam-net/src/noise/handshake.rs b/sunbeam-net/src/noise/handshake.rs index c776cf3a..bd79e0e8 100644 --- a/sunbeam-net/src/noise/handshake.rs +++ b/sunbeam-net/src/noise/handshake.rs @@ -124,13 +124,22 @@ fn protocol_version_prologue(version: u16) -> Vec { /// 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( + stream: &mut S, + host_header: &str, machine_private: &StaticSecret, machine_public: &PublicKey, server_public: &PublicKey, -) -> crate::Result { +) -> crate::Result +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, diff --git a/sunbeam-net/src/noise/stream.rs b/sunbeam-net/src/noise/stream.rs index 86b108e6..7a15ca25 100644 --- a/sunbeam-net/src/noise/stream.rs +++ b/sunbeam-net/src/noise/stream.rs @@ -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, +/// +/// 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 { + inner: Framed, 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 Send for NoiseStream {} -impl Unpin for NoiseStream {} +impl Unpin for NoiseStream {} -impl std::fmt::Debug for NoiseStream { +impl std::fmt::Debug for NoiseStream { 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 NoiseStream +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, @@ -194,7 +200,10 @@ impl NoiseStream { } } -impl AsyncRead for NoiseStream { +impl AsyncRead for NoiseStream +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 AsyncWrite for NoiseStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ fn poll_write( self: Pin<&mut Self>, _cx: &mut Context<'_>, diff --git a/sunbeam-net/src/proxy/engine.rs b/sunbeam-net/src/proxy/engine.rs index ae6c426c..6f0cfc6e 100644 --- a/sunbeam-net/src/proxy/engine.rs +++ b/sunbeam-net/src/proxy/engine.rs @@ -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() } } diff --git a/sunbeam-net/src/tls.rs b/sunbeam-net/src/tls.rs new file mode 100644 index 00000000..57911371 --- /dev/null +++ b/sunbeam-net/src/tls.rs @@ -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> { + 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 { + 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, + ] + } +} diff --git a/sunbeam-net/src/wg/socket.rs b/sunbeam-net/src/wg/socket.rs index 310e2835..4ca12809 100644 --- a/sunbeam-net/src/wg/socket.rs +++ b/sunbeam-net/src/wg/socket.rs @@ -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 { let socket = self.sockets.get_mut::(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,