From b5ff7a6abccbc8a7c5a3204dcc81b40733303a87 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 19:12:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0=20?= =?UTF-8?q?Naive=20Cronet=20=E5=87=BA=E7=AB=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/naive.yml | 69 ++ Cargo.lock | 124 +++- Cargo.toml | 2 + README.md | 2 +- crates/core-config/src/node_uri.rs | 48 +- crates/core-feeds/src/parser.rs | 54 +- crates/core-outbound/Cargo.toml | 14 + crates/core-outbound/src/proto/mod.rs | 2 + crates/core-outbound/src/proto/naive.rs | 810 ++++++++++++++++++++++++ crates/core-outbound/src/registry.rs | 211 ++++++ crates/wuther-core/Cargo.toml | 4 + docs/CONFIGURATION.md | 2 + docs/FEATURES.md | 3 +- docs/NAIVE.md | 112 ++++ 14 files changed, 1448 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/naive.yml create mode 100644 crates/core-outbound/src/proto/naive.rs create mode 100644 docs/NAIVE.md diff --git a/.github/workflows/naive.yml b/.github/workflows/naive.yml new file mode 100644 index 0000000..729ba35 --- /dev/null +++ b/.github/workflows/naive.yml @@ -0,0 +1,69 @@ +name: Naive CI + +on: + pull_request: + branches: ["main"] + paths: + - ".github/workflows/naive.yml" + - "Cargo.lock" + - "Cargo.toml" + - "crates/core-config/**" + - "crates/core-feeds/**" + - "crates/core-outbound/**" + - "crates/wuther-core/**" + push: + branches: ["main"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: naive-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: 1 + +jobs: + naive: + name: Naive / Cronet + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2.9.1 + with: + prefix-key: v1-wuther-core + shared-key: naive + workspaces: | + . -> target + cache-bin: false + + - name: Fetch verified Cronet SDK + shell: bash + run: | + git clone --branch v0.2.0 --depth 1 https://github.com/MiChongs/cronet-rs.git "$RUNNER_TEMP/cronet-rs" + "$RUNNER_TEMP/cronet-rs/scripts/fetch-native.sh" amd64 "$RUNNER_TEMP/cronet-sdk" + echo "CRONET_LIB_DIR=$RUNNER_TEMP/cronet-sdk/lib" >> "$GITHUB_ENV" + echo "CRONET_LIB_NAME=cronet" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$RUNNER_TEMP/cronet-sdk/lib" >> "$GITHUB_ENV" + + - name: Check Naive feature + run: cargo check -p wuther-core --features naive --all-targets --locked + + - name: Test Naive URI and native tunnel + run: | + cargo test -p core-config parse_naive_h2_and_quic_links --locked + cargo test -p core-outbound --features naive naive --locked diff --git a/Cargo.lock b/Cargo.lock index da2721d..f4f338b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -859,7 +859,7 @@ dependencies = [ "dashmap", "futures", "ipnet", - "jni", + "jni 0.21.1", "libc", "netstack-smoltcp", "nix 0.29.0", @@ -1049,13 +1049,16 @@ dependencies = [ "core-config", "core-reality", "crc32fast", + "cronet", "ctr", "curve25519-dalek", "fnv", "futures", + "h2 0.4.15", "h3", "h3-quinn", "hex", + "hickory-proto 0.26.1", "hkdf", "hmac", "http 1.4.2", @@ -1073,6 +1076,7 @@ dependencies = [ "rand 0.8.7", "rand_chacha 0.3.1", "rc4", + "rcgen", "russh", "russh-keys", "rustls 0.23.40", @@ -1083,6 +1087,7 @@ dependencies = [ "smoltcp 0.11.0", "socket2 0.5.10", "thiserror 2.0.19", + "time", "tokio", "tokio-rustls 0.26.4", "tokio-tungstenite", @@ -1098,7 +1103,7 @@ dependencies = [ name = "core-process" version = "0.3.1-rc.5" dependencies = [ - "jni", + "jni 0.21.1", "libc", "once_cell", "parking_lot", @@ -1295,6 +1300,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "cronet" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b371e85816cbf0626835c087a3907b7adc4192e23e30035894f304bf998338" +dependencies = [ + "base64 0.22.1", + "cronet-sys", + "fastrand", + "futures-channel", + "hickory-proto 0.26.1", + "serde_json", + "url", +] + +[[package]] +name = "cronet-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da4d0f1ba8f81c654c131f9d471bb782ad2de688d7c9bec1636f512bf0daf4f" + [[package]] name = "crossbeam-channel" version = "0.5.16" @@ -2103,6 +2135,25 @@ dependencies = [ "url", ] +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "rand 0.10.2", + "ring", + "thiserror 2.0.19", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "hickory-resolver" version = "0.24.4" @@ -2111,7 +2162,7 @@ checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" dependencies = [ "cfg-if", "futures-util", - "hickory-proto", + "hickory-proto 0.24.4", "lru-cache", "once_cell", "parking_lot", @@ -2478,6 +2529,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.19", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2971,6 +3052,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -3084,6 +3169,16 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3204,6 +3299,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + [[package]] name = "potential_utf" version = "0.1.5" @@ -3425,6 +3526,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" dependencies = [ "aws-lc-rs", + "pem", "ring", "rustls-pki-types", "time", @@ -4061,6 +4163,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index a9e4730..3f1fa94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ bytes = "1" url = "2" ipnet = { version = "2", features = ["serde"] } hickory-resolver = { version = "0.24", default-features = false, features = ["tokio-runtime", "dns-over-rustls", "dns-over-https-rustls"] } +hickory-proto = "0.26" # Hash / 集合 / 内存优化 ahash = "0.8" @@ -114,6 +115,7 @@ sha3 = "0.10" sha1 = "0.10" rc4 = "0.1" chacha20 = "0.10" +cronet = "0.2.0" # QUIC + UDP 协议(Hysteria / TUIC) quinn = { version = "0.11", default-features = false, features = ["rustls", "ring", "runtime-tokio"] } diff --git a/README.md b/README.md index bec0eb1..7a7552d 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ WutherCore 读取 YAML 配置,负责订阅更新、节点选择、规则分流 | 基础 | Shadowsocks 系列 | TLS / UUID 系列 | QUIC / 隧道 | | --- | --- | --- | --- | -| Direct、Block、HTTP、SOCKS5、DNS Hijack | Shadowsocks、Shadowsocks 2022、SSR、Snell | Trojan、VLESS、VMess、AnyTLS | Hysteria、Hysteria 2、TUIC、WireGuard、SSH、Mieru、Sudoku、TrustTunnel | +| Direct、Block、HTTP、SOCKS5、DNS Hijack | Shadowsocks、Shadowsocks 2022、SSR、Snell | Trojan、VLESS、VMess、AnyTLS | Hysteria、Hysteria 2、TUIC、WireGuard、SSH、Mieru、Sudoku、TrustTunnel、[Naive(可选)](docs/NAIVE.md) | 不同协议的 UDP、复用和传输层组合并不完全相同。功能矩阵只表示代码路径已经实现,不代替与具体服务端版本的兼容性测试。 diff --git a/crates/core-config/src/node_uri.rs b/crates/core-config/src/node_uri.rs index c5ecaab..5503cdb 100644 --- a/crates/core-config/src/node_uri.rs +++ b/crates/core-config/src/node_uri.rs @@ -27,6 +27,7 @@ pub enum NodeProtocol { Vmess, Vless, Trojan, + Naive, Hysteria, Hysteria2, Tuic, @@ -56,6 +57,8 @@ impl NodeProtocol { "vmess" => Self::Vmess, "vless" => Self::Vless, "trojan" => Self::Trojan, + "naive" | "naive+https" => Self::Naive, + "naive+quic" => Self::Naive, "hysteria" => Self::Hysteria, "hysteria2" | "hy2" => Self::Hysteria2, "tuic" => Self::Tuic, @@ -82,6 +85,7 @@ impl NodeProtocol { Self::Vmess => "vmess", Self::Vless => "vless", Self::Trojan => "trojan", + Self::Naive => "naive", Self::Hysteria => "hysteria", Self::Hysteria2 => "hysteria2", Self::Tuic => "tuic", @@ -166,6 +170,7 @@ pub fn parse_uri(uri: &str) -> ConfigResult { NodeProtocol::Vmess => parse_vmess(uri)?, NodeProtocol::Vless | NodeProtocol::Trojan + | NodeProtocol::Naive | NodeProtocol::Hysteria2 | NodeProtocol::Tuic | NodeProtocol::Hysteria => parse_url_like(uri, proto)?, @@ -221,20 +226,29 @@ fn require_host_port(url: &Url) -> ConfigResult<(String, u16)> { let port = url .port_or_known_default() .ok_or_else(|| ConfigError::bad_node(format!("URI 缺少端口: {url}")))?; - Ok((host.to_string(), port)) + Ok(( + host.strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(host) + .to_string(), + port, + )) } fn parse_url_like(uri: &str, proto: NodeProtocol) -> ConfigResult { let url = Url::parse(uri).map_err(|e| ConfigError::bad_node(format!("非法 URI: {e}")))?; let (host, port) = require_host_port(&url)?; let name = fragment_name(&url, &format!("{}-{}", proto.as_str(), host)); - let params = collect_params(&url); + let mut params = collect_params(&url); + if scheme_is_naive_quic(uri) { + params.insert("quic".into(), "true".into()); + } let mut node = ParsedNode::new(name, proto.clone(), host, port); node.raw = uri.to_string(); node.tls = matches!( proto, - NodeProtocol::Trojan | NodeProtocol::Hysteria2 | NodeProtocol::Tuic + NodeProtocol::Trojan | NodeProtocol::Naive | NodeProtocol::Hysteria2 | NodeProtocol::Tuic ) || params .get("security") .map(|s| matches!(s.as_str(), "tls" | "reality")) @@ -279,6 +293,11 @@ fn parse_url_like(uri: &str, proto: NodeProtocol) -> ConfigResult { Ok(node) } +fn scheme_is_naive_quic(uri: &str) -> bool { + uri.get(..uri.find("://").unwrap_or_default()) + .is_some_and(|scheme| scheme.eq_ignore_ascii_case("naive+quic")) +} + fn reality_settings_from_params( params: &std::collections::BTreeMap, fallback_server_name: &str, @@ -633,6 +652,29 @@ mod tests { assert!(n.tls); } + #[test] + fn parse_naive_h2_and_quic_links() { + let h2 = parse_uri( + "naive://alice:secret@proxy.example:443?insecure_concurrency=2&udp_over_tcp=true&sni=edge.example#H2", + ) + .unwrap(); + assert_eq!(h2.protocol, NodeProtocol::Naive); + assert_eq!(h2.user.as_deref(), Some("alice")); + assert_eq!(h2.password.as_deref(), Some("secret")); + assert_eq!(h2.sni.as_deref(), Some("edge.example")); + assert!(h2.tls); + assert_eq!( + h2.params.get("udp_over_tcp").map(String::as_str), + Some("true") + ); + + let quic = + parse_uri("naive+quic://u:p@[2001:db8::1]:443?quic_congestion_control=bbr2").unwrap(); + assert_eq!(quic.protocol, NodeProtocol::Naive); + assert_eq!(quic.host, "2001:db8::1"); + assert_eq!(quic.params.get("quic").map(String::as_str), Some("true")); + } + #[test] fn parse_tuic_v5_credentials_and_options() { let n = parse_uri( diff --git a/crates/core-feeds/src/parser.rs b/crates/core-feeds/src/parser.rs index ebb2aed..bae110e 100644 --- a/crates/core-feeds/src/parser.rs +++ b/crates/core-feeds/src/parser.rs @@ -135,13 +135,17 @@ fn clash_proxy_to_node(m: &serde_yaml::Mapping) -> Option { let proto = NodeProtocol::from_scheme(&kind); let mut node = ParsedNode::new(name, proto.clone(), host, port); + node.user = str_g("username").or_else(|| str_g("user")); node.password = str_g("password"); node.uuid = str_g("uuid"); node.method = str_g("cipher").or_else(|| str_g("method")); node.tls = g("tls").and_then(|v| v.as_bool()).unwrap_or(false) || matches!( proto, - NodeProtocol::Trojan | NodeProtocol::Hysteria2 | NodeProtocol::Tuic + NodeProtocol::Trojan + | NodeProtocol::Naive + | NodeProtocol::Hysteria2 + | NodeProtocol::Tuic ); node.sni = str_g("sni").or_else(|| str_g("servername")); if let Some(net) = str_g("network") { @@ -169,6 +173,8 @@ fn clash_proxy_to_node(m: &serde_yaml::Mapping) -> Option { | "type" | "server" | "port" + | "username" + | "user" | "password" | "uuid" | "cipher" @@ -263,6 +269,18 @@ fn clash_proxy_to_node(m: &serde_yaml::Mapping) -> Option { } } + // Naive's extra headers are a map rather than scalars. Preserve them with + // an unambiguous prefix for core-outbound. + for key in ["extra-headers", "extra_headers"] { + if let Some(headers) = g(key).and_then(|v| v.as_mapping().cloned()) { + for (name, value) in headers { + if let (Some(name), Some(value)) = (name.as_str(), scalar_to_string(&value)) { + node.params.insert(format!("extra-header.{name}"), value); + } + } + } + } + Some(node) } @@ -430,6 +448,40 @@ proxies: assert_eq!(nodes[1].method.as_deref(), Some("aes-256-gcm")); } + #[test] + fn parse_naive_clash_headers_and_transport_options() { + let yaml = r#" +proxies: + - name: Naive-H3 + type: naive + server: proxy.example.com + port: 443 + username: alice + password: secret + udp: true + udp-over-tcp: true + quic: true + extra-headers: + X-Client: WutherCore +"#; + let nodes = parse_feed_payload(yaml.as_bytes(), FormatHint::Auto); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].protocol, NodeProtocol::Naive); + assert_eq!(nodes[0].user.as_deref(), Some("alice")); + assert_eq!( + nodes[0] + .params + .get("extra-header.X-Client") + .map(String::as_str), + Some("WutherCore") + ); + assert_eq!( + nodes[0].params.get("udp-over-tcp").map(String::as_str), + Some("1") + ); + assert_eq!(nodes[0].params.get("quic").map(String::as_str), Some("1")); + } + #[test] fn keep_drop_rename_dedup() { let nodes = parse_feed_payload( diff --git a/crates/core-outbound/Cargo.toml b/crates/core-outbound/Cargo.toml index 381f0d6..9f745f5 100644 --- a/crates/core-outbound/Cargo.toml +++ b/crates/core-outbound/Cargo.toml @@ -4,6 +4,13 @@ version.workspace = true edition.workspace = true license.workspace = true +[features] +default = [] +# NaiveProxy depends on the separately distributed SagerNet Cronet native +# library. Keep it opt-in so ordinary MIT builds do not silently acquire a +# GPL-3.0 runtime dependency. +naive = ["dep:cronet", "dep:hickory-proto"] + [dependencies] core-config = { workspace = true } core-reality = { workspace = true } @@ -20,6 +27,8 @@ hex = { workspace = true } futures = { workspace = true } pin-project-lite = { workspace = true } if-addrs = "0.15" +cronet = { workspace = true, optional = true } +hickory-proto = { workspace = true, optional = true } # 协议层依赖 aes-gcm = { workspace = true } @@ -85,3 +94,8 @@ windows-sys = { version = "0.59", features = [ # IP_BOUND_IF / IPV6_BOUND_IF setsockopt —— 同 Windows 思路,darwin 平台 # 出站 socket 必须按 ifindex 绑定。 libc = "0.2" + +[dev-dependencies] +h2 = "0.4" +rcgen = "0.14" +time = "0.3" diff --git a/crates/core-outbound/src/proto/mod.rs b/crates/core-outbound/src/proto/mod.rs index 1de9905..c978f5d 100644 --- a/crates/core-outbound/src/proto/mod.rs +++ b/crates/core-outbound/src/proto/mod.rs @@ -24,6 +24,8 @@ pub mod anytls; pub mod hysteria; pub mod hysteria2; pub mod mieru; +#[cfg(feature = "naive")] +pub mod naive; pub mod shadowsocks; pub mod snell; pub mod ss2022; diff --git a/crates/core-outbound/src/proto/naive.rs b/crates/core-outbound/src/proto/naive.rs new file mode 100644 index 0000000..82a00e4 --- /dev/null +++ b/crates/core-outbound/src/proto/naive.rs @@ -0,0 +1,810 @@ +//! NaiveProxy outbound backed by the SagerNet Cronet native stack. +//! +//! Cronet exposes a blocking bidirectional stream. Each tunnel is bridged to a +//! Tokio stream through a loopback socket pair; the native read and write +//! directions run concurrently on scoped OS threads. This keeps blocking +//! Cronet callbacks away from Tokio workers and preserves backpressure. + +use std::{ + io::{self, Read, Write}, + net::{ + IpAddr, Ipv4Addr, Ipv6Addr, Shutdown, SocketAddr, TcpListener, TcpStream, ToSocketAddrs, + UdpSocket, + }, + sync::{Arc, OnceLock, mpsc}, +}; + +use async_trait::async_trait; +use cronet::{BidirectionalConnection, Header, NaiveClient, NaiveClientOptions, NetworkHooks}; +use hickory_proto::{ + op::Message, + rr::{ + RData, Record, RecordType, + rdata::{A, AAAA}, + }, +}; +use rand::{RngCore, rngs::OsRng}; +use socket2::{Domain, Protocol, SockAddr, Socket, Type}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt, ReadHalf, WriteHalf}, + net::TcpStream as TokioTcpStream, + sync::{Mutex as AsyncMutex, oneshot}, +}; + +use crate::adapter::{ + BoxedStream, BoxedUdp, Capabilities, DialContext, OutboundAdapter, UdpSocketLike, + apply_outbound_mark_for_addr, bind_outbound_socket, protect_socket, +}; + +const NAIVE_PADDING_CHUNKS: usize = 8; +const NAIVE_MAX_CHUNK: usize = u16::MAX as usize; +const UOT_V2_MAGIC: &str = "sp.v2.udp-over-tcp.arpa"; + +#[derive(Clone, Debug)] +pub struct NaiveOutboundConfig { + pub host: String, + pub port: u16, + pub username: Option, + pub password: Option, + pub server_name: Option, + pub insecure_concurrency: usize, + pub extra_headers: Vec
, + pub udp_over_tcp: bool, + pub quic: bool, + pub quic_congestion_control: String, + pub receive_window: u64, + pub quic_session_receive_window: u64, + pub trusted_root_certificates: Option, + pub ech_enabled: bool, + pub ech_config_list: Vec, + pub ech_query_server_name: Option, +} + +impl NaiveOutboundConfig { + pub fn new(host: impl Into, port: u16) -> Self { + Self { + host: host.into(), + port, + username: None, + password: None, + server_name: None, + insecure_concurrency: 1, + extra_headers: Vec::new(), + udp_over_tcp: false, + quic: false, + quic_congestion_control: String::new(), + receive_window: 0, + quic_session_receive_window: 0, + trusted_root_certificates: None, + ech_enabled: false, + ech_config_list: Vec::new(), + ech_query_server_name: None, + } + } + + fn validate(&self) -> io::Result<()> { + if self.host.trim().is_empty() { + return Err(invalid_input("Naive server must not be empty")); + } + if self.port == 0 { + return Err(invalid_input("Naive server port must not be zero")); + } + if self.quic && self.insecure_concurrency.max(1) > 1 { + return Err(invalid_input( + "Naive insecure_concurrency is incompatible with QUIC", + )); + } + if !matches!( + self.quic_congestion_control.as_str(), + "" | "TBBR" | "B2ON" | "QBIC" | "RENO" + ) { + return Err(invalid_input(format!( + "unknown Naive QUIC congestion control `{}`", + self.quic_congestion_control + ))); + } + for header in &self.extra_headers { + if header.name.is_empty() + || header.name.starts_with(':') + || header.name.starts_with('-') + || header.name.eq_ignore_ascii_case("proxy-authorization") + || header.name.eq_ignore_ascii_case("padding") + { + return Err(invalid_input(format!( + "Naive extra header `{}` is reserved or invalid", + header.name + ))); + } + if header.name.contains(['\r', '\n']) || header.value.contains(['\r', '\n']) { + return Err(invalid_input("Naive extra headers must not contain CR/LF")); + } + } + Ok(()) + } + + fn client_options(&self) -> NaiveClientOptions { + let authority = format_authority(&self.host, self.port); + let mut options = NaiveClientOptions::new(format!("https://{authority}")); + options.username = self.username.clone(); + options.password = self.password.clone(); + options.insecure_concurrency = self.insecure_concurrency.max(1); + options.extra_headers = self.extra_headers.clone(); + options.quic = self.quic; + options.server_name = self.server_name.clone(); + options.server_address = Some(self.host.clone()); + options.ech_enabled = self.ech_enabled; + options.ech_config_list = self.ech_config_list.clone(); + options.ech_query_server_name = self.ech_query_server_name.clone(); + options.quic_congestion_control = self.quic_congestion_control.clone(); + options.receive_window = self.receive_window; + options.quic_session_receive_window = self.quic_session_receive_window; + options + } +} + +pub struct NaiveOutbound { + name: String, + config: NaiveOutboundConfig, + client: Arc, String>>>, +} + +impl NaiveOutbound { + pub fn new(name: impl Into, config: NaiveOutboundConfig) -> io::Result { + config.validate()?; + Ok(Self { + name: name.into(), + config, + client: Arc::new(OnceLock::new()), + }) + } + + fn initialize_client( + config: &NaiveOutboundConfig, + cell: &OnceLock, String>>, + ) -> io::Result> { + cell.get_or_init(|| { + let mut hooks = NetworkHooks::new() + .tcp_connector(connect_tcp) + .udp_connector(connect_udp) + .dns_resolver(system_dns); + if let Some(certificates) = config.trusted_root_certificates.clone() { + hooks = hooks.trusted_root_certificates(certificates); + } + NaiveClient::start(config.client_options(), hooks) + .map(Arc::new) + .map_err(|error| error.to_string()) + }) + .as_ref() + .map(Arc::clone) + .map_err(|error| io::Error::other(format!("start Naive Cronet client: {error}"))) + } + + async fn dial_tunnel(&self, destination: String) -> io::Result { + let (application, bridge) = tcp_pair()?; + application.set_nonblocking(true)?; + let stream = TokioTcpStream::from_std(application)?; + let config = self.config.clone(); + let client_cell = Arc::clone(&self.client); + let (ready_tx, ready_rx) = oneshot::channel(); + let thread_name = format!("naive-{}", sanitize_thread_name(&self.name)); + + std::thread::Builder::new() + .name(thread_name) + .spawn(move || { + let client = match Self::initialize_client(&config, &client_cell) { + Ok(client) => client, + Err(error) => { + let _ = ready_tx.send(Err(error)); + return; + } + }; + let connection = match client.dial(destination) { + Ok(connection) => connection, + Err(error) => { + let _ = ready_tx.send(Err(error)); + return; + } + }; + if ready_tx.send(Ok(())).is_err() { + connection.cancel(); + return; + } + bridge_connection(connection.inner(), bridge); + }) + .map_err(|error| io::Error::other(format!("spawn Naive bridge: {error}")))?; + + ready_rx + .await + .map_err(|_| io::Error::other("Naive bridge exited before handshake"))??; + Ok(Box::pin(stream)) + } +} + +#[async_trait] +impl OutboundAdapter for NaiveOutbound { + fn name(&self) -> &str { + &self.name + } + + fn protocol(&self) -> &'static str { + "naive" + } + + fn capabilities(&self) -> Capabilities { + Capabilities { + tcp: true, + udp: self.config.udp_over_tcp, + ipv6: true, + multiplex: true, + } + } + + async fn dial_tcp(&self, ctx: DialContext) -> io::Result { + self.dial_tunnel(format_authority(&ctx.host, ctx.port)) + .await + } + + async fn dial_udp(&self, ctx: DialContext) -> io::Result { + if !self.config.udp_over_tcp { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "Naive UDP requires udp_over_tcp", + )); + } + let mut stream = self.dial_tunnel(format!("{UOT_V2_MAGIC}:0")).await?; + let request = encode_uot_request(&ctx.host, ctx.port)?; + stream.write_all(&request).await?; + stream.flush().await?; + let (reader, writer) = tokio::io::split(stream); + Ok(Box::new(NaiveUdp { + target: normalize_host(&ctx.host), + port: ctx.port, + reader: AsyncMutex::new(reader), + writer: AsyncMutex::new(writer), + })) + } +} + +struct NaiveUdp { + target: String, + port: u16, + reader: AsyncMutex>, + writer: AsyncMutex>, +} + +#[async_trait] +impl UdpSocketLike for NaiveUdp { + async fn send_to(&self, packet: &[u8], target: &str, port: u16) -> io::Result { + if normalize_host(target) != self.target || port != self.port { + return Err(invalid_input( + "Naive UoT association cannot send to a different target", + )); + } + let length = u16::try_from(packet.len()) + .map_err(|_| invalid_input("Naive UoT packet exceeds 65535 bytes"))?; + let mut writer = self.writer.lock().await; + writer.write_all(&length.to_be_bytes()).await?; + writer.write_all(packet).await?; + writer.flush().await?; + Ok(packet.len()) + } + + async fn recv_from(&self, output: &mut [u8]) -> io::Result { + let mut reader = self.reader.lock().await; + let length = usize::from(reader.read_u16().await?); + if length > output.len() { + let mut remaining = length; + let mut discard = [0_u8; 2048]; + while remaining != 0 { + let count = remaining.min(discard.len()); + reader.read_exact(&mut discard[..count]).await?; + remaining -= count; + } + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "Naive UoT receive buffer is too small: {} < {length}", + output.len() + ), + )); + } + reader.read_exact(&mut output[..length]).await?; + Ok(length) + } + + async fn close(&self) -> io::Result<()> { + self.writer.lock().await.shutdown().await + } +} + +#[derive(Clone, Copy)] +enum BridgeDirection { + Inbound, + Outbound, +} + +fn bridge_connection(connection: &BidirectionalConnection<'_>, socket: TcpStream) { + let inbound_socket = match socket.try_clone() { + Ok(socket) => socket, + Err(_) => { + connection.cancel(); + return; + } + }; + let shutdown_socket = socket.try_clone().ok(); + let (done_tx, done_rx) = mpsc::channel(); + std::thread::scope(|scope| { + let inbound_done = done_tx.clone(); + scope.spawn(move || { + let result = copy_from_cronet(connection, inbound_socket); + let _ = inbound_done.send((BridgeDirection::Inbound, result)); + }); + scope.spawn(move || { + let result = copy_to_cronet(socket, connection); + let _ = done_tx.send((BridgeDirection::Outbound, result)); + }); + + let first = done_rx.recv(); + match first { + Ok((BridgeDirection::Outbound, Ok(()))) => { + let _ = done_rx.recv(); + } + _ => { + connection.cancel(); + if let Some(socket) = &shutdown_socket { + let _ = socket.shutdown(Shutdown::Both); + } + let _ = done_rx.recv(); + } + } + connection.cancel(); + if let Some(socket) = &shutdown_socket { + let _ = socket.shutdown(Shutdown::Both); + } + }); +} + +fn copy_from_cronet( + connection: &BidirectionalConnection<'_>, + mut socket: TcpStream, +) -> io::Result<()> { + let mut payload = vec![0_u8; 32 * 1024]; + for _ in 0..NAIVE_PADDING_CHUNKS { + let mut header = [0_u8; 3]; + read_cronet_exact(connection, &mut header)?; + let payload_len = usize::from(u16::from_be_bytes([header[0], header[1]])); + let padding_len = usize::from(header[2]); + let mut remaining = payload_len; + while remaining != 0 { + let count = remaining.min(payload.len()); + read_cronet_exact(connection, &mut payload[..count])?; + socket.write_all(&payload[..count])?; + remaining -= count; + } + skip_cronet(connection, padding_len)?; + } + loop { + let count = connection.read_shared(&mut payload)?; + if count == 0 { + socket.shutdown(Shutdown::Write)?; + return Ok(()); + } + socket.write_all(&payload[..count])?; + } +} + +fn copy_to_cronet( + mut socket: TcpStream, + connection: &BidirectionalConnection<'_>, +) -> io::Result<()> { + let mut payload = vec![0_u8; 32 * 1024]; + let mut padded = 0usize; + loop { + let count = socket.read(&mut payload)?; + if count == 0 { + connection.write_shared(&[], true)?; + return Ok(()); + } + if padded < NAIVE_PADDING_CHUNKS { + let payload_len = count.min(NAIVE_MAX_CHUNK); + let padding_len = (OsRng.next_u32() & 0xff) as usize; + let mut frame = Vec::with_capacity(3 + payload_len + padding_len); + frame.extend_from_slice(&(payload_len as u16).to_be_bytes()); + frame.push(padding_len as u8); + frame.extend_from_slice(&payload[..payload_len]); + frame.resize(frame.len() + padding_len, 0); + connection.write_shared(&frame, false)?; + padded += 1; + } else { + connection.write_shared(&payload[..count], false)?; + } + } +} + +fn read_cronet_exact( + connection: &BidirectionalConnection<'_>, + mut output: &mut [u8], +) -> io::Result<()> { + while !output.is_empty() { + let count = connection.read_shared(output)?; + if count == 0 { + return Err(io::Error::from(io::ErrorKind::UnexpectedEof)); + } + output = &mut output[count..]; + } + Ok(()) +} + +fn skip_cronet(connection: &BidirectionalConnection<'_>, mut length: usize) -> io::Result<()> { + let mut discard = [0_u8; 512]; + while length != 0 { + let count = length.min(discard.len()); + read_cronet_exact(connection, &mut discard[..count])?; + length -= count; + } + Ok(()) +} + +fn tcp_pair() -> io::Result<(TcpStream, TcpStream)> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let client = TcpStream::connect(listener.local_addr()?)?; + let (server, _) = listener.accept()?; + Ok((client, server)) +} + +fn connect_tcp(host: &str, port: u16) -> io::Result { + let mut last_error = None; + for peer in (host, port).to_socket_addrs()? { + let domain = if peer.is_ipv4() { + Domain::IPV4 + } else { + Domain::IPV6 + }; + let socket = Socket::new(domain, Type::STREAM, Some(Protocol::TCP))?; + if let Err(error) = prepare_socket(&socket, peer) { + last_error = Some(error); + continue; + } + match socket.connect(&SockAddr::from(peer)) { + Ok(()) => return Ok(socket.into()), + Err(error) => last_error = Some(error), + } + } + Err(last_error.unwrap_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "Naive TCP server has no addresses") + })) +} + +fn connect_udp(host: &str, port: u16) -> io::Result { + let mut last_error = None; + for peer in (host, port).to_socket_addrs()? { + let domain = if peer.is_ipv4() { + Domain::IPV4 + } else { + Domain::IPV6 + }; + let socket = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))?; + if let Err(error) = prepare_socket(&socket, peer) { + last_error = Some(error); + continue; + } + let local = if peer.is_ipv4() { + SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0) + } else { + SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0) + }; + if let Err(error) = socket.bind(&SockAddr::from(local)) { + last_error = Some(error); + continue; + } + match socket.connect(&SockAddr::from(peer)) { + Ok(()) => return Ok(socket.into()), + Err(error) => last_error = Some(error), + } + } + Err(last_error.unwrap_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "Naive UDP server has no addresses") + })) +} + +fn prepare_socket(socket: &Socket, peer: SocketAddr) -> io::Result<()> { + protect_socket(socket)?; + apply_outbound_mark_for_addr(socket, peer)?; + bind_outbound_socket(socket, peer) +} + +fn system_dns(query: &[u8]) -> io::Result> { + let request = Message::from_vec(query).map_err(io::Error::other)?; + let mut response = request.clone().into_response(); + response.answers.clear(); + response.authorities.clear(); + response.additionals.clear(); + let Some(question) = request.queries.first() else { + return response.to_vec().map_err(io::Error::other); + }; + let host = question.name().to_utf8().trim_end_matches('.').to_owned(); + for address in (host.as_str(), 0).to_socket_addrs()? { + let data = match (question.query_type(), address.ip()) { + (RecordType::A, IpAddr::V4(address)) => Some(RData::A(A(address))), + (RecordType::AAAA, IpAddr::V6(address)) => Some(RData::AAAA(AAAA(address))), + _ => None, + }; + if let Some(data) = data { + response.add_answer(Record::from_rdata(question.name().clone(), 60, data)); + } + } + response.to_vec().map_err(io::Error::other) +} + +fn encode_uot_request(host: &str, port: u16) -> io::Result> { + let mut request = vec![1_u8]; // isConnect=true + encode_socks_address(&mut request, host, port, [0x01, 0x04, 0x03])?; + Ok(request) +} + +fn encode_socks_address( + output: &mut Vec, + host: &str, + port: u16, + families: [u8; 3], +) -> io::Result<()> { + let host = normalize_host(host); + if let Ok(address) = host.parse::() { + output.push(families[0]); + output.extend_from_slice(&address.octets()); + } else if let Ok(address) = host.parse::() { + output.push(families[1]); + output.extend_from_slice(&address.octets()); + } else { + let length = u8::try_from(host.len()) + .map_err(|_| invalid_input("Naive UoT domain exceeds 255 bytes"))?; + if length == 0 { + return Err(invalid_input("Naive UoT domain must not be empty")); + } + output.push(families[2]); + output.push(length); + output.extend_from_slice(host.as_bytes()); + } + output.extend_from_slice(&port.to_be_bytes()); + Ok(()) +} + +fn format_authority(host: &str, port: u16) -> String { + let host = normalize_host(host); + if host.parse::().is_ok() { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + +fn normalize_host(host: &str) -> String { + host.strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(host) + .to_ascii_lowercase() +} + +fn sanitize_thread_name(name: &str) -> String { + let name = name + .chars() + .filter(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) + .take(24) + .collect::(); + if name.is_empty() { + "bridge".into() + } else { + name + } +} + +fn invalid_input(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{future::poll_fn, sync::Arc, time::Duration}; + + use bytes::Bytes; + use h2::server::SendResponse; + use http::{Request, Response}; + use rcgen::{ + BasicConstraints, CertificateParams, CertifiedIssuer, ExtendedKeyUsagePurpose, IsCa, + KeyPair, KeyUsagePurpose, + }; + use rustls::{ + ServerConfig, + pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}, + }; + use time::{Duration as TimeDuration, OffsetDateTime}; + use tokio::net::TcpListener as TokioTcpListener; + use tokio_rustls::TlsAcceptor; + + #[test] + fn encodes_uot_v2_connect_request() { + assert_eq!( + encode_uot_request("dns.example", 53).unwrap(), + [ + 1, 3, 11, b'd', b'n', b's', b'.', b'e', b'x', b'a', b'm', b'p', b'l', b'e', 0, 53, + ] + ); + assert_eq!( + encode_uot_request("192.0.2.1", 5353).unwrap(), + [1, 1, 192, 0, 2, 1, 0x14, 0xe9] + ); + } + + #[test] + fn validates_quic_pool_and_reserved_headers() { + let mut config = NaiveOutboundConfig::new("proxy.example", 443); + config.quic = true; + config.insecure_concurrency = 2; + assert!(config.validate().is_err()); + + config.insecure_concurrency = 1; + config.extra_headers.push(Header { + name: "Proxy-Authorization".into(), + value: "secret".into(), + }); + assert!(config.validate().is_err()); + } + + #[tokio::test] + async fn uot_rejects_cross_target_and_discards_oversized_packet() { + let (client, mut server) = tokio::io::duplex(1024); + let (reader, writer) = tokio::io::split(Box::pin(client) as BoxedStream); + let udp = NaiveUdp { + target: "dns.example".into(), + port: 53, + reader: AsyncMutex::new(reader), + writer: AsyncMutex::new(writer), + }; + assert!(udp.send_to(b"x", "other.example", 53).await.is_err()); + + server.write_all(&4_u16.to_be_bytes()).await.unwrap(); + server.write_all(b"data").await.unwrap(); + server.write_all(&2_u16.to_be_bytes()).await.unwrap(); + server.write_all(b"ok").await.unwrap(); + let mut small = [0_u8; 2]; + assert!(udp.recv_from(&mut small).await.is_err()); + assert_eq!(udp.recv_from(&mut small).await.unwrap(), 2); + assert_eq!(&small, b"ok"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn native_h2_tunnel_round_trip() { + let _ = rustls::crypto::ring::default_provider().install_default(); + tokio::time::timeout(Duration::from_secs(20), async { + let ca_key = KeyPair::generate().unwrap(); + let mut ca_params = + CertificateParams::new(vec!["RPKernel Naive test CA".into()]).unwrap(); + let now = OffsetDateTime::now_utc(); + ca_params.not_before = now - TimeDuration::days(1); + ca_params.not_after = now + TimeDuration::days(30); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + ca_params.key_usages = vec![ + KeyUsagePurpose::KeyCertSign, + KeyUsagePurpose::CrlSign, + KeyUsagePurpose::DigitalSignature, + ]; + let ca = CertifiedIssuer::self_signed(ca_params, ca_key).unwrap(); + + let server_key = KeyPair::generate().unwrap(); + let mut server_params = CertificateParams::new(vec!["localhost".into()]).unwrap(); + server_params.not_before = now - TimeDuration::days(1); + server_params.not_after = now + TimeDuration::days(30); + server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + let server_certificate = server_params.signed_by(&server_key, &ca).unwrap(); + let root_pem = ca.pem(); + let certificate: CertificateDer<'static> = server_certificate.der().clone(); + let private_key = + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(server_key.serialize_der())); + let mut tls = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![certificate], private_key) + .unwrap(); + tls.alpn_protocols = vec![b"h2".to_vec()]; + + let listener = TokioTcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(serve_naive_echo(listener, TlsAcceptor::from(Arc::new(tls)))); + + let mut config = NaiveOutboundConfig::new("localhost", port); + config.trusted_root_certificates = Some(root_pem); + let outbound = NaiveOutbound::new("native-test", config).unwrap(); + let mut stream = outbound + .dial_tcp(DialContext::tcp("echo.invalid", 443)) + .await + .unwrap(); + let payloads = [ + b"one".as_slice(), + b"two-two".as_slice(), + b"three-three-three".as_slice(), + b"4".as_slice(), + b"five".as_slice(), + b"six".as_slice(), + b"seven".as_slice(), + b"eight".as_slice(), + b"ninth chunk is deliberately unpadded".as_slice(), + ]; + for payload in payloads { + stream.write_all(payload).await.unwrap(); + stream.flush().await.unwrap(); + let mut echoed = vec![0; payload.len()]; + stream.read_exact(&mut echoed).await.unwrap(); + assert_eq!(echoed, payload); + } + stream.shutdown().await.unwrap(); + server.abort(); + let _ = server.await; + }) + .await + .expect("native Naive H2 test timed out"); + } + + async fn serve_naive_echo(listener: TokioTcpListener, acceptor: TlsAcceptor) -> io::Result<()> { + let (socket, _) = listener.accept().await?; + let socket = acceptor.accept(socket).await.map_err(io::Error::other)?; + let mut connection = h2::server::handshake(socket) + .await + .map_err(io::Error::other)?; + while let Some(request) = connection.accept().await { + let (request, respond) = request.map_err(io::Error::other)?; + tokio::spawn(async move { + let _ = echo_connect(request, respond).await; + }); + } + Ok(()) + } + + async fn echo_connect( + request: Request, + mut respond: SendResponse, + ) -> io::Result<()> { + if request.method() != http::Method::CONNECT { + respond + .send_response( + Response::builder() + .status(405) + .body(()) + .map_err(io::Error::other)?, + true, + ) + .map_err(io::Error::other)?; + return Ok(()); + } + let mut request_body = request.into_body(); + let mut response_body = respond + .send_response( + Response::builder() + .status(200) + .body(()) + .map_err(io::Error::other)?, + false, + ) + .map_err(io::Error::other)?; + while let Some(packet) = request_body.data().await { + let packet = packet.map_err(io::Error::other)?; + request_body + .flow_control() + .release_capacity(packet.len()) + .map_err(io::Error::other)?; + response_body.reserve_capacity(packet.len()); + while response_body.capacity() < packet.len() { + match poll_fn(|context| response_body.poll_capacity(context)).await { + Some(Ok(_)) => {} + Some(Err(error)) => return Err(io::Error::other(error)), + None => return Ok(()), + } + } + response_body + .send_data(packet, false) + .map_err(io::Error::other)?; + } + response_body + .send_data(Bytes::new(), true) + .map_err(io::Error::other) + } +} diff --git a/crates/core-outbound/src/registry.rs b/crates/core-outbound/src/registry.rs index 85ee27a..15bf89a 100644 --- a/crates/core-outbound/src/registry.rs +++ b/crates/core-outbound/src/registry.rs @@ -8,6 +8,11 @@ use base64::Engine as _; use core_config::node_uri::{NodeProtocol, ParsedNode}; use uuid::Uuid; +#[cfg(feature = "naive")] +use crate::proto::naive::{NaiveOutbound, NaiveOutboundConfig}; +#[cfg(feature = "naive")] +use cronet::Header; + use crate::{ adapter::SharedOutbound, block::BlockOutbound, @@ -110,6 +115,7 @@ pub fn build_outbound(node: &ParsedNode) -> SharedOutbound { NodeProtocol::Vmess => build_vmess(node), NodeProtocol::Vless => build_vless(node), NodeProtocol::Trojan => build_trojan(node), + NodeProtocol::Naive => build_naive(node), NodeProtocol::Snell => build_snell(node), NodeProtocol::AnyTls => build_anytls(node), NodeProtocol::Ssh => build_ssh(node), @@ -124,6 +130,177 @@ pub fn build_outbound(node: &ParsedNode) -> SharedOutbound { } } +#[cfg(feature = "naive")] +fn build_naive(node: &ParsedNode) -> SharedOutbound { + let mut config = NaiveOutboundConfig::new(&node.host, node.port); + config.username = node.user.clone(); + config.password = node.password.clone(); + config.server_name = node.sni.clone().filter(|value| !value.is_empty()); + config.insecure_concurrency = + param_usize(node, &["insecure-concurrency", "insecure_concurrency"], 1).max(1); + config.udp_over_tcp = node.udp && param_bool(node, &["udp-over-tcp", "udp_over_tcp"], false); + config.quic = param_bool(node, &["quic"], false); + config.quic_congestion_control = node + .params + .get("quic-congestion-control") + .or_else(|| node.params.get("quic_congestion_control")) + .map(|value| match value.to_ascii_lowercase().as_str() { + "" => "", + "bbr" => "TBBR", + "bbr2" => "B2ON", + "cubic" => "QBIC", + "reno" => "RENO", + _ => value.as_str(), + }) + .unwrap_or_default() + .to_owned(); + config.receive_window = param_u64(node, &["stream-receive-window", "stream_receive_window"], 0); + config.quic_session_receive_window = param_u64( + node, + &["quic-session-receive-window", "quic_session_receive_window"], + 0, + ); + config.extra_headers = node + .params + .iter() + .filter_map(|(key, value)| { + key.strip_prefix("extra-header.") + .or_else(|| key.strip_prefix("header.")) + .map(|name| Header { + name: name.to_owned(), + value: value.clone(), + }) + }) + .collect(); + + if param_bool( + node, + &[ + "insecure", + "allow-insecure", + "allowInsecure", + "skip-cert-verify", + ], + false, + ) { + tracing::warn!( + target: "naive", + node = %node.name, + "Cronet Naive rejects insecure TLS; configure certificate/certificate_path instead" + ); + return StubOutbound::new(node.name.clone(), "naive(insecure-tls-unsupported)"); + } + if let Some(path) = node + .params + .get("certificate-path") + .or_else(|| node.params.get("certificate_path")) + { + match std::fs::read_to_string(path) { + Ok(certificate) => config.trusted_root_certificates = Some(certificate), + Err(error) => { + tracing::warn!( + target: "naive", + node = %node.name, + path, + error = %error, + "failed to read Naive certificate_path" + ); + return StubOutbound::new(node.name.clone(), "naive(invalid-certificate-path)"); + } + } + } else if let Some(certificate) = node.params.get("certificate") { + config.trusted_root_certificates = Some(certificate.clone()); + } + config.ech_enabled = param_bool( + node, + &["ech", "ech-enabled", "ech_enabled", "ech-enable"], + false, + ); + if let Some(encoded) = node + .params + .get("ech-config") + .or_else(|| node.params.get("ech_config")) + { + match decode_config_blob(encoded) { + Some(config_list) => { + config.ech_enabled = true; + config.ech_config_list = config_list; + } + None => { + return StubOutbound::new(node.name.clone(), "naive(invalid-ech-config)"); + } + } + } + config.ech_query_server_name = node + .params + .get("ech-query-server-name") + .or_else(|| node.params.get("ech_query_server_name")) + .cloned(); + + match NaiveOutbound::new(&node.name, config) { + Ok(outbound) => Arc::new(outbound), + Err(error) => { + tracing::warn!(target: "naive", node = %node.name, error = %error, "invalid Naive node"); + StubOutbound::new(node.name.clone(), "naive(invalid-config)") + } + } +} + +#[cfg(not(feature = "naive"))] +fn build_naive(node: &ParsedNode) -> SharedOutbound { + StubOutbound::new(node.name.clone(), "naive(feature-disabled)") +} + +#[cfg(feature = "naive")] +fn param_bool(node: &ParsedNode, keys: &[&str], default: bool) -> bool { + keys.iter() + .find_map(|key| node.params.get(*key)) + .map(|value| { + matches!( + value.to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(default) +} + +#[cfg(feature = "naive")] +fn param_usize(node: &ParsedNode, keys: &[&str], default: usize) -> usize { + keys.iter() + .find_map(|key| node.params.get(*key)) + .and_then(|value| value.parse().ok()) + .unwrap_or(default) +} + +#[cfg(feature = "naive")] +fn param_u64(node: &ParsedNode, keys: &[&str], default: u64) -> u64 { + keys.iter() + .find_map(|key| node.params.get(*key)) + .and_then(|value| value.parse().ok()) + .unwrap_or(default) +} + +#[cfg(feature = "naive")] +fn decode_config_blob(value: &str) -> Option> { + use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; + + let encoded = value + .lines() + .filter(|line| !line.trim_start().starts_with("-----")) + .collect::() + .replace(char::is_whitespace, ""); + if encoded.len().is_multiple_of(2) + && !encoded.is_empty() + && encoded.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return hex::decode(&encoded).ok(); + } + STANDARD + .decode(&encoded) + .ok() + .or_else(|| URL_SAFE_NO_PAD.decode(encoded.trim_end_matches('=')).ok()) +} + fn build_shadowsocks(node: &ParsedNode) -> SharedOutbound { let method = node.method.as_deref().unwrap_or("aes-256-gcm"); let pwd = node.password.as_deref().unwrap_or(""); @@ -978,6 +1155,7 @@ fn proto_static_name(p: &NodeProtocol) -> &'static str { NodeProtocol::Vmess => "vmess", NodeProtocol::Vless => "vless", NodeProtocol::Trojan => "trojan", + NodeProtocol::Naive => "naive", NodeProtocol::Hysteria => "hysteria", NodeProtocol::Hysteria2 => "hysteria2", NodeProtocol::Tuic => "tuic", @@ -998,9 +1176,42 @@ mod tests { use core_config::node_uri::{NodeProtocol, ParsedNode}; use uuid::Uuid; + #[cfg(feature = "naive")] + use super::decode_config_blob; use super::{build_outbound, tuic_from_node}; use crate::proto::tuic::TuicUdpMode; + #[cfg(feature = "naive")] + #[test] + fn naive_registry_enables_uot_and_rejects_unsafe_combinations() { + let mut node = ParsedNode::new("naive", NodeProtocol::Naive, "proxy.example", 443); + node.udp = true; + node.params.insert("udp-over-tcp".into(), "true".into()); + let outbound = build_outbound(&node); + assert_eq!(outbound.protocol(), "naive"); + assert!(outbound.capabilities().tcp); + assert!(outbound.capabilities().udp); + + node.params.insert("quic".into(), "true".into()); + node.params + .insert("insecure-concurrency".into(), "2".into()); + assert_eq!(build_outbound(&node).protocol(), "naive(invalid-config)"); + + node.params.insert("insecure".into(), "true".into()); + assert_eq!( + build_outbound(&node).protocol(), + "naive(insecure-tls-unsupported)" + ); + } + + #[cfg(feature = "naive")] + #[test] + fn naive_registry_decodes_ech_config_formats() { + assert_eq!(decode_config_blob("000102ff"), Some(vec![0, 1, 2, 255])); + assert_eq!(decode_config_blob("AAEC/w=="), Some(vec![0, 1, 2, 255])); + assert_eq!(decode_config_blob("%%%"), None); + } + #[test] fn ss2022_registry_preserves_udp_flag_and_eih_chain() { let identity = base64::engine::general_purpose::STANDARD.encode([0x11u8; 16]); diff --git a/crates/wuther-core/Cargo.toml b/crates/wuther-core/Cargo.toml index 90e3f5e..689d784 100644 --- a/crates/wuther-core/Cargo.toml +++ b/crates/wuther-core/Cargo.toml @@ -11,6 +11,10 @@ readme = "../../README.md" keywords = ["proxy", "networking", "tun", "dns", "rust"] categories = ["command-line-utilities", "network-programming"] +[features] +default = [] +naive = ["core-outbound/naive"] + [[bin]] name = "wuther-core" path = "src/main.rs" diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 82ee469..f83a41b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -71,6 +71,8 @@ groups: 策略组可以从订阅和其他路径聚合节点,并通过 `prefer`、`avoid`、健康检查和粘性配置影响选择。修改后用 API 的 Smart 解释端点确认实际决策。 +Naive 节点需要 `naive` Cargo feature 和匹配的 Cronet 动态库,支持 H2/H3、UoT v2、ECH 与自定义证书。字段、构建和许可说明见 [Naive 出站](NAIVE.md)。 + ## 路由 ```yaml diff --git a/docs/FEATURES.md b/docs/FEATURES.md index f71de26..8cdd812 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -39,7 +39,7 @@ | 通用代理 | HTTP、SOCKS5 | 支持认证;UDP 能力由具体实现决定 | | Shadowsocks | Shadowsocks、Shadowsocks 2022、SSR | 包含多种 AEAD/流加密与 SSR 组件 | | 经典 TLS | Trojan、VLESS、VMess | 支持对应 TLS、UUID 与安全参数 | -| 现代隧道 | AnyTLS、Hysteria、Hysteria 2、TUIC | 包含 TLS/QUIC 路径与协议参数 | +| 现代隧道 | AnyTLS、Hysteria、Hysteria 2、TUIC、Naive | Naive 为可选 feature,包含 H2/H3、ECH、填充与 UoT v2,见 [Naive 指南](NAIVE.md) | | 专用协议 | Snell、Mieru、Sudoku、TrustTunnel | 按各自握手、加密和复用模型实现 | | 系统隧道 | WireGuard、SSH | 密钥或主机校验需要单独配置 | @@ -74,6 +74,7 @@ - 透明代理依赖系统权限和外部网络状态,无法只靠单元测试覆盖。 - 当前配置与 API 尚未承诺 1.0 级别的长期稳定性。 - Android VpnService 需要宿主应用负责生命周期、权限申请和文件描述符传递。 +- Naive 依赖 GPL-3.0-or-later 的 Cronet 组件和平台动态库,默认 MIT 构建与默认 Release 不启用。 - CodeQL 初始告警正在 [Issue #9](https://github.com/MiChongs/WutherCore/issues/9) 中逐条分类。 ## 判断是否适合使用 diff --git a/docs/NAIVE.md b/docs/NAIVE.md new file mode 100644 index 0000000..b98dc1b --- /dev/null +++ b/docs/NAIVE.md @@ -0,0 +1,112 @@ +# Naive 出站 + +WutherCore 的 Naive 出站基于 `cronet` 0.2.0 和 SagerNet/Naive Cronet 原生库。实现与 sing-box 当前 Naive 出站字段对齐,支持: + +- HTTP/2 CONNECT 与 HTTP/3 CONNECT; +- Basic 认证、自定义请求头与 H2 非安全并发连接池; +- Naive 前 8 个数据块填充; +- UDP over TCP v2; +- QUIC 拥塞控制与接收窗口; +- SNI、自定义根证书、ECH Config List 和 ECH DNS 查询; +- 内核的 socket protect、出站 mark、bind-interface 与 DNS 钩子。 + +## 构建 + +Naive 是显式启用的 Cargo feature: + +```bash +cargo build --release -p wuther-core --features naive +``` + +该 feature 需要 Rust 1.88 或更高版本,以及与 `cronet` crate ABI 匹配的 SagerNet/Naive Cronet 动态库。可使用 `cronet-rs` v0.2.0 中带校验和验证的下载脚本。 + +Windows PowerShell: + +```powershell +git clone --branch v0.2.0 --depth 1 https://github.com/MiChongs/cronet-rs.git +Set-Location cronet-rs +.\scripts\fetch-native.ps1 +$env:CRONET_LIB_DIR = "$PWD\target\cronet-sdk\lib" +$env:CRONET_LIB_NAME = "cronet" +$env:PATH = "$PWD\target\cronet-sdk\bin;$env:PATH" +Set-Location ..\WutherCore +cargo build --release -p wuther-core --features naive +``` + +Linux: + +```bash +git clone --branch v0.2.0 --depth 1 https://github.com/MiChongs/cronet-rs.git +cd cronet-rs +./scripts/fetch-native.sh +export CRONET_LIB_DIR="$PWD/target/cronet-sdk/lib" +export CRONET_LIB_NAME=cronet +export LD_LIBRARY_PATH="$CRONET_LIB_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +cd ../WutherCore +cargo build --release -p wuther-core --features naive +``` + +运行时也必须让系统能够找到 `cronet.dll`、`libcronet.so` 或对应平台的兼容动态库。Windows 可将 DLL 放在 `wuther-core.exe` 同目录;Linux 可安装到系统库目录或保留 `LD_LIBRARY_PATH`。 + +## 配置 + +手动节点示例: + +```yaml +nodes: + - name: naive-h2 + type: naive + server: proxy.example.com + port: 443 + username: alice + password: replace-me + udp: true + sni: front.example.com + udp-over-tcp: true + insecure-concurrency: 2 + extra-headers: + X-Client: WutherCore + + - name: naive-h3 + type: naive + server: proxy.example.com + port: 443 + username: alice + password: replace-me + udp: true + quic: true + udp-over-tcp: true + quic-congestion-control: bbr +``` + +也支持 URI: + +```text +naive://alice:password@proxy.example.com:443?udp-over-tcp=true#naive-h2 +naive+quic://alice:password@proxy.example.com:443?udp-over-tcp=true#naive-h3 +``` + +主要扩展字段: + +| 字段 | 说明 | +| --- | --- | +| `insecure-concurrency` | H2 连接池并发数;QUIC 模式只能为 1 | +| `extra-headers` | Mihomo 风格头部映射;URI 可使用 `extra-header.<名称>` | +| `udp-over-tcp` | 使用 sing-box UoT v2 封装 UDP | +| `quic` | 启用 H3;`naive+quic://` 会自动启用 | +| `quic-congestion-control` | `bbr`、`bbr2`、`cubic` 或 `reno` | +| `stream-receive-window` | QUIC 单流接收窗口 | +| `quic-session-receive-window` | QUIC 会话接收窗口 | +| `certificate-path` | PEM 根证书文件 | +| `certificate` | 内联 PEM 根证书 | +| `ech` | 启用 ECH | +| `ech-config` | Base64 或十六进制 ECH Config List | +| `ech-query-server-name` | 用于获取 ECH HTTPS 记录的名称 | + +`insecure`、`skip-cert-verify` 等跳过验证选项会被拒绝。Cronet 不提供可靠的“忽略证书错误”能力;私有 CA 应通过 `certificate-path` 或 `certificate` 配置。 + +UoT v2 是定目标 packet socket:同一 UDP socket 只能收发创建时的目标地址。长度超过调用方缓冲区的数据报会被完整丢弃并返回错误,避免破坏后续帧边界。 + +## 许可与发布 + +WutherCore 默认构建仍为 MIT,且不包含 Naive feature。`cronet` crate 与 SagerNet/Naive Cronet 原生组件使用 GPL-3.0-or-later;启用、链接或分发 Naive 构建时,发布者必须按 GPL-3.0-or-later 履行相应义务。官方默认 Release 产物不会自动携带该动态库。