Skip to content
Merged
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
24 changes: 24 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
75 changes: 75 additions & 0 deletions crates/core-config/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ pub struct Listen {
alias = "splithttp"
)]
pub xhttp: Option<XhttpListenSet>,
/// Snell v1-v5 服务端监听。既接受单个对象,也接受对象数组。
#[serde(default)]
pub snell: Option<SnellListenSet>,
#[serde(default)]
pub share: Option<Share>,
#[serde(default)]
Expand All @@ -222,6 +225,78 @@ pub struct Listen {
pub reality: Vec<RealityListen>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SnellListenSet {
One(SnellListen),
Many(Vec<SnellListen>),
}

impl SnellListenSet {
pub fn into_vec(self) -> Vec<SnellListen> {
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<SnellObfsListen>,
#[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<String>,
}

#[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 写法;
Expand Down
1 change: 1 addition & 0 deletions crates/core-config/src/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
195 changes: 195 additions & 0 deletions crates/core-config/src/runtime_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,38 @@ pub struct ListenPlan {
pub reality: Vec<RealityListen>,
pub panel: Option<PanelListen>,
pub xhttp: Vec<XhttpListenPlan>,
pub snell: Vec<SnellListenPlan>,
pub share: Share,
pub auth: Vec<UserPass>,
}

#[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<SnellObfsListen>,
pub handshake_timeout: Duration,
pub max_connections: usize,
pub tag: String,
}

impl SnellListenPlan {
pub fn socket_addr(&self) -> ConfigResult<SocketAddr> {
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,
Expand Down Expand Up @@ -273,6 +301,7 @@ fn compile_listen(cfg: &UserConfig) -> ConfigResult<ListenPlan> {
local: None,
panel: None,
xhttp: None,
snell: None,
share: None,
auth: vec![],
reality: vec![],
Expand Down Expand Up @@ -341,6 +370,7 @@ fn compile_listen(cfg: &UserConfig) -> ConfigResult<ListenPlan> {
}

let xhttp = compile_xhttp_listeners(listen.xhttp)?;
let snell = compile_snell_listeners(listen.snell)?;

let auth = listen
.auth
Expand All @@ -360,11 +390,100 @@ fn compile_listen(cfg: &UserConfig) -> ConfigResult<ListenPlan> {
reality,
panel,
xhttp,
snell,
share,
auth,
})
}

fn compile_snell_listeners(
listeners: Option<SnellListenSet>,
) -> ConfigResult<Vec<SnellListenPlan>> {
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::<SocketAddr>()
.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<Vec<RealityListen>> {
use base64::Engine as _;

Expand Down Expand Up @@ -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}");
}
}
}
Loading