snolc / src/runtime.rs

github - master - back to tree - upstream
use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use tokio::io::AsyncWriteExt; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{Semaphore, watch}; use tokio::time::interval; use crate::carrier::{Accepted, CarrierRuntime}; use crate::config::{Config, Role, UnknownClient}; use crate::error::{Error, Result}; use crate::identity::Identity; use crate::inbound::{http as http_inbound, socks as socks_inbound}; use crate::logging::{Level, Logger}; use crate::mirror; use crate::mux; use crate::outbound::Connector; use crate::protection::ProtectionMode; use crate::routing::RuleSet; use crate::tunnel::{ self, ClientSession, encode_plain_frame, read_client_hello, server_handshake_with_hello, }; /// Fixed delay between client reconnect attempts. Not exposed in the config; /// `reconnect` only governs whether and how many attempts are made. const RECONNECT_DELAY: Duration = Duration::from_secs(1); /// Client-side yamux limits. The client never receives inbound streams, so /// these only bound how many outbound proxied streams may be open at once. const CLIENT_MAX_STREAMS: usize = 256; /// Interval on which the server re-reads its config file to pick up client /// key revocations/additions, comfortably under the 1-second requirement. const RELOAD_INTERVAL: Duration = Duration::from_millis(200); pub async fn run(path: &Path, logger: Logger) -> Result<()> { let config = Config::load(path)?; let shutdown_logger = logger.clone(); tokio::select! { result = async { match config.role { Role::Client => run_client(path.to_path_buf(), config, logger).await, Role::Server => run_server(path.to_path_buf(), config, logger).await, } } => result, result = shutdown_signal() => { result?; shutdown_logger.record(Level::Debug, "shutdown signal received"); Ok(()) } } } #[cfg(unix)] async fn shutdown_signal() -> std::io::Result<()> { let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; tokio::select! { result = tokio::signal::ctrl_c() => result, _ = terminate.recv() => Ok(()), } } #[cfg(not(unix))] async fn shutdown_signal() -> std::io::Result<()> { tokio::signal::ctrl_c().await } // --------------------------------------------------------------------- // Client // --------------------------------------------------------------------- async fn run_client(path: PathBuf, config: Config, logger: Logger) -> Result<()> { let identity = Identity::from_base64( config .protection .key .as_deref() .ok_or_else(|| Error::Config("client private key is required".to_owned()))?, )?; let mode = config.protection.mode; let heartbeat = Duration::from_secs(config.heartbeat); let remote = config .remote .clone() .ok_or_else(|| Error::Config("client remote is required".to_owned()))?; let routing = config .routing .as_deref() .map(RuleSet::load) .transpose()? .map(Arc::new); let (sessions_tx, sessions_rx) = watch::channel::>(None); let connector = Connector::new(sessions_rx, routing); let mut inbounds = tokio::task::JoinSet::new(); if let Some(socks) = config.socks.clone().filter(|socks| socks.listen) { let connector = connector.clone(); let logger = logger.clone(); inbounds.spawn(async move { socks_inbound::serve(socks, connector, logger).await }); } if let Some(http) = config.http.clone().filter(|http| http.listen) { let connector = connector.clone(); let logger = logger.clone(); inbounds.spawn(async move { http_inbound::serve(http, connector, logger).await }); } if let Some(tun) = config.tun.clone() { let connector = connector.clone(); let logger = logger.clone(); inbounds.spawn(async move { crate::tun::run(tun, connector, logger).await }); } if inbounds.is_empty() { return Err(Error::Config( "client requires an active inbound".to_owned(), )); } let carrier = CarrierRuntime::client(config.carrier.clone()); let mut attempts: i32 = 0; loop { let outcome = connect_once( &remote, &carrier, &identity, mode, heartbeat, &sessions_tx, &logger, ) .await; if let Err(error) = &outcome { logger.record(Level::Warning, &format!("session ended: {error}")); } match config.reconnect { Some(0) | None => { return outcome; } Some(-1) => {} Some(limit) => { attempts += 1; if attempts >= limit { return outcome.and(Err(Error::Carrier( "reconnect attempts exhausted".to_owned(), ))); } } } tokio::select! { () = tokio::time::sleep(RECONNECT_DELAY) => {} Some(result) = inbounds.join_next() => { return match result { Ok(inner) => inner, Err(error) => Err(Error::Runtime(format!("inbound task failed: {error}"))), }; } } // Reload the config so key rotation / remote changes on disk apply // to the next connection attempt without a full process restart. if let Ok(fresh) = Config::load(&path) && fresh.role == Role::Client { logger.record(Level::Debug, "reloaded client configuration"); } } } async fn connect_once( remote: &str, carrier: &CarrierRuntime, identity: &Identity, mode: ProtectionMode, heartbeat: Duration, sessions_tx: &watch::Sender>, logger: &Logger, ) -> Result<()> { let mut stream = carrier.connect(remote).await?; let master = tunnel::client_handshake(&mut stream, identity, mode).await?; logger.record(Level::Debug, "handshake established"); let window = CLIENT_MAX_STREAMS * yamux::DEFAULT_CREDIT as usize; let (mux, mut incoming) = mux::spawn(stream, yamux::Mode::Client, CLIENT_MAX_STREAMS, window)?; // The client never expects the server to open streams. Drain and drop // any that arrive so the yamux driver never blocks on a full channel. tokio::spawn(async move { while incoming.recv().await.is_some() { // Unexpected inbound stream from the server; nothing to do with it. } }); let session = ClientSession::new(mux, master, mode); let _ = sessions_tx.send(Some(session.clone())); let result = session.monitor_heartbeat(heartbeat).await; let _ = sessions_tx.send(None); session.abort().await; result } // --------------------------------------------------------------------- // Server // --------------------------------------------------------------------- #[derive(Clone)] struct ServerAuth { allowed: Vec<[u8; 32]>, unknown: UnknownClient, /// Response cache for the unknown-client mirror fallback, when enabled. /// Rebuilt (and its previous contents dropped) on every config reload. mirror_cache: Option>, } impl ServerAuth { fn new(allowed: Vec<[u8; 32]>, unknown: UnknownClient) -> Self { let mirror_cache = mirror_config(&unknown) .filter(|mirror| mirror.cache) .map(|mirror| Arc::new(mirror::Cache::new(Duration::from_secs(mirror.ttl)))); Self { allowed, unknown, mirror_cache, } } } fn mirror_config(unknown: &UnknownClient) -> Option<&crate::config::MirrorConfig> { match unknown { UnknownClient::Site { mirror, .. } | UnknownClient::Service { mirror, .. } => Some(mirror), UnknownClient::Error | UnknownClient::File { .. } => None, } } async fn run_server(path: PathBuf, config: Config, logger: Logger) -> Result<()> { let bind = config .bind .clone() .ok_or_else(|| Error::Config("server bind is required".to_owned()))?; let limits = config .limits .clone() .ok_or_else(|| Error::Config("server limits are required".to_owned()))?; let mode = config.protection.mode; let heartbeat = Duration::from_secs(config.heartbeat); let allowed = tunnel::decode_public_keys( config .protection .clients .clone() .ok_or_else(|| Error::Config("server clients are required".to_owned()))? .into_iter() .map(|client| client.key), )?; let unknown = config .protection .unknown .clone() .ok_or_else(|| Error::Config("unknown-client behavior is required".to_owned()))?; let (auth_tx, auth_rx) = watch::channel(ServerAuth::new(allowed, unknown)); tokio::spawn(reload_auth(path, auth_tx, logger.clone())); if matches!(config.carrier, crate::config::Carrier::Ssh { .. }) { return run_ssh_server(bind, mode, heartbeat, limits, auth_rx, logger).await; } if matches!(config.carrier, crate::config::Carrier::Webrtc { .. }) { return run_webrtc_server(bind, mode, heartbeat, limits, auth_rx, logger).await; } let carrier = Arc::new(CarrierRuntime::server(config.carrier.clone(), logger.clone()).await?); let listener = TcpListener::bind(&bind).await?; let semaphore = Arc::new(Semaphore::new(limits.connections)); loop { let (stream, peer) = listener.accept().await?; stream.set_nodelay(true)?; let Ok(permit) = semaphore.clone().try_acquire_owned() else { logger.record(Level::Warning, "connection limit reached, dropping client"); continue; }; let carrier = carrier.clone(); let auth = auth_rx.clone(); let limits = limits.clone(); let logger = logger.clone(); tokio::spawn(async move { let _permit = permit; if let Err(error) = handle_connection(stream, carrier, mode, heartbeat, limits, auth, &logger).await { logger.record(Level::Debug, &format!("client {peer}: {error}")); } }); } } async fn reload_auth(path: PathBuf, sender: watch::Sender, logger: Logger) { let mut ticker = interval(RELOAD_INTERVAL); loop { ticker.tick().await; let Ok(config) = Config::load(&path) else { continue; }; if config.role != Role::Server { continue; } let Some(clients) = config.protection.clients else { continue; }; let Some(unknown) = config.protection.unknown else { continue; }; let Ok(allowed) = tunnel::decode_public_keys(clients.into_iter().map(|client| client.key)) else { continue; }; if sender.send(ServerAuth::new(allowed, unknown)).is_err() { return; } let _ = &logger; } } async fn handle_connection( stream: TcpStream, carrier: Arc, mode: ProtectionMode, heartbeat: Duration, limits: crate::config::Limits, auth: watch::Receiver, logger: &Logger, ) -> Result<()> { let stream = match carrier.accept(stream).await? { Accepted::Tunnel(stream) => stream, Accepted::Handled => return Ok(()), }; serve_tunnel(stream, mode, heartbeat, limits, auth, logger).await } /// Runs the snolc handshake and, on success, the multiplexed session, over /// an already carrier-established stream. Shared by every carrier: for /// plain/acme/steal HTTP carriers `stream` comes from one accepted TCP /// connection; for the SSH carrier it comes from one opened SSH channel /// (see [`run_ssh_server`]), of which there may be several per connection. async fn serve_tunnel( mut stream: crate::carrier::BoxedStream, mode: ProtectionMode, heartbeat: Duration, limits: crate::config::Limits, auth: watch::Receiver, logger: &Logger, ) -> Result<()> { let hello = read_client_hello(&mut stream).await?; let identity = crate::handshake::peek_client_identity(&hello.payload)?; let auth = auth.borrow().clone(); if !auth.allowed.contains(&identity) { return respond_to_unknown_client(stream, &hello, &auth, logger).await; } let established = server_handshake_with_hello(&mut stream, hello, mode, &auth.allowed).await?; let window = limits.streams * yamux::DEFAULT_CREDIT as usize; let (server_mux, incoming) = mux::spawn(stream, yamux::Mode::Server, limits.streams, window)?; tunnel::serve_session(incoming, server_mux, established.master, mode, heartbeat).await } /// The SSH carrier's server accept loop. Structurally different from the /// other carriers: russh owns the per-connection protocol loop and hands /// channels to a callback rather than returning a stream synchronously, so /// it cannot go through [`CarrierRuntime::accept`]. `serve_tunnel` runs once /// per opened "session" channel. async fn run_ssh_server( bind: String, mode: ProtectionMode, heartbeat: Duration, limits: crate::config::Limits, auth_rx: watch::Receiver, logger: Logger, ) -> Result<()> { let host_key = Arc::new(crate::ssh::generate_host_key()?); let listener = TcpListener::bind(&bind).await?; let semaphore = Arc::new(Semaphore::new(limits.connections)); loop { let (stream, peer) = listener.accept().await?; stream.set_nodelay(true)?; let Ok(permit) = semaphore.clone().try_acquire_owned() else { logger.record(Level::Warning, "connection limit reached, dropping client"); continue; }; let host_key = host_key.clone(); let auth_for_connection = auth_rx.clone(); let limits_for_connection = limits.clone(); let logger_for_connection = logger.clone(); tokio::spawn(async move { let _permit = permit; let auth_for_channel = auth_for_connection; let limits_for_channel = limits_for_connection; let logger_for_channel = logger_for_connection.clone(); let callback: Arc = Arc::new(move |stream| { let auth = auth_for_channel.clone(); let limits = limits_for_channel.clone(); let logger = logger_for_channel.clone(); tokio::spawn(async move { if let Err(error) = serve_tunnel(stream, mode, heartbeat, limits, auth, &logger).await { logger.record(Level::Debug, &format!("client {peer}: {error}")); } }); }); if let Err(error) = crate::ssh::accept(stream, host_key, callback).await { logger_for_connection.record(Level::Debug, &format!("client {peer}: {error}")); } }); } } /// The WebRTC carrier has a UDP signaling listener and yields one stream per /// established SCTP data channel, so it cannot use the TCP accept loop shared /// by the HTTP carriers. async fn run_webrtc_server( bind: String, mode: ProtectionMode, heartbeat: Duration, limits: crate::config::Limits, auth_rx: watch::Receiver, logger: Logger, ) -> Result<()> { let mut listener = crate::webrtc::Listener::bind(&bind, limits.connections, logger.clone()).await?; loop { let accepted = listener.accept().await?; let auth = auth_rx.clone(); let limits = limits.clone(); let logger = logger.clone(); tokio::spawn(async move { let _permit = accepted.permit; if let Err(error) = serve_tunnel(accepted.stream, mode, heartbeat, limits, auth, &logger).await { logger.record( Level::Debug, &format!("WebRTC client {}: {error}", accepted.peer), ); } }); } } async fn respond_to_unknown_client( mut stream: crate::carrier::BoxedStream, hello: &crate::frame::Frame, auth: &ServerAuth, logger: &Logger, ) -> Result<()> { logger.record( Level::Warning, "rejected connection from an unknown client key", ); match &auth.unknown { UnknownClient::Error => { drop(stream); Err(Error::Authentication("unknown client key".to_owned())) } UnknownClient::File { path } => { let contents = tokio::fs::read(path).await?; stream.write_all(&contents).await?; stream.shutdown().await?; Err(Error::Authentication("unknown client key".to_owned())) } UnknownClient::Site { target, .. } | UnknownClient::Service { target, .. } => { let prefix = encode_plain_frame(hello)?; mirror::serve(stream, target, &prefix, auth.mirror_cache.as_deref()).await?; Err(Error::Authentication("unknown client key".to_owned())) } } } #[cfg(test)] mod tests { use std::net::IpAddr; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio::time::{sleep, timeout}; use super::*; use crate::identity::Identity; async fn free_port() -> u16 { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); listener.local_addr().unwrap().port() } /// Exercises `runtime::run` end to end exactly as the CLI does: real YAML /// files on disk, a real HTTP-carrier TCP connection between client and /// server, the ephemeral handshake, a yamux session, and a SOCKS5 inbound /// proxying traffic to a real TCP echo server. #[tokio::test] async fn client_and_server_runtimes_proxy_a_real_socks5_connection() { let directory = tempfile::tempdir().unwrap(); let identity = Identity::generate().unwrap(); let destination = TcpListener::bind("127.0.0.1:0").await.unwrap(); let destination_address = destination.local_addr().unwrap(); let echo = tokio::spawn(async move { let (mut stream, _) = destination.accept().await.unwrap(); let mut buffer = [0_u8; 5]; stream.read_exact(&mut buffer).await.unwrap(); stream.write_all(&buffer).await.unwrap(); }); let server_port = free_port().await; let socks_port = free_port().await; let server_path = directory.path().join("server.yml"); std::fs::write( &server_path, format!( r#" role: server remote: null bind: "127.0.0.1:{server_port}" carrier: kind: http transport: tcp host: cover.example path: /events tls: null protection: mode: chacha poly key: null clients: - key: {public} unknown: mode: error heartbeat: 1 reconnect: null limits: connections: 4 streams: 8 fragments: 16 memory: 2097152 routing: null tun: null socks: null http: null "#, public = identity.public_base64(), ), ) .unwrap(); let client_path = directory.path().join("client.yml"); std::fs::write( &client_path, format!( r#" role: client remote: "127.0.0.1:{server_port}" bind: null carrier: kind: http transport: tcp host: cover.example path: /events tls: null protection: mode: chacha poly key: {private} clients: null unknown: null heartbeat: 1 reconnect: -1 limits: null routing: null tun: null socks: pass: null user: null host: 127.0.0.1 port: {socks_port} listen: true http: null "#, private = identity.private_base64().as_str(), ), ) .unwrap(); let server = tokio::spawn(async move { run(&server_path, Logger::disabled()).await }); let client = tokio::spawn(async move { run(&client_path, Logger::disabled()).await }); let mut socks_stream = connect_with_retries(socks_port).await; socks_stream.write_all(&[5, 1, 0]).await.unwrap(); let mut method = [0_u8; 2]; socks_stream.read_exact(&mut method).await.unwrap(); assert_eq!(method, [5, 0]); let mut request = vec![5, 1, 0, 1]; let IpAddr::V4(ip) = destination_address.ip() else { unreachable!("loopback listener is always IPv4 here") }; request.extend_from_slice(&ip.octets()); request.extend_from_slice(&destination_address.port().to_be_bytes()); socks_stream.write_all(&request).await.unwrap(); let mut reply = [0_u8; 10]; socks_stream.read_exact(&mut reply).await.unwrap(); assert_eq!(reply[1], 0, "SOCKS5 CONNECT must succeed"); socks_stream.write_all(b"snolc").await.unwrap(); let mut echoed = [0_u8; 5]; socks_stream.read_exact(&mut echoed).await.unwrap(); assert_eq!(&echoed, b"snolc"); drop(socks_stream); echo.await.unwrap(); server.abort(); client.abort(); } /// Same end-to-end flow as above, but through the SSH carrier instead of /// the HTTP one: a real SSH key exchange and session, a "session" /// channel used as the transport, and a real snolc handshake and /// yamux session running inside it. #[tokio::test] async fn client_and_server_runtimes_proxy_over_the_ssh_carrier() { let directory = tempfile::tempdir().unwrap(); let identity = Identity::generate().unwrap(); let destination = TcpListener::bind("127.0.0.1:0").await.unwrap(); let destination_address = destination.local_addr().unwrap(); let echo = tokio::spawn(async move { let (mut stream, _) = destination.accept().await.unwrap(); let mut buffer = [0_u8; 5]; stream.read_exact(&mut buffer).await.unwrap(); stream.write_all(&buffer).await.unwrap(); }); let server_port = free_port().await; let socks_port = free_port().await; let server_path = directory.path().join("server.yml"); std::fs::write( &server_path, format!( r#" role: server remote: null bind: "127.0.0.1:{server_port}" carrier: kind: ssh transport: tcp protection: mode: chacha poly key: null clients: - key: {public} unknown: mode: error heartbeat: 1 reconnect: null limits: connections: 4 streams: 8 fragments: 16 memory: 2097152 routing: null tun: null socks: null http: null "#, public = identity.public_base64(), ), ) .unwrap(); let client_path = directory.path().join("client.yml"); std::fs::write( &client_path, format!( r#" role: client remote: "127.0.0.1:{server_port}" bind: null carrier: kind: ssh transport: tcp protection: mode: chacha poly key: {private} clients: null unknown: null heartbeat: 1 reconnect: -1 limits: null routing: null tun: null socks: pass: null user: null host: 127.0.0.1 port: {socks_port} listen: true http: null "#, private = identity.private_base64().as_str(), ), ) .unwrap(); let server = tokio::spawn(async move { run(&server_path, Logger::disabled()).await }); let client = tokio::spawn(async move { run(&client_path, Logger::disabled()).await }); // The SOCKS listener starts accepting immediately, but the SSH key // exchange + snolc handshake to the server takes a little longer // than the HTTP carrier's near-instant preface; give it a moment // rather than racing the very first CONNECT against it. tokio::time::sleep(Duration::from_millis(500)).await; let mut socks_stream = connect_with_retries(socks_port).await; socks_stream.write_all(&[5, 1, 0]).await.unwrap(); let mut method = [0_u8; 2]; socks_stream.read_exact(&mut method).await.unwrap(); assert_eq!(method, [5, 0]); let mut request = vec![5, 1, 0, 1]; let IpAddr::V4(ip) = destination_address.ip() else { unreachable!("loopback listener is always IPv4 here") }; request.extend_from_slice(&ip.octets()); request.extend_from_slice(&destination_address.port().to_be_bytes()); socks_stream.write_all(&request).await.unwrap(); let mut reply = [0_u8; 10]; socks_stream.read_exact(&mut reply).await.unwrap(); assert_eq!(reply[1], 0, "SOCKS5 CONNECT must succeed"); socks_stream.write_all(b"snolc").await.unwrap(); let mut echoed = [0_u8; 5]; socks_stream.read_exact(&mut echoed).await.unwrap(); assert_eq!(&echoed, b"snolc"); drop(socks_stream); echo.await.unwrap(); server.abort(); client.abort(); } /// The `error` unknown-client fallback must close the connection and /// must not accept an unrecognized identity into a real session. #[tokio::test] async fn unknown_client_error_fallback_closes_the_connection() { let known = Identity::generate().unwrap(); let stranger = Identity::generate().unwrap(); let carrier_config = crate::config::Carrier::Http { transport: crate::config::Transport::Tcp, host: "cover.example".to_owned(), path: "/events".to_owned(), tls: None, }; let limits = crate::config::Limits { connections: 4, streams: 8, fragments: 16, memory: 8 * yamux::DEFAULT_CREDIT as usize, }; let (_auth_tx, auth_rx) = watch::channel(ServerAuth::new(vec![known.public()], UnknownClient::Error)); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let server_carrier = Arc::new( CarrierRuntime::server(carrier_config, Logger::disabled()) .await .unwrap(), ); let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); handle_connection( stream, server_carrier, ProtectionMode::ChaChaPoly, Duration::from_secs(1), limits, auth_rx, &Logger::disabled(), ) .await }); let mut client_stream = TcpStream::connect(address).await.unwrap(); crate::carrier::client_http_preface(&mut client_stream, "cover.example", "/events") .await .unwrap(); let master_attempt = tunnel::client_handshake(&mut client_stream, &stranger, ProtectionMode::ChaChaPoly) .await; // The server closes the socket instead of completing the handshake, // so the client observes an I/O or protocol error, never a session. assert!(master_attempt.is_err()); let outcome = timeout(Duration::from_secs(2), server) .await .unwrap() .unwrap(); assert!(matches!(outcome, Err(Error::Authentication(_)))); } async fn connect_with_retries(port: u16) -> TcpStream { for _ in 0..50 { if let Ok(stream) = TcpStream::connect(("127.0.0.1", port)).await { return stream; } sleep(Duration::from_millis(50)).await; } panic!("SOCKS inbound never became reachable"); } }