Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "shoes"
version = "0.2.8"
version = "0.2.9"
edition = "2024"
license = "MIT"
description = "A multi-protocol proxy server."
Expand Down Expand Up @@ -84,6 +84,7 @@ tun = { version = "*", features = ["async"] }
url = "*"
webpki-roots = { version = "*" }
x509-parser = { version = "*", default-features = false, features = ["verify-aws"] }
zeroize = "1"

[dev-dependencies]
rcgen = { version = "*", default-features = false, features = ["aws_lc_rs", "pem"] }
Expand Down
41 changes: 30 additions & 11 deletions src/anytls/anytls_server_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ const CONTROL_FRAME_TIMEOUT: Duration = Duration::from_secs(5);
/// Prevents memory leaks from hung streams (slow DNS, stuck connections, etc.)
const STREAM_HANDLER_TIMEOUT: Duration = Duration::from_secs(300);

/// Maximum concurrently handled streams per AnyTLS session.
const MAX_ANYTLS_STREAMS_PER_SESSION: usize = 256;

/// AnyTLS Session manages multiplexed streams over a connection
pub struct AnyTlsSession {
/// Underlying connection (split into reader/writer)
Expand Down Expand Up @@ -370,6 +373,8 @@ impl AnyTlsSession {

// Check if stream already exists and register atomically
// This prevents race conditions with duplicate SYNs
let mut reject_reason = None;
let mut tasks = self.stream_tasks.lock().await;
let stream_opt = {
let mut streams = self.streams.write().await;
use std::collections::hash_map::Entry;
Expand All @@ -379,16 +384,26 @@ impl AnyTlsSession {
None
}
Entry::Vacant(entry) => {
// Create new stream with bounded channel for backpressure
let (data_tx, data_rx) = mpsc::channel(STREAM_CHANNEL_BUFFER);
let stream = AnyTlsStream::new(
stream_id,
data_rx,
self.outgoing_tx.clone(),
Arc::clone(&self.is_closed),
);
entry.insert(data_tx);
Some(stream)
if tasks.len() >= MAX_ANYTLS_STREAMS_PER_SESSION {
log::warn!(
"Rejecting AnyTLS stream {}: session stream limit reached ({})",
stream_id,
MAX_ANYTLS_STREAMS_PER_SESSION
);
reject_reason = Some("too many concurrent streams");
None
} else {
// Create new stream with bounded channel for backpressure
let (data_tx, data_rx) = mpsc::channel(STREAM_CHANNEL_BUFFER);
let stream = AnyTlsStream::new(
stream_id,
data_rx,
self.outgoing_tx.clone(),
Arc::clone(&self.is_closed),
);
entry.insert(data_tx);
Some(stream)
}
}
}
};
Expand Down Expand Up @@ -430,9 +445,13 @@ impl AnyTlsSession {
});

// Track the task for cancellation on session close
let mut tasks = self.stream_tasks.lock().await;
tasks.insert(stream_id, handle);
}
drop(tasks);

if let Some(reason) = reject_reason {
self.send_synack(stream_id, Some(reason)).await?;
}
}

Command::SynAck => {
Expand Down
151 changes: 149 additions & 2 deletions src/config/types/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ use super::server::WebsocketPingType;
use super::shadowsocks::ShadowsocksConfig;
use super::transport::{ClientQuicConfig, TcpConfig, Transport};

const REDACTED: &str = "<redacted>";

/// Configuration for h2mux (HTTP/2 multiplexing) on protocols that support it.
///
/// H2MUX multiplexes multiple proxy streams over a single HTTP/2 connection,
Expand Down Expand Up @@ -306,7 +308,7 @@ impl Default for ClientConfig {
}
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ClientProxyConfig {
Direct,
Expand Down Expand Up @@ -437,6 +439,122 @@ pub enum ClientProxyConfig {
},
}

impl std::fmt::Debug for ClientProxyConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClientProxyConfig::Direct => f.write_str("Direct"),
ClientProxyConfig::Http {
username,
password,
resolve_hostname,
} => f
.debug_struct("Http")
.field("username", username)
.field("password", &password.as_ref().map(|_| REDACTED))
.field("resolve_hostname", resolve_hostname)
.finish(),
ClientProxyConfig::Socks { username, password } => f
.debug_struct("Socks")
.field("username", username)
.field("password", &password.as_ref().map(|_| REDACTED))
.finish(),
ClientProxyConfig::Shadowsocks {
config,
udp_enabled,
} => f
.debug_struct("Shadowsocks")
.field("config", config)
.field("udp_enabled", udp_enabled)
.finish(),
ClientProxyConfig::Snell {
config,
udp_enabled,
} => f
.debug_struct("Snell")
.field("config", config)
.field("udp_enabled", udp_enabled)
.finish(),
ClientProxyConfig::Vless {
udp_enabled, h2mux, ..
} => f
.debug_struct("Vless")
.field("user_id", &REDACTED)
.field("udp_enabled", udp_enabled)
.field("h2mux", h2mux)
.finish(),
ClientProxyConfig::Trojan {
shadowsocks, h2mux, ..
} => f
.debug_struct("Trojan")
.field("password", &REDACTED)
.field("shadowsocks", shadowsocks)
.field("h2mux", h2mux)
.finish(),
ClientProxyConfig::Reality {
public_key,
sni_hostname,
cipher_suites,
vision,
protocol,
..
} => f
.debug_struct("Reality")
.field("public_key", public_key)
.field("short_id", &REDACTED)
.field("sni_hostname", sni_hostname)
.field("cipher_suites", cipher_suites)
.field("vision", vision)
.field("protocol", protocol)
.finish(),
ClientProxyConfig::ShadowTls {
sni_hostname,
protocol,
..
} => f
.debug_struct("ShadowTls")
.field("password", &REDACTED)
.field("sni_hostname", sni_hostname)
.field("protocol", protocol)
.finish(),
ClientProxyConfig::Tls(tls_config) => f.debug_tuple("Tls").field(tls_config).finish(),
ClientProxyConfig::Vmess {
cipher,
udp_enabled,
h2mux,
..
} => f
.debug_struct("Vmess")
.field("cipher", cipher)
.field("user_id", &REDACTED)
.field("udp_enabled", udp_enabled)
.field("h2mux", h2mux)
.finish(),
ClientProxyConfig::Websocket(ws_config) => {
f.debug_tuple("Websocket").field(ws_config).finish()
}
ClientProxyConfig::PortForward => f.write_str("PortForward"),
ClientProxyConfig::Anytls {
udp_enabled,
padding_scheme,
..
} => f
.debug_struct("Anytls")
.field("password", &REDACTED)
.field("udp_enabled", udp_enabled)
.field("padding_scheme", padding_scheme)
.finish(),
ClientProxyConfig::Naiveproxy {
username, padding, ..
} => f
.debug_struct("Naiveproxy")
.field("username", username)
.field("password", &REDACTED)
.field("padding", padding)
.finish(),
}
}
}

