Refactor proxy routing and session management
This commit is contained in:
306
apps/windows-client/src-tauri/Cargo.lock
generated
306
apps/windows-client/src-tauri/Cargo.lock
generated
@@ -309,6 +309,23 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -390,6 +407,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -814,6 +840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -994,8 +1021,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1017,8 +1046,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1268,6 +1300,22 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -1668,6 +1716,12 @@ version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.38.0"
|
||||
@@ -2267,6 +2321,62 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
@@ -2288,6 +2398,32 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -2363,6 +2499,46 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.4"
|
||||
@@ -2421,6 +2597,20 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -2436,12 +2626,53 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -2632,6 +2863,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.21.0"
|
||||
@@ -2702,7 +2945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -2830,6 +3073,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.7"
|
||||
@@ -2982,7 +3231,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -3342,6 +3591,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -3632,6 +3891,12 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -3697,11 +3962,14 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
name = "vpn-proxy-windows-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3836,6 +4104,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.5"
|
||||
@@ -3892,6 +4170,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -4122,6 +4409,15 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -4508,6 +4804,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
|
||||
@@ -17,4 +17,6 @@ tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
|
||||
base64 = "0.22"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||
url = "2"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::models::{
|
||||
ComponentId, ComponentState, ComponentStatus, ProxyProtocol, Target, TargetKind,
|
||||
};
|
||||
use crate::models::{LocalSingBoxConfig, SubscriptionCache};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
env, fs,
|
||||
path::Path,
|
||||
@@ -12,25 +11,29 @@ use std::{
|
||||
pub const SINGBOX_ADAPTER_ID: &str = "singbox";
|
||||
pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json";
|
||||
pub const DEFAULT_MIXED_INBOUND_TAG: &str = "vpn-proxy-mixed-in";
|
||||
pub const DEFAULT_VPN_OUTBOUND_TAG: &str = "vpn";
|
||||
pub const DEFAULT_DIRECT_OUTBOUND_TAG: &str = "direct";
|
||||
pub const DEFAULT_BLOCK_OUTBOUND_TAG: &str = "block";
|
||||
|
||||
const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SingBoxAdapter {
|
||||
log_level: String,
|
||||
inbound_tag: String,
|
||||
outbound_tag: String,
|
||||
vpn_outbound_tag: String,
|
||||
}
|
||||
|
||||
impl SingBoxAdapter {
|
||||
pub fn new(
|
||||
log_level: impl Into<String>,
|
||||
inbound_tag: impl Into<String>,
|
||||
outbound_tag: impl Into<String>,
|
||||
vpn_outbound_tag: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
log_level: log_level.into(),
|
||||
inbound_tag: inbound_tag.into(),
|
||||
outbound_tag: outbound_tag.into(),
|
||||
vpn_outbound_tag: vpn_outbound_tag.into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,32 +45,52 @@ impl SingBoxAdapter {
|
||||
where
|
||||
C: SingBoxConfigChecker,
|
||||
{
|
||||
let target = find_local_singbox_target(request.targets)?;
|
||||
ensure_local_singbox_target(target, request.components)?;
|
||||
|
||||
let config = SingBoxConfig {
|
||||
log: SingBoxLog {
|
||||
disabled: false,
|
||||
level: self.log_level.clone(),
|
||||
timestamp: true,
|
||||
let selected_server_tag = request
|
||||
.config
|
||||
.selected_server_tag
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingSelectedServer,
|
||||
"Сервер Local sing-box не выбран",
|
||||
)
|
||||
})?;
|
||||
let vpn_outbound = selected_outbound(
|
||||
&request.subscription_cache.config,
|
||||
selected_server_tag,
|
||||
&self.vpn_outbound_tag,
|
||||
)?;
|
||||
let generated_config = json!({
|
||||
"log": {
|
||||
"disabled": false,
|
||||
"level": self.log_level,
|
||||
"timestamp": true
|
||||
},
|
||||
inbounds: vec![SingBoxInbound {
|
||||
inbound_type: "mixed".to_string(),
|
||||
tag: self.inbound_tag.clone(),
|
||||
listen: target.host.clone(),
|
||||
listen_port: target.port,
|
||||
users: Vec::new(),
|
||||
set_system_proxy: false,
|
||||
}],
|
||||
outbounds: vec![SingBoxOutbound {
|
||||
outbound_type: "direct".to_string(),
|
||||
tag: self.outbound_tag.clone(),
|
||||
}],
|
||||
route: SingBoxRoute {
|
||||
final_outbound: self.outbound_tag.clone(),
|
||||
},
|
||||
};
|
||||
let contents = serde_json::to_string_pretty(&config).map_err(|error| {
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "mixed",
|
||||
"tag": self.inbound_tag,
|
||||
"listen": request.config.listen_host,
|
||||
"listen_port": request.config.listen_port,
|
||||
"users": [],
|
||||
"set_system_proxy": false
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
vpn_outbound,
|
||||
{ "type": "direct", "tag": DEFAULT_DIRECT_OUTBOUND_TAG },
|
||||
{ "type": "block", "tag": DEFAULT_BLOCK_OUTBOUND_TAG }
|
||||
],
|
||||
"route": {
|
||||
"rules": [
|
||||
{ "ip_is_private": true, "outbound": DEFAULT_DIRECT_OUTBOUND_TAG }
|
||||
],
|
||||
"final": self.vpn_outbound_tag
|
||||
}
|
||||
});
|
||||
let contents = serde_json::to_string_pretty(&generated_config).map_err(|error| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::Serialization,
|
||||
format!("Не удалось сериализовать конфиг sing-box: {error}"),
|
||||
@@ -82,9 +105,9 @@ impl SingBoxAdapter {
|
||||
adapter_id: SINGBOX_ADAPTER_ID.to_string(),
|
||||
output_file_name: SINGBOX_OUTPUT_FILE.to_string(),
|
||||
contents,
|
||||
local_target_id: target.id.clone(),
|
||||
listen: target.host.clone(),
|
||||
listen_port: target.port,
|
||||
selected_server_tag: selected_server_tag.to_string(),
|
||||
listen: request.config.listen_host.clone(),
|
||||
listen_port: request.config.listen_port,
|
||||
check,
|
||||
})
|
||||
}
|
||||
@@ -92,30 +115,26 @@ impl SingBoxAdapter {
|
||||
|
||||
impl Default for SingBoxAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new(
|
||||
"info",
|
||||
DEFAULT_MIXED_INBOUND_TAG,
|
||||
DEFAULT_DIRECT_OUTBOUND_TAG,
|
||||
)
|
||||
Self::new("info", DEFAULT_MIXED_INBOUND_TAG, DEFAULT_VPN_OUTBOUND_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SingBoxGenerationRequest<'a> {
|
||||
pub targets: &'a [Target],
|
||||
pub components: &'a [ComponentStatus],
|
||||
pub config: &'a LocalSingBoxConfig,
|
||||
pub subscription_cache: &'a SubscriptionCache,
|
||||
pub binary_path: Option<&'a Path>,
|
||||
}
|
||||
|
||||
impl<'a> SingBoxGenerationRequest<'a> {
|
||||
pub fn new(
|
||||
targets: &'a [Target],
|
||||
components: &'a [ComponentStatus],
|
||||
config: &'a LocalSingBoxConfig,
|
||||
subscription_cache: &'a SubscriptionCache,
|
||||
binary_path: Option<&'a Path>,
|
||||
) -> Self {
|
||||
Self {
|
||||
targets,
|
||||
components,
|
||||
config,
|
||||
subscription_cache,
|
||||
binary_path,
|
||||
}
|
||||
}
|
||||
@@ -126,7 +145,7 @@ pub struct SingBoxGeneratedConfig {
|
||||
pub adapter_id: String,
|
||||
pub output_file_name: String,
|
||||
pub contents: String,
|
||||
pub local_target_id: String,
|
||||
pub selected_server_tag: String,
|
||||
pub listen: String,
|
||||
pub listen_port: u16,
|
||||
pub check: Option<SingBoxCheckResult>,
|
||||
@@ -156,10 +175,9 @@ impl SingBoxConfigError {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SingBoxConfigErrorKind {
|
||||
MissingLocalTarget,
|
||||
MissingRequiredComponent,
|
||||
RequiredComponentNotRunning,
|
||||
UnsupportedTarget,
|
||||
MissingSelectedServer,
|
||||
MissingSelectedOutbound,
|
||||
UnsupportedSelectedOutbound,
|
||||
Serialization,
|
||||
CheckFailed,
|
||||
}
|
||||
@@ -237,114 +255,67 @@ impl SingBoxConfigChecker for SingBoxCommandChecker {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SingBoxConfig {
|
||||
pub log: SingBoxLog,
|
||||
pub inbounds: Vec<SingBoxInbound>,
|
||||
pub outbounds: Vec<SingBoxOutbound>,
|
||||
pub route: SingBoxRoute,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SingBoxLog {
|
||||
pub disabled: bool,
|
||||
pub level: String,
|
||||
pub timestamp: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SingBoxInbound {
|
||||
#[serde(rename = "type")]
|
||||
pub inbound_type: String,
|
||||
pub tag: String,
|
||||
pub listen: String,
|
||||
#[serde(rename = "listen_port")]
|
||||
pub listen_port: u16,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub users: Vec<SingBoxUser>,
|
||||
#[serde(rename = "set_system_proxy")]
|
||||
pub set_system_proxy: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SingBoxUser {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SingBoxOutbound {
|
||||
#[serde(rename = "type")]
|
||||
pub outbound_type: String,
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SingBoxRoute {
|
||||
#[serde(rename = "final")]
|
||||
pub final_outbound: String,
|
||||
}
|
||||
|
||||
fn find_local_singbox_target(targets: &[Target]) -> Result<&Target, SingBoxConfigError> {
|
||||
targets
|
||||
fn selected_outbound(
|
||||
subscription_config: &Value,
|
||||
selected_server_tag: &str,
|
||||
vpn_outbound_tag: &str,
|
||||
) -> Result<Value, SingBoxConfigError> {
|
||||
let outbounds = subscription_config
|
||||
.get("outbounds")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingSelectedOutbound,
|
||||
"В cache подписки нет outbounds",
|
||||
)
|
||||
})?;
|
||||
let outbound = outbounds
|
||||
.iter()
|
||||
.find(|target| {
|
||||
target.kind == TargetKind::Local
|
||||
&& target.requires_component.as_ref() == Some(&ComponentId::Singbox)
|
||||
.find(|outbound| {
|
||||
outbound
|
||||
.get("tag")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|tag| tag.trim() == selected_server_tag)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingLocalTarget,
|
||||
"Локальная цель, требующая sing-box, не настроена",
|
||||
SingBoxConfigErrorKind::MissingSelectedOutbound,
|
||||
format!("Outbound не найден: {selected_server_tag}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
})?;
|
||||
let outbound_type = outbound
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
fn ensure_local_singbox_target(
|
||||
target: &Target,
|
||||
components: &[ComponentStatus],
|
||||
) -> Result<(), SingBoxConfigError> {
|
||||
if target.kind != TargetKind::Local
|
||||
|| target.protocol != ProxyProtocol::Socks5
|
||||
|| target.requires_component.as_ref() != Some(&ComponentId::Singbox)
|
||||
{
|
||||
if !SUPPORTED_PROXY_TYPES.contains(&outbound_type) {
|
||||
return Err(SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::UnsupportedTarget,
|
||||
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
|
||||
format!(
|
||||
"Цель '{}' должна быть локальной SOCKS5-целью, требующей sing-box",
|
||||
target.id
|
||||
"Outbound '{selected_server_tag}' имеет неподдерживаемый тип '{outbound_type}'"
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let Some(status) = components
|
||||
.iter()
|
||||
.find(|component| component.id == ComponentId::Singbox)
|
||||
else {
|
||||
return Err(SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingRequiredComponent,
|
||||
format!(
|
||||
"Локальная цель '{}' требует состояние компонента sing-box",
|
||||
target.id
|
||||
),
|
||||
));
|
||||
};
|
||||
|
||||
if !component_is_running(status) {
|
||||
return Err(SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::RequiredComponentNotRunning,
|
||||
format!(
|
||||
"Локальная цель '{}' требует установленный и запущенный sing-box",
|
||||
target.id
|
||||
),
|
||||
));
|
||||
let mut outbound = outbound.clone();
|
||||
let object = outbound.as_object_mut().ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
|
||||
format!("Outbound '{selected_server_tag}' должен быть JSON-объектом"),
|
||||
)
|
||||
})?;
|
||||
object.insert(
|
||||
"tag".to_string(),
|
||||
Value::String(vpn_outbound_tag.to_string()),
|
||||
);
|
||||
if outbound_type == "vless" && !object.contains_key("packet_encoding") {
|
||||
object.insert(
|
||||
"packet_encoding".to_string(),
|
||||
Value::String("xudp".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn component_is_running(status: &ComponentStatus) -> bool {
|
||||
status.installed && status.running && status.state == ComponentState::Running
|
||||
Ok(outbound)
|
||||
}
|
||||
|
||||
fn now_millis() -> u128 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,7 @@
|
||||
use crate::models::{ComponentId, ComponentState, ComponentStatus};
|
||||
use crate::models::{
|
||||
ComponentId, ComponentState, ComponentStatus, DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT,
|
||||
DEFAULT_LOCAL_SINGBOX_SERVICE_NAME,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
env,
|
||||
@@ -22,6 +25,17 @@ pub struct DetectedProxyfier {
|
||||
pub service_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DetectedSingBox {
|
||||
pub install_dir: PathBuf,
|
||||
pub executable_path: PathBuf,
|
||||
pub wrapper_path: PathBuf,
|
||||
pub binary_exists: bool,
|
||||
pub wrapper_exists: bool,
|
||||
pub running: bool,
|
||||
pub service_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RegistryInstallEntry {
|
||||
pub display_name: String,
|
||||
@@ -101,6 +115,29 @@ pub fn proxyfier_component_from_detection(detected: Option<&DetectedProxyfier>)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detect_singbox_install() -> Option<DetectedSingBox> {
|
||||
detect_singbox_install_with_host(&SystemProxyfierDetectionHost)
|
||||
}
|
||||
|
||||
pub fn detect_singbox_install_with_host(
|
||||
host: &impl ProxyfierDetectionHost,
|
||||
) -> Option<DetectedSingBox> {
|
||||
let running = host.process_running("sing-box.exe")
|
||||
|| host.service_running(DEFAULT_LOCAL_SINGBOX_SERVICE_NAME);
|
||||
|
||||
singbox_candidates(host)
|
||||
.into_iter()
|
||||
.filter_map(|install_dir| detected_singbox_from_dir(host, install_dir, running))
|
||||
.next()
|
||||
}
|
||||
|
||||
pub fn singbox_component_from_detection(detected: Option<&DetectedSingBox>) -> ComponentStatus {
|
||||
match detected {
|
||||
Some(singbox) => detected_singbox_component(singbox),
|
||||
None => missing_singbox_component(),
|
||||
}
|
||||
}
|
||||
|
||||
fn detected_proxyfier_component(proxyfier: &DetectedProxyfier) -> ComponentStatus {
|
||||
let state = if proxyfier.running {
|
||||
ComponentState::Running
|
||||
@@ -154,6 +191,58 @@ fn missing_proxyfier_component() -> ComponentStatus {
|
||||
}
|
||||
}
|
||||
|
||||
fn detected_singbox_component(singbox: &DetectedSingBox) -> ComponentStatus {
|
||||
let state = if singbox.running {
|
||||
ComponentState::Running
|
||||
} else {
|
||||
ComponentState::Stopped
|
||||
};
|
||||
let actions = if singbox.running {
|
||||
vec![
|
||||
"Сгенерировать конфиг".to_string(),
|
||||
"Остановить".to_string(),
|
||||
"Открыть папку".to_string(),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
"Сгенерировать конфиг".to_string(),
|
||||
"Запустить".to_string(),
|
||||
"Открыть папку".to_string(),
|
||||
]
|
||||
};
|
||||
let problems = if singbox.running {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec!["Служба Local sing-box остановлена".to_string()]
|
||||
};
|
||||
|
||||
ComponentStatus {
|
||||
id: ComponentId::Singbox,
|
||||
name: "Local sing-box".to_string(),
|
||||
state,
|
||||
installed: true,
|
||||
running: singbox.running,
|
||||
version: Some("sing-box найден".to_string()),
|
||||
path: Some(singbox.executable_path.display().to_string()),
|
||||
problems,
|
||||
actions,
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_singbox_component() -> ComponentStatus {
|
||||
ComponentStatus {
|
||||
id: ComponentId::Singbox,
|
||||
name: "Local sing-box".to_string(),
|
||||
state: ComponentState::Missing,
|
||||
installed: false,
|
||||
running: false,
|
||||
version: None,
|
||||
path: None,
|
||||
problems: Vec::new(),
|
||||
actions: vec!["Установить Local sing-box".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ProxyfierCandidate {
|
||||
engine: ProxyfierEngine,
|
||||
@@ -272,6 +361,68 @@ fn common_install_dirs(host: &impl ProxyfierDetectionHost, folder_name: &str) ->
|
||||
dirs
|
||||
}
|
||||
|
||||
fn singbox_candidates(host: &impl ProxyfierDetectionHost) -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
if let Some(path) = host.env_var("VPN_PROXY_SINGBOX_ROOT") {
|
||||
push_path_candidate(&mut candidates, PathBuf::from(path));
|
||||
}
|
||||
push_path_candidate(
|
||||
&mut candidates,
|
||||
PathBuf::from(DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT),
|
||||
);
|
||||
push_path_candidate(
|
||||
&mut candidates,
|
||||
PathBuf::from(r"C:\Tools\VpnProxy\sing-box"),
|
||||
);
|
||||
for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
|
||||
if let Some(root) = host.env_var(env_name) {
|
||||
push_path_candidate(
|
||||
&mut candidates,
|
||||
PathBuf::from(&root).join("VpnProxy").join("sing-box"),
|
||||
);
|
||||
push_path_candidate(&mut candidates, PathBuf::from(root).join("sing-box"));
|
||||
}
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn push_path_candidate(candidates: &mut Vec<PathBuf>, candidate: PathBuf) {
|
||||
if !candidates
|
||||
.iter()
|
||||
.any(|existing| same_path(existing, &candidate))
|
||||
{
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
fn detected_singbox_from_dir(
|
||||
host: &impl ProxyfierDetectionHost,
|
||||
install_dir: PathBuf,
|
||||
running: bool,
|
||||
) -> Option<DetectedSingBox> {
|
||||
let executable_path = install_dir.join("sing-box.exe");
|
||||
let wrapper_path = install_dir.join("VpnProxySingBox.exe");
|
||||
let binary_exists = host.path_exists(&executable_path);
|
||||
let wrapper_exists = host.path_exists(&wrapper_path);
|
||||
let exists = host.path_exists(&install_dir) || binary_exists || wrapper_exists;
|
||||
|
||||
if !exists {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(DetectedSingBox {
|
||||
install_dir,
|
||||
executable_path,
|
||||
wrapper_path,
|
||||
binary_exists,
|
||||
wrapper_exists,
|
||||
running,
|
||||
service_name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn executable_name(engine: &ProxyfierEngine) -> &'static str {
|
||||
match engine {
|
||||
ProxyfierEngine::ProxiFyre => "ProxiFyre.exe",
|
||||
|
||||
@@ -4,12 +4,15 @@ mod activity;
|
||||
mod commands;
|
||||
mod component_detection;
|
||||
mod models;
|
||||
mod singbox_service;
|
||||
mod storage;
|
||||
mod subscription;
|
||||
mod validation;
|
||||
|
||||
mod adapters {
|
||||
pub mod proxifyre;
|
||||
pub mod proxy_router;
|
||||
pub mod singbox;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -22,6 +25,11 @@ pub(crate) mod proxy_router {
|
||||
pub use crate::adapters::proxy_router::*;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod singbox {
|
||||
pub use crate::adapters::singbox::*;
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
@@ -35,14 +43,27 @@ fn main() {
|
||||
commands::save_target,
|
||||
commands::get_components,
|
||||
commands::get_proxifyre_setup_status,
|
||||
commands::get_singbox_status,
|
||||
commands::get_singbox_setup_status,
|
||||
commands::resolve_profile_preview,
|
||||
commands::save_singbox_subscription,
|
||||
commands::fetch_singbox_subscription,
|
||||
commands::forget_singbox_subscription,
|
||||
commands::select_singbox_server,
|
||||
commands::ping_singbox_server,
|
||||
commands::ping_all_singbox_servers,
|
||||
commands::generate_singbox_config,
|
||||
commands::apply_profiles,
|
||||
commands::get_logs,
|
||||
commands::open_config_location,
|
||||
commands::start_proxifyre_service,
|
||||
commands::stop_proxifyre_service,
|
||||
commands::install_proxifyre,
|
||||
commands::uninstall_proxifyre
|
||||
commands::uninstall_proxifyre,
|
||||
commands::start_singbox_service,
|
||||
commands::stop_singbox_service,
|
||||
commands::install_singbox,
|
||||
commands::uninstall_singbox
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("не удалось запустить клиент VPN Proxy для Windows");
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1";
|
||||
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080;
|
||||
pub const DEFAULT_LOCAL_SINGBOX_SERVICE_NAME: &str = "VpnProxySingBox";
|
||||
pub const DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT: &str = r"C:\Program Files\VpnProxy\sing-box";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum Protocol {
|
||||
@@ -128,6 +133,65 @@ pub struct ComponentStatus {
|
||||
pub actions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LocalSingBoxConfig {
|
||||
#[serde(default)]
|
||||
pub subscription_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_server_tag: Option<String>,
|
||||
#[serde(default = "default_local_singbox_listen_host")]
|
||||
pub listen_host: String,
|
||||
#[serde(default = "default_local_singbox_listen_port")]
|
||||
pub listen_port: u16,
|
||||
#[serde(default = "default_local_singbox_service_name")]
|
||||
pub service_name: String,
|
||||
#[serde(default = "default_local_singbox_install_root")]
|
||||
pub install_root: String,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
impl LocalSingBoxConfig {
|
||||
pub fn subscription_display_url(&self) -> Option<String> {
|
||||
self.subscription_url
|
||||
.as_deref()
|
||||
.map(redact_subscription_url)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LocalSingBoxConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
subscription_url: None,
|
||||
selected_server_tag: None,
|
||||
listen_host: default_local_singbox_listen_host(),
|
||||
listen_port: default_local_singbox_listen_port(),
|
||||
service_name: default_local_singbox_service_name(),
|
||||
install_root: default_local_singbox_install_root(),
|
||||
updated_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SubscriptionCache {
|
||||
pub config: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub servers: Vec<SubscriptionServer>,
|
||||
#[serde(default)]
|
||||
pub user_info: serde_json::Map<String, serde_json::Value>,
|
||||
pub fetched_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SubscriptionServer {
|
||||
pub tag: String,
|
||||
#[serde(rename = "type")]
|
||||
pub server_type: String,
|
||||
pub server: String,
|
||||
pub server_port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ActivityEntry {
|
||||
pub id: String,
|
||||
@@ -165,3 +229,45 @@ fn default_target_kind() -> String {
|
||||
fn default_proxy_protocol() -> String {
|
||||
"socks5".to_string()
|
||||
}
|
||||
|
||||
fn default_local_singbox_listen_host() -> String {
|
||||
DEFAULT_LOCAL_SINGBOX_LISTEN_HOST.to_string()
|
||||
}
|
||||
|
||||
fn default_local_singbox_listen_port() -> u16 {
|
||||
DEFAULT_LOCAL_SINGBOX_LISTEN_PORT
|
||||
}
|
||||
|
||||
fn default_local_singbox_service_name() -> String {
|
||||
DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string()
|
||||
}
|
||||
|
||||
fn default_local_singbox_install_root() -> String {
|
||||
DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT.to_string()
|
||||
}
|
||||
|
||||
pub fn redact_subscription_url(raw_url: &str) -> String {
|
||||
let trimmed = raw_url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
match trimmed.split_once("://") {
|
||||
Some((scheme, rest)) => {
|
||||
let host = rest
|
||||
.split(['/', '?', '#'])
|
||||
.next()
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("subscription");
|
||||
format!("{scheme}://{host}/...")
|
||||
}
|
||||
None => {
|
||||
let visible = trimmed.chars().take(18).collect::<String>();
|
||||
if trimmed.chars().count() <= 18 {
|
||||
"***".to_string()
|
||||
} else {
|
||||
format!("{visible}...")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
273
apps/windows-client/src-tauri/src/singbox_service.rs
Normal file
273
apps/windows-client/src-tauri/src/singbox_service.rs
Normal file
@@ -0,0 +1,273 @@
|
||||
use crate::component_detection::DetectedSingBox;
|
||||
use crate::models::{DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT, DEFAULT_LOCAL_SINGBOX_SERVICE_NAME};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
pub const SINGBOX_RELEASE_API_URL: &str =
|
||||
"https://api.github.com/repos/SagerNet/sing-box/releases/latest";
|
||||
pub const WINSW_RELEASE_API_URL: &str = "https://api.github.com/repos/winsw/winsw/releases/latest";
|
||||
pub const WINSW_WRAPPER_FILE: &str = "VpnProxySingBox.exe";
|
||||
pub const SINGBOX_BINARY_FILE: &str = "sing-box.exe";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SingBoxServiceAction {
|
||||
Start,
|
||||
Stop,
|
||||
}
|
||||
|
||||
impl SingBoxServiceAction {
|
||||
pub fn action_name(self) -> &'static str {
|
||||
match self {
|
||||
SingBoxServiceAction::Start => "start",
|
||||
SingBoxServiceAction::Stop => "stop",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
SingBoxServiceAction::Start => "запустить",
|
||||
SingBoxServiceAction::Stop => "остановить",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SingBoxSetupStatus {
|
||||
pub ready: bool,
|
||||
pub missing_count: usize,
|
||||
pub items: Vec<SingBoxSetupItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SingBoxSetupItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub installed: bool,
|
||||
pub version: Option<String>,
|
||||
pub details: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceCommandOutput {
|
||||
pub success: bool,
|
||||
pub code: String,
|
||||
pub service_name: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub process_id: Option<u32>,
|
||||
}
|
||||
|
||||
pub fn build_singbox_setup_status(detected: Option<&DetectedSingBox>) -> SingBoxSetupStatus {
|
||||
let install_root = detected
|
||||
.map(|singbox| singbox.install_dir.display().to_string())
|
||||
.unwrap_or_else(|| DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT.to_string());
|
||||
let binary_item = match detected {
|
||||
Some(singbox) if singbox.binary_exists => SingBoxSetupItem {
|
||||
id: "sing-box-binary".to_string(),
|
||||
name: "sing-box".to_string(),
|
||||
installed: true,
|
||||
version: Some("binary найден".to_string()),
|
||||
details: singbox.executable_path.display().to_string(),
|
||||
},
|
||||
_ => SingBoxSetupItem {
|
||||
id: "sing-box-binary".to_string(),
|
||||
name: "sing-box".to_string(),
|
||||
installed: false,
|
||||
version: None,
|
||||
details: format!(
|
||||
"Будет скачан из GitHub releases SagerNet/sing-box и установлен в {install_root}."
|
||||
),
|
||||
},
|
||||
};
|
||||
let wrapper_item = match detected {
|
||||
Some(singbox) if singbox.wrapper_exists => SingBoxSetupItem {
|
||||
id: "winsw-wrapper".to_string(),
|
||||
name: "WinSW service wrapper".to_string(),
|
||||
installed: true,
|
||||
version: Some("wrapper найден".to_string()),
|
||||
details: singbox.wrapper_path.display().to_string(),
|
||||
},
|
||||
_ => SingBoxSetupItem {
|
||||
id: "winsw-wrapper".to_string(),
|
||||
name: "WinSW service wrapper".to_string(),
|
||||
installed: false,
|
||||
version: None,
|
||||
details: format!(
|
||||
"Будет скачан из GitHub releases winsw/winsw как {WINSW_WRAPPER_FILE}."
|
||||
),
|
||||
},
|
||||
};
|
||||
let service_item = match detected {
|
||||
Some(singbox) if singbox.running => SingBoxSetupItem {
|
||||
id: "windows-service".to_string(),
|
||||
name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
|
||||
installed: true,
|
||||
version: Some("служба запущена".to_string()),
|
||||
details: format!("Служба {}", singbox.service_name),
|
||||
},
|
||||
Some(singbox) => SingBoxSetupItem {
|
||||
id: "windows-service".to_string(),
|
||||
name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
|
||||
installed: true,
|
||||
version: Some("служба остановлена".to_string()),
|
||||
details: format!("Служба {}", singbox.service_name),
|
||||
},
|
||||
None => SingBoxSetupItem {
|
||||
id: "windows-service".to_string(),
|
||||
name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
|
||||
installed: false,
|
||||
version: None,
|
||||
details: "Будет создана Windows-служба Local sing-box.".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let items = vec![binary_item, wrapper_item, service_item];
|
||||
let missing_count = items.iter().filter(|item| !item.installed).count();
|
||||
|
||||
SingBoxSetupStatus {
|
||||
ready: missing_count == 0,
|
||||
missing_count,
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_service_command_output(stdout: &[u8]) -> Option<ServiceCommandOutput> {
|
||||
let stdout = String::from_utf8_lossy(stdout);
|
||||
let payload = stdout
|
||||
.lines()
|
||||
.rev()
|
||||
.map(str::trim)
|
||||
.find(|line| line.starts_with('{') && line.ends_with('}'))?;
|
||||
|
||||
serde_json::from_str(payload).ok()
|
||||
}
|
||||
|
||||
pub fn ensure_safe_singbox_install_dir(path: &Path) -> Result<(), String> {
|
||||
let normalized = path
|
||||
.display()
|
||||
.to_string()
|
||||
.replace('/', "\\")
|
||||
.to_ascii_lowercase();
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
if file_name == "sing-box"
|
||||
&& (normalized.contains("\\vpnproxy\\") || normalized.contains("\\vpn-proxy\\"))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Отказываюсь рекурсивно удалять Local sing-box с небезопасным путем: {}",
|
||||
path.display()
|
||||
))
|
||||
}
|
||||
|
||||
pub fn service_control_script(
|
||||
action: SingBoxServiceAction,
|
||||
service_name: &str,
|
||||
config_source: Option<&Path>,
|
||||
config_target: Option<&Path>,
|
||||
) -> String {
|
||||
let action_name = action.action_name();
|
||||
let escaped_service_name = escape_powershell_single(service_name);
|
||||
let escaped_config_source = config_source
|
||||
.map(|path| escape_powershell_single(&path.display().to_string()))
|
||||
.unwrap_or_default();
|
||||
let escaped_config_target = config_target
|
||||
.map(|path| escape_powershell_single(&path.display().to_string()))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
r#"
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$serviceName = '{escaped_service_name}'
|
||||
$action = '{action_name}'
|
||||
$configSource = '{escaped_config_source}'
|
||||
$configTarget = '{escaped_config_target}'
|
||||
|
||||
function Get-ServiceProcessId([string]$name) {{
|
||||
$escapedName = $name.Replace("'", "''")
|
||||
$record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue
|
||||
if ($null -eq $record) {{ return 0 }}
|
||||
return [int]$record.ProcessId
|
||||
}}
|
||||
|
||||
function Get-ServiceStatus([string]$name) {{
|
||||
$current = Get-Service -Name $name -ErrorAction SilentlyContinue
|
||||
if ($null -eq $current) {{ return $null }}
|
||||
return $current.Status.ToString()
|
||||
}}
|
||||
|
||||
function Write-ServiceResult([bool]$success, [string]$code, [string]$status, [int]$processId) {{
|
||||
[PSCustomObject]@{{
|
||||
success = $success
|
||||
code = $code
|
||||
serviceName = $serviceName
|
||||
status = $status
|
||||
processId = $processId
|
||||
}} | ConvertTo-Json -Compress
|
||||
exit 0
|
||||
}}
|
||||
|
||||
function Sync-ServiceConfig {{
|
||||
if ($action -ne 'start' -or [string]::IsNullOrWhiteSpace($configSource)) {{ return }}
|
||||
if (-not (Test-Path -LiteralPath $configSource)) {{
|
||||
Write-ServiceResult $false 'config_source_missing' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
if ([string]::IsNullOrWhiteSpace($configTarget)) {{ return }}
|
||||
|
||||
try {{
|
||||
Copy-Item -LiteralPath $configSource -Destination $configTarget -Force -ErrorAction Stop
|
||||
}} catch {{
|
||||
Write-ServiceResult $false 'config_sync_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
}}
|
||||
|
||||
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
|
||||
if ($null -eq $service) {{
|
||||
Write-ServiceResult $false 'service_not_found' $null 0
|
||||
}}
|
||||
|
||||
if ($action -eq 'start') {{
|
||||
Sync-ServiceConfig
|
||||
|
||||
if ($service.Status -eq 'Running') {{
|
||||
Write-ServiceResult $true 'already_running' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
|
||||
try {{
|
||||
Start-Service -Name $serviceName -ErrorAction Stop
|
||||
$service = Get-Service -Name $serviceName -ErrorAction Stop
|
||||
$service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15))
|
||||
}} catch {{
|
||||
Write-ServiceResult $false 'start_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
|
||||
Write-ServiceResult ($service.Status -eq 'Running') 'started' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
|
||||
if ($service.Status -eq 'Stopped') {{
|
||||
Write-ServiceResult $true 'already_stopped' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
|
||||
try {{
|
||||
Stop-Service -Name $serviceName -Force -ErrorAction Stop
|
||||
$service = Get-Service -Name $serviceName -ErrorAction Stop
|
||||
$service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(15))
|
||||
}} catch {{
|
||||
Write-ServiceResult $false 'stop_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
|
||||
Write-ServiceResult ($service.Status -eq 'Stopped') 'stopped' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
fn escape_powershell_single(value: &str) -> String {
|
||||
value.replace('\'', "''")
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT};
|
||||
use crate::models::{ActivityEntry, ComponentStatus, Profile, Target};
|
||||
use crate::models::{
|
||||
ActivityEntry, ComponentStatus, LocalSingBoxConfig, Profile, SubscriptionCache, Target,
|
||||
};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use std::fs;
|
||||
use std::io::{self, ErrorKind};
|
||||
@@ -18,6 +20,8 @@ pub struct StoragePaths {
|
||||
pub profiles_file: PathBuf,
|
||||
pub targets_file: PathBuf,
|
||||
pub components_file: PathBuf,
|
||||
pub local_singbox_file: PathBuf,
|
||||
pub singbox_subscription_cache_file: PathBuf,
|
||||
pub activity_file: PathBuf,
|
||||
}
|
||||
|
||||
@@ -33,6 +37,8 @@ impl StoragePaths {
|
||||
profiles_file: config_dir.join("profiles.json"),
|
||||
targets_file: config_dir.join("targets.json"),
|
||||
components_file: config_dir.join("components.json"),
|
||||
local_singbox_file: config_dir.join("local-singbox.json"),
|
||||
singbox_subscription_cache_file: state_dir.join("singbox-subscription-cache.json"),
|
||||
activity_file: state_dir.join("activity.json"),
|
||||
config_dir,
|
||||
state_dir,
|
||||
@@ -100,6 +106,30 @@ impl JsonStorage {
|
||||
self.write_json(&self.paths.components_file, components)
|
||||
}
|
||||
|
||||
pub fn read_local_singbox_config(&self) -> io::Result<LocalSingBoxConfig> {
|
||||
self.read_json_or_default(&self.paths.local_singbox_file)
|
||||
}
|
||||
|
||||
pub fn write_local_singbox_config(&self, config: &LocalSingBoxConfig) -> io::Result<()> {
|
||||
self.write_json(&self.paths.local_singbox_file, config)
|
||||
}
|
||||
|
||||
pub fn read_singbox_subscription_cache(&self) -> io::Result<Option<SubscriptionCache>> {
|
||||
self.read_optional_json(&self.paths.singbox_subscription_cache_file)
|
||||
}
|
||||
|
||||
pub fn write_singbox_subscription_cache(&self, cache: &SubscriptionCache) -> io::Result<()> {
|
||||
self.write_json(&self.paths.singbox_subscription_cache_file, cache)
|
||||
}
|
||||
|
||||
pub fn remove_singbox_subscription_cache(&self) -> io::Result<()> {
|
||||
match fs::remove_file(&self.paths.singbox_subscription_cache_file) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_activity(&self) -> io::Result<Vec<ActivityEntry>> {
|
||||
let entries = self.read_json_or_default(&self.paths.activity_file)?;
|
||||
Ok(cap_activity(entries, self.activity_limit))
|
||||
@@ -139,6 +169,17 @@ impl JsonStorage {
|
||||
.map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?;
|
||||
write_atomic(path, &contents)
|
||||
}
|
||||
|
||||
fn read_optional_json<T>(&self, path: &Path) -> io::Result<Option<T>>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
match fs::read_to_string(path) {
|
||||
Ok(contents) => Ok(serde_json::from_str(&contents).ok()),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for JsonStorage {
|
||||
|
||||
269
apps/windows-client/src-tauri/src/subscription.rs
Normal file
269
apps/windows-client/src-tauri/src/subscription.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
use crate::models::{SubscriptionCache, SubscriptionServer};
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use url::Url;
|
||||
|
||||
const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubscriptionError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl SubscriptionError {
|
||||
fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SubscriptionError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SubscriptionError {}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ParsedSubscription {
|
||||
pub config: Value,
|
||||
pub servers: Vec<SubscriptionServer>,
|
||||
}
|
||||
|
||||
pub fn parse_subscription_body(body: &str) -> Result<ParsedSubscription, SubscriptionError> {
|
||||
let config = match serde_json::from_str::<Value>(body) {
|
||||
Ok(value) => value,
|
||||
Err(_) => parse_link_subscription(body)?,
|
||||
};
|
||||
let servers = servers_from_config(&config)?;
|
||||
|
||||
Ok(ParsedSubscription { config, servers })
|
||||
}
|
||||
|
||||
pub fn parse_user_info(header_value: Option<&str>) -> Map<String, Value> {
|
||||
let mut result = Map::new();
|
||||
let Some(header_value) = header_value else {
|
||||
return result;
|
||||
};
|
||||
|
||||
for part in header_value.split(';') {
|
||||
let Some((key, value)) = part.trim().split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(parsed) = value.trim().parse::<i64>() {
|
||||
result.insert(key.to_string(), Value::Number(parsed.into()));
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn fetch_subscription(url: &str) -> Result<SubscriptionCache, SubscriptionError> {
|
||||
let parsed_url =
|
||||
Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?;
|
||||
if !matches!(parsed_url.scheme(), "http" | "https") {
|
||||
return Err(SubscriptionError::new(
|
||||
"Subscription URL must use http or https",
|
||||
));
|
||||
}
|
||||
|
||||
let response = reqwest::blocking::Client::new()
|
||||
.get(parsed_url)
|
||||
.header("user-agent", "singbox")
|
||||
.header("x-device-os", std::env::consts::OS)
|
||||
.header("x-device-model", "vpn-proxy-windows-client")
|
||||
.send()
|
||||
.map_err(|error| SubscriptionError::new(format!("Subscription request failed: {error}")))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(SubscriptionError::new(format!(
|
||||
"Subscription request failed: HTTP {}",
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
|
||||
let user_info = parse_user_info(
|
||||
response
|
||||
.headers()
|
||||
.get("subscription-userinfo")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
);
|
||||
let body = response.text().map_err(|error| {
|
||||
SubscriptionError::new(format!("Subscription body read failed: {error}"))
|
||||
})?;
|
||||
let parsed = parse_subscription_body(&body)?;
|
||||
|
||||
Ok(SubscriptionCache {
|
||||
config: parsed.config,
|
||||
servers: parsed.servers,
|
||||
user_info,
|
||||
fetched_at: now_timestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_link_subscription(body: &str) -> Result<Value, SubscriptionError> {
|
||||
let decoded = maybe_decode_base64(body);
|
||||
let links = decoded
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| line.starts_with("vless://"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if links.is_empty() {
|
||||
return Err(SubscriptionError::new(
|
||||
"Subscription does not contain JSON config or VLESS links",
|
||||
));
|
||||
}
|
||||
|
||||
let outbounds = links
|
||||
.into_iter()
|
||||
.map(parse_vless_url)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(json!({ "outbounds": outbounds }))
|
||||
}
|
||||
|
||||
fn parse_vless_url(raw_url: &str) -> Result<Value, SubscriptionError> {
|
||||
if !raw_url.starts_with("vless://") {
|
||||
return Err(SubscriptionError::new("VLESS URL must start with vless://"));
|
||||
}
|
||||
|
||||
let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid VLESS URL"))?;
|
||||
let tag = parsed.fragment().unwrap_or("vless-out").to_string();
|
||||
let uuid = parsed.username().trim().to_string();
|
||||
let server = parsed.host_str().map(str::to_string).unwrap_or_default();
|
||||
let server_port = parsed.port_or_known_default().unwrap_or(443);
|
||||
let public_key = query_value(&parsed, "pbk").unwrap_or_default();
|
||||
let short_id = query_value(&parsed, "sid").unwrap_or_default();
|
||||
let server_name = query_value(&parsed, "sni").unwrap_or_else(|| server.clone());
|
||||
let fingerprint = query_value(&parsed, "fp").unwrap_or_else(|| "chrome".to_string());
|
||||
let flow = query_value(&parsed, "flow").unwrap_or_default();
|
||||
|
||||
if uuid.is_empty() || server.is_empty() {
|
||||
return Err(SubscriptionError::new(
|
||||
"VLESS URL misses uuid, host or port",
|
||||
));
|
||||
}
|
||||
|
||||
if public_key.is_empty() || short_id.is_empty() {
|
||||
return Err(SubscriptionError::new(
|
||||
"VLESS REALITY parameters pbk and sid are required",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"type": "vless",
|
||||
"tag": tag,
|
||||
"server": server,
|
||||
"server_port": server_port,
|
||||
"uuid": uuid,
|
||||
"flow": flow,
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_name": server_name,
|
||||
"utls": {
|
||||
"enabled": true,
|
||||
"fingerprint": fingerprint
|
||||
},
|
||||
"reality": {
|
||||
"enabled": true,
|
||||
"public_key": public_key,
|
||||
"short_id": short_id
|
||||
}
|
||||
},
|
||||
"packet_encoding": "xudp"
|
||||
}))
|
||||
}
|
||||
|
||||
fn servers_from_config(config: &Value) -> Result<Vec<SubscriptionServer>, SubscriptionError> {
|
||||
let servers = config
|
||||
.get("outbounds")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(server_from_outbound)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if servers.is_empty() {
|
||||
return Err(SubscriptionError::new(
|
||||
"No supported proxy outbounds found in subscription",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(servers)
|
||||
}
|
||||
|
||||
fn server_from_outbound(outbound: &Value) -> Option<SubscriptionServer> {
|
||||
let server_type = outbound.get("type")?.as_str()?.to_string();
|
||||
if !SUPPORTED_PROXY_TYPES.contains(&server_type.as_str()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let server = outbound
|
||||
.get("server")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let server_port = outbound
|
||||
.get("server_port")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u16::try_from(value).ok())
|
||||
.unwrap_or(443);
|
||||
let tag = outbound
|
||||
.get("tag")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("{server_type}-{server}"));
|
||||
|
||||
Some(SubscriptionServer {
|
||||
tag,
|
||||
server_type,
|
||||
server,
|
||||
server_port,
|
||||
})
|
||||
}
|
||||
|
||||
fn maybe_decode_base64(content: &str) -> String {
|
||||
let compact = content.split_whitespace().collect::<String>();
|
||||
if compact.is_empty()
|
||||
|| !compact
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=' | '-' | '_'))
|
||||
{
|
||||
return content.to_string();
|
||||
}
|
||||
|
||||
for engine in [general_purpose::STANDARD, general_purpose::URL_SAFE] {
|
||||
if let Ok(decoded) = engine.decode(compact.as_bytes()) {
|
||||
if let Ok(decoded) = String::from_utf8(decoded) {
|
||||
if decoded.contains("vless://") || decoded.contains('{') {
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content.to_string()
|
||||
}
|
||||
|
||||
fn query_value(url: &Url, key: &str) -> Option<String> {
|
||||
url.query_pairs()
|
||||
.find(|(name, _)| name == key)
|
||||
.map(|(_, value)| value.into_owned())
|
||||
}
|
||||
|
||||
fn now_timestamp() -> String {
|
||||
let seconds = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
format!("unix:{seconds}")
|
||||
}
|
||||
@@ -10,8 +10,14 @@ mod models;
|
||||
mod proxifyre;
|
||||
#[path = "../src/adapters/proxy_router.rs"]
|
||||
mod proxy_router;
|
||||
#[path = "../src/adapters/singbox.rs"]
|
||||
mod singbox;
|
||||
#[path = "../src/singbox_service.rs"]
|
||||
mod singbox_service;
|
||||
#[path = "../src/storage.rs"]
|
||||
mod storage;
|
||||
#[path = "../src/subscription.rs"]
|
||||
mod subscription;
|
||||
#[path = "../src/validation.rs"]
|
||||
mod validation;
|
||||
|
||||
@@ -155,6 +161,25 @@ fn proxifyre_install_script_parses_as_powershell() {
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singbox_runner_preserves_installer_args_with_spaces() {
|
||||
let script = commands::singbox_installer_runner_script(
|
||||
Path::new(r"C:\ProgramData\VpnProxy\state\install-singbox.ps1"),
|
||||
Path::new(r"C:\ProgramData\VpnProxy\state\install.log"),
|
||||
&[
|
||||
"-InstallRoot".to_string(),
|
||||
r"C:\Program Files\VpnProxy\sing-box".to_string(),
|
||||
"-ServiceName".to_string(),
|
||||
"VpnProxySingBox".to_string(),
|
||||
"-Uninstall".to_string(),
|
||||
],
|
||||
);
|
||||
|
||||
assert!(script.contains("$installerArgs = @('-InstallRoot', 'C:\\Program Files\\VpnProxy\\sing-box'"));
|
||||
assert!(script.contains("& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs"));
|
||||
assert!(!script.contains("Start-Process -FilePath 'powershell.exe' -ArgumentList $argumentList"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_generates_derived_config_and_records_activity_with_mock_helper() {
|
||||
let root = test_root("apply");
|
||||
@@ -241,6 +266,7 @@ fn component_status_merges_detected_existing_proxifyre() {
|
||||
running: true,
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let proxyfier = components
|
||||
.iter()
|
||||
|
||||
@@ -4,7 +4,8 @@ mod component_detection;
|
||||
mod models;
|
||||
|
||||
use component_detection::{
|
||||
detect_proxyfier_install_with_host, proxyfier_component_from_detection, ProxyfierDetectionHost,
|
||||
detect_proxyfier_install_with_host, detect_singbox_install_with_host,
|
||||
proxyfier_component_from_detection, singbox_component_from_detection, ProxyfierDetectionHost,
|
||||
ProxyfierEngine, RegistryInstallEntry,
|
||||
};
|
||||
use models::ComponentState;
|
||||
@@ -74,6 +75,66 @@ fn missing_proxyfier_returns_install_action_status() {
|
||||
assert_eq!(component.actions, vec!["Установить ProxiFyre"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_running_local_singbox_from_default_install_root_and_service() {
|
||||
let host = MockHost::new()
|
||||
.with_path(r"C:\Program Files\VpnProxy\sing-box\sing-box.exe")
|
||||
.with_service("VpnProxySingBox");
|
||||
|
||||
let detected =
|
||||
detect_singbox_install_with_host(&host).expect("existing sing-box should be detected");
|
||||
|
||||
assert_eq!(
|
||||
detected.executable_path,
|
||||
PathBuf::from(r"C:\Program Files\VpnProxy\sing-box\sing-box.exe")
|
||||
);
|
||||
assert_eq!(detected.service_name, "VpnProxySingBox");
|
||||
assert!(detected.running);
|
||||
|
||||
let component = singbox_component_from_detection(Some(&detected));
|
||||
assert_eq!(component.state, ComponentState::Running);
|
||||
assert!(component.installed);
|
||||
assert!(component.running);
|
||||
assert_eq!(
|
||||
component.path,
|
||||
Some(r"C:\Program Files\VpnProxy\sing-box\sing-box.exe".to_string())
|
||||
);
|
||||
assert!(component.problems.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_stopped_local_singbox_from_env_override() {
|
||||
let host = MockHost::new()
|
||||
.with_env("VPN_PROXY_SINGBOX_ROOT", r"D:\Portable\sing-box")
|
||||
.with_path(r"D:\Portable\sing-box\sing-box.exe");
|
||||
|
||||
let detected = detect_singbox_install_with_host(&host).expect("env override should be checked");
|
||||
let component = singbox_component_from_detection(Some(&detected));
|
||||
|
||||
assert_eq!(
|
||||
detected.executable_path,
|
||||
PathBuf::from(r"D:\Portable\sing-box\sing-box.exe")
|
||||
);
|
||||
assert_eq!(component.state, ComponentState::Stopped);
|
||||
assert!(component.installed);
|
||||
assert!(!component.running);
|
||||
assert!(component
|
||||
.problems
|
||||
.iter()
|
||||
.any(|problem| problem.contains("остановлена")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_local_singbox_returns_optional_install_action_status() {
|
||||
let component = singbox_component_from_detection(None);
|
||||
|
||||
assert_eq!(component.state, ComponentState::Missing);
|
||||
assert!(!component.installed);
|
||||
assert!(!component.running);
|
||||
assert_eq!(component.actions, vec!["Установить Local sing-box"]);
|
||||
assert!(component.problems.is_empty());
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MockHost {
|
||||
env: HashMap<String, String>,
|
||||
|
||||
@@ -8,14 +8,16 @@ mod proxy_router;
|
||||
mod singbox;
|
||||
|
||||
use models::{
|
||||
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
|
||||
ProxyProtocol, Target, TargetKind,
|
||||
ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, Profile, ProfileItem,
|
||||
ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target,
|
||||
TargetKind,
|
||||
};
|
||||
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig};
|
||||
use proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
|
||||
use singbox::{
|
||||
SingBoxAdapter, SingBoxCheckResult, SingBoxConfig, SingBoxConfigChecker, SingBoxConfigError,
|
||||
SingBoxConfigErrorKind, SingBoxGenerationRequest, SINGBOX_OUTPUT_FILE,
|
||||
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
|
||||
SingBoxConfigErrorKind, SingBoxGenerationRequest, DEFAULT_VPN_OUTBOUND_TAG,
|
||||
SINGBOX_OUTPUT_FILE,
|
||||
};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
@@ -23,25 +25,25 @@ use std::{
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn generates_local_singbox_config_and_runs_check_when_binary_path_is_supplied() {
|
||||
fn generates_selected_outbound_config_and_runs_check_when_binary_path_is_supplied() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let targets = vec![local_singbox_target()];
|
||||
let components = vec![running_singbox_component()];
|
||||
let config = local_singbox_config("nl-1");
|
||||
let cache = subscription_cache();
|
||||
let checker = RecordingChecker::ok("configuration OK");
|
||||
let binary_path = Path::new(r"C:\Tools\VpnProxy\sing-box\sing-box.exe");
|
||||
|
||||
let generated = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&targets, &components, Some(binary_path)),
|
||||
SingBoxGenerationRequest::new(&config, &cache, Some(binary_path)),
|
||||
&checker,
|
||||
)
|
||||
.expect("running local sing-box should generate config");
|
||||
let config: SingBoxConfig =
|
||||
.expect("selected outbound should generate config");
|
||||
let generated_config: serde_json::Value =
|
||||
serde_json::from_str(&generated.contents).expect("generated sing-box json");
|
||||
|
||||
assert_eq!(generated.adapter_id, "singbox");
|
||||
assert_eq!(generated.output_file_name, SINGBOX_OUTPUT_FILE);
|
||||
assert_eq!(generated.local_target_id, "local-singbox");
|
||||
assert_eq!(generated.selected_server_tag, "nl-1");
|
||||
assert_eq!(generated.listen, "127.0.0.1");
|
||||
assert_eq!(generated.listen_port, 1080);
|
||||
assert_eq!(
|
||||
@@ -52,30 +54,39 @@ fn generates_local_singbox_config_and_runs_check_when_binary_path_is_supplied()
|
||||
message: "configuration OK".to_string(),
|
||||
})
|
||||
);
|
||||
assert_eq!(config.log.level, "info");
|
||||
assert_eq!(config.inbounds.len(), 1);
|
||||
assert_eq!(config.inbounds[0].inbound_type, "mixed");
|
||||
assert_eq!(config.inbounds[0].listen, "127.0.0.1");
|
||||
assert_eq!(config.inbounds[0].listen_port, 1080);
|
||||
assert!(!config.inbounds[0].set_system_proxy);
|
||||
assert_eq!(config.outbounds[0].outbound_type, "direct");
|
||||
assert_eq!(config.route.final_outbound, "direct");
|
||||
assert_eq!(generated_config["log"]["level"], "info");
|
||||
assert_eq!(generated_config["inbounds"][0]["type"], "mixed");
|
||||
assert_eq!(generated_config["inbounds"][0]["listen"], "127.0.0.1");
|
||||
assert_eq!(generated_config["inbounds"][0]["listen_port"], 1080);
|
||||
assert_eq!(generated_config["inbounds"][0]["set_system_proxy"], false);
|
||||
assert_eq!(generated_config["outbounds"][0]["type"], "vless");
|
||||
assert_eq!(
|
||||
generated_config["outbounds"][0]["tag"],
|
||||
DEFAULT_VPN_OUTBOUND_TAG
|
||||
);
|
||||
assert_eq!(
|
||||
generated_config["outbounds"][0]["server"],
|
||||
"nl.example.test"
|
||||
);
|
||||
assert_eq!(generated_config["outbounds"][0]["packet_encoding"], "xudp");
|
||||
assert_eq!(generated_config["route"]["final"], DEFAULT_VPN_OUTBOUND_TAG);
|
||||
let calls = checker.calls.borrow();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].0.as_path(), binary_path);
|
||||
assert!(calls[0].1.contains(r#""type": "mixed""#));
|
||||
assert!(calls[0].1.contains(r#""tag": "vpn""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_singbox_check_when_binary_path_is_not_supplied() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let targets = vec![local_singbox_target()];
|
||||
let components = vec![running_singbox_component()];
|
||||
let config = local_singbox_config("nl-1");
|
||||
let cache = subscription_cache();
|
||||
let checker = RecordingChecker::ok("should not run");
|
||||
|
||||
let generated = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&targets, &components, None),
|
||||
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||
&checker,
|
||||
)
|
||||
.expect("binary path is optional");
|
||||
@@ -85,54 +96,53 @@ fn skips_singbox_check_when_binary_path_is_not_supplied() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_local_singbox_config_when_required_component_is_missing() {
|
||||
fn blocks_config_when_server_is_not_selected() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let targets = vec![local_singbox_target()];
|
||||
let components = Vec::new();
|
||||
let mut config = local_singbox_config("nl-1");
|
||||
config.selected_server_tag = None;
|
||||
let cache = subscription_cache();
|
||||
let checker = RecordingChecker::ok("should not run");
|
||||
|
||||
let error = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&targets, &components, None),
|
||||
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||
&checker,
|
||||
)
|
||||
.expect_err("local sing-box target requires component state");
|
||||
.expect_err("missing selected server should block config");
|
||||
|
||||
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingRequiredComponent);
|
||||
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedServer);
|
||||
assert!(checker.calls.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_local_singbox_config_when_component_is_not_running() {
|
||||
fn blocks_config_when_selected_outbound_is_missing() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let targets = vec![local_singbox_target()];
|
||||
let components = vec![stopped_singbox_component()];
|
||||
let config = local_singbox_config("missing-server");
|
||||
let cache = subscription_cache();
|
||||
let checker = RecordingChecker::ok("should not run");
|
||||
|
||||
let error = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&targets, &components, None),
|
||||
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||
&checker,
|
||||
)
|
||||
.expect_err("local sing-box target requires running component");
|
||||
.expect_err("missing outbound should block config");
|
||||
|
||||
assert_eq!(
|
||||
error.kind,
|
||||
SingBoxConfigErrorKind::RequiredComponentNotRunning
|
||||
);
|
||||
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedOutbound);
|
||||
assert!(error.message.contains("missing-server"));
|
||||
assert!(checker.calls.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagates_failed_singbox_check_as_structured_error() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let targets = vec![local_singbox_target()];
|
||||
let components = vec![running_singbox_component()];
|
||||
let config = local_singbox_config("nl-1");
|
||||
let cache = subscription_cache();
|
||||
let checker = RecordingChecker::err("invalid config");
|
||||
|
||||
let error = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&targets, &components, Some(Path::new("sing-box.exe"))),
|
||||
SingBoxGenerationRequest::new(&config, &cache, Some(Path::new("sing-box.exe"))),
|
||||
&checker,
|
||||
)
|
||||
.expect_err("failed sing-box check should block generated config");
|
||||
@@ -202,6 +212,46 @@ impl SingBoxConfigChecker for RecordingChecker {
|
||||
}
|
||||
}
|
||||
|
||||
fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig {
|
||||
LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/list".to_string()),
|
||||
selected_server_tag: Some(selected_server_tag.to_string()),
|
||||
listen_host: "127.0.0.1".to_string(),
|
||||
listen_port: 1080,
|
||||
service_name: "VpnProxySingBox".to_string(),
|
||||
install_root: r"C:\Program Files\VpnProxy\sing-box".to_string(),
|
||||
updated_at: Some("2026-07-07T10:00:00Z".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn subscription_cache() -> SubscriptionCache {
|
||||
SubscriptionCache {
|
||||
config: serde_json::json!({
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "nl-1",
|
||||
"server": "nl.example.test",
|
||||
"server_port": 443,
|
||||
"uuid": "11111111-1111-1111-1111-111111111111"
|
||||
},
|
||||
{
|
||||
"type": "direct",
|
||||
"tag": "direct"
|
||||
}
|
||||
]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
tag: "nl-1".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "nl.example.test".to_string(),
|
||||
server_port: 443,
|
||||
}],
|
||||
user_info: serde_json::Map::new(),
|
||||
fetched_at: "2026-07-07T10:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn discord_profile(target_id: &str) -> Profile {
|
||||
Profile {
|
||||
id: "discord".to_string(),
|
||||
@@ -229,46 +279,6 @@ fn external_socks5_target() -> Target {
|
||||
}
|
||||
}
|
||||
|
||||
fn local_singbox_target() -> Target {
|
||||
Target {
|
||||
id: "local-singbox".to_string(),
|
||||
name: "Local sing-box".to_string(),
|
||||
kind: TargetKind::Local,
|
||||
protocol: ProxyProtocol::Socks5,
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 1080,
|
||||
requires_component: Some(ComponentId::Singbox),
|
||||
}
|
||||
}
|
||||
|
||||
fn running_singbox_component() -> ComponentStatus {
|
||||
ComponentStatus {
|
||||
id: ComponentId::Singbox,
|
||||
name: "Local sing-box".to_string(),
|
||||
state: ComponentState::Running,
|
||||
installed: true,
|
||||
running: true,
|
||||
version: Some("1.11.0".to_string()),
|
||||
path: Some(r"C:\Tools\VpnProxy\sing-box\sing-box.exe".to_string()),
|
||||
problems: Vec::new(),
|
||||
actions: vec!["Restart".to_string(), "Stop".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn stopped_singbox_component() -> ComponentStatus {
|
||||
ComponentStatus {
|
||||
id: ComponentId::Singbox,
|
||||
name: "Local sing-box".to_string(),
|
||||
state: ComponentState::Stopped,
|
||||
installed: true,
|
||||
running: false,
|
||||
version: Some("1.11.0".to_string()),
|
||||
path: Some(r"C:\Tools\VpnProxy\sing-box\sing-box.exe".to_string()),
|
||||
problems: vec!["Service is stopped".to_string()],
|
||||
actions: vec!["Start".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_singbox_component() -> ComponentStatus {
|
||||
ComponentStatus {
|
||||
id: ComponentId::Singbox,
|
||||
|
||||
397
apps/windows-client/src-tauri/tests/singbox_command_tests.rs
Normal file
397
apps/windows-client/src-tauri/tests/singbox_command_tests.rs
Normal file
@@ -0,0 +1,397 @@
|
||||
#[path = "../src/activity.rs"]
|
||||
mod activity;
|
||||
#[path = "../src/commands.rs"]
|
||||
mod commands;
|
||||
#[path = "../src/component_detection.rs"]
|
||||
mod component_detection;
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/adapters/proxifyre.rs"]
|
||||
mod proxifyre;
|
||||
#[path = "../src/adapters/proxy_router.rs"]
|
||||
mod proxy_router;
|
||||
#[path = "../src/adapters/singbox.rs"]
|
||||
mod singbox;
|
||||
#[path = "../src/singbox_service.rs"]
|
||||
mod singbox_service;
|
||||
#[path = "../src/storage.rs"]
|
||||
mod storage;
|
||||
#[path = "../src/subscription.rs"]
|
||||
mod subscription;
|
||||
#[path = "../src/validation.rs"]
|
||||
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,
|
||||
};
|
||||
use models::{
|
||||
ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, SubscriptionCache,
|
||||
SubscriptionServer, TargetKind,
|
||||
};
|
||||
use serde_json::{json, Map};
|
||||
use singbox::{SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use storage::JsonStorage;
|
||||
|
||||
#[test]
|
||||
fn saves_subscription_url_without_exposing_secret_query() {
|
||||
let root = test_root("save-subscription");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
|
||||
let status = save_singbox_subscription_to_storage(
|
||||
&storage,
|
||||
SaveSingBoxSubscriptionInputDto {
|
||||
subscription_url: " https://sub.example.test/path?token=secret ".to_string(),
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
.expect("subscription URL should be saved");
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.expect("read local sing-box config");
|
||||
|
||||
assert_eq!(
|
||||
config.subscription_url,
|
||||
Some("https://sub.example.test/path?token=secret".to_string())
|
||||
);
|
||||
assert!(status.config.has_subscription);
|
||||
assert_eq!(
|
||||
status.config.subscription_display_url,
|
||||
Some("https://sub.example.test/...".to_string())
|
||||
);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_http_subscription_url() {
|
||||
let root = test_root("invalid-subscription");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
|
||||
let error = save_singbox_subscription_to_storage(
|
||||
&storage,
|
||||
SaveSingBoxSubscriptionInputDto {
|
||||
subscription_url: "file:///C:/sub.txt".to_string(),
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
.expect_err("non-http subscription URL should fail");
|
||||
|
||||
assert_eq!(error.code, "singbox_subscription_url_invalid");
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetches_subscription_cache_and_selects_first_server() {
|
||||
let root = test_root("fetch-subscription");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
save_singbox_subscription_to_storage(
|
||||
&storage,
|
||||
SaveSingBoxSubscriptionInputDto {
|
||||
subscription_url: "https://sub.example.test/path?token=secret".to_string(),
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
.expect("save subscription URL");
|
||||
|
||||
let status = fetch_singbox_subscription_with_fetcher(
|
||||
&storage,
|
||||
&MockFetcher(sample_cache()),
|
||||
&FixedClock,
|
||||
)
|
||||
.expect("fetch subscription through mock");
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.expect("read local sing-box config");
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.expect("read cache")
|
||||
.expect("cache should exist");
|
||||
let activity = storage.read_activity().expect("read activity");
|
||||
|
||||
assert_eq!(status.config.selected_server_tag, Some("nl-1".to_string()));
|
||||
assert_eq!(status.cache.expect("status cache").servers.len(), 2);
|
||||
assert_eq!(config.selected_server_tag, Some("nl-1".to_string()));
|
||||
assert_eq!(cache.servers.len(), 2);
|
||||
assert_eq!(activity[0].level, ActivityLevel::Success);
|
||||
assert_eq!(activity[0].title, "Подписка Local sing-box обновлена");
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_server_from_cached_subscription() {
|
||||
let root = test_root("select-server");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
storage
|
||||
.write_singbox_subscription_cache(&sample_cache())
|
||||
.expect("write cache");
|
||||
|
||||
let status = select_singbox_server_in_storage(
|
||||
&storage,
|
||||
SelectSingBoxServerInputDto {
|
||||
tag: "de-1".to_string(),
|
||||
server: None,
|
||||
server_port: None,
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
.expect("server should be selected");
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.expect("read local sing-box config");
|
||||
|
||||
assert_eq!(status.config.selected_server_tag, Some("de-1".to_string()));
|
||||
assert_eq!(config.selected_server_tag, Some("de-1".to_string()));
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_server_by_endpoint_when_display_tag_is_sanitized() {
|
||||
let root = test_root("select-server-sanitized-tag");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
storage
|
||||
.write_singbox_subscription_cache(&sample_cache_with_flag_tag())
|
||||
.expect("write cache");
|
||||
|
||||
let status = select_singbox_server_in_storage(
|
||||
&storage,
|
||||
SelectSingBoxServerInputDto {
|
||||
tag: "Умный".to_string(),
|
||||
server: Some("media.example.test".to_string()),
|
||||
server_port: Some(443),
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
.expect("server should be selected by endpoint fallback");
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.expect("read local sing-box config");
|
||||
|
||||
assert_eq!(
|
||||
status.config.selected_server_tag,
|
||||
Some("Умный 🇳🇱->🇷🇺".to_string())
|
||||
);
|
||||
assert_eq!(config.selected_server_tag, Some("Умный 🇳🇱->🇷🇺".to_string()));
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_writes_config_and_local_singbox_target() {
|
||||
let root = test_root("generate-config");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
storage
|
||||
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/path".to_string()),
|
||||
selected_server_tag: Some("nl-1".to_string()),
|
||||
..LocalSingBoxConfig::default()
|
||||
})
|
||||
.expect("write local sing-box config");
|
||||
storage
|
||||
.write_singbox_subscription_cache(&sample_cache())
|
||||
.expect("write cache");
|
||||
|
||||
let response = generate_singbox_config_with_services(
|
||||
&storage,
|
||||
&SingBoxAdapter::default(),
|
||||
&MockChecker,
|
||||
&FixedClock,
|
||||
Some(Path::new("sing-box.exe")),
|
||||
)
|
||||
.expect("generate sing-box config");
|
||||
let generated = fs::read_to_string(&response.generated_config_path).expect("read generated");
|
||||
let targets = storage.read_targets().expect("read targets");
|
||||
let target = targets
|
||||
.iter()
|
||||
.find(|target| target.id == "local-singbox")
|
||||
.expect("local sing-box target");
|
||||
let activity = storage.read_activity().expect("read activity");
|
||||
|
||||
assert!(response.success);
|
||||
assert_eq!(response.adapter_id, "singbox");
|
||||
assert_eq!(response.selected_server_tag, "nl-1");
|
||||
assert!(response.check.expect("check result").success);
|
||||
assert!(generated.contains("\"type\": \"mixed\""));
|
||||
assert!(generated.contains("\"tag\": \"vpn\""));
|
||||
assert_eq!(target.kind, TargetKind::Local);
|
||||
assert_eq!(target.protocol, ProxyProtocol::Socks5);
|
||||
assert_eq!(target.host, "127.0.0.1");
|
||||
assert_eq!(target.port, 1080);
|
||||
assert_eq!(target.requires_component, Some(ComponentId::Singbox));
|
||||
assert_eq!(activity[0].title, "Конфиг Local sing-box создан");
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_requires_cached_subscription() {
|
||||
let root = test_root("generate-missing-cache");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
|
||||
let error = generate_singbox_config_with_services(
|
||||
&storage,
|
||||
&SingBoxAdapter::default(),
|
||||
&MockChecker,
|
||||
&FixedClock,
|
||||
None,
|
||||
)
|
||||
.expect_err("missing cache should block generation");
|
||||
|
||||
assert_eq!(error.code, "singbox_subscription_cache_missing");
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forget_subscription_clears_url_selection_and_cache() {
|
||||
let root = test_root("forget-subscription");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
storage
|
||||
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/path".to_string()),
|
||||
selected_server_tag: Some("nl-1".to_string()),
|
||||
..LocalSingBoxConfig::default()
|
||||
})
|
||||
.expect("write local sing-box config");
|
||||
storage
|
||||
.write_singbox_subscription_cache(&sample_cache())
|
||||
.expect("write cache");
|
||||
|
||||
let status =
|
||||
forget_singbox_subscription_in_storage(&storage, &FixedClock).expect("forget subscription");
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.expect("read local sing-box config");
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.expect("read cache");
|
||||
|
||||
assert!(!status.config.has_subscription);
|
||||
assert_eq!(config.subscription_url, None);
|
||||
assert_eq!(config.selected_server_tag, None);
|
||||
assert_eq!(cache, None);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
struct MockFetcher(SubscriptionCache);
|
||||
|
||||
impl SubscriptionFetcher for MockFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
url: &str,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||
assert_eq!(url, "https://sub.example.test/path?token=secret");
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct MockChecker;
|
||||
|
||||
impl SingBoxConfigChecker for MockChecker {
|
||||
fn check_config(
|
||||
&self,
|
||||
binary_path: &Path,
|
||||
config_json: &str,
|
||||
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
|
||||
assert_eq!(binary_path, Path::new("sing-box.exe"));
|
||||
assert!(config_json.contains("\"final\": \"vpn\""));
|
||||
Ok(SingBoxCheckResult {
|
||||
checked: true,
|
||||
success: true,
|
||||
message: "mock check passed".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedClock;
|
||||
|
||||
impl Clock for FixedClock {
|
||||
fn now(&self) -> String {
|
||||
"2026-07-07T00:00:00Z".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_cache() -> SubscriptionCache {
|
||||
SubscriptionCache {
|
||||
config: json!({
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "nl-1",
|
||||
"server": "nl.example.test",
|
||||
"server_port": 443,
|
||||
"uuid": "11111111-1111-1111-1111-111111111111"
|
||||
},
|
||||
{
|
||||
"type": "trojan",
|
||||
"tag": "de-1",
|
||||
"server": "de.example.test",
|
||||
"server_port": 443,
|
||||
"password": "secret"
|
||||
}
|
||||
]
|
||||
}),
|
||||
servers: vec![
|
||||
SubscriptionServer {
|
||||
tag: "nl-1".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "nl.example.test".to_string(),
|
||||
server_port: 443,
|
||||
},
|
||||
SubscriptionServer {
|
||||
tag: "de-1".to_string(),
|
||||
server_type: "trojan".to_string(),
|
||||
server: "de.example.test".to_string(),
|
||||
server_port: 443,
|
||||
},
|
||||
],
|
||||
user_info: Map::new(),
|
||||
fetched_at: "2026-07-07T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_cache_with_flag_tag() -> SubscriptionCache {
|
||||
SubscriptionCache {
|
||||
config: json!({
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "Умный 🇳🇱->🇷🇺",
|
||||
"server": "media.example.test",
|
||||
"server_port": 443,
|
||||
"uuid": "11111111-1111-1111-1111-111111111111"
|
||||
}
|
||||
]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
tag: "Умный 🇳🇱->🇷🇺".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "media.example.test".to_string(),
|
||||
server_port: 443,
|
||||
}],
|
||||
user_info: Map::new(),
|
||||
fetched_at: "2026-07-07T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_root(name: &str) -> PathBuf {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock before unix epoch")
|
||||
.as_nanos();
|
||||
|
||||
std::env::temp_dir().join(format!("vpn-proxy-singbox-commands-{name}-{timestamp}"))
|
||||
}
|
||||
|
||||
fn cleanup(root: &Path) {
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
128
apps/windows-client/src-tauri/tests/singbox_service_tests.rs
Normal file
128
apps/windows-client/src-tauri/tests/singbox_service_tests.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
#[path = "../src/component_detection.rs"]
|
||||
mod component_detection;
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/singbox_service.rs"]
|
||||
mod singbox_service;
|
||||
|
||||
use component_detection::DetectedSingBox;
|
||||
use singbox_service::{
|
||||
build_singbox_setup_status, ensure_safe_singbox_install_dir, parse_service_command_output,
|
||||
service_control_script, SingBoxServiceAction,
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(windows)]
|
||||
use std::process::Command as ProcessCommand;
|
||||
|
||||
#[test]
|
||||
fn setup_status_reports_missing_items_when_singbox_is_absent() {
|
||||
let status = build_singbox_setup_status(None);
|
||||
|
||||
assert!(!status.ready);
|
||||
assert_eq!(status.missing_count, 3);
|
||||
assert_eq!(status.items[0].id, "sing-box-binary");
|
||||
assert!(status.items[0].details.contains("SagerNet/sing-box"));
|
||||
assert_eq!(status.items[1].id, "winsw-wrapper");
|
||||
assert!(status.items[1].details.contains("winsw/winsw"));
|
||||
assert_eq!(status.items[2].id, "windows-service");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_status_reports_ready_when_binary_wrapper_and_service_exist() {
|
||||
let detected = detected_singbox(true, true, true);
|
||||
let status = build_singbox_setup_status(Some(&detected));
|
||||
|
||||
assert!(status.ready);
|
||||
assert_eq!(status.missing_count, 0);
|
||||
assert!(status.items.iter().all(|item| item.installed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_last_json_service_command_output_line() {
|
||||
let output = br#"
|
||||
noise
|
||||
{"success":true,"code":"started","serviceName":"VpnProxySingBox","status":"Running","processId":42}
|
||||
"#;
|
||||
let parsed = parse_service_command_output(output).expect("service json should parse");
|
||||
|
||||
assert!(parsed.success);
|
||||
assert_eq!(parsed.code, "started");
|
||||
assert_eq!(parsed.service_name, Some("VpnProxySingBox".to_string()));
|
||||
assert_eq!(parsed.status, Some("Running".to_string()));
|
||||
assert_eq!(parsed.process_id, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_install_dir_allows_only_vpnproxy_singbox_folder() {
|
||||
assert!(
|
||||
ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\VpnProxy\sing-box")).is_ok()
|
||||
);
|
||||
assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Windows")).is_err());
|
||||
assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\sing-box")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_control_script_targets_named_service_and_action() {
|
||||
let script = service_control_script(SingBoxServiceAction::Start, "VpnProxySingBox", None, None);
|
||||
|
||||
assert!(script.contains("$serviceName = 'VpnProxySingBox'"));
|
||||
assert!(script.contains("$action = 'start'"));
|
||||
assert!(script.contains("ConvertTo-Json -Compress"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_control_script_syncs_generated_config_before_start() {
|
||||
let source = Path::new(r"C:\ProgramData\VpnProxy\generated\sing-box-config.json");
|
||||
let target = Path::new(r"C:\Program Files\VpnProxy\sing-box\config.json");
|
||||
let script = service_control_script(
|
||||
SingBoxServiceAction::Start,
|
||||
"VpnProxySingBox",
|
||||
Some(source),
|
||||
Some(target),
|
||||
);
|
||||
|
||||
assert!(script.contains(
|
||||
"$configSource = 'C:\\ProgramData\\VpnProxy\\generated\\sing-box-config.json'"
|
||||
));
|
||||
assert!(script.contains(
|
||||
"$configTarget = 'C:\\Program Files\\VpnProxy\\sing-box\\config.json'"
|
||||
));
|
||||
assert!(script.contains("Copy-Item -LiteralPath $configSource"));
|
||||
assert!(script.contains("'config_sync_failed'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn install_singbox_script_parses_as_powershell() {
|
||||
let script_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("scripts")
|
||||
.join("install-singbox.ps1");
|
||||
let escaped_path = script_path.display().to_string().replace('\'', "''");
|
||||
let parser = format!(
|
||||
"$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}"
|
||||
);
|
||||
let output = ProcessCommand::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", &parser])
|
||||
.output()
|
||||
.expect("powershell parser should run");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"install-singbox.ps1 should parse\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
}
|
||||
|
||||
fn detected_singbox(binary_exists: bool, wrapper_exists: bool, running: bool) -> DetectedSingBox {
|
||||
DetectedSingBox {
|
||||
install_dir: PathBuf::from(r"C:\Program Files\VpnProxy\sing-box"),
|
||||
executable_path: PathBuf::from(r"C:\Program Files\VpnProxy\sing-box\sing-box.exe"),
|
||||
wrapper_path: PathBuf::from(r"C:\Program Files\VpnProxy\sing-box\VpnProxySingBox.exe"),
|
||||
binary_exists,
|
||||
wrapper_exists,
|
||||
running,
|
||||
service_name: "VpnProxySingBox".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ mod models;
|
||||
mod storage;
|
||||
|
||||
use models::{
|
||||
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, Profile,
|
||||
ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind,
|
||||
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
|
||||
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache,
|
||||
SubscriptionServer, Target, TargetKind,
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -54,6 +55,86 @@ fn roundtrips_profiles_targets_components_and_activity() {
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrips_local_singbox_config_and_subscription_cache() {
|
||||
let root = test_root("local-singbox");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let config = LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/path?token=secret".to_string()),
|
||||
selected_server_tag: Some("nl-1".to_string()),
|
||||
listen_host: "127.0.0.1".to_string(),
|
||||
listen_port: 1080,
|
||||
service_name: "VpnProxySingBox".to_string(),
|
||||
install_root: r"C:\Program Files\VpnProxy\sing-box".to_string(),
|
||||
updated_at: Some("2026-07-07T10:00:00Z".to_string()),
|
||||
};
|
||||
let cache = sample_subscription_cache();
|
||||
|
||||
storage
|
||||
.write_local_singbox_config(&config)
|
||||
.expect("write local sing-box config");
|
||||
storage
|
||||
.write_singbox_subscription_cache(&cache)
|
||||
.expect("write subscription cache");
|
||||
|
||||
assert_eq!(
|
||||
storage
|
||||
.read_local_singbox_config()
|
||||
.expect("read local sing-box config"),
|
||||
config
|
||||
);
|
||||
assert_eq!(
|
||||
storage
|
||||
.read_singbox_subscription_cache()
|
||||
.expect("read subscription cache"),
|
||||
Some(cache)
|
||||
);
|
||||
assert_eq!(
|
||||
config.subscription_display_url(),
|
||||
Some("https://sub.example.test/...".to_string())
|
||||
);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_local_singbox_config_defaults_to_optional_empty_state() {
|
||||
let root = test_root("local-singbox-default");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.expect("read default local sing-box config");
|
||||
|
||||
assert_eq!(config.subscription_url, None);
|
||||
assert_eq!(config.selected_server_tag, None);
|
||||
assert_eq!(config.listen_host, "127.0.0.1");
|
||||
assert_eq!(config.listen_port, 1080);
|
||||
assert_eq!(config.service_name, "VpnProxySingBox");
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_subscription_cache_falls_back_to_none() {
|
||||
let root = test_root("invalid-subscription-cache");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
storage.ensure_dirs().expect("create storage dirs");
|
||||
fs::write(
|
||||
&storage.paths().singbox_subscription_cache_file,
|
||||
"{not valid json",
|
||||
)
|
||||
.expect("write invalid cache");
|
||||
|
||||
assert_eq!(
|
||||
storage
|
||||
.read_singbox_subscription_cache()
|
||||
.expect("invalid cache fallback"),
|
||||
None
|
||||
);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_falls_back_to_empty_collection() {
|
||||
let root = test_root("invalid-json");
|
||||
@@ -187,6 +268,29 @@ fn sample_component() -> ComponentStatus {
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_subscription_cache() -> SubscriptionCache {
|
||||
SubscriptionCache {
|
||||
config: serde_json::json!({
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "nl-1",
|
||||
"server": "nl.example.test",
|
||||
"server_port": 443
|
||||
}
|
||||
]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
tag: "nl-1".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "nl.example.test".to_string(),
|
||||
server_port: 443,
|
||||
}],
|
||||
user_info: serde_json::Map::new(),
|
||||
fetched_at: "2026-07-07T10:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_activity(id: &str, at: &str, level: ActivityLevel) -> ActivityEntry {
|
||||
ActivityEntry {
|
||||
id: id.to_string(),
|
||||
|
||||
99
apps/windows-client/src-tauri/tests/subscription_tests.rs
Normal file
99
apps/windows-client/src-tauri/tests/subscription_tests.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/subscription.rs"]
|
||||
mod subscription;
|
||||
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use models::redact_subscription_url;
|
||||
use subscription::{parse_subscription_body, parse_user_info};
|
||||
|
||||
#[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 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 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 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://uuid@example.test/..."
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user