Add device HWID support to sing-box subscriptions
This commit is contained in:
@@ -232,6 +232,8 @@ pub struct LocalSingBoxStatusResponse {
|
||||
pub struct LocalSingBoxConfigDto {
|
||||
pub subscription_display_url: Option<String>,
|
||||
pub has_subscription: bool,
|
||||
pub device_hwid_display: Option<String>,
|
||||
pub has_device_hwid: bool,
|
||||
pub selected_server_tag: Option<String>,
|
||||
pub listen_host: String,
|
||||
pub listen_port: u16,
|
||||
@@ -262,6 +264,15 @@ pub struct SubscriptionServerDto {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveSingBoxSubscriptionInputDto {
|
||||
pub subscription_url: String,
|
||||
#[serde(default)]
|
||||
pub device_hwid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveSingBoxDeviceHwidInputDto {
|
||||
#[serde(default)]
|
||||
pub device_hwid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -491,6 +502,7 @@ pub trait SubscriptionFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
url: &str,
|
||||
identity: &subscription::SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError>;
|
||||
}
|
||||
|
||||
@@ -500,8 +512,9 @@ impl SubscriptionFetcher for SystemSubscriptionFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
url: &str,
|
||||
identity: &subscription::SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||
subscription::fetch_subscription(url)
|
||||
subscription::fetch_subscription_with_identity(url, identity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,6 +684,14 @@ pub fn save_singbox_subscription(
|
||||
save_singbox_subscription_to_storage(&state.storage(), input, &SystemClock)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_singbox_device_hwid(
|
||||
state: tauri::State<'_, CommandState>,
|
||||
input: SaveSingBoxDeviceHwidInputDto,
|
||||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||
save_singbox_device_hwid_to_storage(&state.storage(), input, &SystemClock)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_singbox_subscription(
|
||||
state: tauri::State<'_, CommandState>,
|
||||
@@ -1411,6 +1432,9 @@ pub fn save_singbox_subscription_to_storage(
|
||||
|
||||
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||
config.subscription_url = Some(subscription_url);
|
||||
if let Some(device_hwid) = input.device_hwid {
|
||||
config.device_hwid = normalize_device_hwid(&device_hwid)?;
|
||||
}
|
||||
config.updated_at = Some(clock.now());
|
||||
storage
|
||||
.write_local_singbox_config(&config)
|
||||
@@ -1438,8 +1462,10 @@ pub fn fetch_singbox_subscription_with_fetcher(
|
||||
)
|
||||
})?;
|
||||
|
||||
let identity =
|
||||
subscription::SubscriptionFetchIdentity::with_device_hwid(config.device_hwid.as_deref());
|
||||
let cache = fetcher
|
||||
.fetch_subscription(&subscription_url)
|
||||
.fetch_subscription(&subscription_url, &identity)
|
||||
.map_err(|error| CommandError::new("singbox_subscription_fetch_failed", error.message))?;
|
||||
let selected_tag = config
|
||||
.selected_server_tag
|
||||
@@ -1475,6 +1501,7 @@ pub fn forget_singbox_subscription_in_storage(
|
||||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||
config.subscription_url = None;
|
||||
config.device_hwid = None;
|
||||
config.selected_server_tag = None;
|
||||
config.updated_at = Some(clock.now());
|
||||
storage
|
||||
@@ -1487,6 +1514,24 @@ pub fn forget_singbox_subscription_in_storage(
|
||||
read_singbox_status(storage)
|
||||
}
|
||||
|
||||
pub fn save_singbox_device_hwid_to_storage(
|
||||
storage: &JsonStorage,
|
||||
input: SaveSingBoxDeviceHwidInputDto,
|
||||
clock: &impl Clock,
|
||||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||
config.device_hwid = match input.device_hwid {
|
||||
Some(device_hwid) => normalize_device_hwid(&device_hwid)?,
|
||||
None => None,
|
||||
};
|
||||
config.updated_at = Some(clock.now());
|
||||
storage
|
||||
.write_local_singbox_config(&config)
|
||||
.map_err(storage_error)?;
|
||||
|
||||
read_singbox_status(storage)
|
||||
}
|
||||
|
||||
pub fn select_singbox_server_in_storage(
|
||||
storage: &JsonStorage,
|
||||
input: SelectSingBoxServerInputDto,
|
||||
@@ -1689,6 +1734,29 @@ fn validate_subscription_url(subscription_url: &str) -> Result<(), CommandError>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_device_hwid(raw_hwid: &str) -> Result<Option<String>, CommandError> {
|
||||
let device_hwid = raw_hwid.trim();
|
||||
if device_hwid.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if device_hwid.chars().count() > 256 {
|
||||
return Err(CommandError::new(
|
||||
"singbox_device_hwid_invalid",
|
||||
"HWID устройства должен быть не длиннее 256 символов.",
|
||||
));
|
||||
}
|
||||
|
||||
if !device_hwid.chars().all(|ch| ch.is_ascii_graphic()) {
|
||||
return Err(CommandError::new(
|
||||
"singbox_device_hwid_invalid",
|
||||
"HWID устройства может содержать только печатные ASCII-символы без пробелов.",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Some(device_hwid.to_string()))
|
||||
}
|
||||
|
||||
fn ping_subscription_server(server: &SubscriptionServer) -> PingServerResponse {
|
||||
ping_endpoint(&server.tag, &server.server, server.server_port)
|
||||
}
|
||||
@@ -4166,6 +4234,11 @@ impl From<&LocalSingBoxConfig> for LocalSingBoxConfigDto {
|
||||
.subscription_url
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.trim().is_empty()),
|
||||
device_hwid_display: config.device_hwid_display(),
|
||||
has_device_hwid: config
|
||||
.device_hwid
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.trim().is_empty()),
|
||||
selected_server_tag: config.selected_server_tag.clone(),
|
||||
listen_host: config.listen_host.clone(),
|
||||
listen_port: config.listen_port,
|
||||
|
||||
@@ -51,6 +51,7 @@ fn main() {
|
||||
commands::get_singbox_setup_status,
|
||||
commands::resolve_profile_preview,
|
||||
commands::save_singbox_subscription,
|
||||
commands::save_singbox_device_hwid,
|
||||
commands::fetch_singbox_subscription,
|
||||
commands::forget_singbox_subscription,
|
||||
commands::select_singbox_server,
|
||||
|
||||
@@ -138,6 +138,8 @@ pub struct LocalSingBoxConfig {
|
||||
#[serde(default)]
|
||||
pub subscription_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub device_hwid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_server_tag: Option<String>,
|
||||
#[serde(default = "default_local_singbox_listen_host")]
|
||||
pub listen_host: String,
|
||||
@@ -157,12 +159,17 @@ impl LocalSingBoxConfig {
|
||||
.as_deref()
|
||||
.map(redact_subscription_url)
|
||||
}
|
||||
|
||||
pub fn device_hwid_display(&self) -> Option<String> {
|
||||
self.device_hwid.as_deref().map(redact_device_hwid)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LocalSingBoxConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
subscription_url: None,
|
||||
device_hwid: None,
|
||||
selected_server_tag: None,
|
||||
listen_host: default_local_singbox_listen_host(),
|
||||
listen_port: default_local_singbox_listen_port(),
|
||||
@@ -271,3 +278,27 @@ pub fn redact_subscription_url(raw_url: &str) -> String {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redact_device_hwid(raw_hwid: &str) -> String {
|
||||
let trimmed = raw_hwid.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let length = trimmed.chars().count();
|
||||
if length <= 8 {
|
||||
return "***".to_string();
|
||||
}
|
||||
|
||||
let prefix = trimmed.chars().take(4).collect::<String>();
|
||||
let suffix = trimmed
|
||||
.chars()
|
||||
.rev()
|
||||
.take(4)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
|
||||
format!("{prefix}...{suffix}")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use url::Url;
|
||||
|
||||
const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"];
|
||||
const DEFAULT_APP_NAME: &str = "ProxyWarden";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubscriptionError {
|
||||
@@ -33,6 +34,41 @@ pub struct ParsedSubscription {
|
||||
pub servers: Vec<SubscriptionServer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubscriptionFetchIdentity {
|
||||
pub device_hwid: Option<String>,
|
||||
pub app_name: String,
|
||||
pub user_agent: String,
|
||||
pub device_os: String,
|
||||
pub device_model: String,
|
||||
}
|
||||
|
||||
impl SubscriptionFetchIdentity {
|
||||
pub fn with_device_hwid(device_hwid: Option<&str>) -> Self {
|
||||
Self {
|
||||
device_hwid: device_hwid
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SubscriptionFetchIdentity {
|
||||
fn default() -> Self {
|
||||
let device_os = std::env::consts::OS.to_string();
|
||||
|
||||
Self {
|
||||
device_hwid: None,
|
||||
app_name: DEFAULT_APP_NAME.to_string(),
|
||||
user_agent: format!("{DEFAULT_APP_NAME}/{device_os}"),
|
||||
device_os,
|
||||
device_model: DEFAULT_APP_NAME.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_subscription_body(body: &str) -> Result<ParsedSubscription, SubscriptionError> {
|
||||
let config = match serde_json::from_str::<Value>(body) {
|
||||
Ok(value) => value,
|
||||
@@ -66,6 +102,13 @@ pub fn parse_user_info(header_value: Option<&str>) -> Map<String, Value> {
|
||||
}
|
||||
|
||||
pub fn fetch_subscription(url: &str) -> Result<SubscriptionCache, SubscriptionError> {
|
||||
fetch_subscription_with_identity(url, &SubscriptionFetchIdentity::default())
|
||||
}
|
||||
|
||||
pub fn fetch_subscription_with_identity(
|
||||
url: &str,
|
||||
identity: &SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, SubscriptionError> {
|
||||
let parsed_url =
|
||||
Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?;
|
||||
if !matches!(parsed_url.scheme(), "http" | "https") {
|
||||
@@ -74,11 +117,23 @@ pub fn fetch_subscription(url: &str) -> Result<SubscriptionCache, SubscriptionEr
|
||||
));
|
||||
}
|
||||
|
||||
let response = reqwest::blocking::Client::new()
|
||||
let mut request = reqwest::blocking::Client::new()
|
||||
.get(parsed_url)
|
||||
.header("user-agent", "singbox")
|
||||
.header("x-device-os", std::env::consts::OS)
|
||||
.header("x-device-model", "proxywarden")
|
||||
.header("user-agent", identity.user_agent.as_str())
|
||||
.header("x-app-name", identity.app_name.as_str())
|
||||
.header("x-device-os", identity.device_os.as_str())
|
||||
.header("x-device-model", identity.device_model.as_str());
|
||||
|
||||
if let Some(device_hwid) = identity
|
||||
.device_hwid
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
request = request.header("x-hwid", device_hwid);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.map_err(|error| SubscriptionError::new(format!("Subscription request failed: {error}")))?;
|
||||
|
||||
|
||||
@@ -217,6 +217,7 @@ impl SingBoxConfigChecker for RecordingChecker {
|
||||
fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig {
|
||||
LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/list".to_string()),
|
||||
device_hwid: None,
|
||||
selected_server_tag: Some(selected_server_tag.to_string()),
|
||||
listen_host: "127.0.0.1".to_string(),
|
||||
listen_port: 1080,
|
||||
|
||||
@@ -25,9 +25,10 @@ mod validation;
|
||||
|
||||
use commands::{
|
||||
fetch_singbox_subscription_with_fetcher, forget_singbox_subscription_in_storage,
|
||||
generate_singbox_config_with_services, save_singbox_subscription_to_storage,
|
||||
select_singbox_server_in_storage, Clock, SaveSingBoxSubscriptionInputDto,
|
||||
SelectSingBoxServerInputDto, SubscriptionFetcher,
|
||||
generate_singbox_config_with_services, save_singbox_device_hwid_to_storage,
|
||||
save_singbox_subscription_to_storage, select_singbox_server_in_storage, Clock,
|
||||
SaveSingBoxDeviceHwidInputDto, SaveSingBoxSubscriptionInputDto, SelectSingBoxServerInputDto,
|
||||
SubscriptionFetcher,
|
||||
};
|
||||
use models::{
|
||||
ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, SubscriptionCache,
|
||||
@@ -49,6 +50,7 @@ fn saves_subscription_url_without_exposing_secret_query() {
|
||||
&storage,
|
||||
SaveSingBoxSubscriptionInputDto {
|
||||
subscription_url: " https://sub.example.test/path?token=secret ".to_string(),
|
||||
device_hwid: Some(" hwid-1234567890 ".to_string()),
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
@@ -61,11 +63,58 @@ fn saves_subscription_url_without_exposing_secret_query() {
|
||||
config.subscription_url,
|
||||
Some("https://sub.example.test/path?token=secret".to_string())
|
||||
);
|
||||
assert_eq!(config.device_hwid, Some("hwid-1234567890".to_string()));
|
||||
assert!(status.config.has_subscription);
|
||||
assert!(status.config.has_device_hwid);
|
||||
assert_eq!(
|
||||
status.config.subscription_display_url,
|
||||
Some("https://sub.example.test/...".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
status.config.device_hwid_display,
|
||||
Some("hwid...7890".to_string())
|
||||
);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_and_clears_device_hwid_without_exposing_raw_value() {
|
||||
let root = test_root("save-hwid");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
|
||||
let status = save_singbox_device_hwid_to_storage(
|
||||
&storage,
|
||||
SaveSingBoxDeviceHwidInputDto {
|
||||
device_hwid: Some(" device-abcdef1234 ".to_string()),
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
.expect("device hwid should be saved");
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.expect("read local sing-box config");
|
||||
|
||||
assert_eq!(config.device_hwid, Some("device-abcdef1234".to_string()));
|
||||
assert!(status.config.has_device_hwid);
|
||||
assert_eq!(
|
||||
status.config.device_hwid_display,
|
||||
Some("devi...1234".to_string())
|
||||
);
|
||||
|
||||
let status = save_singbox_device_hwid_to_storage(
|
||||
&storage,
|
||||
SaveSingBoxDeviceHwidInputDto { device_hwid: None },
|
||||
&FixedClock,
|
||||
)
|
||||
.expect("device hwid should be cleared");
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.expect("read local sing-box config");
|
||||
|
||||
assert_eq!(config.device_hwid, None);
|
||||
assert!(!status.config.has_device_hwid);
|
||||
assert_eq!(status.config.device_hwid_display, None);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
@@ -79,6 +128,7 @@ fn rejects_non_http_subscription_url() {
|
||||
&storage,
|
||||
SaveSingBoxSubscriptionInputDto {
|
||||
subscription_url: "file:///C:/sub.txt".to_string(),
|
||||
device_hwid: None,
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
@@ -97,6 +147,7 @@ fn fetches_subscription_cache_and_selects_first_server() {
|
||||
&storage,
|
||||
SaveSingBoxSubscriptionInputDto {
|
||||
subscription_url: "https://sub.example.test/path?token=secret".to_string(),
|
||||
device_hwid: None,
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
@@ -127,6 +178,33 @@ fn fetches_subscription_cache_and_selects_first_server() {
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetches_subscription_with_saved_device_hwid() {
|
||||
let root = test_root("fetch-subscription-hwid");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
save_singbox_subscription_to_storage(
|
||||
&storage,
|
||||
SaveSingBoxSubscriptionInputDto {
|
||||
subscription_url: "https://sub.example.test/path?token=secret".to_string(),
|
||||
device_hwid: Some("hwid-abcdef1234".to_string()),
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
.expect("save subscription URL and HWID");
|
||||
|
||||
fetch_singbox_subscription_with_fetcher(
|
||||
&storage,
|
||||
&HwidAssertingFetcher {
|
||||
cache: sample_cache(),
|
||||
expected_hwid: Some("hwid-abcdef1234"),
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
.expect("fetch subscription through mock");
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_server_from_cached_subscription() {
|
||||
let root = test_root("select-server");
|
||||
@@ -193,6 +271,7 @@ fn generate_writes_config_and_local_singbox_target() {
|
||||
storage
|
||||
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/path".to_string()),
|
||||
device_hwid: Some("hwid-abcdef1234".to_string()),
|
||||
selected_server_tag: Some("nl-1".to_string()),
|
||||
..LocalSingBoxConfig::default()
|
||||
})
|
||||
@@ -259,6 +338,7 @@ fn forget_subscription_clears_url_selection_and_cache() {
|
||||
storage
|
||||
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/path".to_string()),
|
||||
device_hwid: Some("hwid-abcdef1234".to_string()),
|
||||
selected_server_tag: Some("nl-1".to_string()),
|
||||
..LocalSingBoxConfig::default()
|
||||
})
|
||||
@@ -278,6 +358,7 @@ fn forget_subscription_clears_url_selection_and_cache() {
|
||||
|
||||
assert!(!status.config.has_subscription);
|
||||
assert_eq!(config.subscription_url, None);
|
||||
assert_eq!(config.device_hwid, None);
|
||||
assert_eq!(config.selected_server_tag, None);
|
||||
assert_eq!(cache, None);
|
||||
|
||||
@@ -290,12 +371,34 @@ impl SubscriptionFetcher for MockFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
url: &str,
|
||||
_identity: &subscription::SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||
assert_eq!(url, "https://sub.example.test/path?token=secret");
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct HwidAssertingFetcher {
|
||||
cache: SubscriptionCache,
|
||||
expected_hwid: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl SubscriptionFetcher for HwidAssertingFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
url: &str,
|
||||
identity: &subscription::SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||
assert_eq!(url, "https://sub.example.test/path?token=secret");
|
||||
assert_eq!(identity.device_hwid.as_deref(), self.expected_hwid);
|
||||
assert_eq!(identity.app_name, "ProxyWarden");
|
||||
assert!(identity.user_agent.starts_with("ProxyWarden/"));
|
||||
assert_eq!(identity.device_os, std::env::consts::OS);
|
||||
assert_eq!(identity.device_model, "ProxyWarden");
|
||||
Ok(self.cache.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct MockChecker;
|
||||
|
||||
impl SingBoxConfigChecker for MockChecker {
|
||||
|
||||
@@ -59,6 +59,7 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let config = LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/path?token=secret".to_string()),
|
||||
device_hwid: Some("hwid-abcdef1234".to_string()),
|
||||
selected_server_tag: Some("nl-1".to_string()),
|
||||
listen_host: "127.0.0.1".to_string(),
|
||||
listen_port: 1080,
|
||||
@@ -91,6 +92,10 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
|
||||
config.subscription_display_url(),
|
||||
Some("https://sub.example.test/...".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
config.device_hwid_display(),
|
||||
Some("hwid...1234".to_string())
|
||||
);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
@@ -104,6 +109,7 @@ fn missing_local_singbox_config_defaults_to_optional_empty_state() {
|
||||
.expect("read default local sing-box config");
|
||||
|
||||
assert_eq!(config.subscription_url, None);
|
||||
assert_eq!(config.device_hwid, None);
|
||||
assert_eq!(config.selected_server_tag, None);
|
||||
assert_eq!(config.listen_host, "127.0.0.1");
|
||||
assert_eq!(config.listen_port, 1080);
|
||||
|
||||
@@ -5,7 +5,10 @@ mod subscription;
|
||||
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use models::redact_subscription_url;
|
||||
use subscription::{parse_subscription_body, parse_user_info};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::time::Duration;
|
||||
use subscription::{parse_subscription_body, parse_user_info, SubscriptionFetchIdentity};
|
||||
|
||||
#[test]
|
||||
fn parses_singbox_json_config_servers() {
|
||||
@@ -78,6 +81,55 @@ fn rejects_invalid_or_non_http_subscription_url_before_network() {
|
||||
assert!(unsupported.message.contains("http or https"));
|
||||
}
|
||||
|
||||
#[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 identity = SubscriptionFetchIdentity::with_device_hwid(Some("hwid-abc123"));
|
||||
let cache = subscription::fetch_subscription_with_identity(&url, &identity)
|
||||
.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-model: proxywarden"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_subscription_url_for_display() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user