diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f02b425..010e898 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -210,6 +210,8 @@ pub struct LocalSingBoxStatusResponse { pub component: ComponentStatusDto, pub generated_config_path: String, pub lan_listen_host: Option, + #[cfg(debug_assertions)] + pub subscription_identity: SubscriptionRequestIdentityDto, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -243,6 +245,21 @@ pub struct SubscriptionServerDto { pub server_port: u16, } +#[cfg(debug_assertions)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionRequestIdentityDto { + pub headers: Vec, +} + +#[cfg(debug_assertions)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionRequestHeaderDto { + pub name: String, + pub value: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SaveSingBoxSubscriptionInputDto { @@ -492,6 +509,21 @@ impl SubscriptionFetcher for SystemSubscriptionFetcher { } } +#[cfg(debug_assertions)] +fn subscription_request_identity_for_display() -> SubscriptionRequestIdentityDto { + let identity = subscription::SubscriptionFetchIdentity::default(); + let headers = identity + .request_headers_without_device_hwid() + .into_iter() + .map(|(name, value)| SubscriptionRequestHeaderDto { + name: name.to_string(), + value, + }) + .collect(); + + SubscriptionRequestIdentityDto { headers } +} + pub trait Clock { fn now(&self) -> String; } @@ -1404,6 +1436,8 @@ fn read_singbox_status_with_detection( .display() .to_string(), lan_listen_host: local_lan_ipv4(), + #[cfg(debug_assertions)] + subscription_identity: subscription_request_identity_for_display(), }) } diff --git a/src-tauri/src/subscription.rs b/src-tauri/src/subscription.rs index 0f1915d..e02426b 100644 --- a/src-tauri/src/subscription.rs +++ b/src-tauri/src/subscription.rs @@ -54,6 +54,30 @@ impl SubscriptionFetchIdentity { ..Self::default() } } + + pub fn request_headers_without_device_hwid(&self) -> Vec<(&'static str, String)> { + let mut headers = vec![ + ("User-Agent", self.user_agent.clone()), + ("X-App-Name", self.app_name.clone()), + ("X-Device-OS", self.device_os.clone()), + ("X-Device-Model", self.device_model.clone()), + ]; + + if let Some(device_os_version) = self + .device_os_version + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let header_value = sanitize_header_value(device_os_version); + if !header_value.is_empty() { + headers.push(("X-Device-OS-Version", header_value.clone())); + headers.push(("X-Ver-OS", header_value)); + } + } + + headers + } } impl Default for SubscriptionFetchIdentity { @@ -119,23 +143,10 @@ pub fn fetch_subscription_with_identity( )); } - let mut request = reqwest::blocking::Client::new() - .get(parsed_url) - .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()); + let mut request = reqwest::blocking::Client::new().get(parsed_url); - if let Some(device_os_version) = identity - .device_os_version - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - let header_value = sanitize_header_value(device_os_version); - if !header_value.is_empty() { - request = request.header("x-device-os-version", header_value); - } + for (name, value) in identity.request_headers_without_device_hwid() { + request = request.header(name, value); } if let Some(device_hwid) = identity diff --git a/src-tauri/tests/singbox_command_tests.rs b/src-tauri/tests/singbox_command_tests.rs index 5716137..22254c6 100644 --- a/src-tauri/tests/singbox_command_tests.rs +++ b/src-tauri/tests/singbox_command_tests.rs @@ -3,9 +3,9 @@ use proxywarden_lib::adapters::singbox::{ }; use proxywarden_lib::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, read_singbox_status, + save_singbox_subscription_to_storage, select_singbox_server_in_storage, Clock, + SaveSingBoxSubscriptionInputDto, SelectSingBoxServerInputDto, SubscriptionFetcher, }; use proxywarden_lib::models::{ ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, SubscriptionCache, @@ -49,6 +49,39 @@ fn saves_subscription_url_without_exposing_secret_query() { cleanup(&root); } +#[cfg(debug_assertions)] +#[test] +fn singbox_status_exposes_dev_subscription_headers_without_hwid() { + let root = test_root("dev-subscription-identity"); + let storage = JsonStorage::new(root.clone()); + + let status = read_singbox_status(&storage).expect("read sing-box status"); + let headers = status + .subscription_identity + .headers + .iter() + .map(|header| (header.name.clone(), header.value.clone())) + .collect::>(); + let expected_headers = subscription::SubscriptionFetchIdentity::default() + .request_headers_without_device_hwid() + .into_iter() + .map(|(name, value)| (name.to_string(), value)) + .collect::>(); + + assert_eq!(headers, expected_headers); + assert!(headers + .iter() + .all(|(name, _)| !name.eq_ignore_ascii_case("x-hwid"))); + let serialized = serde_json::to_value(&status).expect("serialize status"); + assert!(serialized.get("subscriptionIdentity").is_some()); + assert!(!serialized + .to_string() + .to_ascii_lowercase() + .contains("x-hwid")); + + cleanup(&root); +} + #[test] fn rejects_non_http_subscription_url() { let root = test_root("invalid-subscription"); diff --git a/src-tauri/tests/subscription_tests.rs b/src-tauri/tests/subscription_tests.rs index e92d105..4c45bb4 100644 --- a/src-tauri/tests/subscription_tests.rs +++ b/src-tauri/tests/subscription_tests.rs @@ -139,6 +139,7 @@ fn fetch_subscription_sends_device_hwid_header_when_identity_is_set() { 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")); } diff --git a/src/api/tauriCommands.ts b/src/api/tauriCommands.ts index 15829a3..04e6c5c 100644 --- a/src/api/tauriCommands.ts +++ b/src/api/tauriCommands.ts @@ -80,6 +80,16 @@ export interface LocalSingBoxStatusResponse { component: ComponentStatus; generatedConfigPath: string; lanListenHost?: string; + subscriptionIdentity?: SubscriptionRequestIdentity; +} + +export interface SubscriptionRequestIdentity { + headers: SubscriptionRequestHeader[]; +} + +export interface SubscriptionRequestHeader { + name: string; + value: string; } export interface PingServerResponse { diff --git a/src/app/App.tsx b/src/app/App.tsx index 1a02fe9..b1ea6b9 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -142,6 +142,7 @@ const MAIN_PROFILE_ID = 'main-profile'; const LOCAL_SINGBOX_TARGET_ID = 'local-singbox'; const LOG_VISIBLE_MS = 6500; const PANEL_ORDER: PanelId[] = ['proxifyre', 'summary', 'proxy']; +const SHOW_DEV_SUBSCRIPTION_IDENTITY = import.meta.env.DEV; const proxyWardenToggleOnImage = new URL('../assets/proxywarden-toggle-on.png', import.meta.url).href; const proxyWardenToggleOffImage = new URL('../assets/proxywarden-toggle-off.png', import.meta.url).href; @@ -1381,7 +1382,13 @@ export function App() { inlineActions={( @@ -2552,6 +2559,7 @@ function singBoxDetailLines( status: LocalSingBoxStatusResponse | null, setupStatus: SingBoxSetupStatus | null, selectedServerTag: string | undefined, + showDevSubscriptionIdentity = false, ) { const setupDetails = setupStatus ? setupStatus.items @@ -2559,7 +2567,7 @@ function singBoxDetailLines( .join('; ') : 'состав не проверен'; - return [ + const details = [ `Локально: ${localSingBoxAddress(status)}`, `LAN: ${lanSingBoxAddress(status) ?? 'недоступен'}`, `Сервер: ${selectedServerTag ? displayServerTag(selectedServerTag) : 'не выбран'}`, @@ -2568,6 +2576,30 @@ function singBoxDetailLines( `Подписка: ${status?.config.subscriptionDisplayUrl ?? (status?.config.hasSubscription ? 'сохранена' : 'не загружена')}`, `Состав: ${setupDetails}`, ]; + + if (showDevSubscriptionIdentity) { + details.push(...subscriptionIdentityDetailLines(status)); + } + + return details; +} + +function subscriptionIdentityDetailLines(status: LocalSingBoxStatusResponse | null) { + const headers = status?.subscriptionIdentity?.headers + ?.filter((header) => header.name.trim().toLowerCase() !== 'x-hwid') ?? []; + + if (!headers.length) return ['Dev headers подписки: недоступны']; + + const details = [ + 'Dev headers подписки (без HWID):', + ...headers.map((header) => `${header.name}: ${header.value || 'пусто'}`), + ]; + const sendsLegacyOsVersion = headers.some((header) => header.name.trim().toLowerCase() === 'x-ver-os'); + if (!sendsLegacyOsVersion) { + details.push('X-Ver-OS: не отправится, версия ОС не определена'); + } + + return details; } function componentDetails(component: ComponentStatus | undefined, checking: boolean) { diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +///