mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-04 18:28:42 +02:00
feat: xray support
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{VlessRealityConfig, XrayError, XrayResult};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct XrayClientRuntime {
|
||||
pub listen_port: u16,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl XrayClientRuntime {
|
||||
pub fn validate(&self) -> XrayResult<()> {
|
||||
if self.listen_port == 0 {
|
||||
return Err(XrayError::InvalidField {
|
||||
field: "listen_port",
|
||||
reason: "must be between 1 and 65535",
|
||||
});
|
||||
}
|
||||
validate_socks_credential("username", &self.username)?;
|
||||
validate_socks_credential("password", &self.password)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_client_config(
|
||||
config: &VlessRealityConfig,
|
||||
runtime: &XrayClientRuntime,
|
||||
) -> XrayResult<Value> {
|
||||
config.validate()?;
|
||||
runtime.validate()?;
|
||||
|
||||
Ok(json!({
|
||||
"log": {
|
||||
"loglevel": "warning"
|
||||
},
|
||||
"inbounds": [{
|
||||
"tag": "local-socks",
|
||||
"listen": "127.0.0.1",
|
||||
"port": runtime.listen_port,
|
||||
"protocol": "socks",
|
||||
"settings": {
|
||||
"auth": "password",
|
||||
"accounts": [{
|
||||
"user": runtime.username,
|
||||
"pass": runtime.password
|
||||
}],
|
||||
"udp": true,
|
||||
"ip": "127.0.0.1"
|
||||
}
|
||||
}],
|
||||
"outbounds": [{
|
||||
"tag": "proxy",
|
||||
"protocol": "vless",
|
||||
"settings": {
|
||||
"vnext": [{
|
||||
"address": config.address,
|
||||
"port": config.port,
|
||||
"users": [{
|
||||
"id": config.id,
|
||||
"encryption": "none",
|
||||
"flow": config.flow.as_str()
|
||||
}]
|
||||
}]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
"security": "reality",
|
||||
"realitySettings": {
|
||||
"show": false,
|
||||
"fingerprint": config.reality.fingerprint.as_str(),
|
||||
"serverName": config.reality.server_name,
|
||||
"publicKey": config.reality.public_key,
|
||||
"shortId": config.reality.short_id,
|
||||
"spiderX": config.reality.spider_x
|
||||
},
|
||||
"sockopt": {
|
||||
"tcpKeepAliveIdle": 30,
|
||||
"tcpKeepAliveInterval": 15
|
||||
}
|
||||
}
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn build_client_config_json(
|
||||
config: &VlessRealityConfig,
|
||||
runtime: &XrayClientRuntime,
|
||||
) -> XrayResult<String> {
|
||||
serde_json::to_string_pretty(&build_client_config(config, runtime)?)
|
||||
.map_err(|_| XrayError::Serialization)
|
||||
}
|
||||
|
||||
fn validate_socks_credential(field: &'static str, value: &str) -> XrayResult<()> {
|
||||
if value.is_empty() || value.len() > 255 {
|
||||
return Err(XrayError::InvalidField {
|
||||
field,
|
||||
reason: "must contain between 1 and 255 URL-safe ASCII characters",
|
||||
});
|
||||
}
|
||||
if !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~'))
|
||||
{
|
||||
return Err(XrayError::InvalidField {
|
||||
field,
|
||||
reason: "must contain only URL-safe ASCII characters",
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
|
||||
use super::super::{RealityFingerprint, RealitySettings, VlessFlow};
|
||||
use super::*;
|
||||
|
||||
fn valid_config() -> VlessRealityConfig {
|
||||
VlessRealityConfig {
|
||||
address: "vpn.example.com".to_string(),
|
||||
port: 443,
|
||||
id: "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4".to_string(),
|
||||
flow: VlessFlow::Vision,
|
||||
reality: RealitySettings {
|
||||
server_name: "www.example.com".to_string(),
|
||||
public_key: URL_SAFE_NO_PAD.encode([7_u8; 32]),
|
||||
short_id: "0123456789abcdef".to_string(),
|
||||
fingerprint: RealityFingerprint::Chrome,
|
||||
spider_x: "/".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime() -> XrayClientRuntime {
|
||||
XrayClientRuntime {
|
||||
listen_port: 41_321,
|
||||
username: "local_user-1".to_string(),
|
||||
password: "local_password-1".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_minimal_authenticated_tcp_reality_config() {
|
||||
let value = build_client_config(&valid_config(), &runtime()).unwrap();
|
||||
|
||||
assert_eq!(value["log"]["loglevel"], "warning");
|
||||
assert_eq!(value["inbounds"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(value["inbounds"][0]["listen"], "127.0.0.1");
|
||||
assert_eq!(value["inbounds"][0]["port"], 41_321);
|
||||
assert_eq!(value["inbounds"][0]["protocol"], "socks");
|
||||
assert_eq!(value["inbounds"][0]["settings"]["auth"], "password");
|
||||
assert_eq!(
|
||||
value["inbounds"][0]["settings"]["accounts"][0]["user"],
|
||||
"local_user-1"
|
||||
);
|
||||
assert_eq!(
|
||||
value["inbounds"][0]["settings"]["accounts"][0]["pass"],
|
||||
"local_password-1"
|
||||
);
|
||||
assert_eq!(value["inbounds"][0]["settings"]["udp"], true);
|
||||
assert_eq!(value["inbounds"][0]["settings"]["ip"], "127.0.0.1");
|
||||
|
||||
assert_eq!(value["outbounds"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(value["outbounds"][0]["protocol"], "vless");
|
||||
assert_eq!(
|
||||
value["outbounds"][0]["settings"]["vnext"][0]["address"],
|
||||
"vpn.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
value["outbounds"][0]["settings"]["vnext"][0]["users"][0]["encryption"],
|
||||
"none"
|
||||
);
|
||||
assert_eq!(
|
||||
value["outbounds"][0]["settings"]["vnext"][0]["users"][0]["flow"],
|
||||
"xtls-rprx-vision"
|
||||
);
|
||||
assert_eq!(value["outbounds"][0]["streamSettings"]["network"], "tcp");
|
||||
assert_eq!(
|
||||
value["outbounds"][0]["streamSettings"]["security"],
|
||||
"reality"
|
||||
);
|
||||
assert_eq!(
|
||||
value["outbounds"][0]["streamSettings"]["realitySettings"]["fingerprint"],
|
||||
"chrome"
|
||||
);
|
||||
assert_eq!(
|
||||
value["outbounds"][0]["streamSettings"]["realitySettings"]["serverName"],
|
||||
"www.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
value["outbounds"][0]["streamSettings"]["realitySettings"]["shortId"],
|
||||
"0123456789abcdef"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_add_bypass_or_observability_surfaces() {
|
||||
let value = build_client_config(&valid_config(), &runtime()).unwrap();
|
||||
for absent in ["api", "dns", "policy", "routing", "stats"] {
|
||||
assert!(value.get(absent).is_none(), "{absent}");
|
||||
}
|
||||
assert!(value["outbounds"][0].get("mux").is_none());
|
||||
assert!(!value.to_string().contains("freedom"));
|
||||
assert!(!value.to_string().contains("blackhole"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_output_round_trips_without_shape_changes() {
|
||||
let value = build_client_config(&valid_config(), &runtime()).unwrap();
|
||||
let json = build_client_config_json(&valid_config(), &runtime()).unwrap();
|
||||
let reparsed: Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(reparsed, value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_requires_nonzero_port_and_url_safe_credentials() {
|
||||
let cases = [
|
||||
XrayClientRuntime {
|
||||
listen_port: 0,
|
||||
..runtime()
|
||||
},
|
||||
XrayClientRuntime {
|
||||
username: String::new(),
|
||||
..runtime()
|
||||
},
|
||||
XrayClientRuntime {
|
||||
password: "contains:@".to_string(),
|
||||
..runtime()
|
||||
},
|
||||
XrayClientRuntime {
|
||||
username: "a".repeat(256),
|
||||
..runtime()
|
||||
},
|
||||
];
|
||||
for runtime in cases {
|
||||
assert!(runtime.validate().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_model_is_rejected_before_generation() {
|
||||
let mut config = valid_config();
|
||||
config.reality.public_key = "secret-invalid-key".to_string();
|
||||
let error = build_client_config(&config, &runtime()).unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
XrayError::InvalidField { field: "pbk", .. }
|
||||
));
|
||||
assert!(!error.to_string().contains(&config.reality.public_key));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use thiserror::Error;
|
||||
|
||||
pub type XrayResult<T> = Result<T, XrayError>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum XrayError {
|
||||
#[error("invalid VLESS URI")]
|
||||
InvalidUri,
|
||||
#[error("URI scheme must be vless")]
|
||||
UnsupportedScheme,
|
||||
#[error("missing required field: {0}")]
|
||||
MissingField(&'static str),
|
||||
#[error("invalid field: {field} ({reason})")]
|
||||
InvalidField {
|
||||
field: &'static str,
|
||||
reason: &'static str,
|
||||
},
|
||||
#[error("unsupported query parameter: {0}")]
|
||||
UnsupportedParameter(String),
|
||||
#[error("duplicate query parameter: {0}")]
|
||||
DuplicateParameter(String),
|
||||
#[error("unsupported value for {field}; expected {expected}")]
|
||||
UnsupportedValue {
|
||||
field: &'static str,
|
||||
expected: &'static str,
|
||||
},
|
||||
#[error("failed to serialize Xray client configuration")]
|
||||
Serialization,
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
mod client;
|
||||
mod error;
|
||||
mod model;
|
||||
mod uri;
|
||||
|
||||
pub use client::{build_client_config, build_client_config_json, XrayClientRuntime};
|
||||
pub use error::{XrayError, XrayResult};
|
||||
pub use model::{
|
||||
ParsedVlessUri, RealityFingerprint, RealitySettings, VlessFlow, VlessRealityConfig,
|
||||
};
|
||||
pub use uri::{export_vless_uri, parse_vless_uri};
|
||||
@@ -0,0 +1,412 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Host;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{XrayError, XrayResult};
|
||||
|
||||
const MAX_SPIDER_X_BYTES: usize = 2048;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VlessFlow {
|
||||
#[default]
|
||||
#[serde(rename = "xtls-rprx-vision")]
|
||||
Vision,
|
||||
}
|
||||
|
||||
impl VlessFlow {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Vision => "xtls-rprx-vision",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RealityFingerprint {
|
||||
#[default]
|
||||
Chrome,
|
||||
Firefox,
|
||||
Safari,
|
||||
Edge,
|
||||
Ios,
|
||||
Android,
|
||||
}
|
||||
|
||||
impl RealityFingerprint {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Chrome => "chrome",
|
||||
Self::Firefox => "firefox",
|
||||
Self::Safari => "safari",
|
||||
Self::Edge => "edge",
|
||||
Self::Ios => "ios",
|
||||
Self::Android => "android",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse(value: &str) -> XrayResult<Self> {
|
||||
match value {
|
||||
"chrome" => Ok(Self::Chrome),
|
||||
"firefox" => Ok(Self::Firefox),
|
||||
"safari" => Ok(Self::Safari),
|
||||
"edge" => Ok(Self::Edge),
|
||||
"ios" => Ok(Self::Ios),
|
||||
"android" => Ok(Self::Android),
|
||||
_ => Err(XrayError::UnsupportedValue {
|
||||
field: "fp",
|
||||
expected: "chrome, firefox, safari, edge, ios, or android",
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RealitySettings {
|
||||
pub server_name: String,
|
||||
pub public_key: String,
|
||||
#[serde(default)]
|
||||
pub short_id: String,
|
||||
#[serde(default)]
|
||||
pub fingerprint: RealityFingerprint,
|
||||
#[serde(default = "default_spider_x")]
|
||||
pub spider_x: String,
|
||||
}
|
||||
|
||||
impl RealitySettings {
|
||||
pub fn validate(&self) -> XrayResult<()> {
|
||||
validate_server_name(&self.server_name)?;
|
||||
validate_public_key(&self.public_key)?;
|
||||
validate_short_id(&self.short_id)?;
|
||||
validate_spider_x(&self.spider_x)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct VlessRealityConfig {
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub flow: VlessFlow,
|
||||
pub reality: RealitySettings,
|
||||
}
|
||||
|
||||
impl VlessRealityConfig {
|
||||
pub fn validate(&self) -> XrayResult<()> {
|
||||
validate_endpoint_address(&self.address)?;
|
||||
if self.port == 0 {
|
||||
return Err(XrayError::InvalidField {
|
||||
field: "port",
|
||||
reason: "must be between 1 and 65535",
|
||||
});
|
||||
}
|
||||
Uuid::parse_str(&self.id).map_err(|_| XrayError::InvalidField {
|
||||
field: "id",
|
||||
reason: "must be a UUID",
|
||||
})?;
|
||||
self.reality.validate()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ParsedVlessUri {
|
||||
pub name: Option<String>,
|
||||
pub config: VlessRealityConfig,
|
||||
}
|
||||
|
||||
impl ParsedVlessUri {
|
||||
pub fn validate(&self) -> XrayResult<()> {
|
||||
if let Some(name) = &self.name {
|
||||
validate_display_name(name)?;
|
||||
}
|
||||
self.config.validate()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn default_spider_x() -> String {
|
||||
"/".to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn validate_display_name(name: &str) -> XrayResult<()> {
|
||||
if name.is_empty() {
|
||||
return Err(XrayError::InvalidField {
|
||||
field: "name",
|
||||
reason: "must not be empty",
|
||||
});
|
||||
}
|
||||
if name.chars().count() > 200 {
|
||||
return Err(XrayError::InvalidField {
|
||||
field: "name",
|
||||
reason: "must not exceed 200 characters",
|
||||
});
|
||||
}
|
||||
if name.chars().any(char::is_control) {
|
||||
return Err(XrayError::InvalidField {
|
||||
field: "name",
|
||||
reason: "must not contain control characters",
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_endpoint_address(address: &str) -> XrayResult<()> {
|
||||
if address.is_empty()
|
||||
|| address.trim() != address
|
||||
|| address.starts_with('[')
|
||||
|| address.ends_with(']')
|
||||
{
|
||||
return Err(XrayError::InvalidField {
|
||||
field: "address",
|
||||
reason: "must be a valid hostname or IP address",
|
||||
});
|
||||
}
|
||||
if address.parse::<IpAddr>().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
Host::parse(address).map_err(|_| XrayError::InvalidField {
|
||||
field: "address",
|
||||
reason: "must be a valid hostname or IP address",
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_server_name(server_name: &str) -> XrayResult<()> {
|
||||
if server_name.is_empty() || server_name.trim() != server_name {
|
||||
return Err(XrayError::InvalidField {
|
||||
field: "sni",
|
||||
reason: "must be a valid DNS name",
|
||||
});
|
||||
}
|
||||
match Host::parse(server_name) {
|
||||
Ok(Host::Domain(_)) => Ok(()),
|
||||
_ => Err(XrayError::InvalidField {
|
||||
field: "sni",
|
||||
reason: "must be a valid DNS name",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_public_key(public_key: &str) -> XrayResult<()> {
|
||||
let decoded = URL_SAFE_NO_PAD
|
||||
.decode(public_key)
|
||||
.map_err(|_| XrayError::InvalidField {
|
||||
field: "pbk",
|
||||
reason: "must be an unpadded base64url-encoded 32-byte key",
|
||||
})?;
|
||||
if decoded.len() != 32 || URL_SAFE_NO_PAD.encode(decoded) != public_key {
|
||||
return Err(XrayError::InvalidField {
|
||||
field: "pbk",
|
||||
reason: "must be an unpadded base64url-encoded 32-byte key",
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_short_id(short_id: &str) -> XrayResult<()> {
|
||||
if short_id.len() > 16 || !short_id.len().is_multiple_of(2) {
|
||||
return Err(XrayError::InvalidField {
|
||||
field: "sid",
|
||||
reason: "must be empty or contain up to 16 even-length hexadecimal characters",
|
||||
});
|
||||
}
|
||||
if !short_id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err(XrayError::InvalidField {
|
||||
field: "sid",
|
||||
reason: "must be empty or contain up to 16 even-length hexadecimal characters",
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_spider_x(spider_x: &str) -> XrayResult<()> {
|
||||
if !spider_x.starts_with('/') {
|
||||
return Err(XrayError::InvalidField {
|
||||
field: "spx",
|
||||
reason: "must start with /",
|
||||
});
|
||||
}
|
||||
if spider_x.len() > MAX_SPIDER_X_BYTES || spider_x.chars().any(char::is_control) {
|
||||
return Err(XrayError::InvalidField {
|
||||
field: "spx",
|
||||
reason: "must be a valid relative path no longer than 2048 bytes",
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn public_key() -> String {
|
||||
URL_SAFE_NO_PAD.encode([7_u8; 32])
|
||||
}
|
||||
|
||||
fn valid_config() -> VlessRealityConfig {
|
||||
VlessRealityConfig {
|
||||
address: "vpn.example.com".to_string(),
|
||||
port: 443,
|
||||
id: "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4".to_string(),
|
||||
flow: VlessFlow::Vision,
|
||||
reality: RealitySettings {
|
||||
server_name: "www.example.com".to_string(),
|
||||
public_key: public_key(),
|
||||
short_id: "0123456789abcdef".to_string(),
|
||||
fingerprint: RealityFingerprint::Chrome,
|
||||
spider_x: "/".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_model_passes_validation() {
|
||||
assert_eq!(valid_config().validate(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_accepts_ipv4_ipv6_and_dns() {
|
||||
for address in ["198.51.100.4", "2001:db8::1", "vpn.example.com"] {
|
||||
let mut config = valid_config();
|
||||
config.address = address.to_string();
|
||||
assert_eq!(config.validate(), Ok(()), "{address}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_rejects_empty_whitespace_and_invalid_hosts() {
|
||||
for address in [
|
||||
"",
|
||||
" vpn.example.com",
|
||||
"vpn example.com",
|
||||
"vpn.example.com:443",
|
||||
"[2001:db8::1]",
|
||||
] {
|
||||
let mut config = valid_config();
|
||||
config.address = address.to_string();
|
||||
assert!(matches!(
|
||||
config.validate(),
|
||||
Err(XrayError::InvalidField {
|
||||
field: "address",
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_must_be_a_uuid() {
|
||||
let mut config = valid_config();
|
||||
config.id = "not-a-uuid".to_string();
|
||||
assert_eq!(
|
||||
config.validate(),
|
||||
Err(XrayError::InvalidField {
|
||||
field: "id",
|
||||
reason: "must be a UUID",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_name_must_be_dns_name() {
|
||||
for server_name in ["", "203.0.113.5", "bad server"] {
|
||||
let mut config = valid_config();
|
||||
config.reality.server_name = server_name.to_string();
|
||||
assert!(matches!(
|
||||
config.validate(),
|
||||
Err(XrayError::InvalidField { field: "sni", .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_key_must_be_canonical_base64url_and_32_bytes() {
|
||||
let short_key = URL_SAFE_NO_PAD.encode([1_u8; 31]);
|
||||
for public_key in [
|
||||
"not-base64!",
|
||||
short_key.as_str(),
|
||||
"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
|
||||
] {
|
||||
let mut config = valid_config();
|
||||
config.reality.public_key = public_key.to_string();
|
||||
let error = config.validate().unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
XrayError::InvalidField { field: "pbk", .. }
|
||||
));
|
||||
assert!(!error.to_string().contains(public_key));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_id_accepts_empty_or_even_hex_up_to_sixteen_chars() {
|
||||
for short_id in ["", "ab", "0123456789abcdef", "ABCDEF"] {
|
||||
let mut config = valid_config();
|
||||
config.reality.short_id = short_id.to_string();
|
||||
assert_eq!(config.validate(), Ok(()), "{short_id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_id_rejects_odd_non_hex_and_overlong_values() {
|
||||
for short_id in ["a", "xz", "0123456789abcdef00"] {
|
||||
let mut config = valid_config();
|
||||
config.reality.short_id = short_id.to_string();
|
||||
assert!(matches!(
|
||||
config.validate(),
|
||||
Err(XrayError::InvalidField { field: "sid", .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spider_x_must_be_safe_relative_path() {
|
||||
for spider_x in ["relative", "/line\nbreak"] {
|
||||
let mut config = valid_config();
|
||||
config.reality.spider_x = spider_x.to_string();
|
||||
assert!(matches!(
|
||||
config.validate(),
|
||||
Err(XrayError::InvalidField { field: "spx", .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_defaults_preserve_the_supported_profile() {
|
||||
let value = serde_json::json!({
|
||||
"address": "vpn.example.com",
|
||||
"port": 443,
|
||||
"id": "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4",
|
||||
"reality": {
|
||||
"server_name": "www.example.com",
|
||||
"public_key": public_key()
|
||||
}
|
||||
});
|
||||
let config: VlessRealityConfig = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(config.flow, VlessFlow::Vision);
|
||||
assert_eq!(config.reality.fingerprint, RealityFingerprint::Chrome);
|
||||
assert_eq!(config.reality.short_id, "");
|
||||
assert_eq!(config.reality.spider_x, "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_rejects_unknown_configuration_fields() {
|
||||
let value = serde_json::json!({
|
||||
"address": "vpn.example.com",
|
||||
"port": 443,
|
||||
"id": "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4",
|
||||
"transport": "websocket",
|
||||
"reality": {
|
||||
"server_name": "www.example.com",
|
||||
"public_key": public_key()
|
||||
}
|
||||
});
|
||||
assert!(serde_json::from_value::<VlessRealityConfig>(value).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use url::{Host, Url};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
model::validate_display_name, ParsedVlessUri, RealityFingerprint, RealitySettings, VlessFlow,
|
||||
VlessRealityConfig, XrayError, XrayResult,
|
||||
};
|
||||
|
||||
const SUPPORTED_PARAMETERS: &[&str] = &[
|
||||
"encryption",
|
||||
"flow",
|
||||
"security",
|
||||
"sni",
|
||||
"fp",
|
||||
"pbk",
|
||||
"sid",
|
||||
"spx",
|
||||
"type",
|
||||
"headerType",
|
||||
];
|
||||
|
||||
pub fn parse_vless_uri(input: &str) -> XrayResult<ParsedVlessUri> {
|
||||
if input.trim() != input {
|
||||
return Err(XrayError::InvalidUri);
|
||||
}
|
||||
let url = Url::parse(input).map_err(|_| XrayError::InvalidUri)?;
|
||||
if url.scheme() != "vless" {
|
||||
return Err(XrayError::UnsupportedScheme);
|
||||
}
|
||||
if url.password().is_some() {
|
||||
return Err(XrayError::InvalidField {
|
||||
field: "id",
|
||||
reason: "password-style user information is not supported",
|
||||
});
|
||||
}
|
||||
if !matches!(url.path(), "" | "/") {
|
||||
return Err(XrayError::InvalidField {
|
||||
field: "path",
|
||||
reason: "VLESS TCP URIs must not contain a path",
|
||||
});
|
||||
}
|
||||
|
||||
let raw_id = url.username();
|
||||
if raw_id.is_empty() {
|
||||
return Err(XrayError::MissingField("id"));
|
||||
}
|
||||
let id = Uuid::parse_str(raw_id)
|
||||
.map_err(|_| XrayError::InvalidField {
|
||||
field: "id",
|
||||
reason: "must be a UUID",
|
||||
})?
|
||||
.to_string();
|
||||
let address = match url.host().ok_or(XrayError::MissingField("address"))? {
|
||||
Host::Domain(value) => value.to_string(),
|
||||
Host::Ipv4(value) => value.to_string(),
|
||||
Host::Ipv6(value) => value.to_string(),
|
||||
};
|
||||
let port = url.port().ok_or(XrayError::MissingField("port"))?;
|
||||
|
||||
let parameters = parse_parameters(&url)?;
|
||||
require_value(¶meters, "security", "reality")?;
|
||||
require_value(¶meters, "flow", VlessFlow::Vision.as_str())?;
|
||||
optional_value(¶meters, "encryption", "none")?;
|
||||
match parameters.get("type").map(String::as_str) {
|
||||
None | Some("tcp" | "raw") => {}
|
||||
Some(_) => {
|
||||
return Err(XrayError::UnsupportedValue {
|
||||
field: "type",
|
||||
expected: "tcp",
|
||||
});
|
||||
}
|
||||
}
|
||||
optional_value(¶meters, "headerType", "none")?;
|
||||
|
||||
let server_name = required_parameter(¶meters, "sni")?.to_string();
|
||||
let public_key = required_parameter(¶meters, "pbk")?.to_string();
|
||||
let short_id = parameters.get("sid").cloned().unwrap_or_default();
|
||||
let spider_x = parameters
|
||||
.get("spx")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "/".to_string());
|
||||
let fingerprint = parameters
|
||||
.get("fp")
|
||||
.map(|value| RealityFingerprint::parse(value))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
|
||||
let name = url
|
||||
.fragment()
|
||||
.filter(|fragment| !fragment.is_empty())
|
||||
.map(|fragment| {
|
||||
urlencoding::decode(fragment)
|
||||
.map(|value| value.into_owned())
|
||||
.map_err(|_| XrayError::InvalidField {
|
||||
field: "name",
|
||||
reason: "must use valid percent encoding",
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let parsed = ParsedVlessUri {
|
||||
name,
|
||||
config: VlessRealityConfig {
|
||||
address,
|
||||
port,
|
||||
id,
|
||||
flow: VlessFlow::Vision,
|
||||
reality: RealitySettings {
|
||||
server_name,
|
||||
public_key,
|
||||
short_id,
|
||||
fingerprint,
|
||||
spider_x,
|
||||
},
|
||||
},
|
||||
};
|
||||
parsed.validate()?;
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
pub fn export_vless_uri(config: &VlessRealityConfig, name: Option<&str>) -> XrayResult<String> {
|
||||
config.validate()?;
|
||||
if let Some(name) = name {
|
||||
validate_display_name(name)?;
|
||||
}
|
||||
|
||||
let mut url = Url::parse("vless://placeholder@127.0.0.1").expect("static VLESS URL is valid");
|
||||
url
|
||||
.set_username(&config.id)
|
||||
.map_err(|_| XrayError::InvalidUri)?;
|
||||
let uri_host = match config.address.parse::<IpAddr>() {
|
||||
Ok(IpAddr::V6(address)) => format!("[{address}]"),
|
||||
_ => config.address.clone(),
|
||||
};
|
||||
url
|
||||
.set_host(Some(&uri_host))
|
||||
.map_err(|_| XrayError::InvalidField {
|
||||
field: "address",
|
||||
reason: "must be a valid hostname or IP address",
|
||||
})?;
|
||||
url
|
||||
.set_port(Some(config.port))
|
||||
.map_err(|_| XrayError::InvalidField {
|
||||
field: "port",
|
||||
reason: "must be between 1 and 65535",
|
||||
})?;
|
||||
|
||||
{
|
||||
let mut query = url.query_pairs_mut();
|
||||
query.append_pair("encryption", "none");
|
||||
query.append_pair("flow", config.flow.as_str());
|
||||
query.append_pair("security", "reality");
|
||||
query.append_pair("sni", &config.reality.server_name);
|
||||
query.append_pair("fp", config.reality.fingerprint.as_str());
|
||||
query.append_pair("pbk", &config.reality.public_key);
|
||||
query.append_pair("sid", &config.reality.short_id);
|
||||
query.append_pair("spx", &config.reality.spider_x);
|
||||
query.append_pair("type", "tcp");
|
||||
query.append_pair("headerType", "none");
|
||||
}
|
||||
url.set_fragment(name);
|
||||
Ok(url.into())
|
||||
}
|
||||
|
||||
fn parse_parameters(url: &Url) -> XrayResult<HashMap<String, String>> {
|
||||
let mut parameters = HashMap::new();
|
||||
for (name, value) in url.query_pairs() {
|
||||
if !SUPPORTED_PARAMETERS.contains(&name.as_ref()) {
|
||||
return Err(XrayError::UnsupportedParameter(name.into_owned()));
|
||||
}
|
||||
if parameters
|
||||
.insert(name.to_string(), value.into_owned())
|
||||
.is_some()
|
||||
{
|
||||
return Err(XrayError::DuplicateParameter(name.into_owned()));
|
||||
}
|
||||
}
|
||||
Ok(parameters)
|
||||
}
|
||||
|
||||
fn required_parameter<'a>(
|
||||
parameters: &'a HashMap<String, String>,
|
||||
name: &'static str,
|
||||
) -> XrayResult<&'a str> {
|
||||
parameters
|
||||
.get(name)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(String::as_str)
|
||||
.ok_or(XrayError::MissingField(name))
|
||||
}
|
||||
|
||||
fn require_value(
|
||||
parameters: &HashMap<String, String>,
|
||||
name: &'static str,
|
||||
expected: &'static str,
|
||||
) -> XrayResult<()> {
|
||||
let value = required_parameter(parameters, name)?;
|
||||
if value != expected {
|
||||
return Err(XrayError::UnsupportedValue {
|
||||
field: name,
|
||||
expected,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn optional_value(
|
||||
parameters: &HashMap<String, String>,
|
||||
name: &'static str,
|
||||
expected: &'static str,
|
||||
) -> XrayResult<()> {
|
||||
if parameters.get(name).is_some_and(|value| value != expected) {
|
||||
return Err(XrayError::UnsupportedValue {
|
||||
field: name,
|
||||
expected,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
|
||||
use super::*;
|
||||
|
||||
const ID: &str = "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4";
|
||||
|
||||
fn public_key() -> String {
|
||||
URL_SAFE_NO_PAD.encode([7_u8; 32])
|
||||
}
|
||||
|
||||
fn uri(overrides: &[(&str, &str)]) -> String {
|
||||
let key = public_key();
|
||||
let mut parameters = vec![
|
||||
("encryption", "none"),
|
||||
("flow", "xtls-rprx-vision"),
|
||||
("security", "reality"),
|
||||
("sni", "www.example.com"),
|
||||
("fp", "chrome"),
|
||||
("pbk", key.as_str()),
|
||||
("sid", "0123456789abcdef"),
|
||||
("spx", "/"),
|
||||
("type", "tcp"),
|
||||
("headerType", "none"),
|
||||
];
|
||||
for (name, value) in overrides {
|
||||
if let Some(parameter) = parameters.iter_mut().find(|(key, _)| key == name) {
|
||||
parameter.1 = value;
|
||||
} else {
|
||||
parameters.push((name, value));
|
||||
}
|
||||
}
|
||||
let query = parameters
|
||||
.into_iter()
|
||||
.map(|(name, value)| format!("{name}={}", urlencoding::encode(value)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
format!("vless://{ID}@vpn.example.com:443?{query}#Primary")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_supported_reality_vision_uri() {
|
||||
let parsed = parse_vless_uri(&uri(&[])).unwrap();
|
||||
assert_eq!(parsed.name.as_deref(), Some("Primary"));
|
||||
assert_eq!(parsed.config.address, "vpn.example.com");
|
||||
assert_eq!(parsed.config.port, 443);
|
||||
assert_eq!(parsed.config.id, ID);
|
||||
assert_eq!(parsed.config.flow, VlessFlow::Vision);
|
||||
assert_eq!(parsed.config.reality.server_name, "www.example.com");
|
||||
assert_eq!(parsed.config.reality.public_key, public_key());
|
||||
assert_eq!(parsed.config.reality.short_id, "0123456789abcdef");
|
||||
assert_eq!(
|
||||
parsed.config.reality.fingerprint,
|
||||
RealityFingerprint::Chrome
|
||||
);
|
||||
assert_eq!(parsed.config.reality.spider_x, "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_ipv6_and_percent_encoded_metadata() {
|
||||
let input = uri(&[("spx", "/search?q=hello world")])
|
||||
.replace("vpn.example.com", "[2001:db8::1]")
|
||||
.replace("#Primary", "#Home%20server");
|
||||
let parsed = parse_vless_uri(&input).unwrap();
|
||||
assert_eq!(parsed.config.address, "2001:db8::1");
|
||||
assert_eq!(parsed.config.reality.spider_x, "/search?q=hello world");
|
||||
assert_eq!(parsed.name.as_deref(), Some("Home server"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_only_safe_optional_defaults() {
|
||||
let key = public_key();
|
||||
let input = format!(
|
||||
"vless://{ID}@vpn.example.com:443?flow=xtls-rprx-vision&security=reality&sni=www.example.com&pbk={key}"
|
||||
);
|
||||
let parsed = parse_vless_uri(&input).unwrap();
|
||||
assert_eq!(
|
||||
parsed.config.reality.fingerprint,
|
||||
RealityFingerprint::Chrome
|
||||
);
|
||||
assert_eq!(parsed.config.reality.short_id, "");
|
||||
assert_eq!(parsed.config.reality.spider_x, "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_raw_as_tcp_alias() {
|
||||
assert!(parse_vless_uri(&uri(&[("type", "raw")])).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_scheme_credentials_path_and_missing_port() {
|
||||
let valid = uri(&[]);
|
||||
let cases = [
|
||||
valid.replacen("vless://", "https://", 1),
|
||||
valid.replacen(ID, &format!("{ID}:password"), 1),
|
||||
valid.replacen(":443?", ":443/path?", 1),
|
||||
valid.replacen(":443?", "?", 1),
|
||||
];
|
||||
for input in cases {
|
||||
assert!(parse_vless_uri(&input).is_err(), "{input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_required_reality_values() {
|
||||
let key = public_key();
|
||||
let cases = [
|
||||
format!("vless://{ID}@vpn.example.com:443?flow=xtls-rprx-vision&sni=www.example.com&pbk={key}"),
|
||||
format!("vless://{ID}@vpn.example.com:443?security=reality&sni=www.example.com&pbk={key}"),
|
||||
format!("vless://{ID}@vpn.example.com:443?flow=xtls-rprx-vision&security=reality&pbk={key}"),
|
||||
format!("vless://{ID}@vpn.example.com:443?flow=xtls-rprx-vision&security=reality&sni=www.example.com"),
|
||||
];
|
||||
for input in cases {
|
||||
assert!(parse_vless_uri(&input).is_err(), "{input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_security_transport_flow_and_encryption() {
|
||||
for (name, value) in [
|
||||
("security", "tls"),
|
||||
("type", "ws"),
|
||||
("flow", ""),
|
||||
("encryption", "auto"),
|
||||
("headerType", "http"),
|
||||
("fp", "unsafe"),
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
parse_vless_uri(&uri(&[(name, value)])),
|
||||
Err(XrayError::UnsupportedValue { .. } | XrayError::MissingField(_))
|
||||
),
|
||||
"{name}={value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_and_duplicate_parameters() {
|
||||
assert_eq!(
|
||||
parse_vless_uri(&uri(&[("serviceName", "unsupported")])),
|
||||
Err(XrayError::UnsupportedParameter("serviceName".to_string()))
|
||||
);
|
||||
let input = uri(&[]).replace("#Primary", "&sni=duplicate.example.com#Primary");
|
||||
assert_eq!(
|
||||
parse_vless_uri(&input),
|
||||
Err(XrayError::DuplicateParameter("sni".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_uuid_key_short_id_and_spider_x_without_echoing_secrets() {
|
||||
let invalid_key = "private-value-that-must-not-be-echoed";
|
||||
let cases = [
|
||||
uri(&[]).replacen(ID, "not-a-uuid", 1),
|
||||
uri(&[("pbk", invalid_key)]),
|
||||
uri(&[("sid", "xyz")]),
|
||||
uri(&[("spx", "relative")]),
|
||||
];
|
||||
for input in cases {
|
||||
let error = parse_vless_uri(&input).unwrap_err();
|
||||
assert!(!error.to_string().contains(invalid_key));
|
||||
assert!(!error.to_string().contains(ID));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_is_canonical_and_round_trips() {
|
||||
let parsed = parse_vless_uri(&uri(&[("fp", "firefox")])).unwrap();
|
||||
let exported = export_vless_uri(&parsed.config, Some("Home server")).unwrap();
|
||||
assert!(exported.starts_with(&format!("vless://{ID}@vpn.example.com:443?")));
|
||||
assert!(exported.contains("type=tcp"));
|
||||
assert!(exported.contains("flow=xtls-rprx-vision"));
|
||||
assert!(exported.ends_with("#Home%20server"));
|
||||
|
||||
let reparsed = parse_vless_uri(&exported).unwrap();
|
||||
assert_eq!(
|
||||
reparsed,
|
||||
ParsedVlessUri {
|
||||
name: Some("Home server".to_string()),
|
||||
config: parsed.config,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_handles_ipv6_host() {
|
||||
let mut parsed = parse_vless_uri(&uri(&[])).unwrap();
|
||||
parsed.config.address = "2001:db8::1".to_string();
|
||||
let exported = export_vless_uri(&parsed.config, None).unwrap();
|
||||
assert!(exported.starts_with(&format!("vless://{ID}@[2001:db8::1]:443?")));
|
||||
assert_eq!(
|
||||
parse_vless_uri(&exported).unwrap().config.address,
|
||||
"2001:db8::1"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user