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

@@ -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,

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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!(