From 52c2509bac668420b0e975095fa9a18dae9a9140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B7=9D=E6=84=8F=20=C2=B7=20MiChongs?= <77006751+MiChongs@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:36:48 +0800 Subject: [PATCH] feat(snell): implement full client and server protocol --- Cargo.lock | 24 + Cargo.toml | 1 + crates/core-config/src/model.rs | 75 + crates/core-config/src/profile.rs | 1 + crates/core-config/src/runtime_plan.rs | 195 +++ crates/core-feeds/src/parser.rs | 34 + crates/core-inbound/src/lib.rs | 2 + crates/core-inbound/src/snell.rs | 812 +++++++++++ crates/core-outbound/Cargo.toml | 1 + crates/core-outbound/src/proto/mod.rs | 1 + crates/core-outbound/src/proto/snell.rs | 1256 +++++++++-------- crates/core-outbound/src/proto/snell_codec.rs | 1034 ++++++++++++++ crates/core-outbound/src/registry.rs | 95 +- crates/core-outbound/src/transport/mod.rs | 1 + .../src/transport/simple_obfs.rs | 689 +++++++++ crates/wuther-core/src/host_resources.rs | 40 + crates/wuther-core/src/main.rs | 70 +- docs/CONFIGURATION.md | 8 +- docs/FEATURES.md | 4 +- docs/README.md | 1 + docs/SNELL.md | 95 ++ 21 files changed, 3842 insertions(+), 597 deletions(-) create mode 100644 crates/core-inbound/src/snell.rs create mode 100644 crates/core-outbound/src/proto/snell_codec.rs create mode 100644 crates/core-outbound/src/transport/simple_obfs.rs create mode 100644 docs/SNELL.md diff --git a/Cargo.lock b/Cargo.lock index 9a646c9..10ba169 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,6 +165,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -1188,6 +1200,7 @@ dependencies = [ "aes", "aes-gcm", "anyhow", + "argon2", "async-trait", "base64 0.22.1", "blake2", @@ -3442,6 +3455,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" diff --git a/Cargo.toml b/Cargo.toml index aec7f94..3abafe7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,6 +97,7 @@ redb = "2.2" # 加密 / TLS / WS / 协议层 aes-gcm = "0.10" chacha20poly1305 = "0.10" +argon2 = "0.5" hkdf = "0.12" sha2 = "0.10" hmac = "0.12" diff --git a/crates/core-config/src/model.rs b/crates/core-config/src/model.rs index 3122d12..8645c50 100644 --- a/crates/core-config/src/model.rs +++ b/crates/core-config/src/model.rs @@ -212,6 +212,9 @@ pub struct Listen { alias = "splithttp" )] pub xhttp: Option, + /// Snell v1-v5 服务端监听。既接受单个对象,也接受对象数组。 + #[serde(default)] + pub snell: Option, #[serde(default)] pub share: Option, #[serde(default)] @@ -222,6 +225,78 @@ pub struct Listen { pub reality: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SnellListenSet { + One(SnellListen), + Many(Vec), +} + +impl SnellListenSet { + pub fn into_vec(self) -> Vec { + match self { + Self::One(listener) => vec![listener], + Self::Many(listeners) => listeners, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SnellListen { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_snell_listen_address", alias = "host")] + pub address: String, + pub port: u16, + pub psk: String, + #[serde(default = "default_snell_inbound_version")] + pub version: u8, + #[serde(default)] + pub udp: bool, + #[serde(default, rename = "obfs-opts", alias = "obfs_opts")] + pub obfs_opts: Option, + #[serde( + default = "default_snell_handshake_timeout", + rename = "handshake-timeout", + alias = "handshake_timeout", + with = "humantime_serde" + )] + pub handshake_timeout: Duration, + #[serde( + default = "default_snell_max_connections", + rename = "max-connections", + alias = "max_connections" + )] + pub max_connections: usize, + #[serde(default)] + pub tag: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SnellObfsListen { + pub mode: String, + #[serde(default)] + pub host: String, +} + +fn default_snell_listen_address() -> String { + "127.0.0.1".into() +} + +fn default_snell_inbound_version() -> u8 { + 4 +} + +fn default_snell_handshake_timeout() -> Duration { + Duration::from_secs(10) +} + +fn default_snell_max_connections() -> usize { + 4096 +} + /// Xray REALITY 服务端监听配置。 /// /// 字段名同时接受 Xray 的 camelCase 与本项目常用的 snake/kebab 写法; diff --git a/crates/core-config/src/profile.rs b/crates/core-config/src/profile.rs index 6387258..ab79185 100644 --- a/crates/core-config/src/profile.rs +++ b/crates/core-config/src/profile.rs @@ -16,6 +16,7 @@ pub fn apply_defaults(cfg: &mut UserConfig) { share: None, auth: vec![], reality: vec![], + snell: None, }); if listen.local.is_none() { listen.local = match profile { diff --git a/crates/core-config/src/runtime_plan.rs b/crates/core-config/src/runtime_plan.rs index 54b8f3b..558eee3 100644 --- a/crates/core-config/src/runtime_plan.rs +++ b/crates/core-config/src/runtime_plan.rs @@ -44,10 +44,38 @@ pub struct ListenPlan { pub reality: Vec, pub panel: Option, pub xhttp: Vec, + pub snell: Vec, pub share: Share, pub auth: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SnellListenPlan { + pub enabled: bool, + pub address: String, + pub port: u16, + pub psk: String, + pub version: u8, + pub udp: bool, + pub obfs: Option, + pub handshake_timeout: Duration, + pub max_connections: usize, + pub tag: String, +} + +impl SnellListenPlan { + pub fn socket_addr(&self) -> ConfigResult { + format!("{}:{}", self.address, self.port) + .parse() + .map_err(|_| { + ConfigError::invalid(format!( + "非法 Snell 监听地址: {}:{}", + self.address, self.port + )) + }) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MixedListen { pub host: String, @@ -273,6 +301,7 @@ fn compile_listen(cfg: &UserConfig) -> ConfigResult { local: None, panel: None, xhttp: None, + snell: None, share: None, auth: vec![], reality: vec![], @@ -341,6 +370,7 @@ fn compile_listen(cfg: &UserConfig) -> ConfigResult { } let xhttp = compile_xhttp_listeners(listen.xhttp)?; + let snell = compile_snell_listeners(listen.snell)?; let auth = listen .auth @@ -360,11 +390,100 @@ fn compile_listen(cfg: &UserConfig) -> ConfigResult { reality, panel, xhttp, + snell, share, auth, }) } +fn compile_snell_listeners( + listeners: Option, +) -> ConfigResult> { + let listeners = listeners.map(SnellListenSet::into_vec).unwrap_or_default(); + let mut plans = Vec::with_capacity(listeners.len()); + let mut tags = std::collections::HashSet::new(); + let mut sockets = std::collections::HashSet::new(); + for (index, listener) in listeners.into_iter().enumerate() { + let path = format!("listen.snell[{index}]"); + if listener.enabled { + if listener.port == 0 { + return Err(ConfigError::invalid(format!("{path}.port 不能为 0"))); + } + if listener.psk.is_empty() { + return Err(ConfigError::invalid(format!("{path}.psk 不能为空"))); + } + if !(1..=5).contains(&listener.version) { + return Err(ConfigError::invalid(format!( + "{path}.version 只支持 1、2、3、4、5" + ))); + } + if listener.udp && listener.version < 3 { + return Err(ConfigError::invalid(format!( + "{path}.udp 需要 Snell version 3、4 或 5" + ))); + } + if listener.handshake_timeout.is_zero() { + return Err(ConfigError::invalid(format!( + "{path}.handshake-timeout 必须大于 0" + ))); + } + if listener.max_connections == 0 || listener.max_connections > 65_535 { + return Err(ConfigError::invalid(format!( + "{path}.max-connections 必须在 1..=65535" + ))); + } + } + if let Some(obfs) = &listener.obfs_opts { + if !matches!(obfs.mode.to_ascii_lowercase().as_str(), "http" | "tls") { + return Err(ConfigError::invalid(format!( + "{path}.obfs-opts.mode 只支持 http 或 tls" + ))); + } + if obfs.host.trim().is_empty() { + return Err(ConfigError::invalid(format!( + "{path}.obfs-opts.host 不能为空" + ))); + } + } + let tag = listener + .tag + .unwrap_or_else(|| format!("snell-{}", index + 1)); + if !tags.insert(tag.clone()) { + return Err(ConfigError::invalid(format!( + "{path}.tag `{tag}` 与其它 Snell 监听重复" + ))); + } + if listener.enabled { + let socket = format!("{}:{}", listener.address, listener.port) + .parse::() + .map_err(|_| { + ConfigError::invalid(format!( + "{path} 监听地址非法: {}:{}", + listener.address, listener.port + )) + })?; + if !sockets.insert(socket) { + return Err(ConfigError::invalid(format!( + "{path} 与其它 Snell 监听占用相同地址 {socket}" + ))); + } + } + plans.push(SnellListenPlan { + enabled: listener.enabled, + address: listener.address, + port: listener.port, + psk: listener.psk, + version: listener.version, + udp: listener.udp, + obfs: listener.obfs_opts, + handshake_timeout: listener.handshake_timeout, + max_connections: listener.max_connections, + tag, + }); + } + Ok(plans) +} + fn compile_reality_listeners(listeners: &[RealityListen]) -> ConfigResult> { use base64::Engine as _; @@ -4578,4 +4697,80 @@ route: .to_string(); assert!(error.contains("循环链"), "error={error}"); } + + #[test] + fn snell_listener_single_and_array_forms_preserve_all_fields() { + let plan = compile_cfg( + r#" +version: 1 +profile: server +listen: + panel: false + snell: + - address: 127.0.0.1 + port: 8388 + psk: first-secret + version: 3 + udp: true + handshake-timeout: 7s + max-connections: 128 + tag: snell-v3 + - address: 127.0.0.1 + port: 8389 + psk: second-secret + version: 5 + udp: true + obfs-opts: {mode: tls, host: cdn.example.com} + tag: snell-v5 +route: {preset: direct, final: direct} +"#, + ); + assert_eq!(plan.listen.snell.len(), 2); + assert_eq!(plan.listen.snell[0].version, 3); + assert_eq!(plan.listen.snell[0].handshake_timeout.as_secs(), 7); + assert_eq!(plan.listen.snell[0].max_connections, 128); + let obfs = plan.listen.snell[1].obfs.as_ref().unwrap(); + assert_eq!(obfs.mode, "tls"); + assert_eq!(obfs.host, "cdn.example.com"); + + let plan = compile_cfg( + r#" +version: 1 +profile: server +listen: + panel: false + snell: + port: 8390 + psk: single-secret + version: 4 +route: {preset: direct, final: direct} +"#, + ); + assert_eq!(plan.listen.snell.len(), 1); + assert_eq!(plan.listen.snell[0].tag, "snell-1"); + } + + #[test] + fn snell_listener_rejects_invalid_feature_combinations() { + for (fragment, expected) in [ + ( + "port: 8388\n psk: secret\n version: 2\n udp: true", + "udp", + ), + ("port: 8388\n psk: secret\n version: 6", "version"), + ( + "port: 8388\n psk: secret\n obfs-opts: {mode: websocket}", + "obfs-opts.mode", + ), + ("port: 8388\n psk: ''", "psk"), + ] { + let yaml = format!( + "version: 1\nprofile: server\nlisten:\n panel: false\n snell:\n {fragment}\nroute: {{preset: direct, final: direct}}\n" + ); + let mut config: UserConfig = serde_yaml::from_str(&yaml).unwrap(); + apply_defaults(&mut config); + let error = compile(config).unwrap_err().to_string(); + assert!(error.contains(expected), "error={error}"); + } + } } diff --git a/crates/core-feeds/src/parser.rs b/crates/core-feeds/src/parser.rs index 9235136..d4f9327 100644 --- a/crates/core-feeds/src/parser.rs +++ b/crates/core-feeds/src/parser.rs @@ -252,6 +252,7 @@ fn clash_proxy_to_node(m: &serde_yaml::Mapping) -> Option { &mut node.params, "ech-", ); + flatten_transport_opts(m, "obfs-opts", &["mode", "host"], &mut node.params, "obfs-"); // 5. ws-opts 嵌套 path / headers(headers 是 map) if let Some(ws_opts) = g("ws-opts").and_then(|v| v.as_mapping().cloned()) { @@ -485,6 +486,39 @@ proxies: ); } + #[test] + fn parse_clash_snell_preserves_all_protocol_and_obfs_fields() { + let yaml = r#" +proxies: + - name: Snell + type: snell + server: snell.example.com + port: 443 + psk: secret + version: 4 + udp: true + reuse: true + obfs-opts: + mode: tls + host: cdn.example.com +"#; + let nodes = parse_feed_payload(yaml.as_bytes(), FormatHint::Auto); + assert_eq!(nodes.len(), 1); + let node = &nodes[0]; + assert_eq!(node.params.get("psk").map(String::as_str), Some("secret")); + assert_eq!(node.params.get("version").map(String::as_str), Some("4")); + assert_eq!(node.params.get("reuse").map(String::as_str), Some("1")); + assert_eq!( + node.params.get("obfs-mode").map(String::as_str), + Some("tls") + ); + assert_eq!( + node.params.get("obfs-host").map(String::as_str), + Some("cdn.example.com") + ); + assert!(node.udp); + } + #[test] fn keep_drop_rename_dedup() { let nodes = parse_feed_payload( diff --git a/crates/core-inbound/src/lib.rs b/crates/core-inbound/src/lib.rs index a1e8b6e..131badb 100644 --- a/crates/core-inbound/src/lib.rs +++ b/crates/core-inbound/src/lib.rs @@ -10,6 +10,7 @@ pub mod listener; pub mod mixed; pub mod privilege; pub mod reality; +pub mod snell; pub mod vless; pub mod xhttp; mod xhttp_body_budget; @@ -23,6 +24,7 @@ pub use privilege::{ PrivilegeLevel, PrivilegeReport, ensure_best_effort_privilege, try_request_root_android, }; pub use reality::{RealityListener, run_reality}; +pub use snell::{SnellListenerHandle, start_snell_listener, start_snell_listeners}; pub use vless::{VlessConnectionContext, VlessInboundConfig, serve_vless_stream}; pub use xhttp_listener::{XhttpListenerHandle, start_xhttp_listener, start_xhttp_listeners}; pub use xhttp_tls::{XrayServerTlsAcceptor, XrayServerTlsCarrier, XrayServerTlsStream}; diff --git a/crates/core-inbound/src/snell.rs b/crates/core-inbound/src/snell.rs new file mode 100644 index 0000000..6b239fe --- /dev/null +++ b/crates/core-inbound/src/snell.rs @@ -0,0 +1,812 @@ +//! Complete Snell v1-v5 inbound. +//! +//! The listener shares the authenticated record layer and simple-obfs +//! implementation with the outbound, supports TCP, v3+ UDP and the sequential +//! connection reuse negotiated by command 5. + +use std::{ + collections::HashMap, + io, + net::{IpAddr, SocketAddr}, + sync::Arc, +}; + +use core_config::{model::SnellObfsListen, runtime_plan::SnellListenPlan}; +use core_outbound::{ + adapter::{BoxedStream, UdpSocketLike}, + proto::{ + snell::{ + SnellRequest, encode_udp_response, error_reply, parse_request_header, + parse_udp_request, pong_reply, tunnel_reply, + }, + snell_codec::{ + MAX_FRAME_LENGTH, SnellFrameEvent, SnellReadHalf, SnellStream, SnellVersion, + SnellWriteHalf, + }, + }, + transport::simple_obfs::{SimpleObfsMode, SimpleObfsStream}, +}; +use core_runtime::{InboundMetadata, ListenerHandler, Runtime}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + sync::{Mutex, Semaphore, mpsc}, + task::{JoinHandle, JoinSet}, +}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +const MAX_UDP_TARGETS: usize = 64; +const UDP_QUEUE_DEPTH: usize = 32; + +#[derive(Clone)] +struct SnellServerConfig { + password: Arc<[u8]>, + version: SnellVersion, + udp: bool, + obfs: Option, + handshake_timeout: std::time::Duration, + tag: String, +} + +pub struct SnellListenerHandle { + tag: String, + local_addr: SocketAddr, + shutdown: CancellationToken, + task: Option>>, +} + +impl SnellListenerHandle { + pub fn tag(&self) -> &str { + &self.tag + } + + pub fn local_addr(&self) -> SocketAddr { + self.local_addr + } + + pub fn is_finished(&self) -> bool { + self.task.as_ref().is_none_or(JoinHandle::is_finished) + } + + pub async fn shutdown(&mut self) -> io::Result<()> { + self.shutdown.cancel(); + let Some(task) = self.task.take() else { + return Ok(()); + }; + task.await + .map_err(|error| io::Error::other(format!("Snell listener task failed: {error}")))? + } +} + +impl Drop for SnellListenerHandle { + fn drop(&mut self) { + self.shutdown.cancel(); + if let Some(task) = self.task.take() { + task.abort(); + } + } +} + +pub async fn start_snell_listeners( + plans: &[SnellListenPlan], + runtime: Arc, +) -> io::Result> { + let mut handles = Vec::new(); + for plan in plans.iter().filter(|plan| plan.enabled) { + match start_snell_listener(plan, Arc::clone(&runtime)).await { + Ok(handle) => handles.push(handle), + Err(error) => { + for handle in &mut handles { + let _ = handle.shutdown().await; + } + return Err(io::Error::new( + error.kind(), + format!("Snell listener `{}` startup failed: {error}", plan.tag), + )); + } + } + } + Ok(handles) +} + +pub async fn start_snell_listener( + plan: &SnellListenPlan, + runtime: Arc, +) -> io::Result { + validate_plan(plan)?; + let address = plan + .socket_addr() + .map_err(|error| invalid_input(error.to_string()))?; + let listener = TcpListener::bind(address).await?; + let local_addr = listener.local_addr()?; + let config = Arc::new(SnellServerConfig { + password: Arc::from(plan.psk.as_bytes()), + version: SnellVersion::parse(plan.version).map_err(invalid_input)?, + udp: plan.udp, + obfs: plan + .obfs + .as_ref() + .map(|obfs| server_obfs(obfs, plan.port)) + .transpose()?, + handshake_timeout: plan.handshake_timeout, + tag: plan.tag.clone(), + }); + let shutdown = CancellationToken::new(); + let task = tokio::spawn(run_listener( + listener, + config, + runtime, + Arc::new(Semaphore::new(plan.max_connections)), + shutdown.clone(), + )); + info!( + target: "inbound::snell", + tag = %plan.tag, + addr = %local_addr, + version = plan.version, + udp = plan.udp, + obfs = plan.obfs.as_ref().map(|value| value.mode.as_str()).unwrap_or("none"), + "Snell listener ready" + ); + Ok(SnellListenerHandle { + tag: plan.tag.clone(), + local_addr, + shutdown, + task: Some(task), + }) +} + +fn validate_plan(plan: &SnellListenPlan) -> io::Result<()> { + if !plan.enabled { + return Err(invalid_input("cannot start a disabled Snell listener")); + } + if plan.psk.is_empty() { + return Err(invalid_input("Snell listener PSK must not be empty")); + } + let version = SnellVersion::parse(plan.version).map_err(invalid_input)?; + if plan.udp && !version.supports_udp() { + return Err(invalid_input(format!( + "Snell v{} does not support UDP", + version.number() + ))); + } + if plan.handshake_timeout.is_zero() { + return Err(invalid_input("Snell handshake timeout must be non-zero")); + } + if plan.max_connections == 0 { + return Err(invalid_input( + "Snell max connections must be greater than zero", + )); + } + Ok(()) +} + +fn server_obfs(obfs: &SnellObfsListen, port: u16) -> io::Result { + match obfs.mode.to_ascii_lowercase().as_str() { + "http" => Ok(SimpleObfsMode::Http { + host: obfs.host.clone(), + port, + }), + "tls" => Ok(SimpleObfsMode::Tls { + host: obfs.host.clone(), + }), + mode => Err(invalid_input(format!( + "unsupported Snell simple-obfs mode `{mode}`" + ))), + } +} + +async fn run_listener( + listener: TcpListener, + config: Arc, + runtime: Arc, + permits: Arc, + shutdown: CancellationToken, +) -> io::Result<()> { + let mut connections = JoinSet::new(); + loop { + tokio::select! { + _ = shutdown.cancelled() => break, + Some(result) = connections.join_next(), if !connections.is_empty() => { + match result { + Ok(Ok(())) => {} + Ok(Err(error)) => debug!(target: "inbound::snell", %error, "Snell connection closed with error"), + Err(error) if error.is_cancelled() => {} + Err(error) => warn!(target: "inbound::snell", %error, "Snell connection task failed"), + } + } + accepted = listener.accept() => { + let (stream, peer) = accepted?; + let permit = match Arc::clone(&permits).try_acquire_owned() { + Ok(permit) => permit, + Err(_) => { + debug!(target: "inbound::snell", %peer, "Snell connection rejected by limit"); + drop(stream); + continue; + } + }; + let local = stream.local_addr()?; + let config = Arc::clone(&config); + let runtime = Arc::clone(&runtime); + let connection_shutdown = shutdown.child_token(); + connections.spawn(async move { + let _permit = permit; + tokio::select! { + _ = connection_shutdown.cancelled() => Ok(()), + result = serve_connection(stream, peer, local, config, runtime) => result, + } + }); + } + } + } + connections.abort_all(); + while connections.join_next().await.is_some() {} + Ok(()) +} + +async fn serve_connection( + stream: TcpStream, + peer: SocketAddr, + local: SocketAddr, + config: Arc, + runtime: Arc, +) -> io::Result<()> { + let stream: BoxedStream = Box::pin(stream); + let stream: BoxedStream = match &config.obfs { + Some(mode) => Box::pin(SimpleObfsStream::server(stream, mode.clone())), + None => stream, + }; + let mut stream = SnellStream::new(stream, config.password.clone(), config.version, false)?; + let first = tokio::time::timeout(config.handshake_timeout, stream.read_event()) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "Snell handshake timed out"))??; + let (reader, writer) = stream.into_split(); + let first = match first { + SnellFrameEvent::Data(frame) => frame, + SnellFrameEvent::End | SnellFrameEvent::TransportEof => { + return Err(invalid_data("Snell connection ended before command header")); + } + }; + serve_commands( + reader, + writer, + Some(first), + peer, + local, + config, + ListenerHandler::new(runtime), + ) + .await +} + +async fn serve_commands( + mut reader: SnellReadHalf, + mut writer: SnellWriteHalf, + mut first: Option>, + peer: SocketAddr, + local: SocketAddr, + config: Arc, + handler: ListenerHandler, +) -> io::Result<()> { + loop { + let frame = if let Some(frame) = first.take() { + frame + } else { + match reader.read_event().await? { + SnellFrameEvent::Data(frame) => frame, + SnellFrameEvent::End | SnellFrameEvent::TransportEof => return Ok(()), + } + }; + let request = match parse_request_header(&frame) { + Ok(request) => request, + Err(error) => { + let _ = writer + .write_frame(&error_reply(1, &error.to_string())) + .await; + let _ = writer.write_end().await; + return Err(error); + } + }; + match request { + SnellRequest::Ping => { + writer.write_frame(&pong_reply()).await?; + } + SnellRequest::Udp => { + if !config.udp { + writer + .write_frame(&error_reply(2, "UDP is disabled on this listener")) + .await?; + writer.write_end().await?; + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "Snell UDP is disabled", + )); + } + return serve_udp(&mut reader, writer, peer, local, &config, handler).await; + } + SnellRequest::Tcp { host, port, reuse } => { + let reusable = reuse && config.version.supports_reuse(); + if let Err(error) = serve_tcp( + &mut reader, + &mut writer, + peer, + local, + &config.tag, + &host, + port, + &handler, + ) + .await + { + let _ = writer + .write_frame(&error_reply(3, &error.to_string())) + .await; + let _ = writer.write_end().await; + if !reusable { + return Err(error); + } + } + if !reusable { + return Ok(()); + } + } + } + } +} + +async fn serve_tcp( + reader: &mut SnellReadHalf, + writer: &mut SnellWriteHalf, + peer: SocketAddr, + local: SocketAddr, + tag: &str, + host: &str, + port: u16, + handler: &ListenerHandler, +) -> io::Result<()> { + let metadata = InboundMetadata::tcp(tag, "Snell", peer, local, host, port); + let prepared = handler.prepare_tcp(metadata).await?; + writer.write_frame(&tunnel_reply()).await?; + handler.runtime().metrics.inc_connection(); + let (mut target_read, mut target_write) = tokio::io::split(prepared.result.stream); + let guard = &prepared.guard; + let upload = async { + loop { + match reader.read_event().await? { + SnellFrameEvent::Data(frame) => { + target_write.write_all(&frame).await?; + handler.record_upload(guard, frame.len() as u64); + } + SnellFrameEvent::End => { + target_write.shutdown().await?; + return Ok(()); + } + SnellFrameEvent::TransportEof => { + let _ = target_write.shutdown().await; + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "Snell transport closed before logical request end", + )); + } + } + } + }; + let download = async { + let mut buffer = vec![0u8; MAX_FRAME_LENGTH]; + loop { + let length = target_read.read(&mut buffer).await?; + if length == 0 { + writer.write_end().await?; + return Ok(()); + } + writer.write_all(&buffer[..length]).await?; + writer.flush().await?; + handler.record_download(guard, length as u64); + } + }; + let result = tokio::try_join!(upload, download).map(|_| ()); + handler.runtime().metrics.dec_connection(); + result +} + +#[derive(Debug, Clone, Eq, Hash, PartialEq)] +struct UdpTarget { + host: String, + port: u16, +} + +async fn serve_udp( + reader: &mut SnellReadHalf, + mut writer: SnellWriteHalf, + peer: SocketAddr, + local: SocketAddr, + config: &SnellServerConfig, + handler: ListenerHandler, +) -> io::Result<()> { + if config.version.uses_v4_records() { + writer.write_frame(&tunnel_reply()).await?; + } + let writer = Arc::new(Mutex::new(writer)); + let mut sessions = HashMap::>>::new(); + let mut tasks = JoinSet::new(); + let result = loop { + let frame = match reader.read_event().await { + Ok(SnellFrameEvent::Data(frame)) => frame, + Ok(SnellFrameEvent::End | SnellFrameEvent::TransportEof) => break Ok(()), + Err(error) => break Err(error), + }; + let request = match parse_udp_request(&frame) { + Ok(request) => request, + Err(error) => break Err(error), + }; + let target = UdpTarget { + host: request.host, + port: request.port, + }; + if !sessions.contains_key(&target) { + if sessions.len() >= MAX_UDP_TARGETS { + break Err(io::Error::new( + io::ErrorKind::OutOfMemory, + "Snell UDP target limit exceeded", + )); + } + let metadata = InboundMetadata::udp( + &config.tag, + "Snell", + peer, + Some(local), + target.host.clone(), + target.port, + ); + let prepared = match handler.prepare_udp(metadata).await { + Ok(prepared) => prepared, + Err(error) => break Err(error), + }; + let fallback_source = match resolve_udp_source(&target).await { + Ok(source) => source, + Err(error) => break Err(error), + }; + let (sender, receiver) = mpsc::channel(UDP_QUEUE_DEPTH); + sessions.insert(target.clone(), sender); + tasks.spawn(run_udp_target( + prepared.socket.into(), + prepared.guard, + target.clone(), + fallback_source, + receiver, + Arc::clone(&writer), + handler.clone(), + )); + } + let Some(sender) = sessions.get(&target) else { + break Err(io::Error::other( + "Snell UDP target session registration was lost", + )); + }; + if sender.send(request.payload.to_vec()).await.is_err() { + sessions.remove(&target); + break Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "Snell UDP target session stopped", + )); + } + }; + drop(sessions); + tasks.abort_all(); + while tasks.join_next().await.is_some() {} + result +} + +async fn resolve_udp_source(target: &UdpTarget) -> io::Result { + if let Ok(ip) = target.host.parse::() { + return Ok(SocketAddr::new(ip, target.port)); + } + tokio::net::lookup_host((target.host.as_str(), target.port)) + .await? + .next() + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!( + "Snell UDP target {}:{} resolved no address", + target.host, target.port + ), + ) + }) +} + +async fn run_udp_target( + socket: Arc, + guard: core_observe::ConnectionGuard, + target: UdpTarget, + fallback_source: SocketAddr, + mut packets: mpsc::Receiver>, + writer: Arc>, + handler: ListenerHandler, +) -> io::Result<()> { + let mut response = vec![0u8; MAX_FRAME_LENGTH]; + loop { + tokio::select! { + packet = packets.recv() => { + let Some(packet) = packet else { break }; + let sent = socket.send_to(&packet, &target.host, target.port).await?; + if sent != packet.len() { + return Err(io::Error::new( + io::ErrorKind::WriteZero, + "Snell UDP outbound truncated a datagram", + )); + } + handler.record_upload(&guard, sent as u64); + } + received = socket.recv_from_endpoint(&mut response) => { + let (length, source) = received?; + if length == 0 { + continue; + } + let source = source.unwrap_or(fallback_source); + let frame = encode_udp_response(source.ip(), source.port(), &response[..length])?; + let mut writer = writer.lock().await; + writer.write_frame(&frame).await?; + handler.record_download(&guard, length as u64); + } + } + } + socket.close().await +} + +fn invalid_input(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, message.into()) +} + +fn invalid_data(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use core_outbound::{ + proto::{ + snell::{encode_udp_request, parse_udp_response}, + snell_codec::SnellVersion, + }, + transport::simple_obfs::{SimpleObfsMode, SimpleObfsStream}, + }; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream, UdpSocket}, + }; + + use super::*; + + async fn tcp_echo() -> (SocketAddr, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let (mut read, mut write) = tokio::io::split(stream); + let _ = tokio::io::copy(&mut read, &mut write).await; + }); + } + }); + (address, task) + } + + async fn udp_echo() -> (SocketAddr, JoinHandle<()>) { + let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let address = socket.local_addr().unwrap(); + let task = tokio::spawn(async move { + let mut packet = [0u8; 65_535]; + loop { + let Ok((length, peer)) = socket.recv_from(&mut packet).await else { + break; + }; + if socket.send_to(&packet[..length], peer).await.is_err() { + break; + } + } + }); + (address, task) + } + + async fn runtime() -> Arc { + let plan = core_config::loader::load_from_str( + r#" +version: 1 +profile: server +listen: {panel: false} +route: {preset: direct, final: direct} +"#, + ) + .unwrap(); + Arc::new(Runtime::build(plan).unwrap()) + } + + async fn listener( + version: SnellVersion, + udp: bool, + obfs: Option<(&str, &str)>, + ) -> (SnellListenerHandle, Arc) { + let reservation = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = reservation.local_addr().unwrap().port(); + drop(reservation); + let runtime = runtime().await; + let plan = SnellListenPlan { + enabled: true, + address: "127.0.0.1".into(), + port, + psk: "test-password".into(), + version: version.number(), + udp, + obfs: obfs.map(|(mode, host)| SnellObfsListen { + mode: mode.into(), + host: host.into(), + }), + handshake_timeout: std::time::Duration::from_secs(2), + max_connections: 32, + tag: format!("test-v{}", version.number()), + }; + let handle = start_snell_listener(&plan, Arc::clone(&runtime)) + .await + .unwrap(); + (handle, runtime) + } + + async fn client( + address: SocketAddr, + version: SnellVersion, + obfs: Option, + expect_reply: bool, + ) -> SnellStream { + let stream: BoxedStream = Box::pin(TcpStream::connect(address).await.unwrap()); + let stream: BoxedStream = match obfs { + Some(mode) => Box::pin(SimpleObfsStream::client(stream, mode)), + None => stream, + }; + SnellStream::new( + stream, + Arc::from(&b"test-password"[..]), + version, + expect_reply, + ) + .unwrap() + } + + fn tcp_header(target: SocketAddr, reuse: bool) -> Vec { + let host = target.ip().to_string(); + let mut header = vec![1, if reuse { 5 } else { 1 }, 0, host.len() as u8]; + header.extend_from_slice(host.as_bytes()); + header.extend_from_slice(&target.port().to_be_bytes()); + header + } + + async fn logical_echo(stream: &mut SnellStream, target: SocketAddr, reuse: bool, text: &[u8]) { + stream + .write_frame(&tcp_header(target, reuse)) + .await + .unwrap(); + stream.write_all(text).await.unwrap(); + stream.flush().await.unwrap(); + let mut echoed = vec![0u8; text.len()]; + stream.read_exact(&mut echoed).await.unwrap(); + assert_eq!(echoed, text); + stream.write_end().await.unwrap(); + assert_eq!(stream.read_event().await.unwrap(), SnellFrameEvent::End); + } + + #[tokio::test] + async fn every_version_relays_tcp_and_reuse_keeps_one_transport() { + let (target, echo_task) = tcp_echo().await; + for version in [ + SnellVersion::V1, + SnellVersion::V2, + SnellVersion::V3, + SnellVersion::V4, + SnellVersion::V5, + ] { + let (mut listener, runtime) = listener(version, false, None).await; + let reusable = version.supports_reuse(); + let mut stream = client(listener.local_addr(), version, None, true).await; + logical_echo(&mut stream, target, reusable, b"first").await; + if reusable { + logical_echo(&mut stream, target, true, b"second").await; + } + drop(stream); + listener.shutdown().await.unwrap(); + runtime.shutdown().await; + } + echo_task.abort(); + } + + #[tokio::test] + async fn http_and_tls_simple_obfs_relay_real_snell_sessions() { + let (target, echo_task) = tcp_echo().await; + for (mode, client_mode) in [ + ( + "http", + SimpleObfsMode::Http { + host: "cdn.example.com".into(), + port: 443, + }, + ), + ( + "tls", + SimpleObfsMode::Tls { + host: "cdn.example.com".into(), + }, + ), + ] { + let (mut listener, runtime) = + listener(SnellVersion::V4, false, Some((mode, "cdn.example.com"))).await; + let mut stream = client( + listener.local_addr(), + SnellVersion::V4, + Some(client_mode), + true, + ) + .await; + logical_echo(&mut stream, target, false, mode.as_bytes()).await; + listener.shutdown().await.unwrap(); + runtime.shutdown().await; + } + echo_task.abort(); + } + + #[tokio::test] + async fn udp_v3_and_v5_preserve_multiple_target_datagrams() { + let (first_target, first_echo) = udp_echo().await; + let (second_target, second_echo) = udp_echo().await; + for version in [SnellVersion::V3, SnellVersion::V5] { + let (mut listener, runtime) = listener(version, true, None).await; + let mut stream = client( + listener.local_addr(), + version, + None, + version.uses_v4_records(), + ) + .await; + stream.write_frame(&[1, 6, 0]).await.unwrap(); + stream + .write_frame( + &encode_udp_request( + &first_target.ip().to_string(), + first_target.port(), + b"one", + ) + .unwrap(), + ) + .await + .unwrap(); + stream + .write_frame( + &encode_udp_request( + &second_target.ip().to_string(), + second_target.port(), + b"two", + ) + .unwrap(), + ) + .await + .unwrap(); + let mut payloads = BTreeSet::new(); + for _ in 0..2 { + let frame = stream.read_frame().await.unwrap().unwrap(); + let (_, _, payload) = parse_udp_response(&frame).unwrap(); + payloads.insert(payload.to_vec()); + } + assert_eq!(payloads, BTreeSet::from([b"one".to_vec(), b"two".to_vec()])); + stream.write_end().await.unwrap(); + drop(stream); + listener.shutdown().await.unwrap(); + runtime.shutdown().await; + } + first_echo.abort(); + second_echo.abort(); + } +} diff --git a/crates/core-outbound/Cargo.toml b/crates/core-outbound/Cargo.toml index 23f6b6a..e27138a 100644 --- a/crates/core-outbound/Cargo.toml +++ b/crates/core-outbound/Cargo.toml @@ -32,6 +32,7 @@ regex = { workspace = true } # 协议层依赖 aes-gcm = { workspace = true } chacha20poly1305 = { workspace = true } +argon2 = { workspace = true } hkdf = { workspace = true } sha1 = "0.10" sha2 = { workspace = true } diff --git a/crates/core-outbound/src/proto/mod.rs b/crates/core-outbound/src/proto/mod.rs index 1de9905..9a00d8a 100644 --- a/crates/core-outbound/src/proto/mod.rs +++ b/crates/core-outbound/src/proto/mod.rs @@ -26,6 +26,7 @@ pub mod hysteria2; pub mod mieru; pub mod shadowsocks; pub mod snell; +pub mod snell_codec; pub mod ss2022; pub mod ssh; pub mod ssr; diff --git a/crates/core-outbound/src/proto/snell.rs b/crates/core-outbound/src/proto/snell.rs index ce21aa9..ae9b93e 100644 --- a/crates/core-outbound/src/proto/snell.rs +++ b/crates/core-outbound/src/proto/snell.rs @@ -1,102 +1,105 @@ -//! Snell v3 出站 —— 完整实现,与 mihomo / Surge 互通。 +//! Snell v1-v5 client protocol. //! -//! Snell 是 Surge 设计的轻量协议。本实现覆盖 v2 / v3 / v4 完整命令集与 UDP 转发。 -//! -//! ## 加密 -//! * 流上密:`AES-128-GCM` / `ChaCha20-Poly1305` + HKDF-SHA1(与 SS AEAD 一致) -//! * 客户端首次发送随机 salt(PSK 等长),双方按 HKDF 派生 subkey -//! -//! ## 命令字(version=0x03/0x04) -//! ```text -//! version(1) || cmd(1) || ... -//! ``` -//! cmd 取值: -//! * `0x00` Ping —— 客户端心跳(v4+) -//! * `0x01` Connect —— TCP CONNECT:cmd_payload = client_id_len(1) || client_id || host_len(1) || host || port(2 BE) -//! * `0x02` UDPForward —— UDP relay:第一帧之后双向走 UDPPacket -//! * `0x03` UDPStream —— UDP stream(v4+) -//! * `0x05` Pong —— 客户端 -> 服务器 -//! * `0x06` Tunnel —— 服务器 -> 客户端 OK -//! * `0x07` Error —— 服务器响应错误 -//! -//! ## 服务器响应 -//! ```text -//! status(1) || (status_specific...) -//! status=0x00 Tunnel OK -//! status=0x01 Pong -//! status=0x02 Error: errno(1) || msg_len(1) || msg(N) -//! ``` -//! -//! ## UDP 包格式(双向) -//! ```text -//! chunk_size(2 BE) || addr(SOCKS5) || udp_payload -//! ``` -//! 通过同一条 AEAD 加密的 TCP 流转发。 +//! The authenticated record layer lives in [`super::snell_codec`]. This module +//! implements the official command header, TCP tunnel acknowledgement, and +//! Snell's non-SOCKS UDP address format used by Mihomo and Surge. use std::{ + fmt, io, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, pin::Pin, sync::Arc, task::{Context, Poll}, + time::{Duration, Instant}, }; -use aes_gcm::{ - Aes128Gcm, Nonce, - aead::{Aead, KeyInit}, -}; use async_trait::async_trait; -use bytes::{Buf, BufMut, BytesMut}; -use chacha20poly1305::ChaCha20Poly1305; -use hkdf::Hkdf; -use md5::{Digest, Md5}; -use pin_project_lite::pin_project; -use rand::RngCore; -use sha1::Sha1; use tokio::{ - io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf, ReadHalf, WriteHalf}, - sync::Mutex as AsyncMutex, + io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf}, + sync::Mutex, }; use crate::{ adapter::{BoxedStream, BoxedUdp, Capabilities, DialContext, OutboundAdapter, UdpSocketLike}, - proto::addr::encode_socks_addr, - transport::{Transport, tcp::TcpTransport}, + proto::snell_codec::{ + MAX_FRAME_LENGTH, SnellReadHalf, SnellReadStatus, SnellStream, SnellVersion, SnellWriteHalf, + }, + transport::{ + Transport, + simple_obfs::{SimpleObfsMode, SimpleObfsStream}, + tcp::TcpTransport, + }, }; -const PAYLOAD_MAX: usize = 0x3fff; +const PROTOCOL_VERSION: u8 = 1; +const COMMAND_CONNECT: u8 = 1; +const COMMAND_CONNECT_V2: u8 = 5; +const COMMAND_UDP: u8 = 6; +const COMMAND_UDP_FORWARD: u8 = 1; +const REPLY_TUNNEL: u8 = 0; +const REPLY_PONG: u8 = 1; +const REPLY_ERROR: u8 = 2; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SnellCipher { - Aes128Gcm, - Chacha20Poly1305, +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SnellObfs { + None, + Http { host: String }, + Tls { host: String }, +} + +const REUSE_POOL_SIZE: usize = 10; +const REUSE_POOL_IDLE: Duration = Duration::from_secs(15); + +struct PooledSnell { + stream: SnellStream, + idle_since: Instant, } -impl SnellCipher { - pub fn key_len(self) -> usize { - match self { - Self::Aes128Gcm => 16, - Self::Chacha20Poly1305 => 32, +struct SnellPool { + streams: Mutex>, +} + +impl SnellPool { + fn new() -> Self { + Self { + streams: Mutex::new(Vec::new()), } } - pub fn parse(s: &str) -> Option { - match s.to_ascii_lowercase().as_str() { - "aes-128-gcm" | "aes128gcm" => Some(Self::Aes128Gcm), - "chacha20-poly1305" | "chacha20-ietf-poly1305" => Some(Self::Chacha20Poly1305), - _ => None, + + async fn take(&self) -> Option { + let now = Instant::now(); + let mut streams = self.streams.lock().await; + streams.retain(|entry| now.duration_since(entry.idle_since) <= REUSE_POOL_IDLE); + streams.pop().map(|entry| entry.stream) + } + + fn put(self: &Arc, stream: SnellStream) { + let entry = PooledSnell { + stream, + idle_since: Instant::now(), + }; + if let Ok(mut streams) = self.streams.try_lock() { + if streams.len() < REUSE_POOL_SIZE { + streams.push(entry); + } + return; + } + let pool = Arc::clone(self); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + let mut streams = pool.streams.lock().await; + if streams.len() < REUSE_POOL_SIZE { + streams.push(entry); + } + }); } } } -/// Snell 命令字 -#[allow(dead_code)] -pub mod cmd { - pub const PING: u8 = 0x00; - pub const CONNECT: u8 = 0x01; - pub const UDP_FORWARD: u8 = 0x02; - pub const UDP_STREAM: u8 = 0x03; - pub const PONG: u8 = 0x05; - pub const TUNNEL: u8 = 0x00; - pub const PONG_RESPONSE: u8 = 0x01; - pub const ERROR: u8 = 0x02; +impl fmt::Debug for SnellPool { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SnellPool") + } } #[derive(Debug, Clone)] @@ -104,24 +107,12 @@ pub struct SnellOutbound { pub name: String, pub host: String, pub port: u16, - pub cipher: SnellCipher, - pub key: Arc<[u8]>, - pub version: u8, + pub password: Arc<[u8]>, + pub version: SnellVersion, pub udp: bool, + pub reuse: bool, pub obfs: SnellObfs, -} - -#[derive(Debug, Clone)] -pub enum SnellObfs { - None, - /// HTTP 单字节随机 obfs;与 mihomo `obfs=http` 兼容 - Http { - host: String, - }, - /// TLS 1.2 ServerHello 模拟;与 mihomo `obfs=tls` 兼容 - Tls { - host: String, - }, + pool: Arc, } impl SnellOutbound { @@ -129,30 +120,211 @@ impl SnellOutbound { name: impl Into, host: impl Into, port: u16, - cipher: SnellCipher, password: &str, ) -> Self { - let key = evp_bytes_to_key(password.as_bytes(), cipher.key_len()); Self { name: name.into(), host: host.into(), port, - cipher, - key: Arc::from(key.into_boxed_slice()), - version: 4, - udp: true, + password: Arc::from(password.as_bytes()), + version: SnellVersion::V1, + udp: false, + reuse: false, obfs: SnellObfs::None, + pool: Arc::new(SnellPool::new()), + } + } + + pub fn with_version(mut self, version: u8) -> Result { + self.version = SnellVersion::parse(version)?; + Ok(self) + } + + pub fn with_udp(mut self, enabled: bool) -> Result { + if enabled && !self.version.supports_udp() { + return Err(format!( + "Snell v{} does not support UDP; use version 3, 4 or 5", + self.version.number() + )); + } + self.udp = enabled; + Ok(self) + } + + pub fn with_reuse(mut self, enabled: bool) -> Result { + if enabled && !self.version.supports_reuse() { + return Err(format!( + "Snell reuse requires version 4 or 5, got v{}", + self.version.number() + )); + } + self.reuse = enabled; + Ok(self) + } + + pub fn with_obfs_http(mut self, host: impl Into) -> Result { + let host = host.into(); + if host.trim().is_empty() { + return Err("Snell HTTP obfs host must not be empty".into()); + } + self.obfs = SnellObfs::Http { host }; + Ok(self) + } + + pub fn with_obfs_tls(mut self, host: impl Into) -> Result { + let host = host.into(); + if host.trim().is_empty() { + return Err("Snell TLS obfs host must not be empty".into()); + } + self.obfs = SnellObfs::Tls { host }; + Ok(self) + } + + async fn connect_stream(&self, expect_reply: bool) -> io::Result { + let stream = TcpTransport::default() + .connect(&self.host, self.port) + .await?; + let stream: BoxedStream = match &self.obfs { + SnellObfs::None => stream, + SnellObfs::Http { host } => Box::pin(SimpleObfsStream::client( + stream, + SimpleObfsMode::Http { + host: host.clone(), + port: self.port, + }, + )), + SnellObfs::Tls { host } => Box::pin(SimpleObfsStream::client( + stream, + SimpleObfsMode::Tls { host: host.clone() }, + )), + }; + SnellStream::new(stream, self.password.clone(), self.version, expect_reply) + } + + fn reuse_enabled(&self) -> bool { + self.version == SnellVersion::V2 || self.reuse + } + + async fn reusable_stream(&self) -> io::Result { + if let Some(stream) = self.pool.take().await { + Ok(stream) + } else { + self.connect_stream(true).await } } +} + +struct ReusableSnellStream { + stream: Option, + pool: Arc, + read_ended: bool, + write_ended: bool, + reusable: bool, +} - pub fn with_obfs_http(mut self, host: impl Into) -> Self { - self.obfs = SnellObfs::Http { host: host.into() }; - self +impl ReusableSnellStream { + fn new(stream: SnellStream, pool: Arc) -> Self { + Self { + stream: Some(stream), + pool, + read_ended: false, + write_ended: false, + reusable: true, + } } - pub fn with_obfs_tls(mut self, host: impl Into) -> Self { - self.obfs = SnellObfs::Tls { host: host.into() }; - self + fn finish_if_ready(&mut self) { + if self.reusable && self.read_ended && self.write_ended { + if let Some(stream) = self.stream.take() { + self.pool.put(stream); + } + } + } +} + +impl Drop for ReusableSnellStream { + fn drop(&mut self) { + self.finish_if_ready(); + } +} + +impl AsyncRead for ReusableSnellStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + output: &mut ReadBuf<'_>, + ) -> Poll> { + if self.read_ended { + return Poll::Ready(Ok(())); + } + let Some(stream) = self.stream.as_mut() else { + return Poll::Ready(Ok(())); + }; + match stream.poll_session_read(cx, output) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(error)) => { + self.reusable = false; + Poll::Ready(Err(error)) + } + Poll::Ready(Ok(SnellReadStatus::Data)) => Poll::Ready(Ok(())), + Poll::Ready(Ok(SnellReadStatus::End)) => { + self.read_ended = true; + self.finish_if_ready(); + Poll::Ready(Ok(())) + } + Poll::Ready(Ok(SnellReadStatus::TransportEof)) => { + self.read_ended = true; + self.reusable = false; + Poll::Ready(Ok(())) + } + } + } +} + +impl AsyncWrite for ReusableSnellStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + input: &[u8], + ) -> Poll> { + if self.write_ended { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "Snell logical stream is half-closed", + ))); + } + let Some(stream) = self.stream.as_mut() else { + return Poll::Ready(Err(io::ErrorKind::NotConnected.into())); + }; + Pin::new(stream).poll_write(cx, input) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let Some(stream) = self.stream.as_mut() else { + return Poll::Ready(Ok(())); + }; + Pin::new(stream).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.write_ended { + return Poll::Ready(Ok(())); + } + let Some(stream) = self.stream.as_mut() else { + return Poll::Ready(Ok(())); + }; + match stream.poll_write_end(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(error)) => { + self.reusable = false; + Poll::Ready(Err(error)) + } + Poll::Ready(Ok(())) => { + self.write_ended = true; + self.finish_if_ready(); + Poll::Ready(Ok(())) + } + } } } @@ -161,576 +333,506 @@ impl OutboundAdapter for SnellOutbound { fn name(&self) -> &str { &self.name } + fn protocol(&self) -> &'static str { "snell" } + fn capabilities(&self) -> Capabilities { Capabilities { tcp: true, udp: self.udp, ipv6: true, - multiplex: false, + multiplex: self.reuse_enabled(), } } - async fn dial_tcp(&self, ctx: DialContext) -> std::io::Result { - let mut stream = TcpTransport::default() - .connect(&self.host, self.port) - .await?; - - // 1) salt - let salt_len = self.cipher.key_len(); - let mut salt = vec![0u8; salt_len]; - rand::rngs::OsRng.fill_bytes(&mut salt); - stream.write_all(&salt).await?; - - // 2) 派生 subkey - let send_key = hkdf_subkey(&self.key, &salt, salt_len, b"snell-subkey"); - - // 3) 选择 cmd —— TCP 走 CONNECT;UDP 走 UDP_FORWARD(v4 用 UDP_STREAM) - let is_udp = ctx.network == "udp"; - let cmd_byte = if is_udp { - if self.version >= 4 { - cmd::UDP_STREAM - } else { - cmd::UDP_FORWARD - } + async fn dial_tcp(&self, ctx: DialContext) -> io::Result { + let reusable = self.reuse_enabled(); + let mut stream = if reusable { + self.reusable_stream().await? } else { - cmd::CONNECT + self.connect_stream(true).await? }; - - // 4) cmd payload:v3+ 都是 client_id_len(1)=0 || host_len(1) || host || port(2 BE) - let mut cmd_payload = Vec::with_capacity(8 + ctx.host.len()); - cmd_payload.put_u8(self.version); - cmd_payload.put_u8(cmd_byte); - cmd_payload.put_u8(0x00); // client_id_len = 0 - cmd_payload.put_u8(ctx.host.len().min(255) as u8); - cmd_payload.extend_from_slice(ctx.host.as_bytes()); - cmd_payload.put_u16(ctx.port); - - // 5) 写出第一帧 - let mut send = SnellCryptor::new(self.cipher, &send_key); - let pkt = send.encrypt_chunk(&cmd_payload)?; - stream.write_all(&pkt).await?; + let command = if self.version == SnellVersion::V2 || reusable { + COMMAND_CONNECT_V2 + } else { + COMMAND_CONNECT + }; + let header = encode_tcp_request(command, &ctx.host, ctx.port)?; + stream.write_frame(&header).await?; tracing::info!( target: "dial::snell", id = ctx.dial_id, proxy = %self.name, + version = self.version.number(), + reuse = self.reuse, server = %format!("{}:{}", self.host, self.port), - target = %format!("{}:{}", ctx.host, ctx.port), - network = ctx.network, - "request command sent", + destination = %format!("{}:{}", ctx.host, ctx.port), + "Snell TCP command sent", ); - - Ok(Box::pin(SnellStream { - inner: stream, - send, - recv_state: RecvState::WaitSalt, - master: self.key.clone(), - cipher: self.cipher, - cipher_buf: BytesMut::with_capacity(16 * 1024), - plain_buf: BytesMut::with_capacity(16 * 1024), - response_state: ResponseState::WaitStatus, - })) + if reusable { + Ok(Box::pin(ReusableSnellStream::new( + stream, + Arc::clone(&self.pool), + ))) + } else { + Ok(Box::pin(stream)) + } } - async fn dial_udp(&self, mut ctx: DialContext) -> std::io::Result { + async fn dial_udp(&self, ctx: DialContext) -> io::Result { if !self.udp { - return Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - format!("outbound `{}`/snell udp disabled by config", self.name), + return Err(io::Error::new( + io::ErrorKind::Unsupported, + format!("outbound `{}`/snell UDP disabled by config", self.name), )); } - ctx.network = "udp"; - let stream = self.dial_tcp(ctx.clone()).await?; - let (read, write) = tokio::io::split(stream); - tracing::info!( - target: "dial::snell", - id = ctx.dial_id, - proxy = %self.name, - target = %format!("{}:{}", ctx.host, ctx.port), - "udp stream ok", - ); + if !self.version.supports_udp() { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + format!("Snell v{} does not support UDP", self.version.number()), + )); + } + let mut stream = self.connect_stream(self.version.uses_v4_records()).await?; + stream + .write_frame(&[PROTOCOL_VERSION, COMMAND_UDP, 0]) + .await?; + let (read, write) = stream.into_split(); Ok(Box::new(SnellUdp { - read: AsyncMutex::new(read), - write: AsyncMutex::new(write), + read: Mutex::new(read), + write: Mutex::new(write), + target_host: ctx.host, + target_port: ctx.port, })) } } -struct SnellUdp { - read: AsyncMutex>, - write: AsyncMutex>, +fn encode_tcp_request(command: u8, host: &str, port: u16) -> io::Result> { + let host = host.as_bytes(); + let host_length = u8::try_from(host.len()) + .map_err(|_| invalid_input("Snell destination hostname exceeds 255 bytes"))?; + let mut header = Vec::with_capacity(6 + host.len()); + header.extend_from_slice(&[PROTOCOL_VERSION, command, 0, host_length]); + header.extend_from_slice(host); + header.extend_from_slice(&port.to_be_bytes()); + Ok(header) } -#[async_trait] -impl UdpSocketLike for SnellUdp { - async fn send_to(&self, buf: &[u8], target: &str, port: u16) -> std::io::Result { - let addr = encode_socks_addr(target, port); - let packet = encode_udp_packet(&addr, buf); - let mut write = self.write.lock().await; - write.write_all(&packet).await?; - Ok(buf.len()) - } - - async fn recv_from(&self, buf: &mut [u8]) -> std::io::Result { - let mut read = self.read.lock().await; - let mut len = [0u8; 2]; - read.read_exact(&mut len).await?; - let body_len = u16::from_be_bytes(len) as usize; - let mut packet = Vec::with_capacity(2 + body_len); - packet.extend_from_slice(&len); - packet.resize(2 + body_len, 0); - if body_len > 0 { - read.read_exact(&mut packet[2..]).await?; - } - let (_, _, payload) = decode_udp_packet(&packet)?; - let copy_len = payload.len().min(buf.len()); - buf[..copy_len].copy_from_slice(&payload[..copy_len]); - Ok(copy_len) - } - - async fn close(&self) -> std::io::Result<()> { - let mut write = self.write.lock().await; - let _ = write.shutdown().await; - Ok(()) - } -} - -enum SnellAead { - Aes128(Aes128Gcm), - Chacha(ChaCha20Poly1305), -} - -struct SnellCryptor { - aead: SnellAead, - nonce: [u8; 12], +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SnellRequest { + Ping, + Tcp { + host: String, + port: u16, + reuse: bool, + }, + Udp, } -impl SnellCryptor { - fn new(cipher: SnellCipher, key: &[u8]) -> Self { - let aead = match cipher { - SnellCipher::Aes128Gcm => { - SnellAead::Aes128(Aes128Gcm::new_from_slice(key).expect("len")) +pub fn parse_request_header(frame: &[u8]) -> io::Result { + if frame.len() < 3 || frame[0] != PROTOCOL_VERSION { + return Err(invalid_data("invalid Snell command header")); + } + let command = frame[1]; + let client_id_length = frame[2] as usize; + let mut offset = 3usize + .checked_add(client_id_length) + .ok_or_else(|| invalid_data("Snell client ID length overflow"))?; + if frame.len() < offset { + return Err(invalid_data("truncated Snell client ID")); + } + match command { + 0 => Ok(SnellRequest::Ping), + COMMAND_UDP => { + if frame.len() != offset { + return Err(invalid_data("Snell UDP header contains trailing bytes")); } - SnellCipher::Chacha20Poly1305 => { - SnellAead::Chacha(ChaCha20Poly1305::new_from_slice(key).expect("len")) - } - }; - Self { - aead, - nonce: [0u8; 12], + Ok(SnellRequest::Udp) } - } - - fn next_nonce(&mut self) -> [u8; 12] { - let n = self.nonce; - for b in self.nonce.iter_mut() { - let (v, c) = b.overflowing_add(1); - *b = v; - if !c { - break; + COMMAND_CONNECT | COMMAND_CONNECT_V2 => { + let host_length = *frame + .get(offset) + .ok_or_else(|| invalid_data("Snell TCP header has no hostname length"))? + as usize; + offset += 1; + if frame.len() != offset + host_length + 2 { + return Err(invalid_data("truncated or extended Snell TCP header")); } + let host = std::str::from_utf8(&frame[offset..offset + host_length]) + .map_err(|_| invalid_data("Snell destination hostname is not UTF-8"))? + .to_owned(); + if host.is_empty() { + return Err(invalid_data("Snell destination hostname is empty")); + } + offset += host_length; + let port = u16::from_be_bytes([frame[offset], frame[offset + 1]]); + if port == 0 { + return Err(invalid_data("Snell destination port is zero")); + } + Ok(SnellRequest::Tcp { + host, + port, + reuse: command == COMMAND_CONNECT_V2, + }) } - n - } - - fn encrypt(&mut self, msg: &[u8]) -> std::io::Result> { - let n = self.next_nonce(); - match &self.aead { - SnellAead::Aes128(c) => c - .encrypt(Nonce::from_slice(&n), msg) - .map_err(|_| io_err("snell encrypt aes")), - SnellAead::Chacha(c) => c - .encrypt(chacha20poly1305::Nonce::from_slice(&n), msg) - .map_err(|_| io_err("snell encrypt chacha")), - } - } - - fn decrypt(&mut self, ct: &[u8]) -> std::io::Result> { - let n = self.next_nonce(); - match &self.aead { - SnellAead::Aes128(c) => c - .decrypt(Nonce::from_slice(&n), ct) - .map_err(|_| io_err("snell decrypt aes")), - SnellAead::Chacha(c) => c - .decrypt(chacha20poly1305::Nonce::from_slice(&n), ct) - .map_err(|_| io_err("snell decrypt chacha")), - } - } - - fn encrypt_chunk(&mut self, data: &[u8]) -> std::io::Result> { - let chunk = &data[..data.len().min(PAYLOAD_MAX)]; - let len_be = (chunk.len() as u16).to_be_bytes(); - let mut out = Vec::with_capacity(2 + 16 + chunk.len() + 16); - out.extend(self.encrypt(&len_be)?); - out.extend(self.encrypt(chunk)?); - Ok(out) + command => Err(io::Error::new( + io::ErrorKind::Unsupported, + format!("unsupported Snell command {command}"), + )), } } -enum RecvState { - WaitSalt, - Ready { - recv: SnellCryptor, - expecting_len: Option, - }, +pub fn tunnel_reply() -> [u8; 1] { + [REPLY_TUNNEL] } -#[derive(Debug)] -enum ResponseState { - /// 等待第一字节状态码 - WaitStatus, - /// 已收到 OK,进入 payload 透传 - Payload, - /// 收到 Pong(保活)—— 继续等下一帧 - PongReceived, - /// 错误状态:errno + msg - Error { remaining: usize, errno: u8 }, +pub fn pong_reply() -> [u8; 1] { + [REPLY_PONG] } -pin_project! { - struct SnellStream { - #[pin] - inner: BoxedStream, - send: SnellCryptor, - recv_state: RecvState, - master: Arc<[u8]>, - cipher: SnellCipher, - cipher_buf: BytesMut, - plain_buf: BytesMut, - response_state: ResponseState, - } +pub fn error_reply(code: u8, message: &str) -> Vec { + let message = message.as_bytes(); + let length = message.len().min(u8::MAX as usize); + let mut reply = Vec::with_capacity(3 + length); + reply.extend_from_slice(&[REPLY_ERROR, code, length as u8]); + reply.extend_from_slice(&message[..length]); + reply } -impl AsyncRead for SnellStream { - fn poll_read( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - let mut this = self.project(); - loop { - if !this.plain_buf.is_empty() { - let n = std::cmp::min(buf.remaining(), this.plain_buf.len()); - buf.put_slice(&this.plain_buf[..n]); - this.plain_buf.advance(n); - return Poll::Ready(Ok(())); - } - - let progress: std::io::Result = (|| -> std::io::Result { - match this.recv_state { - RecvState::WaitSalt => { - let salt_len = this.cipher.key_len(); - if this.cipher_buf.len() < salt_len { - return Ok(false); - } - let salt = this.cipher_buf.split_to(salt_len); - let subkey = hkdf_subkey(this.master, &salt, salt_len, b"snell-subkey"); - *this.recv_state = RecvState::Ready { - recv: SnellCryptor::new(*this.cipher, &subkey), - expecting_len: None, - }; - Ok(true) - } - RecvState::Ready { - recv, - expecting_len, - } => { - let tag = 16; - if expecting_len.is_none() { - if this.cipher_buf.len() < 2 + tag { - return Ok(false); - } - let chunk = this.cipher_buf.split_to(2 + tag).to_vec(); - let dec = recv.decrypt(&chunk)?; - let length = u16::from_be_bytes([dec[0], dec[1]]) as usize; - *expecting_len = Some(length); - Ok(true) - } else { - let length = expecting_len.unwrap(); - if this.cipher_buf.len() < length + tag { - return Ok(false); - } - let chunk = this.cipher_buf.split_to(length + tag).to_vec(); - let dec = recv.decrypt(&chunk)?; - *expecting_len = None; - // 根据 response_state 分发 - handle_payload(&mut *this.response_state, &dec, this.plain_buf)?; - Ok(true) - } - } - } - })(); - match progress { - Ok(true) => continue, - Ok(false) => {} - Err(e) => return Poll::Ready(Err(e)), - } - - let mut tmp = [0u8; 16 * 1024]; - let mut rb = ReadBuf::new(&mut tmp); - match this.inner.as_mut().poll_read(cx, &mut rb) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), - Poll::Ready(Ok(())) => { - let filled = rb.filled().len(); - if filled == 0 { - return Poll::Ready(Ok(())); - } - this.cipher_buf.extend_from_slice(rb.filled()); - } - } - } - } +struct SnellUdp { + read: Mutex, + write: Mutex, + target_host: String, + target_port: u16, } -fn handle_payload( - state: &mut ResponseState, - dec: &[u8], - plain_buf: &mut BytesMut, -) -> std::io::Result<()> { - match state { - ResponseState::WaitStatus => { - if dec.is_empty() { - return Err(io_err("snell empty response")); - } - match dec[0] { - 0x00 => { - // Tunnel OK - *state = ResponseState::Payload; - if dec.len() > 1 { - plain_buf.extend_from_slice(&dec[1..]); - } - } - 0x01 => { - // Pong (服务器返回 Pong) - *state = ResponseState::PongReceived; - // 继续等下一个 status 字节 - *state = ResponseState::WaitStatus; - } - 0x02 => { - // Error - if dec.len() < 3 { - return Err(io_err("snell error frame too short")); - } - let errno = dec[1]; - let msg_len = dec[2] as usize; - if dec.len() >= 3 + msg_len { - let msg = String::from_utf8_lossy(&dec[3..3 + msg_len]); - return Err(std::io::Error::new( - std::io::ErrorKind::ConnectionRefused, - format!("snell error errno={errno}: {msg}"), - )); - } - *state = ResponseState::Error { - remaining: 3 + msg_len - dec.len(), - errno, - }; - } - other => { - return Err(io_err_owned(format!( - "snell unknown status byte 0x{other:02x}" - ))); - } - } +#[async_trait] +impl UdpSocketLike for SnellUdp { + async fn send_to(&self, payload: &[u8], target: &str, port: u16) -> io::Result { + if target != self.target_host || port != self.target_port { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "Snell UDP association is pinned to {}:{}, got {target}:{port}", + self.target_host, self.target_port + ), + )); } - ResponseState::Payload => { - plain_buf.extend_from_slice(dec); - } - ResponseState::PongReceived => { - // 不应该走到,回到 WaitStatus - *state = ResponseState::WaitStatus; - } - ResponseState::Error { remaining, errno } => { - // 继续读 error msg - let take = (*remaining).min(dec.len()); - *remaining -= take; - if *remaining == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::ConnectionRefused, - format!("snell error errno={errno}"), - )); - } + let packet = encode_udp_request(target, port, payload)?; + self.write.lock().await.write_frame(&packet).await?; + Ok(payload.len()) + } + + async fn recv_from(&self, output: &mut [u8]) -> io::Result { + let frame = self.read.lock().await.read_frame().await?.ok_or_else(|| { + io::Error::new(io::ErrorKind::UnexpectedEof, "Snell UDP stream ended") + })?; + let (_, _, payload) = parse_udp_response(&frame)?; + if payload.len() > output.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Snell UDP response is {} bytes but caller buffer is {} bytes", + payload.len(), + output.len() + ), + )); } + output[..payload.len()].copy_from_slice(payload); + Ok(payload.len()) + } + + async fn close(&self) -> io::Result<()> { + let mut writer = self.write.lock().await; + writer.write_end().await?; + writer.shutdown().await } - Ok(()) } -impl AsyncWrite for SnellStream { - fn poll_write( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - data: &[u8], - ) -> Poll> { - let mut this = self.project(); - let chunk = &data[..data.len().min(PAYLOAD_MAX)]; - let pkt = match this.send.encrypt_chunk(chunk) { - Ok(v) => v, - Err(e) => return Poll::Ready(Err(e)), - }; - let mut written = 0; - while written < pkt.len() { - match this.inner.as_mut().poll_write(cx, &pkt[written..]) { - Poll::Ready(Ok(0)) => { - return Poll::Ready(Err(std::io::ErrorKind::WriteZero.into())); - } - Poll::Ready(Ok(n)) => written += n, - Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), - Poll::Pending => return Poll::Pending, +pub fn encode_udp_request(host: &str, port: u16, payload: &[u8]) -> io::Result> { + let mut output = Vec::with_capacity(24 + payload.len()); + output.push(COMMAND_UDP_FORWARD); + match host.parse::() { + Ok(IpAddr::V4(address)) => { + output.extend_from_slice(&[0, 4]); + output.extend_from_slice(&address.octets()); + } + Ok(IpAddr::V6(address)) => { + output.extend_from_slice(&[0, 6]); + output.extend_from_slice(&address.octets()); + } + Err(_) => { + let host = host.as_bytes(); + let length = u8::try_from(host.len()) + .map_err(|_| invalid_input("Snell UDP hostname exceeds 255 bytes"))?; + if length == 0 { + return Err(invalid_input("Snell UDP hostname is empty")); } + output.push(length); + output.extend_from_slice(host); } - Poll::Ready(Ok(chunk.len())) - } - fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.project().inner.poll_flush(cx) } - fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.project().inner.poll_shutdown(cx) + output.extend_from_slice(&port.to_be_bytes()); + if output.len() + payload.len() > MAX_FRAME_LENGTH { + return Err(invalid_input("Snell UDP request exceeds 0x3fff bytes")); } + output.extend_from_slice(payload); + Ok(output) } -/// Snell UDP 包编码(双向):`size_be(2) || addr(SOCKS5) || payload` -pub fn encode_udp_packet(addr_socks: &[u8], payload: &[u8]) -> Vec { - let body_len = addr_socks.len() + payload.len(); - let mut out = Vec::with_capacity(2 + body_len); - out.put_u16(body_len as u16); - out.extend_from_slice(addr_socks); - out.extend_from_slice(payload); - out +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnellUdpRequest<'a> { + pub host: String, + pub port: u16, + pub payload: &'a [u8], } -pub fn decode_udp_packet(buf: &[u8]) -> std::io::Result<(usize, &[u8], &[u8])> { - if buf.len() < 2 { - return Err(io_err("udp pkt too short")); +pub fn parse_udp_request(packet: &[u8]) -> io::Result> { + if packet.len() < 4 || packet[0] != COMMAND_UDP_FORWARD { + return Err(invalid_data("invalid Snell UDP request")); } - let body_len = u16::from_be_bytes([buf[0], buf[1]]) as usize; - if buf.len() < 2 + body_len { - return Err(io_err("udp pkt body truncated")); - } - let body = &buf[2..2 + body_len]; - if body.is_empty() { - return Err(io_err("udp pkt empty body")); - } - let atyp = body[0]; - let (addr_len, _) = match atyp { - 0x01 => (1 + 4 + 2, "v4"), - 0x03 => { - if body.len() < 2 { - return Err(io_err("udp pkt domain trunc")); - } - (1 + 1 + body[1] as usize + 2, "domain") + let address_length = packet[1] as usize; + if address_length != 0 { + let address_end = 2 + address_length; + if packet.len() <= address_end + 2 { + return Err(invalid_data("truncated Snell UDP domain request")); } - 0x04 => (1 + 16 + 2, "v6"), - _ => return Err(io_err("udp pkt unknown atyp")), + let host = std::str::from_utf8(&packet[2..address_end]) + .map_err(|_| invalid_data("Snell UDP hostname is not UTF-8"))? + .to_owned(); + let port = u16::from_be_bytes([packet[address_end], packet[address_end + 1]]); + return Ok(SnellUdpRequest { + host, + port, + payload: &packet[address_end + 2..], + }); + } + let family = *packet + .get(2) + .ok_or_else(|| invalid_data("Snell UDP IP family is missing"))?; + let (host, offset) = match family { + 4 if packet.len() >= 9 => ( + Ipv4Addr::new(packet[3], packet[4], packet[5], packet[6]).to_string(), + 7, + ), + 6 if packet.len() >= 21 => { + let octets: [u8; 16] = packet[3..19].try_into().expect("checked IPv6 length"); + (Ipv6Addr::from(octets).to_string(), 19) + } + 4 | 6 => return Err(invalid_data("truncated Snell UDP IP request")), + _ => return Err(invalid_data("invalid Snell UDP IP family")), }; - if body.len() < addr_len { - return Err(io_err("udp pkt addr trunc")); - } - Ok((2 + body_len, &body[..addr_len], &body[addr_len..])) + let port = u16::from_be_bytes([packet[offset], packet[offset + 1]]); + Ok(SnellUdpRequest { + host, + port, + payload: &packet[offset + 2..], + }) } -fn evp_bytes_to_key(password: &[u8], key_len: usize) -> Vec { - let mut key = Vec::with_capacity(key_len); - let mut prev: Vec = Vec::new(); - while key.len() < key_len { - let mut h = Md5::new(); - h.update(&prev); - h.update(password); - let digest = h.finalize(); - prev = digest.to_vec(); - key.extend_from_slice(&prev); - } - key.truncate(key_len); - key +pub fn encode_udp_response(source: IpAddr, port: u16, payload: &[u8]) -> io::Result> { + let mut output = Vec::with_capacity(19 + payload.len()); + match source { + IpAddr::V4(address) => { + output.push(4); + output.extend_from_slice(&address.octets()); + } + IpAddr::V6(address) => { + output.push(6); + output.extend_from_slice(&address.octets()); + } + } + output.extend_from_slice(&port.to_be_bytes()); + if output.len() + payload.len() > MAX_FRAME_LENGTH { + return Err(invalid_input("Snell UDP response exceeds 0x3fff bytes")); + } + output.extend_from_slice(payload); + Ok(output) } -fn hkdf_subkey(master: &[u8], salt: &[u8], len: usize, info: &[u8]) -> Vec { - let hk = Hkdf::::new(Some(salt), master); - let mut okm = vec![0u8; len]; - hk.expand(info, &mut okm).expect("hkdf"); - okm +pub fn parse_udp_response(packet: &[u8]) -> io::Result<(IpAddr, u16, &[u8])> { + let family = *packet + .first() + .ok_or_else(|| invalid_data("empty Snell UDP response"))?; + match family { + 4 if packet.len() >= 7 => Ok(( + IpAddr::V4(Ipv4Addr::new(packet[1], packet[2], packet[3], packet[4])), + u16::from_be_bytes([packet[5], packet[6]]), + &packet[7..], + )), + 6 if packet.len() >= 19 => { + let octets: [u8; 16] = packet[1..17].try_into().expect("checked IPv6 length"); + Ok(( + IpAddr::V6(Ipv6Addr::from(octets)), + u16::from_be_bytes([packet[17], packet[18]]), + &packet[19..], + )) + } + 4 | 6 => Err(invalid_data("truncated Snell UDP response")), + _ => Err(invalid_data("invalid Snell UDP response IP family")), + } } -fn io_err(s: &'static str) -> std::io::Error { - std::io::Error::new(std::io::ErrorKind::Other, s) +fn invalid_input(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, message.into()) } -fn io_err_owned(s: String) -> std::io::Error { - std::io::Error::new(std::io::ErrorKind::Other, s) +fn invalid_data(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) } #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + }; + use super::*; #[test] - fn cipher_parse() { + fn official_tcp_headers_are_version_one_for_every_generation() { assert_eq!( - SnellCipher::parse("aes-128-gcm"), - Some(SnellCipher::Aes128Gcm) + encode_tcp_request(COMMAND_CONNECT, "example.com", 443).unwrap(), + b"\x01\x01\x00\x0bexample.com\x01\xbb" ); assert_eq!( - SnellCipher::parse("chacha20-poly1305"), - Some(SnellCipher::Chacha20Poly1305) + encode_tcp_request(COMMAND_CONNECT_V2, "a", 80).unwrap(), + b"\x01\x05\x00\x01a\x00\x50" ); - assert_eq!(SnellCipher::parse("rc4"), None); - } - - #[test] - fn key_len_correct() { - assert_eq!(SnellCipher::Aes128Gcm.key_len(), 16); - assert_eq!(SnellCipher::Chacha20Poly1305.key_len(), 32); - } - - #[test] - fn round_trip_chunk() { - let key = vec![0xa1u8; 16]; - let mut send = SnellCryptor::new(SnellCipher::Aes128Gcm, &key); - let mut recv = SnellCryptor::new(SnellCipher::Aes128Gcm, &key); - let pkt = send.encrypt_chunk(b"hello snell").unwrap(); - let len_dec = recv.decrypt(&pkt[..2 + 16]).unwrap(); - let length = u16::from_be_bytes([len_dec[0], len_dec[1]]) as usize; - assert_eq!(length, 11); - let payload = recv.decrypt(&pkt[2 + 16..]).unwrap(); - assert_eq!(payload, b"hello snell"); } #[test] - fn udp_packet_round_trip_v4() { - let addr = b"\x01\x01\x02\x03\x04\x00\x50"; - let payload = b"payload-bytes"; - let pkt = encode_udp_packet(addr, payload); - let (consumed, decoded_addr, decoded_payload) = decode_udp_packet(&pkt).unwrap(); - assert_eq!(consumed, pkt.len()); - assert_eq!(decoded_addr, addr); - assert_eq!(decoded_payload, payload); + fn request_parser_accepts_tcp_udp_and_reuse() { + assert_eq!( + parse_request_header(b"\x01\x01\x00\x03dns\x00\x35").unwrap(), + SnellRequest::Tcp { + host: "dns".into(), + port: 53, + reuse: false, + } + ); + assert_eq!( + parse_request_header(b"\x01\x05\x00\x01a\x00\x50").unwrap(), + SnellRequest::Tcp { + host: "a".into(), + port: 80, + reuse: true, + } + ); + assert_eq!( + parse_request_header(b"\x01\x06\x00").unwrap(), + SnellRequest::Udp + ); } #[test] - fn udp_packet_round_trip_domain() { - let mut addr = vec![0x03, 9]; - addr.extend_from_slice(b"localhost"); - addr.extend_from_slice(&53u16.to_be_bytes()); - let pkt = encode_udp_packet(&addr, b"dns-query"); - let (_, da, dp) = decode_udp_packet(&pkt).unwrap(); - assert_eq!(da, &addr[..]); - assert_eq!(dp, b"dns-query"); + fn udp_request_round_trips_domain_ipv4_and_ipv6() { + for host in ["example.com", "192.0.2.1", "2001:db8::1"] { + let encoded = encode_udp_request(host, 5353, b"dns").unwrap(); + let decoded = parse_udp_request(&encoded).unwrap(); + assert_eq!(decoded.host, host); + assert_eq!(decoded.port, 5353); + assert_eq!(decoded.payload, b"dns"); + } } #[test] - fn snell_obfs_construct() { - let ob = SnellOutbound::new("s", "1.2.3.4", 8388, SnellCipher::Aes128Gcm, "p") - .with_obfs_http("example.com"); - match ob.obfs { - SnellObfs::Http { ref host } => assert_eq!(host, "example.com"), - _ => panic!(), + fn udp_response_round_trips_both_families() { + for address in [ + "192.0.2.10".parse::().unwrap(), + "2001:db8::10".parse::().unwrap(), + ] { + let encoded = encode_udp_response(address, 53, b"answer").unwrap(); + let (decoded, port, payload) = parse_udp_response(&encoded).unwrap(); + assert_eq!(decoded, address); + assert_eq!(port, 53); + assert_eq!(payload, b"answer"); } } #[test] - fn udp_capability_is_declared_when_enabled() { - let ob = SnellOutbound::new("s", "1.2.3.4", 8388, SnellCipher::Aes128Gcm, "p"); - assert!(ob.capabilities().udp); + fn version_capabilities_are_strict() { + assert!(!SnellVersion::V2.supports_udp()); + assert!(SnellVersion::V3.supports_udp()); + assert!(!SnellVersion::V3.supports_reuse()); + assert!(SnellVersion::V2.supports_reuse()); + assert!(SnellVersion::V4.supports_reuse()); + assert!(SnellVersion::V5.supports_reuse()); + } + + #[tokio::test] + async fn v2_and_v4_clients_reuse_one_authenticated_transport() { + for version in [2u8, 4u8] { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let accepted = Arc::new(AtomicUsize::new(0)); + let accepted_server = Arc::clone(&accepted); + let server = tokio::spawn(async move { + let (transport, _) = listener.accept().await.unwrap(); + accepted_server.fetch_add(1, Ordering::SeqCst); + let version = SnellVersion::parse(version).unwrap(); + let mut stream = SnellStream::new( + Box::pin(transport), + Arc::from(&b"pool-password"[..]), + version, + false, + ) + .unwrap(); + for _ in 0..2 { + let header = stream.read_frame().await.unwrap().unwrap(); + assert!(matches!( + parse_request_header(&header).unwrap(), + SnellRequest::Tcp { reuse: true, .. } + )); + stream.write_frame(&tunnel_reply()).await.unwrap(); + loop { + match stream.read_event().await.unwrap() { + crate::proto::snell_codec::SnellFrameEvent::Data(frame) => { + stream.write_frame(&frame).await.unwrap(); + } + crate::proto::snell_codec::SnellFrameEvent::End => { + stream.write_end().await.unwrap(); + break; + } + crate::proto::snell_codec::SnellFrameEvent::TransportEof => { + panic!("pooled transport closed between logical sessions"); + } + } + } + } + }); + + let outbound = SnellOutbound::new( + format!("snell-v{version}"), + address.ip().to_string(), + address.port(), + "pool-password", + ) + .with_version(version) + .unwrap() + .with_reuse(version == 4) + .unwrap(); + for payload in [b"first".as_slice(), b"second".as_slice()] { + let mut stream = outbound + .dial_tcp(DialContext::tcp("target.invalid", 443)) + .await + .unwrap(); + stream.write_all(payload).await.unwrap(); + stream.flush().await.unwrap(); + let mut echoed = vec![0u8; payload.len()]; + stream.read_exact(&mut echoed).await.unwrap(); + assert_eq!(echoed, payload); + stream.shutdown().await.unwrap(); + let mut eof = [0u8; 1]; + assert_eq!(stream.read(&mut eof).await.unwrap(), 0); + drop(stream); + } + server.await.unwrap(); + assert_eq!(accepted.load(Ordering::SeqCst), 1); + } } } diff --git a/crates/core-outbound/src/proto/snell_codec.rs b/crates/core-outbound/src/proto/snell_codec.rs new file mode 100644 index 0000000..2f1d399 --- /dev/null +++ b/crates/core-outbound/src/proto/snell_codec.rs @@ -0,0 +1,1034 @@ +//! Snell v1-v5 authenticated record layer. +//! +//! The wire format follows Mihomo v1.19.29 `transport/snell`: v1 uses +//! ChaCha20-Poly1305, v2/v3 use AES-128-GCM, and v4/v5 use the padded v4 +//! AES-128-GCM record format. Every generation derives per-direction keys with +//! Argon2id (memory=8 KiB, iterations=3, lanes=1). + +use std::{ + io, + pin::Pin, + sync::Arc, + task::{Context, Poll}, + time::{Duration, Instant}, +}; + +use aes_gcm::{ + Aes128Gcm, Nonce, + aead::{Aead, KeyInit}, +}; +use argon2::{Algorithm, Argon2, Params, Version}; +use bytes::{Buf, BytesMut}; +use chacha20poly1305::ChaCha20Poly1305; +use rand::RngCore; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf, ReadHalf, WriteHalf}; + +use crate::adapter::BoxedStream; + +pub const MAX_FRAME_LENGTH: usize = 0x3fff; +const SALT_LENGTH: usize = 16; +const TAG_LENGTH: usize = 16; +const V4_HEADER_PLAIN_LENGTH: usize = 7; +const V4_HEADER_CIPHER_LENGTH: usize = V4_HEADER_PLAIN_LENGTH + TAG_LENGTH; +const V4_FRAME_SIZE: usize = 1460; +const V4_INITIAL_PADDING_MIN: usize = 0x100; +const V4_INITIAL_PADDING_SPAN: usize = 0x100; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SnellVersion { + V1, + V2, + V3, + V4, + V5, +} + +impl SnellVersion { + pub fn parse(value: u8) -> Result { + match value { + 1 => Ok(Self::V1), + 2 => Ok(Self::V2), + 3 => Ok(Self::V3), + 4 => Ok(Self::V4), + 5 => Ok(Self::V5), + _ => Err(format!("Snell version must be in 1..=5, got {value}")), + } + } + + pub fn number(self) -> u8 { + match self { + Self::V1 => 1, + Self::V2 => 2, + Self::V3 => 3, + Self::V4 => 4, + Self::V5 => 5, + } + } + + pub fn supports_udp(self) -> bool { + self.number() >= 3 + } + + pub fn supports_reuse(self) -> bool { + matches!(self, Self::V2 | Self::V4 | Self::V5) + } + + fn is_v4_records(self) -> bool { + self.number() >= 4 + } + + pub fn uses_v4_records(self) -> bool { + self.is_v4_records() + } +} + +enum Cipher { + Aes(Aes128Gcm), + ChaCha(ChaCha20Poly1305), +} + +impl Cipher { + fn new(version: SnellVersion, password: &[u8], salt: &[u8]) -> io::Result { + let key = snell_kdf(password, salt)?; + if version == SnellVersion::V1 { + Ok(Self::ChaCha( + ChaCha20Poly1305::new_from_slice(&key) + .map_err(|_| invalid_data("invalid Snell ChaCha20 key"))?, + )) + } else { + Ok(Self::Aes( + Aes128Gcm::new_from_slice(&key[..16]) + .map_err(|_| invalid_data("invalid Snell AES key"))?, + )) + } + } + + fn seal(&self, nonce: &[u8; 12], plaintext: &[u8]) -> io::Result> { + match self { + Self::Aes(cipher) => cipher + .encrypt(Nonce::from_slice(nonce), plaintext) + .map_err(|_| invalid_data("Snell AES-GCM encryption failed")), + Self::ChaCha(cipher) => cipher + .encrypt(chacha20poly1305::Nonce::from_slice(nonce), plaintext) + .map_err(|_| invalid_data("Snell ChaCha20-Poly1305 encryption failed")), + } + } + + fn open(&self, nonce: &[u8; 12], ciphertext: &[u8]) -> io::Result> { + match self { + Self::Aes(cipher) => cipher + .decrypt(Nonce::from_slice(nonce), ciphertext) + .map_err(|_| invalid_data("Snell AES-GCM authentication failed")), + Self::ChaCha(cipher) => cipher + .decrypt(chacha20poly1305::Nonce::from_slice(nonce), ciphertext) + .map_err(|_| invalid_data("Snell ChaCha20-Poly1305 authentication failed")), + } + } +} + +fn snell_kdf(password: &[u8], salt: &[u8]) -> io::Result<[u8; 32]> { + let params = Params::new(8, 3, 1, Some(32)) + .map_err(|error| invalid_data(format!("invalid Snell Argon2 parameters: {error}")))?; + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut output = [0u8; 32]; + argon2 + .hash_password_into(password, salt, &mut output) + .map_err(|error| invalid_data(format!("Snell Argon2 derivation failed: {error}")))?; + Ok(output) +} + +fn increment_nonce(nonce: &mut [u8; 12]) { + for byte in nonce { + *byte = byte.wrapping_add(1); + if *byte != 0 { + break; + } + } +} + +enum DecodeState { + Salt, + LegacyLength { + cipher: Cipher, + nonce: [u8; 12], + }, + LegacyPayload { + cipher: Cipher, + nonce: [u8; 12], + length: usize, + }, + V4Header { + cipher: Cipher, + nonce: [u8; 12], + }, + V4Payload { + cipher: Cipher, + nonce: [u8; 12], + padding: usize, + length: usize, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReplyState { + None, + Waiting, + Accepted, +} + +/// One authenticated Snell record-layer event. +/// +/// `End` is the protocol's zero-length chunk and is intentionally distinct +/// from `TransportEof`: v4/v5 connection reuse starts the next logical request +/// on the same encrypted transport after an `End` marker. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SnellFrameEvent { + Data(Vec), + End, + TransportEof, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SnellReadStatus { + Data, + End, + TransportEof, +} + +pub struct SnellReadHalf { + inner: ReadHalf, + password: Arc<[u8]>, + version: SnellVersion, + state: DecodeState, + wire: BytesMut, + plain: BytesMut, + reply: ReplyState, + transport_eof: bool, +} + +impl SnellReadHalf { + fn new( + inner: ReadHalf, + password: Arc<[u8]>, + version: SnellVersion, + expect_reply: bool, + ) -> Self { + Self { + inner, + password, + version, + state: DecodeState::Salt, + wire: BytesMut::with_capacity(16 * 1024), + plain: BytesMut::with_capacity(16 * 1024), + reply: if expect_reply { + ReplyState::Waiting + } else { + ReplyState::None + }, + transport_eof: false, + } + } + + fn try_decode_frame(&mut self) -> io::Result> { + loop { + match std::mem::replace(&mut self.state, DecodeState::Salt) { + DecodeState::Salt => { + if self.wire.len() < SALT_LENGTH { + self.state = DecodeState::Salt; + return Ok(None); + } + let salt = self.wire.split_to(SALT_LENGTH); + let cipher = Cipher::new(self.version, &self.password, &salt)?; + self.state = if self.version.is_v4_records() { + DecodeState::V4Header { + cipher, + nonce: [0; 12], + } + } else { + DecodeState::LegacyLength { + cipher, + nonce: [0; 12], + } + }; + } + DecodeState::LegacyLength { cipher, mut nonce } => { + if self.wire.len() < 2 + TAG_LENGTH { + self.state = DecodeState::LegacyLength { cipher, nonce }; + return Ok(None); + } + let ciphertext = self.wire.split_to(2 + TAG_LENGTH); + let decoded = cipher.open(&nonce, &ciphertext)?; + increment_nonce(&mut nonce); + if decoded.len() != 2 { + return Err(invalid_data("Snell legacy length has invalid size")); + } + let length = u16::from_be_bytes([decoded[0], decoded[1]]) as usize; + if length > MAX_FRAME_LENGTH { + return Err(invalid_data("Snell legacy frame exceeds 0x3fff bytes")); + } + self.state = DecodeState::LegacyPayload { + cipher, + nonce, + length, + }; + } + DecodeState::LegacyPayload { + cipher, + mut nonce, + length, + } => { + let needed = length + TAG_LENGTH; + if self.wire.len() < needed { + self.state = DecodeState::LegacyPayload { + cipher, + nonce, + length, + }; + return Ok(None); + } + let ciphertext = self.wire.split_to(needed); + let decoded = cipher.open(&nonce, &ciphertext)?; + increment_nonce(&mut nonce); + let end = decoded.is_empty(); + self.state = DecodeState::LegacyLength { cipher, nonce }; + return Ok(Some(if end { + SnellFrameEvent::End + } else { + SnellFrameEvent::Data(decoded) + })); + } + DecodeState::V4Header { cipher, mut nonce } => { + if self.wire.len() < V4_HEADER_CIPHER_LENGTH { + self.state = DecodeState::V4Header { cipher, nonce }; + return Ok(None); + } + let ciphertext = self.wire.split_to(V4_HEADER_CIPHER_LENGTH); + let header = cipher.open(&nonce, &ciphertext)?; + increment_nonce(&mut nonce); + if header.len() != V4_HEADER_PLAIN_LENGTH || header[0] != 4 { + return Err(invalid_data("invalid Snell v4 record header")); + } + let padding = u16::from_be_bytes([header[3], header[4]]) as usize; + let length = u16::from_be_bytes([header[5], header[6]]) as usize; + if length > MAX_FRAME_LENGTH || padding > MAX_FRAME_LENGTH { + return Err(invalid_data("Snell v4 record exceeds 0x3fff bytes")); + } + if length == 0 && padding != 0 { + return Err(invalid_data("Snell v4 zero chunk contains padding")); + } + self.state = DecodeState::V4Payload { + cipher, + nonce, + padding, + length, + }; + } + DecodeState::V4Payload { + cipher, + mut nonce, + padding, + length, + } => { + if length == 0 { + self.state = DecodeState::V4Header { cipher, nonce }; + return Ok(Some(SnellFrameEvent::End)); + } + let needed = padding + length + TAG_LENGTH; + if self.wire.len() < needed { + self.state = DecodeState::V4Payload { + cipher, + nonce, + padding, + length, + }; + return Ok(None); + } + let mut frame = self.wire.split_to(needed).to_vec(); + let (padding_bytes, payload_cipher) = frame.split_at_mut(padding); + swap_padding(padding_bytes, payload_cipher); + let decoded = cipher.open(&nonce, payload_cipher)?; + increment_nonce(&mut nonce); + self.state = DecodeState::V4Header { cipher, nonce }; + return Ok(Some(SnellFrameEvent::Data(decoded))); + } + } + } + } + + fn process_reply(&mut self, frame: Vec) -> io::Result>> { + if self.reply != ReplyState::Waiting { + return Ok(Some(frame)); + } + let status = *frame + .first() + .ok_or_else(|| invalid_data("empty Snell server reply"))?; + match status { + 0 => { + self.reply = ReplyState::Accepted; + Ok((frame.len() > 1).then(|| frame[1..].to_vec())) + } + 1 => Err(io::Error::new( + io::ErrorKind::WouldBlock, + "Snell server returned pong instead of tunnel", + )), + 2 => { + if frame.len() < 3 { + return Err(invalid_data("truncated Snell error reply")); + } + let message_length = frame[2] as usize; + if frame.len() < 3 + message_length { + return Err(invalid_data("truncated Snell error message")); + } + Err(io::Error::new( + io::ErrorKind::ConnectionRefused, + format!( + "Snell server error {}: {}", + frame[1], + String::from_utf8_lossy(&frame[3..3 + message_length]) + ), + )) + } + status => Err(invalid_data(format!( + "unsupported Snell server reply {status}" + ))), + } + } + + fn poll_next_event(&mut self, cx: &mut Context<'_>) -> Poll> { + loop { + match self.try_decode_frame() { + Ok(Some(SnellFrameEvent::Data(frame))) => match self.process_reply(frame) { + Ok(Some(frame)) => { + return Poll::Ready(Ok(SnellFrameEvent::Data(frame))); + } + Ok(None) => continue, + Err(error) => return Poll::Ready(Err(error)), + }, + Ok(Some(SnellFrameEvent::End)) => { + if self.reply == ReplyState::Accepted { + self.reply = ReplyState::Waiting; + } + return Poll::Ready(Ok(SnellFrameEvent::End)); + } + Ok(Some(SnellFrameEvent::TransportEof)) => unreachable!("decoder event"), + Ok(None) => {} + Err(error) => return Poll::Ready(Err(error)), + } + + if self.transport_eof { + return Poll::Ready(if self.wire.is_empty() { + Ok(SnellFrameEvent::TransportEof) + } else { + Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "truncated Snell encrypted record", + )) + }); + } + + let mut temporary = [0u8; 16 * 1024]; + let mut buffer = ReadBuf::new(&mut temporary); + match Pin::new(&mut self.inner).poll_read(cx, &mut buffer) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(())) if buffer.filled().is_empty() => { + self.transport_eof = true; + } + Poll::Ready(Ok(())) => self.wire.extend_from_slice(buffer.filled()), + } + } + } + + pub async fn read_event(&mut self) -> io::Result { + std::future::poll_fn(|cx| self.poll_next_event(cx)).await + } + + pub async fn read_frame(&mut self) -> io::Result>> { + match self.read_event().await? { + SnellFrameEvent::Data(frame) => Ok(Some(frame)), + SnellFrameEvent::End | SnellFrameEvent::TransportEof => Ok(None), + } + } + + fn poll_session_read( + &mut self, + cx: &mut Context<'_>, + output: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + if !self.plain.is_empty() { + let length = output.remaining().min(self.plain.len()); + output.put_slice(&self.plain[..length]); + self.plain.advance(length); + return Poll::Ready(Ok(SnellReadStatus::Data)); + } + match self.poll_next_event(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(SnellFrameEvent::End)) => { + return Poll::Ready(Ok(SnellReadStatus::End)); + } + Poll::Ready(Ok(SnellFrameEvent::TransportEof)) => { + return Poll::Ready(Ok(SnellReadStatus::TransportEof)); + } + Poll::Ready(Ok(SnellFrameEvent::Data(frame))) => { + self.plain.extend_from_slice(&frame); + } + } + } + } +} + +impl AsyncRead for SnellReadHalf { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + output: &mut ReadBuf<'_>, + ) -> Poll> { + match self.poll_session_read(cx, output) { + Poll::Pending => Poll::Pending, + Poll::Ready(Ok(_)) => Poll::Ready(Ok(())), + Poll::Ready(Err(error)) => Poll::Ready(Err(error)), + } + } +} + +struct Encoder { + version: SnellVersion, + cipher: Option, + nonce: [u8; 12], + salt: [u8; SALT_LENGTH], + salt_sent: bool, + initial_padding: usize, + payload_limit: usize, + last_write: Option, +} + +impl Encoder { + fn new(password: Arc<[u8]>, version: SnellVersion) -> io::Result { + let mut salt = [0u8; SALT_LENGTH]; + rand::rngs::OsRng.fill_bytes(&mut salt); + let cipher = Cipher::new(version, &password, &salt)?; + let initial_padding = V4_INITIAL_PADDING_MIN + random_below(V4_INITIAL_PADDING_SPAN)?; + Ok(Self { + version, + cipher: Some(cipher), + nonce: [0; 12], + salt, + salt_sent: false, + initial_padding, + payload_limit: 0, + last_write: None, + }) + } + + fn next_payload_limit(&mut self) -> usize { + if !self.version.is_v4_records() { + return MAX_FRAME_LENGTH; + } + let now = Instant::now(); + let limit = match self.last_write { + None => V4_FRAME_SIZE - 55 - self.initial_padding, + Some(previous) if now.duration_since(previous) > Duration::from_secs(30) => { + V4_FRAME_SIZE - 39 + } + Some(_) => self.payload_limit, + }; + self.last_write = Some(now); + self.payload_limit = (limit + V4_FRAME_SIZE - 39).min(MAX_FRAME_LENGTH); + if limit == 0 || limit > MAX_FRAME_LENGTH { + MAX_FRAME_LENGTH + } else { + limit + } + } + + fn encode(&mut self, payload: &[u8], packet: bool) -> io::Result> { + if payload.len() > MAX_FRAME_LENGTH { + return Err(invalid_input("Snell frame exceeds 0x3fff bytes")); + } + if self.version.is_v4_records() { + self.encode_v4(payload, packet) + } else { + self.encode_legacy(payload) + } + } + + fn prefix_salt(&mut self, output: &mut Vec) { + if !self.salt_sent { + output.extend_from_slice(&self.salt); + self.salt_sent = true; + } + } + + fn encode_legacy(&mut self, payload: &[u8]) -> io::Result> { + let mut output = Vec::with_capacity( + (!self.salt_sent as usize) * SALT_LENGTH + 2 + TAG_LENGTH + payload.len() + TAG_LENGTH, + ); + self.prefix_salt(&mut output); + let cipher = self.cipher.as_ref().expect("encoder cipher"); + output.extend(cipher.seal(&self.nonce, &(payload.len() as u16).to_be_bytes())?); + increment_nonce(&mut self.nonce); + output.extend(cipher.seal(&self.nonce, payload)?); + increment_nonce(&mut self.nonce); + Ok(output) + } + + fn encode_v4(&mut self, payload: &[u8], packet: bool) -> io::Result> { + let padding_length = if !self.salt_sent && !payload.is_empty() { + self.initial_padding + } else { + 0 + }; + if payload.is_empty() && padding_length != 0 { + return Err(invalid_input("Snell v4 zero chunk cannot contain padding")); + } + let mut header = [0u8; V4_HEADER_PLAIN_LENGTH]; + header[0] = 4; + header[3..5].copy_from_slice(&(padding_length as u16).to_be_bytes()); + header[5..7].copy_from_slice(&(payload.len() as u16).to_be_bytes()); + let cipher = self.cipher.as_ref().expect("encoder cipher"); + let header_cipher = cipher.seal(&self.nonce, &header)?; + increment_nonce(&mut self.nonce); + let mut payload_cipher = if payload.is_empty() { + Vec::new() + } else { + let sealed = cipher.seal(&self.nonce, payload)?; + increment_nonce(&mut self.nonce); + sealed + }; + let mut output = Vec::with_capacity( + (!self.salt_sent as usize) * SALT_LENGTH + + header_cipher.len() + + padding_length + + payload_cipher.len(), + ); + self.prefix_salt(&mut output); + output.extend_from_slice(&header_cipher); + if padding_length > 0 { + let mut padding = make_v4_padding(&payload_cipher, padding_length)?; + swap_padding(&mut padding, &mut payload_cipher); + output.extend_from_slice(&padding); + } + output.extend_from_slice(&payload_cipher); + if packet { + self.last_write = Some(Instant::now()); + } + Ok(output) + } +} + +pub struct SnellWriteHalf { + inner: WriteHalf, + encoder: Encoder, + pending: Vec, + pending_offset: usize, + pending_plain_length: usize, + end_queued: bool, +} + +impl SnellWriteHalf { + fn new( + inner: WriteHalf, + password: Arc<[u8]>, + version: SnellVersion, + ) -> io::Result { + Ok(Self { + inner, + encoder: Encoder::new(password, version)?, + pending: Vec::new(), + pending_offset: 0, + pending_plain_length: 0, + end_queued: false, + }) + } + + fn poll_pending(&mut self, cx: &mut Context<'_>) -> Poll> { + while self.pending_offset < self.pending.len() { + match Pin::new(&mut self.inner).poll_write(cx, &self.pending[self.pending_offset..]) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(0)) => { + return Poll::Ready(Err(io::ErrorKind::WriteZero.into())); + } + Poll::Ready(Ok(written)) => self.pending_offset += written, + } + } + let plain_length = self.pending_plain_length; + self.pending.clear(); + self.pending_offset = 0; + self.pending_plain_length = 0; + Poll::Ready(Ok(plain_length)) + } + + pub async fn write_frame(&mut self, payload: &[u8]) -> io::Result<()> { + self.flush().await?; + let frame = self.encoder.encode(payload, true)?; + self.inner.write_all(&frame).await + } + + pub async fn write_end(&mut self) -> io::Result<()> { + self.write_frame(&[]).await?; + self.inner.flush().await + } + + fn poll_write_end(&mut self, cx: &mut Context<'_>) -> Poll> { + if !self.end_queued { + if !self.pending.is_empty() { + match self.poll_pending(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(_)) => {} + } + } + match self.encoder.encode(&[], true) { + Ok(frame) => { + self.pending = frame; + self.pending_plain_length = 0; + self.end_queued = true; + } + Err(error) => return Poll::Ready(Err(error)), + } + } + if !self.pending.is_empty() { + match self.poll_pending(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(_)) => {} + } + } + match Pin::new(&mut self.inner).poll_flush(cx) { + Poll::Ready(Ok(())) => { + self.end_queued = false; + Poll::Ready(Ok(())) + } + result => result, + } + } +} + +impl AsyncWrite for SnellWriteHalf { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + input: &[u8], + ) -> Poll> { + if !self.pending.is_empty() { + return self.poll_pending(cx); + } + if input.is_empty() { + return Poll::Ready(Ok(0)); + } + let limit = self.encoder.next_payload_limit().min(input.len()); + match self.encoder.encode(&input[..limit], false) { + Ok(frame) => { + self.pending = frame; + self.pending_plain_length = limit; + self.poll_pending(cx) + } + Err(error) => Poll::Ready(Err(error)), + } + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if !self.pending.is_empty() { + match self.poll_pending(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(_)) => {} + } + } + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.as_mut().poll_flush(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(())) => {} + } + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + +pub struct SnellStream { + read: SnellReadHalf, + write: SnellWriteHalf, +} + +impl SnellStream { + pub fn new( + stream: BoxedStream, + password: Arc<[u8]>, + version: SnellVersion, + expect_reply: bool, + ) -> io::Result { + let (read, write) = tokio::io::split(stream); + Ok(Self { + read: SnellReadHalf::new(read, password.clone(), version, expect_reply), + write: SnellWriteHalf::new(write, password, version)?, + }) + } + + pub fn into_split(self) -> (SnellReadHalf, SnellWriteHalf) { + (self.read, self.write) + } + + pub async fn read_frame(&mut self) -> io::Result>> { + self.read.read_frame().await + } + + pub async fn read_event(&mut self) -> io::Result { + self.read.read_event().await + } + + pub async fn write_frame(&mut self, payload: &[u8]) -> io::Result<()> { + self.write.write_frame(payload).await + } + + pub async fn write_end(&mut self) -> io::Result<()> { + self.write.write_end().await + } + + pub(crate) fn poll_session_read( + &mut self, + cx: &mut Context<'_>, + output: &mut ReadBuf<'_>, + ) -> Poll> { + self.read.poll_session_read(cx, output) + } + + pub(crate) fn poll_write_end(&mut self, cx: &mut Context<'_>) -> Poll> { + self.write.poll_write_end(cx) + } +} + +impl AsyncRead for SnellStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + output: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.read).poll_read(cx, output) + } +} + +impl AsyncWrite for SnellStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + input: &[u8], + ) -> Poll> { + Pin::new(&mut self.write).poll_write(cx, input) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.write).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.write).poll_shutdown(cx) + } +} + +fn swap_padding(padding: &mut [u8], payload_cipher: &mut [u8]) { + let limit = padding.len().min(payload_cipher.len()); + for index in (0..limit).step_by(2) { + std::mem::swap(&mut padding[index], &mut payload_cipher[index]); + } +} + +fn make_v4_padding(payload_cipher: &[u8], length: usize) -> io::Result> { + if length == 0 { + return Ok(Vec::new()); + } + let considered = payload_cipher.len() & !3; + let ones: usize = payload_cipher[..considered] + .iter() + .map(|byte| byte.count_ones() as usize) + .sum(); + let zeros = 8 * payload_cipher.len() - ones; + if zeros == 0 { + return random_bytes(length); + } + let ratio = ones as f64 / zeros as f64; + if !(0.5..1.6).contains(&ratio) { + return random_bytes(length); + } + let base = if zeros < ones { 0.4 } else { 1.6 }; + let target_ratio = base + random_unit()? / 10.0; + let total_bits = 8 * (length + payload_cipher.len()); + let target_ones = + (total_bits as f64 * (target_ratio / (target_ratio + 1.0)) - ones as f64) as isize; + if target_ones < 0 || target_ones as usize > 8 * length { + return random_bytes(length); + } + bit_count_padding(length, target_ones as usize) +} + +fn bit_count_padding(length: usize, ones: usize) -> io::Result> { + let total_bits = length * 8; + if ones > total_bits { + return Err(invalid_input("invalid Snell v4 padding bit count")); + } + let mut bits = vec![false; total_bits]; + bits[..ones].fill(true); + for index in (1..total_bits).rev() { + let other = random_below(index + 1)?; + bits.swap(index, other); + } + let mut output = vec![0u8; length]; + for (index, bit) in bits.into_iter().enumerate() { + if bit { + output[index / 8] |= 1 << (index % 8); + } + } + Ok(output) +} + +fn random_bytes(length: usize) -> io::Result> { + let mut output = vec![0u8; length]; + rand::rngs::OsRng.fill_bytes(&mut output); + Ok(output) +} + +fn random_below(maximum: usize) -> io::Result { + if maximum == 0 { + return Err(invalid_input("random upper bound must be positive")); + } + let zone = usize::MAX - usize::MAX % maximum; + loop { + let value = rand::rngs::OsRng.next_u64() as usize; + if value < zone { + return Ok(value % maximum); + } + } +} + +fn random_unit() -> io::Result { + let value = rand::rngs::OsRng.next_u64() >> 11; + Ok(value as f64 / (1u64 << 53) as f64) +} + +fn invalid_data(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +fn invalid_input(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::AsyncReadExt; + + fn boxed(stream: tokio::io::DuplexStream) -> BoxedStream { + Box::pin(stream) + } + + #[tokio::test] + async fn every_version_round_trips_fragmented_bidirectional_records() { + for version in [ + SnellVersion::V1, + SnellVersion::V2, + SnellVersion::V3, + SnellVersion::V4, + SnellVersion::V5, + ] { + let (left, right) = tokio::io::duplex(97); + let password: Arc<[u8]> = Arc::from(&b"correct horse battery staple"[..]); + let mut client = + SnellStream::new(boxed(left), password.clone(), version, false).unwrap(); + let mut server = SnellStream::new(boxed(right), password, version, false).unwrap(); + let payload = vec![version.number(); 32 * 1024]; + let expected = payload.clone(); + let send = tokio::spawn(async move { + client.write_all(&payload).await.unwrap(); + client.flush().await.unwrap(); + }); + let mut received = vec![0u8; expected.len()]; + server.read_exact(&mut received).await.unwrap(); + send.await.unwrap(); + assert_eq!(received, expected); + } + } + + #[tokio::test] + async fn packet_frames_preserve_datagram_boundaries() { + let (left, right) = tokio::io::duplex(64); + let password: Arc<[u8]> = Arc::from(&b"psk"[..]); + let mut client = + SnellStream::new(boxed(left), password.clone(), SnellVersion::V4, false).unwrap(); + let mut server = SnellStream::new(boxed(right), password, SnellVersion::V4, false).unwrap(); + let send = tokio::spawn(async move { + client.write_frame(b"first").await.unwrap(); + client.write_frame(b"second").await.unwrap(); + }); + assert_eq!(server.read_frame().await.unwrap().unwrap(), b"first"); + assert_eq!(server.read_frame().await.unwrap().unwrap(), b"second"); + send.await.unwrap(); + } + + #[tokio::test] + async fn every_version_rejects_an_incorrect_psk() { + for version in [ + SnellVersion::V1, + SnellVersion::V2, + SnellVersion::V3, + SnellVersion::V4, + SnellVersion::V5, + ] { + let (left, right) = tokio::io::duplex(16 * 1024); + let mut client = + SnellStream::new(boxed(left), Arc::from(&b"client-psk"[..]), version, false) + .unwrap(); + let mut server = + SnellStream::new(boxed(right), Arc::from(&b"server-psk"[..]), version, false) + .unwrap(); + + client.write_frame(b"authenticated payload").await.unwrap(); + let error = server.read_event().await.unwrap_err(); + assert_eq!( + error.kind(), + io::ErrorKind::InvalidData, + "version {version:?}" + ); + } + } + + #[tokio::test] + async fn truncated_authenticated_frame_is_never_accepted_as_eof() { + for version in [SnellVersion::V1, SnellVersion::V4] { + let (left, right) = tokio::io::duplex(16 * 1024); + let password: Arc<[u8]> = Arc::from(&b"shared-psk"[..]); + let mut encoder = Encoder::new(password.clone(), version).unwrap(); + let mut frame = encoder + .encode(b"must authenticate completely", false) + .unwrap(); + frame.truncate(frame.len() - 1); + + let send = tokio::spawn(async move { + let mut left = left; + left.write_all(&frame).await.unwrap(); + left.shutdown().await.unwrap(); + }); + let mut server = SnellStream::new(boxed(right), password, version, false).unwrap(); + let error = server.read_event().await.unwrap_err(); + assert_eq!( + error.kind(), + io::ErrorKind::UnexpectedEof, + "version {version:?}" + ); + send.await.unwrap(); + } + } + + #[test] + fn argon2id_matches_mihomo_parameters() { + assert_eq!( + hex::encode(snell_kdf(b"password", &[7u8; 16]).unwrap()), + "8dfb1b2d65df89c27e48e7df6227434c90694924c3eb48383fe6df0cb25712ca" + ); + } +} diff --git a/crates/core-outbound/src/registry.rs b/crates/core-outbound/src/registry.rs index 92016aa..74f2e03 100644 --- a/crates/core-outbound/src/registry.rs +++ b/crates/core-outbound/src/registry.rs @@ -26,7 +26,7 @@ use crate::{ hysteria2::Hysteria2Outbound, mieru::{MieruCipher, MieruOutbound}, shadowsocks::{ShadowsocksOutbound, SsCipher}, - snell::{SnellCipher, SnellOutbound}, + snell::SnellOutbound, ss2022::{Ss22Cipher, Ss2022Outbound}, ssh::SshOutbound, ssr::{SsrCipher, SsrObfs, SsrOutbound, SsrProtocol}, @@ -1398,11 +1398,6 @@ fn build_trojan(node: &ParsedNode) -> Result { } fn build_snell(node: &ParsedNode) -> Result { - let cipher = match node.params.get("cipher").or_else(|| node.method.as_ref()) { - Some(value) => SnellCipher::parse(value) - .ok_or_else(|| format!("unsupported Snell cipher `{value}`"))?, - None => SnellCipher::Aes128Gcm, - }; let pwd = node .password .as_deref() @@ -1411,22 +1406,65 @@ fn build_snell(node: &ParsedNode) -> Result { if pwd.is_empty() { return Err("Snell PSK must not be empty".into()); } - let mut ob = SnellOutbound::new(&node.name, &node.host, node.port, cipher, pwd); - ob.udp = node.udp; - if let Some(value) = node.params.get("version") { - ob.version = value - .parse::() - .map_err(|_| format!("invalid Snell version `{value}`"))?; + let version = node + .params + .get("version") + .map(|value| { + value + .parse::() + .map_err(|_| format!("invalid Snell version `{value}`")) + }) + .transpose()? + .unwrap_or(1); + let mut ob = + SnellOutbound::new(&node.name, &node.host, node.port, pwd).with_version(version)?; + if let Some(cipher) = node.params.get("cipher").or_else(|| node.method.as_ref()) { + let expected = if version == 1 { + "chacha20-poly1305" + } else { + "aes-128-gcm" + }; + let normalized = cipher.to_ascii_lowercase(); + let matches = if version == 1 { + matches!( + normalized.as_str(), + "chacha20-poly1305" | "chacha20-ietf-poly1305" + ) + } else { + matches!(normalized.as_str(), "aes-128-gcm" | "aes128gcm") + }; + if !matches { + return Err(format!( + "unsupported Snell cipher `{cipher}`: v{version} uses fixed cipher `{expected}`" + )); + } } - if let Some(obfs_type) = node.params.get("obfs").map(|s| s.as_str()) { + ob = ob.with_udp(node.udp)?; + let reuse = node + .params + .get("reuse") + .map(|value| match value.to_ascii_lowercase().as_str() { + "true" | "1" | "yes" | "on" => Ok(true), + "false" | "0" | "no" | "off" => Ok(false), + _ => Err(format!("invalid Snell reuse boolean `{value}`")), + }) + .transpose()? + .unwrap_or(false); + ob = ob.with_reuse(reuse)?; + if let Some(obfs_type) = node + .params + .get("obfs") + .or_else(|| node.params.get("obfs-mode")) + .map(String::as_str) + { let obfs_host = node .params .get("obfs-host") .cloned() .unwrap_or_else(|| node.host.clone()); match obfs_type { - "http" => ob = ob.with_obfs_http(obfs_host), - "tls" => ob = ob.with_obfs_tls(obfs_host), + "http" => ob = ob.with_obfs_http(obfs_host)?, + "tls" => ob = ob.with_obfs_tls(obfs_host)?, other => return Err(format!("unsupported Snell obfs `{other}`")), } } @@ -2096,6 +2134,33 @@ mod tests { ); } + #[test] + fn snell_registry_validates_and_registers_all_native_options() { + let mut snell = node(NodeProtocol::Snell); + snell.params.insert("psk".into(), "secret".into()); + snell.params.insert("version".into(), "4".into()); + snell.params.insert("reuse".into(), "true".into()); + snell.params.insert("obfs-mode".into(), "tls".into()); + snell + .params + .insert("obfs-host".into(), "cdn.example.com".into()); + snell.udp = true; + let outbound = build_outbound(&snell).unwrap(); + assert_eq!(outbound.protocol(), "snell"); + assert!(outbound.capabilities().tcp); + assert!(outbound.capabilities().udp); + assert!(outbound.capabilities().multiplex); + + snell.params.insert("reuse".into(), "perhaps".into()); + assert!(build_error(&snell).contains("reuse boolean")); + snell.params.insert("reuse".into(), "true".into()); + snell.params.insert("version".into(), "2".into()); + assert!(build_error(&snell).contains("does not support UDP")); + snell.udp = false; + snell.params.insert("obfs-mode".into(), "websocket".into()); + assert!(build_error(&snell).contains("unsupported Snell obfs")); + } + #[test] fn primary_typed_tls_reaches_xhttp_options_without_string_map_loss() { let mut node = ParsedNode::new("typed-tls", NodeProtocol::Vless, "origin.example", 443); diff --git a/crates/core-outbound/src/transport/mod.rs b/crates/core-outbound/src/transport/mod.rs index 856e7d9..06c4bf3 100644 --- a/crates/core-outbound/src/transport/mod.rs +++ b/crates/core-outbound/src/transport/mod.rs @@ -23,6 +23,7 @@ pub mod grpc_transport; pub mod h2_transport; pub mod http_transport; pub mod reality; +pub mod simple_obfs; #[allow(unsafe_code)] pub mod tcp; pub mod tls; diff --git a/crates/core-outbound/src/transport/simple_obfs.rs b/crates/core-outbound/src/transport/simple_obfs.rs new file mode 100644 index 0000000..c3b2415 --- /dev/null +++ b/crates/core-outbound/src/transport/simple_obfs.rs @@ -0,0 +1,689 @@ +//! SIP003 simple-obfs HTTP and TLS stream camouflage. +//! +//! Both client and server directions are implemented so protocol inbounds can +//! use exactly the same parser as outbounds. Parsing is incremental and +//! bounded; fragmented headers never cause data loss. + +use std::{ + io, + pin::Pin, + task::{Context, Poll}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE}; +use bytes::{Buf, BytesMut}; +use chrono::{DateTime, Utc}; +use rand::RngCore; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf, ReadHalf, WriteHalf}; + +use crate::adapter::BoxedStream; + +const MAX_HEADER_BYTES: usize = 64 * 1024; +const TLS_CHUNK_BYTES: usize = 1 << 14; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SimpleObfsMode { + Http { host: String, port: u16 }, + Tls { host: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Role { + Client, + Server, +} + +pub struct SimpleObfsStream { + read: ObfsRead, + write: ObfsWrite, +} + +impl SimpleObfsStream { + pub fn client(stream: BoxedStream, mode: SimpleObfsMode) -> Self { + Self::new(stream, mode, Role::Client) + } + + pub fn server(stream: BoxedStream, mode: SimpleObfsMode) -> Self { + Self::new(stream, mode, Role::Server) + } + + fn new(stream: BoxedStream, mode: SimpleObfsMode, role: Role) -> Self { + let (read, write) = tokio::io::split(stream); + Self { + read: ObfsRead::new(read, mode.clone(), role), + write: ObfsWrite::new(write, mode, role), + } + } +} + +impl AsyncRead for SimpleObfsStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + output: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.read).poll_read(cx, output) + } +} + +impl AsyncWrite for SimpleObfsStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + input: &[u8], + ) -> Poll> { + Pin::new(&mut self.write).poll_write(cx, input) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.write).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.write).poll_shutdown(cx) + } +} + +struct ObfsRead { + inner: ReadHalf, + mode: SimpleObfsMode, + role: Role, + wire: BytesMut, + plain: BytesMut, + first: bool, + eof: bool, +} + +impl ObfsRead { + fn new(inner: ReadHalf, mode: SimpleObfsMode, role: Role) -> Self { + Self { + inner, + mode, + role, + wire: BytesMut::with_capacity(16 * 1024), + plain: BytesMut::with_capacity(16 * 1024), + first: true, + eof: false, + } + } + + fn try_decode(&mut self) -> io::Result { + match self.mode { + SimpleObfsMode::Http { .. } => self.try_decode_http(), + SimpleObfsMode::Tls { .. } => self.try_decode_tls(), + } + } + + fn try_decode_http(&mut self) -> io::Result { + if !self.first { + if self.wire.is_empty() { + return Ok(false); + } + self.plain.extend_from_slice(&self.wire.split()); + return Ok(true); + } + let Some(header_end) = find_subslice(&self.wire, b"\r\n\r\n") else { + if self.wire.len() > MAX_HEADER_BYTES { + return Err(invalid_data("simple-obfs HTTP header exceeds 64 KiB")); + } + return Ok(false); + }; + let header_length = header_end + 4; + let header = std::str::from_utf8(&self.wire[..header_length]) + .map_err(|_| invalid_data("simple-obfs HTTP header is not UTF-8"))?; + let mut lines = header.split("\r\n"); + let start = lines + .next() + .ok_or_else(|| invalid_data("simple-obfs HTTP start line is missing"))?; + let content_length = header_value(header, "content-length") + .map(|value| { + value + .parse::() + .map_err(|_| invalid_data("invalid simple-obfs Content-Length")) + }) + .transpose()? + .unwrap_or(0); + match self.role { + Role::Client => { + let status = start + .split_whitespace() + .nth(1) + .ok_or_else(|| invalid_data("simple-obfs HTTP status is missing"))?; + if status != "101" { + return Err(invalid_data(format!( + "simple-obfs HTTP server returned status {status}" + ))); + } + } + Role::Server => { + if !start.starts_with("GET ") { + return Err(invalid_data("simple-obfs HTTP request is not GET")); + } + let connection = header_value(header, "connection").unwrap_or_default(); + let upgrade = header_value(header, "upgrade").unwrap_or_default(); + if !connection + .split(',') + .any(|token| token.trim().eq_ignore_ascii_case("upgrade")) + || !upgrade.eq_ignore_ascii_case("websocket") + { + return Err(invalid_data( + "simple-obfs HTTP request is not a WebSocket upgrade", + )); + } + } + } + if self.wire.len() < header_length + content_length { + return Ok(false); + } + self.wire.advance(header_length); + if content_length > 0 { + self.plain + .extend_from_slice(&self.wire.split_to(content_length)); + } + if !self.wire.is_empty() { + self.plain.extend_from_slice(&self.wire.split()); + } + self.first = false; + Ok(true) + } + + fn try_decode_tls(&mut self) -> io::Result { + if self.first { + let decoded = match self.role { + Role::Server => decode_client_hello_ticket(&self.wire)?, + Role::Client => decode_server_hello_payload(&self.wire)?, + }; + let Some((consumed, payload)) = decoded else { + if self.wire.len() > MAX_HEADER_BYTES { + return Err(invalid_data("simple-obfs TLS handshake exceeds 64 KiB")); + } + return Ok(false); + }; + self.wire.advance(consumed); + self.plain.extend_from_slice(&payload); + self.first = false; + return Ok(true); + } + let Some((consumed, payload)) = decode_tls_record(&self.wire, 0x17)? else { + return Ok(false); + }; + let payload = payload.to_vec(); + self.wire.advance(consumed); + self.plain.extend_from_slice(&payload); + Ok(true) + } +} + +impl AsyncRead for ObfsRead { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + output: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + if !self.plain.is_empty() { + let length = output.remaining().min(self.plain.len()); + output.put_slice(&self.plain[..length]); + self.plain.advance(length); + return Poll::Ready(Ok(())); + } + match self.try_decode() { + Ok(true) => continue, + Ok(false) => {} + Err(error) => return Poll::Ready(Err(error)), + } + if self.eof { + return Poll::Ready(if self.wire.is_empty() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "truncated simple-obfs frame", + )) + }); + } + let mut temporary = [0u8; 16 * 1024]; + let mut buffer = ReadBuf::new(&mut temporary); + match Pin::new(&mut self.inner).poll_read(cx, &mut buffer) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(())) if buffer.filled().is_empty() => self.eof = true, + Poll::Ready(Ok(())) => self.wire.extend_from_slice(buffer.filled()), + } + } + } +} + +struct ObfsWrite { + inner: WriteHalf, + mode: SimpleObfsMode, + role: Role, + first: bool, + pending: Vec, + offset: usize, + plain_length: usize, +} + +impl ObfsWrite { + fn new(inner: WriteHalf, mode: SimpleObfsMode, role: Role) -> Self { + Self { + inner, + mode, + role, + first: true, + pending: Vec::new(), + offset: 0, + plain_length: 0, + } + } + + fn encode(&mut self, input: &[u8]) -> io::Result<(Vec, usize)> { + let length = input.len().min(TLS_CHUNK_BYTES); + let payload = &input[..length]; + let encoded = match (&self.mode, self.role, self.first) { + (SimpleObfsMode::Http { host, port }, Role::Client, true) => { + http_request(host, *port, payload) + } + (SimpleObfsMode::Http { .. }, Role::Server, true) => http_response(payload), + (SimpleObfsMode::Http { .. }, _, false) => payload.to_vec(), + (SimpleObfsMode::Tls { host }, Role::Client, true) => tls_client_hello(host, payload)?, + (SimpleObfsMode::Tls { .. }, Role::Server, true) => tls_server_hello(payload)?, + (SimpleObfsMode::Tls { .. }, _, false) => tls_application_record(payload), + }; + self.first = false; + Ok((encoded, length)) + } + + fn poll_pending(&mut self, cx: &mut Context<'_>) -> Poll> { + while self.offset < self.pending.len() { + match Pin::new(&mut self.inner).poll_write(cx, &self.pending[self.offset..]) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(0)) => { + return Poll::Ready(Err(io::ErrorKind::WriteZero.into())); + } + Poll::Ready(Ok(written)) => self.offset += written, + } + } + let length = self.plain_length; + self.pending.clear(); + self.offset = 0; + self.plain_length = 0; + Poll::Ready(Ok(length)) + } +} + +impl AsyncWrite for ObfsWrite { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + input: &[u8], + ) -> Poll> { + if !self.pending.is_empty() { + return self.poll_pending(cx); + } + if input.is_empty() { + return Poll::Ready(Ok(0)); + } + match self.encode(input) { + Ok((encoded, length)) => { + self.pending = encoded; + self.plain_length = length; + self.poll_pending(cx) + } + Err(error) => Poll::Ready(Err(error)), + } + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if !self.pending.is_empty() { + match self.poll_pending(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(_)) => {} + } + } + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.as_mut().poll_flush(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(())) => {} + } + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + +fn http_request(host: &str, port: u16, payload: &[u8]) -> Vec { + let mut random = [0u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut random); + let authority = if port == 80 { + host.to_owned() + } else { + format!("{host}:{port}") + }; + let minor = rand::rngs::OsRng.next_u32() % 54; + let patch = rand::rngs::OsRng.next_u32() % 2; + let header = format!( + "GET / HTTP/1.1\r\nHost: {authority}\r\nUser-Agent: curl/7.{minor}.{patch}\r\n\ + Upgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {}\r\n\ + Content-Length: {}\r\n\r\n", + URL_SAFE.encode(random), + payload.len() + ); + [header.as_bytes(), payload].concat() +} + +fn http_response(payload: &[u8]) -> Vec { + let mut random = [0u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut random); + let now: DateTime = SystemTime::now().into(); + let header = format!( + "HTTP/1.1 101 Switching Protocols\r\nServer: nginx/1.8.1\r\nDate: {}\r\n\ + Upgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {}\r\n\ + Content-Length: {}\r\n\r\n", + now.format("%a, %d %b %Y %H:%M:%S GMT"), + URL_SAFE.encode(random), + payload.len() + ); + [header.as_bytes(), payload].concat() +} + +fn header_value<'a>(header: &'a str, name: &str) -> Option<&'a str> { + header.split("\r\n").skip(1).find_map(|line| { + let (field, value) = line.split_once(':')?; + field + .trim() + .eq_ignore_ascii_case(name) + .then_some(value.trim()) + }) +} + +fn tls_client_hello(host: &str, payload: &[u8]) -> io::Result> { + let host = host.as_bytes(); + let host_length = u16::try_from(host.len()) + .map_err(|_| invalid_input("simple-obfs TLS hostname is too long"))?; + let mut random = [0u8; 28]; + let mut session_id = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut random); + rand::rngs::OsRng.fill_bytes(&mut session_id); + let mut body = Vec::with_capacity(256 + host.len() + payload.len()); + body.extend_from_slice(&[0x03, 0x03]); + body.extend_from_slice( + &(SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as u32) + .to_be_bytes(), + ); + body.extend_from_slice(&random); + body.push(32); + body.extend_from_slice(&session_id); + const CIPHERS: &[u8] = &[ + 0xc0, 0x2c, 0xc0, 0x30, 0x00, 0x9f, 0xcc, 0xa9, 0xcc, 0xa8, 0xcc, 0xaa, 0xc0, 0x2b, 0xc0, + 0x2f, 0x00, 0x9e, 0xc0, 0x24, 0xc0, 0x28, 0x00, 0x6b, 0xc0, 0x23, 0xc0, 0x27, 0x00, 0x67, + 0xc0, 0x0a, 0xc0, 0x14, 0x00, 0x39, 0xc0, 0x09, 0xc0, 0x13, 0x00, 0x33, 0x00, 0x9d, 0x00, + 0x9c, 0x00, 0x3d, 0x00, 0x3c, 0x00, 0x35, 0x00, 0x2f, 0x00, 0xff, + ]; + body.extend_from_slice(&(CIPHERS.len() as u16).to_be_bytes()); + body.extend_from_slice(CIPHERS); + body.extend_from_slice(&[1, 0]); + let mut extensions = Vec::new(); + extensions.extend_from_slice(&[0, 0x23]); + extensions.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + extensions.extend_from_slice(payload); + extensions.extend_from_slice(&[0, 0]); + extensions.extend_from_slice(&(host_length + 5).to_be_bytes()); + extensions.extend_from_slice(&(host_length + 3).to_be_bytes()); + extensions.push(0); + extensions.extend_from_slice(&host_length.to_be_bytes()); + extensions.extend_from_slice(host); + extensions.extend_from_slice(&[ + 0x00, 0x0b, 0x00, 0x04, 0x03, 0x01, 0x00, 0x02, 0x00, 0x0a, 0x00, 0x0a, 0x00, 0x08, 0x00, + 0x1d, 0x00, 0x17, 0x00, 0x19, 0x00, 0x18, 0x00, 0x0d, 0x00, 0x20, 0x00, 0x1e, 0x06, 0x01, + 0x06, 0x02, 0x06, 0x03, 0x05, 0x01, 0x05, 0x02, 0x05, 0x03, 0x04, 0x01, 0x04, 0x02, 0x04, + 0x03, 0x03, 0x01, 0x03, 0x02, 0x03, 0x03, 0x02, 0x01, 0x02, 0x02, 0x02, 0x03, 0x00, 0x16, + 0x00, 0x00, 0x00, 0x17, 0x00, 0x00, + ]); + body.extend_from_slice(&(extensions.len() as u16).to_be_bytes()); + body.extend_from_slice(&extensions); + let mut handshake = Vec::with_capacity(body.len() + 4); + handshake.push(1); + put_u24(&mut handshake, body.len())?; + handshake.extend_from_slice(&body); + tls_record(0x16, 0x0301, &handshake) +} + +fn tls_server_hello(payload: &[u8]) -> io::Result> { + let mut random = [0u8; 28]; + let mut session_id = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut random); + rand::rngs::OsRng.fill_bytes(&mut session_id); + let mut body = Vec::with_capacity(87); + body.extend_from_slice(&[0x03, 0x03]); + body.extend_from_slice( + &(SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as u32) + .to_be_bytes(), + ); + body.extend_from_slice(&random); + body.push(32); + body.extend_from_slice(&session_id); + body.extend_from_slice(&[0xcc, 0xa8, 0]); + body.extend_from_slice(&[ + 0x00, 0x00, 0xff, 0x01, 0x00, 0x01, 0x00, 0x00, 0x17, 0x00, 0x00, 0x00, 0x0b, 0x00, 0x02, + 0x01, 0x00, + ]); + let mut handshake = Vec::new(); + handshake.push(2); + put_u24(&mut handshake, body.len())?; + handshake.extend_from_slice(&body); + let mut output = tls_record(0x16, 0x0301, &handshake)?; + output.extend_from_slice(&tls_record(0x14, 0x0303, &[1])?); + output.extend_from_slice(&tls_record(0x16, 0x0303, payload)?); + Ok(output) +} + +fn tls_application_record(payload: &[u8]) -> Vec { + tls_record(0x17, 0x0303, payload).expect("TLS chunk is bounded to u16") +} + +fn tls_record(kind: u8, version: u16, payload: &[u8]) -> io::Result> { + let length = u16::try_from(payload.len()) + .map_err(|_| invalid_input("simple-obfs TLS record exceeds u16"))?; + let mut output = Vec::with_capacity(5 + payload.len()); + output.push(kind); + output.extend_from_slice(&version.to_be_bytes()); + output.extend_from_slice(&length.to_be_bytes()); + output.extend_from_slice(payload); + Ok(output) +} + +fn decode_tls_record(input: &[u8], expected_kind: u8) -> io::Result> { + if input.len() < 5 { + return Ok(None); + } + if input[0] != expected_kind || !matches!(&input[1..3], [0x03, 0x01] | [0x03, 0x03]) { + return Err(invalid_data("invalid simple-obfs TLS record header")); + } + let length = u16::from_be_bytes([input[3], input[4]]) as usize; + if input.len() < 5 + length { + return Ok(None); + } + Ok(Some((5 + length, &input[5..5 + length]))) +} + +fn decode_client_hello_ticket(input: &[u8]) -> io::Result)>> { + let Some((consumed, handshake)) = decode_tls_record(input, 0x16)? else { + return Ok(None); + }; + if handshake.len() < 4 || handshake[0] != 1 { + return Err(invalid_data("simple-obfs TLS message is not ClientHello")); + } + let declared = read_u24(&handshake[1..4]); + if handshake.len() != 4 + declared { + return Err(invalid_data("invalid simple-obfs ClientHello length")); + } + let body = &handshake[4..]; + let mut offset = 34; + let session_length = read_u8(body, &mut offset)? as usize; + take(body, &mut offset, session_length)?; + let cipher_length = read_u16(body, &mut offset)? as usize; + take(body, &mut offset, cipher_length)?; + let compression_length = read_u8(body, &mut offset)? as usize; + take(body, &mut offset, compression_length)?; + let extensions_length = read_u16(body, &mut offset)? as usize; + let extensions = take(body, &mut offset, extensions_length)?; + if offset != body.len() { + return Err(invalid_data( + "simple-obfs ClientHello contains trailing bytes", + )); + } + let mut cursor = 0; + while cursor < extensions.len() { + let kind = read_u16(extensions, &mut cursor)?; + let length = read_u16(extensions, &mut cursor)? as usize; + let value = take(extensions, &mut cursor, length)?; + if kind == 0x23 { + return Ok(Some((consumed, value.to_vec()))); + } + } + Err(invalid_data( + "simple-obfs ClientHello has no session-ticket payload", + )) +} + +fn decode_server_hello_payload(input: &[u8]) -> io::Result)>> { + let Some((first, server_hello)) = decode_tls_record(input, 0x16)? else { + return Ok(None); + }; + if server_hello.first() != Some(&2) { + return Err(invalid_data("simple-obfs TLS message is not ServerHello")); + } + let Some((second, change_cipher)) = decode_tls_record(&input[first..], 0x14)? else { + return Ok(None); + }; + if change_cipher != [1] { + return Err(invalid_data("invalid simple-obfs ChangeCipherSpec")); + } + let Some((third, payload)) = decode_tls_record(&input[first + second..], 0x16)? else { + return Ok(None); + }; + Ok(Some((first + second + third, payload.to_vec()))) +} + +fn read_u8(input: &[u8], offset: &mut usize) -> io::Result { + let value = *input + .get(*offset) + .ok_or_else(|| invalid_data("truncated simple-obfs TLS structure"))?; + *offset += 1; + Ok(value) +} + +fn read_u16(input: &[u8], offset: &mut usize) -> io::Result { + let value = take(input, offset, 2)?; + Ok(u16::from_be_bytes([value[0], value[1]])) +} + +fn take<'a>(input: &'a [u8], offset: &mut usize, length: usize) -> io::Result<&'a [u8]> { + let end = offset + .checked_add(length) + .ok_or_else(|| invalid_data("simple-obfs TLS offset overflow"))?; + let value = input + .get(*offset..end) + .ok_or_else(|| invalid_data("truncated simple-obfs TLS structure"))?; + *offset = end; + Ok(value) +} + +fn put_u24(output: &mut Vec, value: usize) -> io::Result<()> { + if value > 0x00ff_ffff { + return Err(invalid_input("simple-obfs handshake exceeds u24")); + } + output.extend_from_slice(&[ + ((value >> 16) & 0xff) as u8, + ((value >> 8) & 0xff) as u8, + (value & 0xff) as u8, + ]); + Ok(()) +} + +fn read_u24(input: &[u8]) -> usize { + (input[0] as usize) << 16 | (input[1] as usize) << 8 | input[2] as usize +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +fn invalid_input(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, message.into()) +} + +fn invalid_data(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +#[cfg(test)] +mod tests { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + use super::*; + + fn boxed(stream: tokio::io::DuplexStream) -> BoxedStream { + Box::pin(stream) + } + + async fn round_trip(mode: SimpleObfsMode) { + let (left, right) = tokio::io::duplex(31); + let mut client = SimpleObfsStream::client(boxed(left), mode.clone()); + let mut server = SimpleObfsStream::server(boxed(right), mode); + let request = b"client encrypted bytes".repeat(100); + let expected_request = request.clone(); + let client_send = tokio::spawn(async move { + client.write_all(&request).await.unwrap(); + client.flush().await.unwrap(); + let mut response = vec![0u8; 2100]; + client.read_exact(&mut response).await.unwrap(); + response + }); + let mut decoded_request = vec![0u8; expected_request.len()]; + server.read_exact(&mut decoded_request).await.unwrap(); + assert_eq!(decoded_request, expected_request); + server.write_all(&vec![9u8; 2100]).await.unwrap(); + server.flush().await.unwrap(); + assert_eq!(client_send.await.unwrap(), vec![9u8; 2100]); + } + + #[tokio::test] + async fn http_client_and_server_round_trip_fragmented_io() { + round_trip(SimpleObfsMode::Http { + host: "example.com".into(), + port: 443, + }) + .await; + } + + #[tokio::test] + async fn tls_client_and_server_round_trip_fragmented_io() { + round_trip(SimpleObfsMode::Tls { + host: "example.com".into(), + }) + .await; + } + + #[test] + fn client_hello_ticket_parser_extracts_payload_and_sni_structure() { + let hello = tls_client_hello("example.com", b"ciphertext").unwrap(); + let (consumed, payload) = decode_client_hello_ticket(&hello).unwrap().unwrap(); + assert_eq!(consumed, hello.len()); + assert_eq!(payload, b"ciphertext"); + } +} diff --git a/crates/wuther-core/src/host_resources.rs b/crates/wuther-core/src/host_resources.rs index 7abf2ad..382d808 100644 --- a/crates/wuther-core/src/host_resources.rs +++ b/crates/wuther-core/src/host_resources.rs @@ -42,6 +42,21 @@ pub(crate) fn listener_resource_claims(plan: &RuntimePlan) -> Result anyhow::Result<()> { @@ -862,6 +905,10 @@ async fn cmd_run(config: PathBuf) -> anyhow::Result<()> { // 任何一项失败都会关闭已启动的同类监听并直接返回 cmd_run。 let mut xhttp_listener_handles = start_configured_xhttp_inbounds(&plan, runtime.clone()).await?; + // Snell 同样采用预绑定、事务式启动;配置、PSK、混淆或端口有误时, + // 不允许进程以“配置存在但监听未运行”的半成功状态继续。 + let mut snell_listener_handles = + start_configured_snell_inbounds(&plan, runtime.clone()).await?; // 把运行期 LogBus 挂到 tracing 桥上 —— 让 /v1/logs 与 Clash 兼容 /logs WS // 流式输出。tracing 可能已被早期初始化占用,所以 observe 层使用可后挂载的 @@ -1110,6 +1157,16 @@ async fn cmd_run(config: PathBuf) -> anyhow::Result<()> { warn!(target: "mesh", error = %error, "mesh supervisor stop failed"); } feed_mgr_handle.stop(); + for listener in &mut snell_listener_handles { + if let Err(error) = listener.shutdown().await { + warn!( + target: "inbound::snell", + tag = listener.tag(), + %error, + "Snell listener shutdown failed" + ); + } + } for listener in &mut xhttp_listener_handles { if let Err(error) = listener.shutdown().await { warn!( @@ -1151,6 +1208,15 @@ async fn start_configured_xhttp_inbounds( .context("XHTTP 入站启动失败") } +async fn start_configured_snell_inbounds( + plan: &core_config::runtime_plan::RuntimePlan, + runtime: Arc, +) -> anyhow::Result> { + start_snell_listeners(&plan.listen.snell, runtime) + .await + .context("Snell 入站启动失败") +} + /// 把 [`core_ruleset::RulesetIndex`] 适配为 [`core_capture::IpSetProvider`]。 /// /// `route_address_set: ["geoip-cn"]` → 查 ruleset_index 的 `geoip-cn`, diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 82ee469..30a5330 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -24,7 +24,7 @@ wuther-core run -c config.yaml | `profile` | `desktop`、`router`、`server` 或 `mobile` 默认值 | | `name` | 配置显示名称 | | `log` | 日志级别、过滤器和文件输出 | -| `listen` | Mixed 入站、管理面板、共享和认证 | +| `listen` | Mixed、XHTTP、Snell 等入站、管理面板、共享和认证 | | `feeds` | 订阅源 | | `nodes` | 手动节点 | | `groups` | 节点选择策略 | @@ -156,6 +156,12 @@ ui: 端点和鉴权方式见 [管理 API](API.md)。 +## Snell 入站与节点 + +Snell v1–v5 可同时作为出站节点和服务端监听使用。`listen.snell` 支持单个 +对象或数组,并在启动前预绑定端口;版本、UDP、连接复用、HTTP/TLS +simple-obfs 和所有服务端资源字段见 [Snell 配置](SNELL.md)。 + ## 迁移与升级 ```bash diff --git a/docs/FEATURES.md b/docs/FEATURES.md index aa7272d..ec206c2 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -40,12 +40,12 @@ | Shadowsocks | Shadowsocks、Shadowsocks 2022、SSR | 包含多种 AEAD/流加密与 SSR 组件 | | 经典 TLS | Trojan、VLESS、VMess | 支持对应 TLS、UUID 与安全参数 | | 现代隧道 | AnyTLS、Hysteria、Hysteria 2、TUIC | 包含 TLS/QUIC 路径与协议参数 | -| 专用协议 | Snell、Mieru、Sudoku、TrustTunnel | 按各自握手、加密和复用模型实现 | +| 专用协议 | Snell、Mieru、Sudoku、TrustTunnel | Snell v1–v5 含 TCP、v3+ UDP、复用和 HTTP/TLS simple-obfs;其余按各自握手、加密和复用模型实现 | | 系统隧道 | WireGuard、SSH | 密钥或主机校验需要单独配置 | ## 传输与解析 -代码中包含 TCP、TLS、WebSocket、HTTP、HTTP/2、gRPC 与 XHTTP 等传输配置路径。XHTTP 的客户端、服务端、TLS/ECH 和完整字段说明见 [XHTTP / SplitHTTP 配置](XHTTP.md)。可用组合由具体协议、节点字段和服务端实现共同决定;不要假设任意协议都能与任意传输组合。 +代码中包含 TCP、TLS、WebSocket、HTTP、HTTP/2、gRPC 与 XHTTP 等传输配置路径。XHTTP 的客户端、服务端、TLS/ECH 和完整字段说明见 [XHTTP / SplitHTTP 配置](XHTTP.md),Snell 的版本、UDP、复用、混淆和服务端字段见 [Snell 配置](SNELL.md)。可用组合由具体协议、节点字段和服务端实现共同决定;不要假设任意协议都能与任意传输组合。 节点来源支持: diff --git a/docs/README.md b/docs/README.md index b9417e2..00819b9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ | --- | --- | | [功能矩阵](FEATURES.md) | 当前有哪些能力,哪些行为依赖平台或服务端 | | [配置指南](CONFIGURATION.md) | 如何选择 Profile、组织配置、校验与迁移 | +| [Snell 配置](SNELL.md) | Snell v1–v5 客户端、服务端、UDP、复用与 simple-obfs | | [Linux TUN auto_redirect](LINUX-TUN-AUTO-REDIRECT.md) | 本机 TCP REDIRECT + UDP TUN 的前置条件、安全边界和回滚 | | [管理 API](API.md) | 如何鉴权、查询状态和控制运行时 | | [排错手册](TROUBLESHOOTING.md) | TUN、DNS、订阅、权限和连接失败如何定位 | diff --git a/docs/SNELL.md b/docs/SNELL.md new file mode 100644 index 0000000..abe9508 --- /dev/null +++ b/docs/SNELL.md @@ -0,0 +1,95 @@ +# Snell 配置 + +WutherCore 实现 Snell v1–v5 的客户端与服务端。记录层与 Mihomo +`transport/snell` 对齐:v1 使用 ChaCha20-Poly1305,v2/v3 使用 +AES-128-GCM,v4/v5 使用带自适应首帧填充的 v4 记录格式;各方向均以 +16 字节随机盐和 Argon2id 派生独立密钥。 + +## 出站节点 + +Mihomo/Clash YAML 中支持以下字段: + +```yaml +nodes: + - name: snell-v5 + type: snell + server: proxy.example.com + port: 443 + psk: replace-with-a-strong-secret + version: 5 + udp: true + reuse: true + obfs-opts: + mode: tls + host: cdn.example.com +``` + +| 字段 | 必填 | 说明 | +| --- | :---: | --- | +| `server`、`port` | 是 | Snell 服务端地址 | +| `psk` | 是 | 预共享密钥,不允许为空 | +| `version` | 否 | `1`–`5`,默认 `1` | +| `udp` | 否 | v3–v5 可用;v1/v2 配置为 `true` 会被拒绝 | +| `reuse` | 否 | v4/v5 显式开启;v2 按协议要求自动复用 | +| `obfs-opts.mode` | 否 | `http` 或 `tls` | +| `obfs-opts.host` | 否 | simple-obfs 伪装主机;省略时使用 `server` | + +旧配置中的 `cipher` 仅作为一致性校验:v1 必须是 +`chacha20-poly1305`,v2–v5 必须是 `aes-128-gcm`。算法由协议版本固定, +不能用该字段替换。 + +## 服务端监听 + +`listen.snell` 可写单个对象或数组。监听在主进程报告启动成功前完成 +配置校验和 TCP 预绑定;任一监听失败时,同批已启动监听会被关闭。 + +```yaml +profile: server + +listen: + panel: false + snell: + - enabled: true + address: 0.0.0.0 + port: 443 + psk: replace-with-a-strong-secret + version: 5 + udp: true + obfs-opts: + mode: tls + host: cdn.example.com + handshake-timeout: 10s + max-connections: 4096 + tag: public-snell +``` + +| 字段 | 默认值 | 说明 | +| --- | --- | --- | +| `enabled` | `true` | 是否启动此监听 | +| `address` | `127.0.0.1` | TCP 绑定地址;也接受别名 `host` | +| `port` | 无 | 必须为 `1`–`65535` | +| `psk` | 无 | 必填预共享密钥 | +| `version` | `4` | `1`–`5` | +| `udp` | `false` | 启用 TCP 承载的 Snell UDP;仅 v3–v5 | +| `obfs-opts` | 无 | 与客户端相同的 HTTP/TLS simple-obfs | +| `handshake-timeout` | `10s` | 首个认证命令的读取期限,必须大于零 | +| `max-connections` | `4096` | 并发底层 TCP 连接上限,范围 `1`–`65535` | +| `tag` | `snell-N` | 入站名称;同一配置内必须唯一 | + +UDP 关联支持同一 Snell 连接内最多 64 个目标,每个目标建立独立的运行时 +路由、出站关联和连接计费。协议帧最大载荷为 `0x3fff` 字节,超过上限的 +数据报会被拒绝,不会截断。 + +## 版本行为 + +| 版本 | 加密记录 | TCP | UDP | 顺序连接复用 | +| --- | --- | :---: | :---: | :---: | +| v1 | ChaCha20-Poly1305 | 是 | 否 | 否 | +| v2 | AES-128-GCM | 是 | 否 | 自动 | +| v3 | AES-128-GCM | 是 | 是 | 否 | +| v4 | AES-128-GCM + 填充 | 是 | 是 | `reuse: true` | +| v5 | v4 兼容记录 | 是 | 是 | `reuse: true` | + +服务端的 TCP 与 UDP 都进入统一 `ListenerHandler`,因此会应用正常的域名 +解析、路由规则、代理链、连接表与上下行计费。暴露到公网时请使用强随机 +PSK,并配合系统防火墙限制来源。