Add device HWID support to sing-box subscriptions

This commit is contained in:
2026-07-08 20:31:21 +03:00
parent 7316e932f0
commit 42b85cc8fa
12 changed files with 450 additions and 19 deletions

View File

@@ -232,6 +232,8 @@ pub struct LocalSingBoxStatusResponse {
pub struct LocalSingBoxConfigDto { pub struct LocalSingBoxConfigDto {
pub subscription_display_url: Option<String>, pub subscription_display_url: Option<String>,
pub has_subscription: bool, pub has_subscription: bool,
pub device_hwid_display: Option<String>,
pub has_device_hwid: bool,
pub selected_server_tag: Option<String>, pub selected_server_tag: Option<String>,
pub listen_host: String, pub listen_host: String,
pub listen_port: u16, pub listen_port: u16,
@@ -262,6 +264,15 @@ pub struct SubscriptionServerDto {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SaveSingBoxSubscriptionInputDto { pub struct SaveSingBoxSubscriptionInputDto {
pub subscription_url: String, 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -491,6 +502,7 @@ pub trait SubscriptionFetcher {
fn fetch_subscription( fn fetch_subscription(
&self, &self,
url: &str, url: &str,
identity: &subscription::SubscriptionFetchIdentity,
) -> Result<SubscriptionCache, subscription::SubscriptionError>; ) -> Result<SubscriptionCache, subscription::SubscriptionError>;
} }
@@ -500,8 +512,9 @@ impl SubscriptionFetcher for SystemSubscriptionFetcher {
fn fetch_subscription( fn fetch_subscription(
&self, &self,
url: &str, url: &str,
identity: &subscription::SubscriptionFetchIdentity,
) -> Result<SubscriptionCache, subscription::SubscriptionError> { ) -> 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) 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] #[tauri::command]
pub async fn fetch_singbox_subscription( pub async fn fetch_singbox_subscription(
state: tauri::State<'_, CommandState>, 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)?; let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
config.subscription_url = Some(subscription_url); 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()); config.updated_at = Some(clock.now());
storage storage
.write_local_singbox_config(&config) .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 let cache = fetcher
.fetch_subscription(&subscription_url) .fetch_subscription(&subscription_url, &identity)
.map_err(|error| CommandError::new("singbox_subscription_fetch_failed", error.message))?; .map_err(|error| CommandError::new("singbox_subscription_fetch_failed", error.message))?;
let selected_tag = config let selected_tag = config
.selected_server_tag .selected_server_tag
@@ -1475,6 +1501,7 @@ pub fn forget_singbox_subscription_in_storage(
) -> Result<LocalSingBoxStatusResponse, CommandError> { ) -> Result<LocalSingBoxStatusResponse, CommandError> {
let mut config = storage.read_local_singbox_config().map_err(storage_error)?; let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
config.subscription_url = None; config.subscription_url = None;
config.device_hwid = None;
config.selected_server_tag = None; config.selected_server_tag = None;
config.updated_at = Some(clock.now()); config.updated_at = Some(clock.now());
storage storage
@@ -1487,6 +1514,24 @@ pub fn forget_singbox_subscription_in_storage(
read_singbox_status(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( pub fn select_singbox_server_in_storage(
storage: &JsonStorage, storage: &JsonStorage,
input: SelectSingBoxServerInputDto, input: SelectSingBoxServerInputDto,
@@ -1689,6 +1734,29 @@ fn validate_subscription_url(subscription_url: &str) -> Result<(), CommandError>
Ok(()) 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 { fn ping_subscription_server(server: &SubscriptionServer) -> PingServerResponse {
ping_endpoint(&server.tag, &server.server, server.server_port) ping_endpoint(&server.tag, &server.server, server.server_port)
} }
@@ -4166,6 +4234,11 @@ impl From<&LocalSingBoxConfig> for LocalSingBoxConfigDto {
.subscription_url .subscription_url
.as_deref() .as_deref()
.is_some_and(|value| !value.trim().is_empty()), .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(), selected_server_tag: config.selected_server_tag.clone(),
listen_host: config.listen_host.clone(), listen_host: config.listen_host.clone(),
listen_port: config.listen_port, listen_port: config.listen_port,

View File

@@ -51,6 +51,7 @@ fn main() {
commands::get_singbox_setup_status, commands::get_singbox_setup_status,
commands::resolve_profile_preview, commands::resolve_profile_preview,
commands::save_singbox_subscription, commands::save_singbox_subscription,
commands::save_singbox_device_hwid,
commands::fetch_singbox_subscription, commands::fetch_singbox_subscription,
commands::forget_singbox_subscription, commands::forget_singbox_subscription,
commands::select_singbox_server, commands::select_singbox_server,

View File

@@ -138,6 +138,8 @@ pub struct LocalSingBoxConfig {
#[serde(default)] #[serde(default)]
pub subscription_url: Option<String>, pub subscription_url: Option<String>,
#[serde(default)] #[serde(default)]
pub device_hwid: Option<String>,
#[serde(default)]
pub selected_server_tag: Option<String>, pub selected_server_tag: Option<String>,
#[serde(default = "default_local_singbox_listen_host")] #[serde(default = "default_local_singbox_listen_host")]
pub listen_host: String, pub listen_host: String,
@@ -157,12 +159,17 @@ impl LocalSingBoxConfig {
.as_deref() .as_deref()
.map(redact_subscription_url) .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 { impl Default for LocalSingBoxConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
subscription_url: None, subscription_url: None,
device_hwid: None,
selected_server_tag: None, selected_server_tag: None,
listen_host: default_local_singbox_listen_host(), listen_host: default_local_singbox_listen_host(),
listen_port: default_local_singbox_listen_port(), 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}")
}

View File

@@ -5,6 +5,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use url::Url; use url::Url;
const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"]; const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"];
const DEFAULT_APP_NAME: &str = "ProxyWarden";
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubscriptionError { pub struct SubscriptionError {
@@ -33,6 +34,41 @@ pub struct ParsedSubscription {
pub servers: Vec<SubscriptionServer>, 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> { pub fn parse_subscription_body(body: &str) -> Result<ParsedSubscription, SubscriptionError> {
let config = match serde_json::from_str::<Value>(body) { let config = match serde_json::from_str::<Value>(body) {
Ok(value) => value, 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> { 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 = let parsed_url =
Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?; Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?;
if !matches!(parsed_url.scheme(), "http" | "https") { 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) .get(parsed_url)
.header("user-agent", "singbox") .header("user-agent", identity.user_agent.as_str())
.header("x-device-os", std::env::consts::OS) .header("x-app-name", identity.app_name.as_str())
.header("x-device-model", "proxywarden") .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() .send()
.map_err(|error| SubscriptionError::new(format!("Subscription request failed: {error}")))?; .map_err(|error| SubscriptionError::new(format!("Subscription request failed: {error}")))?;

View File

@@ -217,6 +217,7 @@ impl SingBoxConfigChecker for RecordingChecker {
fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig { fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig {
LocalSingBoxConfig { LocalSingBoxConfig {
subscription_url: Some("https://sub.example.test/list".to_string()), subscription_url: Some("https://sub.example.test/list".to_string()),
device_hwid: None,
selected_server_tag: Some(selected_server_tag.to_string()), selected_server_tag: Some(selected_server_tag.to_string()),
listen_host: "127.0.0.1".to_string(), listen_host: "127.0.0.1".to_string(),
listen_port: 1080, listen_port: 1080,

View File

@@ -25,9 +25,10 @@ mod validation;
use commands::{ use commands::{
fetch_singbox_subscription_with_fetcher, forget_singbox_subscription_in_storage, fetch_singbox_subscription_with_fetcher, forget_singbox_subscription_in_storage,
generate_singbox_config_with_services, save_singbox_subscription_to_storage, generate_singbox_config_with_services, save_singbox_device_hwid_to_storage,
select_singbox_server_in_storage, Clock, SaveSingBoxSubscriptionInputDto, save_singbox_subscription_to_storage, select_singbox_server_in_storage, Clock,
SelectSingBoxServerInputDto, SubscriptionFetcher, SaveSingBoxDeviceHwidInputDto, SaveSingBoxSubscriptionInputDto, SelectSingBoxServerInputDto,
SubscriptionFetcher,
}; };
use models::{ use models::{
ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, SubscriptionCache, ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, SubscriptionCache,
@@ -49,6 +50,7 @@ fn saves_subscription_url_without_exposing_secret_query() {
&storage, &storage,
SaveSingBoxSubscriptionInputDto { SaveSingBoxSubscriptionInputDto {
subscription_url: " https://sub.example.test/path?token=secret ".to_string(), subscription_url: " https://sub.example.test/path?token=secret ".to_string(),
device_hwid: Some(" hwid-1234567890 ".to_string()),
}, },
&FixedClock, &FixedClock,
) )
@@ -61,11 +63,58 @@ fn saves_subscription_url_without_exposing_secret_query() {
config.subscription_url, config.subscription_url,
Some("https://sub.example.test/path?token=secret".to_string()) 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_subscription);
assert!(status.config.has_device_hwid);
assert_eq!( assert_eq!(
status.config.subscription_display_url, status.config.subscription_display_url,
Some("https://sub.example.test/...".to_string()) 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); cleanup(&root);
} }
@@ -79,6 +128,7 @@ fn rejects_non_http_subscription_url() {
&storage, &storage,
SaveSingBoxSubscriptionInputDto { SaveSingBoxSubscriptionInputDto {
subscription_url: "file:///C:/sub.txt".to_string(), subscription_url: "file:///C:/sub.txt".to_string(),
device_hwid: None,
}, },
&FixedClock, &FixedClock,
) )
@@ -97,6 +147,7 @@ fn fetches_subscription_cache_and_selects_first_server() {
&storage, &storage,
SaveSingBoxSubscriptionInputDto { SaveSingBoxSubscriptionInputDto {
subscription_url: "https://sub.example.test/path?token=secret".to_string(), subscription_url: "https://sub.example.test/path?token=secret".to_string(),
device_hwid: None,
}, },
&FixedClock, &FixedClock,
) )
@@ -127,6 +178,33 @@ fn fetches_subscription_cache_and_selects_first_server() {
cleanup(&root); 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] #[test]
fn selects_server_from_cached_subscription() { fn selects_server_from_cached_subscription() {
let root = test_root("select-server"); let root = test_root("select-server");
@@ -193,6 +271,7 @@ fn generate_writes_config_and_local_singbox_target() {
storage storage
.write_local_singbox_config(&LocalSingBoxConfig { .write_local_singbox_config(&LocalSingBoxConfig {
subscription_url: Some("https://sub.example.test/path".to_string()), 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()), selected_server_tag: Some("nl-1".to_string()),
..LocalSingBoxConfig::default() ..LocalSingBoxConfig::default()
}) })
@@ -259,6 +338,7 @@ fn forget_subscription_clears_url_selection_and_cache() {
storage storage
.write_local_singbox_config(&LocalSingBoxConfig { .write_local_singbox_config(&LocalSingBoxConfig {
subscription_url: Some("https://sub.example.test/path".to_string()), 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()), selected_server_tag: Some("nl-1".to_string()),
..LocalSingBoxConfig::default() ..LocalSingBoxConfig::default()
}) })
@@ -278,6 +358,7 @@ fn forget_subscription_clears_url_selection_and_cache() {
assert!(!status.config.has_subscription); assert!(!status.config.has_subscription);
assert_eq!(config.subscription_url, None); assert_eq!(config.subscription_url, None);
assert_eq!(config.device_hwid, None);
assert_eq!(config.selected_server_tag, None); assert_eq!(config.selected_server_tag, None);
assert_eq!(cache, None); assert_eq!(cache, None);
@@ -290,12 +371,34 @@ impl SubscriptionFetcher for MockFetcher {
fn fetch_subscription( fn fetch_subscription(
&self, &self,
url: &str, url: &str,
_identity: &subscription::SubscriptionFetchIdentity,
) -> Result<SubscriptionCache, subscription::SubscriptionError> { ) -> Result<SubscriptionCache, subscription::SubscriptionError> {
assert_eq!(url, "https://sub.example.test/path?token=secret"); assert_eq!(url, "https://sub.example.test/path?token=secret");
Ok(self.0.clone()) 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; struct MockChecker;
impl SingBoxConfigChecker for MockChecker { impl SingBoxConfigChecker for MockChecker {

View File

@@ -59,6 +59,7 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
let storage = JsonStorage::new(root.clone()); let storage = JsonStorage::new(root.clone());
let config = LocalSingBoxConfig { let config = LocalSingBoxConfig {
subscription_url: Some("https://sub.example.test/path?token=secret".to_string()), 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()), selected_server_tag: Some("nl-1".to_string()),
listen_host: "127.0.0.1".to_string(), listen_host: "127.0.0.1".to_string(),
listen_port: 1080, listen_port: 1080,
@@ -91,6 +92,10 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
config.subscription_display_url(), config.subscription_display_url(),
Some("https://sub.example.test/...".to_string()) Some("https://sub.example.test/...".to_string())
); );
assert_eq!(
config.device_hwid_display(),
Some("hwid...1234".to_string())
);
cleanup(&root); cleanup(&root);
} }
@@ -104,6 +109,7 @@ fn missing_local_singbox_config_defaults_to_optional_empty_state() {
.expect("read default local sing-box config"); .expect("read default local sing-box config");
assert_eq!(config.subscription_url, None); assert_eq!(config.subscription_url, None);
assert_eq!(config.device_hwid, None);
assert_eq!(config.selected_server_tag, None); assert_eq!(config.selected_server_tag, None);
assert_eq!(config.listen_host, "127.0.0.1"); assert_eq!(config.listen_host, "127.0.0.1");
assert_eq!(config.listen_port, 1080); assert_eq!(config.listen_port, 1080);

View File

@@ -5,7 +5,10 @@ mod subscription;
use base64::{engine::general_purpose, Engine}; use base64::{engine::general_purpose, Engine};
use models::redact_subscription_url; 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] #[test]
fn parses_singbox_json_config_servers() { 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")); 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] #[test]
fn redacts_subscription_url_for_display() { fn redacts_subscription_url_for_display() {
assert_eq!( assert_eq!(

View File

@@ -199,9 +199,18 @@ export function getSingBoxSetupStatus(): Promise<SingBoxSetupStatus> {
return invoke<SingBoxSetupStatus>('get_singbox_setup_status'); return invoke<SingBoxSetupStatus>('get_singbox_setup_status');
} }
export function saveSingBoxSubscription(subscriptionUrl: string): Promise<LocalSingBoxStatusResponse> { export function saveSingBoxSubscription(
subscriptionUrl: string,
deviceHwid?: string,
): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>('save_singbox_subscription', { return invoke<LocalSingBoxStatusResponse>('save_singbox_subscription', {
input: { subscriptionUrl }, input: { subscriptionUrl, deviceHwid },
});
}
export function saveSingBoxDeviceHwid(deviceHwid?: string): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>('save_singbox_device_hwid', {
input: { deviceHwid },
}); });
} }

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import { Cpu, FileCode2, FolderOpen, Gauge, Link2, ShieldAlert, Trash2, Wand2 } from 'lucide-react'; import { Cpu, FileCode2, Fingerprint, FolderOpen, Gauge, Link2, ShieldAlert, Trash2, Wand2 } from 'lucide-react';
import { import {
applyProfiles, applyProfiles,
fetchSingBoxSubscription, fetchSingBoxSubscription,
@@ -19,6 +19,7 @@ import {
pingSingBoxServer, pingSingBoxServer,
restartAsAdmin, restartAsAdmin,
saveProfile, saveProfile,
saveSingBoxDeviceHwid,
saveSingBoxSubscription, saveSingBoxSubscription,
saveTarget, saveTarget,
selectSingBoxServer, selectSingBoxServer,
@@ -44,7 +45,7 @@ import { serviceControlState } from './viewModel';
type DraftItemType = Extract<ProfileItemType, 'process' | 'folder' | 'exe'>; type DraftItemType = Extract<ProfileItemType, 'process' | 'folder' | 'exe'>;
type ProxiFyreAction = 'start' | 'stop' | 'restart' | 'install' | 'uninstall'; type ProxiFyreAction = 'start' | 'stop' | 'restart' | 'install' | 'uninstall';
type SingBoxAction = 'start' | 'stop' | 'install' | 'uninstall' | 'fetch' | 'forget' | 'generate' | 'ping'; type SingBoxAction = 'start' | 'stop' | 'install' | 'uninstall' | 'fetch' | 'forget' | 'identity' | 'generate' | 'ping';
type RouteMode = 'external' | 'local-singbox'; type RouteMode = 'external' | 'local-singbox';
type ServiceVisualState = 'active' | 'settling' | null; type ServiceVisualState = 'active' | 'settling' | null;
type PanelId = 'summary' | 'proxifyre' | 'proxy'; type PanelId = 'summary' | 'proxifyre' | 'proxy';
@@ -184,6 +185,7 @@ export function App() {
const [singBoxStatus, setSingBoxStatus] = useState<LocalSingBoxStatusResponse | null>(null); const [singBoxStatus, setSingBoxStatus] = useState<LocalSingBoxStatusResponse | null>(null);
const [singBoxSetupStatus, setSingBoxSetupStatus] = useState<SingBoxSetupStatus | null>(null); const [singBoxSetupStatus, setSingBoxSetupStatus] = useState<SingBoxSetupStatus | null>(null);
const [subscriptionInput, setSubscriptionInput] = useState(''); const [subscriptionInput, setSubscriptionInput] = useState('');
const [deviceHwidInput, setDeviceHwidInput] = useState('');
const [serverPings, setServerPings] = useState<Record<string, PingServerResponse>>({}); const [serverPings, setServerPings] = useState<Record<string, PingServerResponse>>({});
const [proxyCheck, setProxyCheck] = useState<ProxyTargetCheckResponse | null>(null); const [proxyCheck, setProxyCheck] = useState<ProxyTargetCheckResponse | null>(null);
const [adminStatus, setAdminStatus] = useState<AdminStatusResponse | null>(null); const [adminStatus, setAdminStatus] = useState<AdminStatusResponse | null>(null);
@@ -711,6 +713,7 @@ export function App() {
async function syncSingBoxSubscription() { async function syncSingBoxSubscription() {
const subscriptionUrl = subscriptionInput.trim(); const subscriptionUrl = subscriptionInput.trim();
const deviceHwid = deviceHwidInput.trim();
if (!subscriptionUrl && !singBoxStatus?.config.hasSubscription) { if (!subscriptionUrl && !singBoxStatus?.config.hasSubscription) {
showNotice({ showNotice({
kind: 'error', kind: 'error',
@@ -723,12 +726,15 @@ export function App() {
setSingBoxAction('fetch'); setSingBoxAction('fetch');
try { try {
if (subscriptionUrl) { if (subscriptionUrl) {
await saveSingBoxSubscription(subscriptionUrl); await saveSingBoxSubscription(subscriptionUrl, deviceHwid || undefined);
} else if (deviceHwid) {
await saveSingBoxDeviceHwid(deviceHwid);
} }
const status = await fetchSingBoxSubscription(); const status = await fetchSingBoxSubscription();
setSingBoxStatus(status); setSingBoxStatus(status);
setComponents((current) => upsertComponent(current, status.component)); setComponents((current) => upsertComponent(current, status.component));
setSubscriptionInput(''); setSubscriptionInput('');
setDeviceHwidInput('');
setServerPings({}); setServerPings({});
setProxyCheck(null); setProxyCheck(null);
showNotice({ showNotice({
@@ -747,6 +753,64 @@ export function App() {
} }
} }
async function saveSingBoxDeviceHwidData() {
const deviceHwid = deviceHwidInput.trim();
if (!deviceHwid) {
showNotice({
kind: 'error',
title: 'HWID не указан',
text: 'Вставь hard ID устройства для заголовка X-Hwid.',
});
return;
}
setSingBoxAction('identity');
try {
const status = await saveSingBoxDeviceHwid(deviceHwid);
setSingBoxStatus(status);
setComponents((current) => upsertComponent(current, status.component));
setDeviceHwidInput('');
showNotice({
kind: 'success',
title: 'HWID сохранен',
text: status.config.deviceHwidDisplay
? `Для подписки будет отправляться X-Hwid: ${status.config.deviceHwidDisplay}`
: 'Для подписки будет отправляться X-Hwid.',
});
} catch (error) {
showNotice({
kind: 'error',
title: 'HWID не сохранен',
text: errorMessage(error),
});
} finally {
setSingBoxAction(null);
}
}
async function forgetSingBoxDeviceHwidData() {
setSingBoxAction('identity');
try {
const status = await saveSingBoxDeviceHwid(undefined);
setSingBoxStatus(status);
setComponents((current) => upsertComponent(current, status.component));
setDeviceHwidInput('');
showNotice({
kind: 'info',
title: 'HWID очищен',
text: 'Заголовок X-Hwid больше не будет отправляться при загрузке подписки.',
});
} catch (error) {
showNotice({
kind: 'error',
title: 'HWID не очищен',
text: errorMessage(error),
});
} finally {
setSingBoxAction(null);
}
}
async function forgetSingBoxSubscriptionData() { async function forgetSingBoxSubscriptionData() {
setSingBoxAction('forget'); setSingBoxAction('forget');
setIsSingBoxMenuOpen(false); setIsSingBoxMenuOpen(false);
@@ -759,7 +823,7 @@ export function App() {
showNotice({ showNotice({
kind: 'info', kind: 'info',
title: 'Подписка очищена', title: 'Подписка очищена',
text: 'Ссылка, cache и выбранный сервер Local sing-box удалены.', text: 'Ссылка, HWID, cache и выбранный сервер Local sing-box удалены.',
}); });
} catch (error) { } catch (error) {
showNotice({ showNotice({
@@ -1413,6 +1477,7 @@ export function App() {
loading={singBoxAction === 'fetch'} loading={singBoxAction === 'fetch'}
loadingLabel="Загружаю" loadingLabel="Загружаю"
variant="neutral" variant="neutral"
disabled={Boolean(singBoxAction)}
> >
{subscriptionInput.trim() || !singBoxStatus?.config.hasSubscription ? 'Загрузить' : 'Обновить'} {subscriptionInput.trim() || !singBoxStatus?.config.hasSubscription ? 'Загрузить' : 'Обновить'}
</Button> </Button>
@@ -1427,6 +1492,38 @@ export function App() {
/> />
</div> </div>
<div className="subscription-line identity-line">
<span className="subscription-icon" aria-hidden="true">
<Fingerprint size={18} strokeWidth={1.9} />
</span>
<input
value={deviceHwidInput}
onChange={(event) => setDeviceHwidInput(event.target.value)}
placeholder={singBoxStatus?.config.deviceHwidDisplay ?? 'X-Hwid hard ID'}
spellCheck={false}
aria-label="Hard ID устройства для X-Hwid"
/>
<Button
type="button"
onClick={() => void saveSingBoxDeviceHwidData()}
loading={singBoxAction === 'identity'}
loadingLabel="Сохраняю"
variant="neutral"
disabled={Boolean(singBoxAction) || !deviceHwidInput.trim()}
>
{singBoxStatus?.config.hasDeviceHwid ? 'Заменить' : 'Сохранить'}
</Button>
<IconButton
type="button"
variant="danger"
onClick={() => void forgetSingBoxDeviceHwidData()}
disabled={Boolean(singBoxAction) || !singBoxStatus?.config.hasDeviceHwid}
label="Очистить hard ID устройства"
tooltip="Очистить HWID"
icon={<Trash2 size={18} strokeWidth={1.9} />}
/>
</div>
<div className="singbox-workspace-head"> <div className="singbox-workspace-head">
<span>Серверы подписки</span> <span>Серверы подписки</span>
<div className="singbox-workspace-actions"> <div className="singbox-workspace-actions">
@@ -2565,6 +2662,7 @@ function singBoxDetailLines(
`Файл: ${component?.path ?? 'не найден'}`, `Файл: ${component?.path ?? 'не найден'}`,
`Конфиг: ${status?.generatedConfigPath ?? 'не создан'}`, `Конфиг: ${status?.generatedConfigPath ?? 'не создан'}`,
`Подписка: ${status?.config.subscriptionDisplayUrl ?? (status?.config.hasSubscription ? 'сохранена' : 'не загружена')}`, `Подписка: ${status?.config.subscriptionDisplayUrl ?? (status?.config.hasSubscription ? 'сохранена' : 'не загружена')}`,
`X-Hwid: ${status?.config.deviceHwidDisplay ?? (status?.config.hasDeviceHwid ? 'сохранен' : 'не задан')}`,
`Состав: ${setupDetails}`, `Состав: ${setupDetails}`,
]; ];
} }

View File

@@ -71,6 +71,8 @@ export interface ComponentStatus {
export interface LocalSingBoxConfig { export interface LocalSingBoxConfig {
subscriptionDisplayUrl?: string; subscriptionDisplayUrl?: string;
hasSubscription: boolean; hasSubscription: boolean;
deviceHwidDisplay?: string;
hasDeviceHwid: boolean;
selectedServerTag?: string; selectedServerTag?: string;
listenHost: string; listenHost: string;
listenPort: number; listenPort: number;

View File

@@ -1896,7 +1896,7 @@ button.summary-card:hover {
white-space: nowrap; white-space: nowrap;
} }
.subscription-line .icon-command { .subscription-line .ui-icon-button {
display: grid; display: grid;
place-items: center; place-items: center;
width: 42px; width: 42px;
@@ -3153,11 +3153,11 @@ button.summary-card:hover {
grid-column: 2 / 4; grid-column: 2 / 4;
} }
.subscription-line button:not(.icon-command) { .subscription-line button:not(.ui-icon-button) {
grid-column: 1 / 3; grid-column: 1 / 3;
} }
.subscription-line .icon-command { .subscription-line .ui-icon-button {
grid-column: 3; grid-column: 3;
width: 42px; width: 42px;
} }