278 lines
9.9 KiB
Rust
278 lines
9.9 KiB
Rust
use base64::{engine::general_purpose, Engine};
|
|
use proxywarden_lib::models::redact_subscription_url;
|
|
use proxywarden_lib::subscription::{
|
|
self, parse_subscription_body, parse_user_info, validate_resolved_subscription_addresses,
|
|
SubscriptionFetchIdentity, SubscriptionFetchPolicy,
|
|
};
|
|
use std::io::{Read, Write};
|
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener};
|
|
use std::time::Duration;
|
|
|
|
#[test]
|
|
fn parses_singbox_json_config_servers() {
|
|
let parsed = parse_subscription_body(
|
|
r#"{
|
|
"outbounds": [
|
|
{ "type": "direct", "tag": "direct" },
|
|
{ "type": "vless", "tag": "nl-1", "server": "nl.example.test", "server_port": 443 },
|
|
{ "type": "trojan", "tag": "de-1", "server": "de.example.test", "server_port": 8443 }
|
|
]
|
|
}"#,
|
|
)
|
|
.expect("json subscription should parse");
|
|
|
|
assert_eq!(parsed.servers.len(), 2);
|
|
assert_eq!(parsed.servers[0].tag, "nl-1");
|
|
assert_eq!(parsed.servers[0].server_type, "vless");
|
|
assert_eq!(parsed.servers[1].server_port, 8443);
|
|
}
|
|
|
|
#[test]
|
|
fn server_ids_are_opaque_and_distinguish_credentials_on_same_endpoint() {
|
|
let parsed = parse_subscription_body(
|
|
r#"{
|
|
"outbounds": [
|
|
{ "type": "vless", "tag": "same", "server": "edge.example.test", "server_port": 443, "uuid": "11111111-1111-1111-1111-111111111111" },
|
|
{ "type": "vless", "tag": "same", "server": "edge.example.test", "server_port": 443, "uuid": "22222222-2222-2222-2222-222222222222" }
|
|
]
|
|
}"#,
|
|
)
|
|
.expect("duplicate endpoint subscription should parse");
|
|
|
|
assert_ne!(parsed.servers[0].id, parsed.servers[1].id);
|
|
assert!(parsed
|
|
.servers
|
|
.iter()
|
|
.all(|server| server.id.starts_with("pw-")));
|
|
assert!(parsed
|
|
.servers
|
|
.iter()
|
|
.all(|server| !server.id.contains("11111111")));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_base64_vless_link_list() {
|
|
let link = sample_vless_link("nl-1");
|
|
let encoded = general_purpose::STANDARD.encode(format!("{link}\n"));
|
|
|
|
let parsed = parse_subscription_body(&encoded).expect("base64 vless list should parse");
|
|
let outbound = &parsed.config["outbounds"][0];
|
|
|
|
assert_eq!(parsed.servers.len(), 1);
|
|
assert_eq!(parsed.servers[0].tag, "nl-1");
|
|
assert_eq!(outbound["type"], "vless");
|
|
assert_eq!(outbound["server"], "nl.example.test");
|
|
assert_eq!(outbound["packet_encoding"], "xudp");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_trojan_shadowsocks_and_vmess_link_formats() {
|
|
let vmess_payload = serde_json::json!({
|
|
"v": "2",
|
|
"ps": "VMess NL",
|
|
"add": "vmess.example.test",
|
|
"port": "443",
|
|
"id": "33333333-3333-3333-3333-333333333333",
|
|
"scy": "auto",
|
|
"net": "ws",
|
|
"host": "cdn.example.test",
|
|
"path": "/ws",
|
|
"tls": "tls",
|
|
"sni": "vmess.example.test"
|
|
});
|
|
let vmess_link = format!(
|
|
"vmess://{}",
|
|
general_purpose::STANDARD_NO_PAD.encode(vmess_payload.to_string())
|
|
);
|
|
let body = format!(
|
|
"trojan://secret@trojan.example.test:443?sni=edge.example.test#Trojan%20DE\nss://aes-256-gcm:password@ss.example.test:8388#SS%20US\n{vmess_link}"
|
|
);
|
|
|
|
let parsed = parse_subscription_body(&body).expect("supported link formats should parse");
|
|
|
|
assert_eq!(parsed.servers.len(), 3);
|
|
assert_eq!(parsed.servers[0].server_type, "trojan");
|
|
assert_eq!(parsed.servers[0].tag, "Trojan DE");
|
|
assert_eq!(
|
|
parsed.config["outbounds"][0]["tls"]["server_name"],
|
|
"edge.example.test"
|
|
);
|
|
assert_eq!(parsed.servers[1].server_type, "shadowsocks");
|
|
assert_eq!(parsed.config["outbounds"][1]["method"], "aes-256-gcm");
|
|
assert_eq!(parsed.servers[2].server_type, "vmess");
|
|
assert_eq!(parsed.config["outbounds"][2]["transport"]["type"], "ws");
|
|
assert_eq!(parsed.config["outbounds"][2]["tls"]["enabled"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn decodes_percent_encoded_vless_fragment_tag() {
|
|
let link = sample_vless_link(
|
|
"%D0%A3%D0%BC%D0%BD%D1%8B%D0%B9%20%F0%9F%87%B3%F0%9F%87%B1-%3E%F0%9F%87%B7%F0%9F%87%BA",
|
|
);
|
|
|
|
let parsed = parse_subscription_body(&link).expect("vless link should parse");
|
|
let outbound = &parsed.config["outbounds"][0];
|
|
|
|
assert_eq!(parsed.servers[0].tag, "Умный 🇳🇱->🇷🇺");
|
|
assert_eq!(outbound["tag"], "Умный 🇳🇱->🇷🇺");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_body_without_supported_outbounds() {
|
|
let error = parse_subscription_body(r#"{"outbounds":[{"type":"direct","tag":"direct"}]}"#)
|
|
.expect_err("unsupported subscription should fail");
|
|
|
|
assert!(error.message.contains("No supported proxy outbounds found"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_vless_without_reality_parameters() {
|
|
let error = parse_subscription_body("vless://uuid@nl.example.test:443#nl-1")
|
|
.expect_err("missing reality params should fail");
|
|
|
|
assert!(error.message.contains("pbk and sid"));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_subscription_user_info_header() {
|
|
let user_info = parse_user_info(Some("upload=10; download=20; total=30; expire=bad"));
|
|
|
|
assert_eq!(user_info["upload"], 10);
|
|
assert_eq!(user_info["download"], 20);
|
|
assert_eq!(user_info["total"], 30);
|
|
assert!(!user_info.contains_key("expire"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_invalid_or_non_http_subscription_url_before_network() {
|
|
let invalid = subscription::fetch_subscription("not a url")
|
|
.expect_err("invalid url should fail before request");
|
|
let unsupported = subscription::fetch_subscription("file:///C:/subscription.txt")
|
|
.expect_err("non-http url should fail before request");
|
|
|
|
assert!(invalid.message.contains("Invalid subscription URL"));
|
|
assert!(unsupported.message.contains("http or https"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unsafe_local_subscription_urls_before_network() {
|
|
for url in [
|
|
"http://127.0.0.1:9/subscription",
|
|
"http://localhost/subscription",
|
|
"http://169.254.169.254/latest/meta-data",
|
|
"http://192.168.0.1/subscription",
|
|
"http://[::1]/subscription",
|
|
] {
|
|
let error = subscription::fetch_subscription(url)
|
|
.expect_err("unsafe local URL should fail before request");
|
|
assert!(
|
|
error.message.contains("local, private"),
|
|
"unexpected error for {url}: {}",
|
|
error.message
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_dns_results_containing_private_or_metadata_addresses() {
|
|
for ip in [
|
|
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
|
|
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
|
|
IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
|
|
] {
|
|
let error = validate_resolved_subscription_addresses(&[SocketAddr::new(ip, 443)])
|
|
.expect_err("unsafe resolved address should be blocked");
|
|
assert!(error.message.contains("resolves to"));
|
|
}
|
|
|
|
validate_resolved_subscription_addresses(&[SocketAddr::new(
|
|
IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)),
|
|
443,
|
|
)])
|
|
.expect("public resolved address should be accepted");
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_subscription_sends_device_hwid_header_when_identity_is_set() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test listener");
|
|
let url = format!("http://{}/subscription", listener.local_addr().unwrap());
|
|
let request_thread = std::thread::spawn(move || {
|
|
let (mut stream, _) = listener.accept().expect("accept test request");
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(2)))
|
|
.expect("set read timeout");
|
|
|
|
let mut request = Vec::new();
|
|
let mut buffer = [0_u8; 512];
|
|
loop {
|
|
let bytes_read = stream.read(&mut buffer).expect("read request");
|
|
if bytes_read == 0 {
|
|
break;
|
|
}
|
|
request.extend_from_slice(&buffer[..bytes_read]);
|
|
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let body = r#"{"outbounds":[{"type":"vless","tag":"nl-1","server":"nl.example.test","server_port":443}]}"#;
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
);
|
|
stream
|
|
.write_all(response.as_bytes())
|
|
.expect("write response");
|
|
|
|
String::from_utf8_lossy(&request).to_ascii_lowercase()
|
|
});
|
|
|
|
let mut identity = SubscriptionFetchIdentity::with_device_hwid(Some("hwid-abc123"));
|
|
identity.device_os_version = Some("Windows 11 Pro | 25H2 | build 26200.8655".to_string());
|
|
let cache = subscription::fetch_subscription_with_identity_and_policy(
|
|
&url,
|
|
&identity,
|
|
SubscriptionFetchPolicy {
|
|
allow_unsafe_local_urls: true,
|
|
},
|
|
)
|
|
.expect("fetch subscription through local test server");
|
|
let request = request_thread.join().expect("request thread");
|
|
|
|
assert_eq!(cache.servers[0].tag, "nl-1");
|
|
assert!(request.contains("x-hwid: hwid-abc123"));
|
|
assert!(request.contains("user-agent: proxywarden/"));
|
|
assert!(request.contains("x-app-name: proxywarden"));
|
|
assert!(request.contains("x-device-os:"));
|
|
assert!(request.contains("x-device-os-version: windows 11 pro | 25h2 | build 26200.8655"));
|
|
assert!(request.contains("x-ver-os: windows 11 pro | 25h2 | build 26200.8655"));
|
|
assert!(request.contains("x-device-model: proxywarden"));
|
|
}
|
|
|
|
#[test]
|
|
fn redacts_subscription_url_for_display() {
|
|
assert_eq!(
|
|
redact_subscription_url("https://sub.example.test/path?token=secret"),
|
|
"https://sub.example.test/..."
|
|
);
|
|
assert_eq!(
|
|
redact_subscription_url("vless://uuid@example.test"),
|
|
"vless://example.test/..."
|
|
);
|
|
assert_eq!(
|
|
redact_subscription_url(
|
|
"https://user:password@sub.example.test:8443/path?token=secret#frag"
|
|
),
|
|
"https://sub.example.test:8443/..."
|
|
);
|
|
}
|
|
|
|
fn sample_vless_link(tag: &str) -> String {
|
|
format!(
|
|
"vless://{}@nl.example.test:443?security=reality&sni=example.test&fp=chrome&pbk=public-key&sid=short-id&flow=xtls-rprx-vision#{}",
|
|
"11111111-1111-1111-1111-111111111111",
|
|
tag
|
|
)
|
|
}
|