impl ClientProxyConfig {
pub fn is_direct(&self) -> bool {
matches!(self, ClientProxyConfig::Direct)
Expand Down Expand Up @@ -464,7 +582,7 @@ impl ClientProxyConfig {
}
}

#[derive(Debug, Clone, Serialize)]
#[derive(Clone, Serialize)]
pub struct TlsClientConfig {
#[serde(default = "default_true", skip_serializing_if = "is_true")]
pub verify: bool,
Expand Down Expand Up @@ -498,6 +616,22 @@ pub struct TlsClientConfig {
pub protocol: Box<ClientProxyConfig>,
}

impl std::fmt::Debug for TlsClientConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TlsClientConfig")
.field("verify", &self.verify)
.field("server_fingerprints", &self.server_fingerprints)
.field("sni_hostname", &self.sni_hostname)
.field("alpn_protocols", &self.alpn_protocols)
.field("tls_buffer_size", &self.tls_buffer_size)
.field("key", &self.key.as_ref().map(|_| REDACTED))
.field("cert", &self.cert.as_ref().map(|_| "<present>"))
.field("vision", &self.vision)
.field("protocol", &self.protocol)
.finish()
}
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WebsocketClientConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -541,6 +675,19 @@ mod tests {
));
}

#[test]
fn test_client_config_debug_redacts_secrets() {
let config = ClientProxyConfig::Socks {
username: Some("client_user".to_string()),
password: Some("client_pass".to_string()),
};

let debug = format!("{config:?}");

assert!(debug.contains(REDACTED));
assert!(!debug.contains("client_pass"));
}

#[test]
fn test_rejects_unknown_field_in_vmess_client() {
// Test ClientProxyConfig::Vmess directly
Expand Down
37 changes: 35 additions & 2 deletions src/config/types/groups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ use super::selection::ConfigSelection;
use super::server::ServerConfig;
use super::tun::TunConfig;

const REDACTED: &str = "<redacted>";

/// A named group of client proxies.
///
/// Groups can reference other groups using `ConfigSelection::GroupName`.
Expand All @@ -31,18 +33,36 @@ pub struct RuleConfigGroup {
pub rules: OneOrSome<RuleConfig>,
}

#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct NamedPem {
pub pem: String, // The name identifier
pub source: PemSource,
}

#[derive(Debug, Clone)]
impl std::fmt::Debug for NamedPem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NamedPem")
.field("pem", &self.pem)
.field("source", &self.source)
.finish()
}
}

#[derive(Clone)]
pub enum PemSource {
Path(String),
Data(String),
}

impl std::fmt::Debug for PemSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PemSource::Path(path) => f.debug_tuple("Path").field(path).finish(),
PemSource::Data(_) => f.debug_tuple("Data").field(&REDACTED).finish(),
}
}
}

impl<'de> Deserialize<'de> for NamedPem {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
Expand Down Expand Up @@ -465,6 +485,19 @@ client_proxies:
}
}

#[test]
fn test_named_pem_debug_redacts_inline_data() {
let pem = NamedPem {
pem: "server_key".to_string(),
source: PemSource::Data("inline-secret-key".to_string()),
};

let debug = format!("{pem:?}");

assert!(debug.contains(REDACTED));
assert!(!debug.contains("inline-secret-key"));
}

#[test]
fn test_named_pem_invalid_yaml() {
// Missing pem field
Expand Down
Loading