Files
donutbrowser/src-tauri/src/xray/model.rs
T
2026-07-31 01:04:58 +04:00

413 lines
10 KiB
Rust

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());
}
}