Refine ProxyWarden routing and config flows

This commit is contained in:
2026-07-10 21:15:17 +03:00
parent 9fd0a8c0b9
commit dbba3806cc
31 changed files with 1823 additions and 191 deletions
+146 -7
View File
@@ -258,6 +258,9 @@ fn proxifyre_install_script_uses_resilient_download_helpers() {
assert!(script.contains("function Get-SafeUriForLog([string]$uri)"));
assert!(script.contains("function Invoke-ReleaseApi([string]$uri, [string]$label)"));
assert!(script.contains("function Resolve-ReleaseAsset("));
assert!(script.contains("function Get-PinnedWindowsPacketFilterAsset([string]$arch)"));
assert!(script.contains("function Get-PinnedProxiFyreAsset([string]$arch)"));
assert!(
script.contains("function Invoke-Download([string]$uri, [string]$path, [string]$label)")
);
@@ -266,20 +269,150 @@ fn proxifyre_install_script_uses_resilient_download_helpers() {
assert!(script.contains("Invoke-CurlDownload $uri $partialPath"));
assert!(script.contains("--user-agent 'proxywarden' --output $partialPath --url $uri"));
assert!(script.contains("Move-Item -LiteralPath $partialPath -Destination $path -Force"));
assert!(script.contains("function Get-BundledAsset([string]$pattern, [string]$label)"));
assert!(script.contains("function Verify-BundledAssetHash([string]$path, [string]$label)"));
assert!(script
.contains("Invoke-Download $vcRedistUrl $vcRedistPath 'Microsoft Visual C++ Runtime'"));
assert!(script.contains("Invoke-ReleaseApi $ndisapiReleaseApi 'Windows Packet Filter'"));
assert!(script
.contains("Resolve-ReleaseAsset $ndisapiReleaseApi $ndisPattern 'Windows Packet Filter'"));
assert!(script.contains(
"Invoke-Download $ndisAsset.browser_download_url $ndisPath 'Windows Packet Filter'"
));
assert!(script.contains("Invoke-ReleaseApi $proxifyreReleaseApi 'ProxiFyre'"));
assert!(
script.contains("Resolve-ReleaseAsset $proxifyreReleaseApi $proxifyrePattern 'ProxiFyre'")
);
assert!(script.contains(
"Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath 'ProxiFyre'"
));
assert!(script.contains("github.com/wiresock/ndisapi/releases/download"));
assert!(script.contains("github.com/wiresock/proxifyre/releases/download"));
let packet_filter_step = script
.find("Write-ProxyWardenProgress 'install' 'packet-filter'")
.expect("packet filter install step should be present");
let vc_runtime_step = script
.find("Write-ProxyWardenProgress 'install' 'vc-runtime'")
.expect("runtime install step should be present");
let proxifyre_step = script
.find("Write-ProxyWardenProgress 'install' 'proxifyre'")
.expect("proxifyre install step should be present");
assert!(packet_filter_step < vc_runtime_step);
assert!(vc_runtime_step < proxifyre_step);
cleanup(&root);
}
#[test]
fn proxifyre_install_script_prefers_bundled_assets_before_downloads() {
let root = test_root("proxifyre-install-script-bundled-assets");
let bundle_dir = root.join("bundle");
let script = commands::install_proxifyre_script_with_bundle(
&root.join("proxifyre-app-config.json"),
Some(&bundle_dir),
);
assert!(script.contains(&format!(
"$bundledAssetDir = '{}'",
bundle_dir.display().to_string().replace('\'', "''")
)));
assert!(script.contains("$script:bundledAssetDir = [string]$bundledAssetDir"));
assert!(script.contains("function Get-BundledAssetDir"));
assert!(script.contains("$manifestPath = [IO.Path]::Combine($assetDir, 'manifest.json')"));
assert!(script.contains("$script:bundledAssetManifest = Get-BundledAssetManifest"));
assert!(script.contains("Copy-BundledAsset $bundledNdisPath $ndisPath"));
assert!(script.contains("Copy-BundledAsset $bundledVcPath $vcRedistPath"));
assert!(script.contains("Copy-BundledAsset $bundledProxiFyrePath $proxifyreZipPath"));
let bundled_ndis = script
.find("Get-BundledAsset $ndisPattern 'Windows Packet Filter'")
.expect("ndis bundle check should be present");
let online_ndis = script
.find("Resolve-ReleaseAsset $ndisapiReleaseApi $ndisPattern 'Windows Packet Filter'")
.expect("ndis online fallback should be present");
assert!(bundled_ndis < online_ndis);
let bundled_proxifyre = script
.find("Get-BundledAsset $proxifyrePattern 'ProxiFyre'")
.expect("proxifyre bundle check should be present");
let online_proxifyre = script
.find("Resolve-ReleaseAsset $proxifyreReleaseApi $proxifyrePattern 'ProxiFyre'")
.expect("proxifyre online fallback should be present");
assert!(bundled_proxifyre < online_proxifyre);
cleanup(&root);
}
#[test]
#[cfg(windows)]
fn proxifyre_uninstall_script_parses_as_powershell() {
let root = test_root("proxifyre-uninstall-script");
fs::create_dir_all(&root).expect("test root should be created");
let detected = DetectedProxyfier {
engine: ProxyfierEngine::ProxiFyre,
name: "ProxiFyre".to_string(),
install_dir: root.join("ProxiFyre"),
executable_path: root.join("ProxiFyre").join("ProxiFyre.exe"),
config_path: Some(root.join("ProxiFyre").join("app-config.json")),
running: false,
service_name: Some("ProxiFyreService".to_string()),
service_status: Some("stopped".to_string()),
};
let script = commands::wrap_elevated_package_script(
&commands::uninstall_proxifyre_script(Some(&detected)),
&root.join("uninstall.log"),
);
let script_path = root.join("uninstall.ps1");
let mut script_bytes = vec![0xEF, 0xBB, 0xBF];
script_bytes.extend_from_slice(script.as_bytes());
fs::write(&script_path, script_bytes).expect("script should be written");
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(),
"uninstall script should parse\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
cleanup(&root);
}
#[test]
fn proxifyre_uninstall_script_removes_packet_filter_after_proxifyre() {
let detected = DetectedProxyfier {
engine: ProxyfierEngine::ProxiFyre,
name: "ProxiFyre".to_string(),
install_dir: PathBuf::from(r"C:\Tools\ProxiFyre"),
executable_path: PathBuf::from(r"C:\Tools\ProxiFyre\ProxiFyre.exe"),
config_path: Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")),
running: true,
service_name: Some("ProxiFyreService".to_string()),
service_status: Some("running".to_string()),
};
let script = commands::uninstall_proxifyre_script(Some(&detected));
assert!(script.contains("function Resolve-MsiProductCode($program, [string]$label)"));
assert!(script.contains("Отказываюсь запускать произвольный UninstallString"));
assert!(script.contains("Start-Process -FilePath 'msiexec.exe'"));
assert!(script.contains("ArgumentList @('/x', $productCode, '/qn', '/norestart'"));
assert!(script.contains("Uninstall-MsiProgram $packetFilter 'Windows Packet Filter'"));
let proxifyre_step = script
.find("Write-ProxyWardenProgress 'uninstall' 'proxifyre'")
.expect("proxifyre uninstall step should be present");
let packet_filter_step = script
.find("Write-ProxyWardenProgress 'uninstall' 'packet-filter'")
.expect("packet filter uninstall step should be present");
assert!(proxifyre_step < packet_filter_step);
}
#[test]
fn singbox_runner_preserves_installer_args_with_spaces() {
let script = commands::singbox_installer_runner_script(
@@ -287,15 +420,16 @@ fn singbox_runner_preserves_installer_args_with_spaces() {
Path::new(r"C:\ProgramData\ProxyWarden\state\install.log"),
&[
"-InstallRoot".to_string(),
r"C:\Program Files\ProxyWarden\sing-box".to_string(),
r"C:\Program Files\ProxyWarden\components\sing-box".to_string(),
"-ServiceName".to_string(),
"ProxyWardenSingBox".to_string(),
"-Uninstall".to_string(),
],
);
assert!(script
.contains("$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\sing-box'"));
assert!(script.contains(
"$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\components\\sing-box'"
));
assert!(script.contains(
"& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs"
));
@@ -390,6 +524,7 @@ fn component_status_merges_detected_existing_proxifyre() {
config_path: Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")),
running: true,
service_name: Some("ProxiFyreService".to_string()),
service_status: Some("running".to_string()),
}),
None,
);
@@ -540,8 +675,8 @@ impl ProxyfierDetectionHost for DetectionHost {
false
}
fn service_running(&self, _service_name: &str) -> bool {
false
fn service_status(&self, _service_name: &str) -> Option<String> {
None
}
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
@@ -625,6 +760,8 @@ fn proxyfier_running() -> ComponentStatus {
running: true,
version: Some("2.2.1".to_string()),
path: Some(r"C:\Tools\ProxiFyre".to_string()),
service_name: Some("ProxiFyreService".to_string()),
service_status: Some("running".to_string()),
problems: Vec::new(),
actions: vec!["Restart".to_string()],
}
@@ -639,6 +776,8 @@ fn singbox_missing() -> ComponentStatus {
running: false,
version: None,
path: None,
service_name: Some("ProxyWardenSingBox".to_string()),
service_status: None,
problems: vec!["Локальный sing-box не установлен".to_string()],
actions: vec!["Установить локальный sing-box".to_string()],
}
+56 -7
View File
@@ -14,6 +14,7 @@ fn detects_existing_proxifyre_from_registry_install_location() {
let host = MockHost::new()
.with_registry("ProxiFyre", r"C:\Tools\ProxiFyre")
.with_path(r"C:\Tools\ProxiFyre")
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
.with_service("ProxiFyreService");
let detected = detect_proxyfier_install_with_host(&host)
@@ -26,15 +27,30 @@ fn detects_existing_proxifyre_from_registry_install_location() {
Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json"))
);
assert!(detected.running);
assert_eq!(detected.service_name, Some("ProxiFyreService".to_string()));
assert_eq!(detected.service_status, Some("running".to_string()));
let component = proxyfier_component_from_detection(Some(&detected));
assert_eq!(component.state, ComponentState::Running);
assert!(component.installed);
assert!(component.running);
assert_eq!(component.path, Some(r"C:\Tools\ProxiFyre".to_string()));
assert_eq!(component.service_name, Some("ProxiFyreService".to_string()));
assert_eq!(component.service_status, Some("running".to_string()));
assert!(component.problems.is_empty());
}
#[test]
fn ignores_empty_common_proxifyre_folder_without_executable() {
let host = MockHost::new().with_path(r"C:\Tools\ProxiFyre");
assert!(detect_proxyfier_install_with_host(&host).is_none());
let component = proxyfier_component_from_detection(None);
assert_eq!(component.state, ComponentState::Missing);
assert!(!component.installed);
}
#[test]
fn ignores_plain_proxifier_install() {
let host = MockHost::new()
@@ -61,6 +77,25 @@ fn env_override_can_point_to_portable_proxifyre_install() {
);
}
#[test]
fn reports_stopped_proxifyre_service_when_executable_exists() {
let host = MockHost::new()
.with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"C:\Tools\ProxiFyre")
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
.with_stopped_service("ProxiFyreService");
let detected =
detect_proxyfier_install_with_host(&host).expect("proxifyre executable should be detected");
let component = proxyfier_component_from_detection(Some(&detected));
assert_eq!(component.state, ComponentState::Installed);
assert!(component.installed);
assert!(!component.running);
assert_eq!(component.service_name, Some("ProxiFyreService".to_string()));
assert_eq!(component.service_status, Some("stopped".to_string()));
assert!(component.problems.is_empty());
}
#[test]
fn missing_proxyfier_returns_install_action_status() {
let component = proxyfier_component_from_detection(None);
@@ -73,7 +108,7 @@ fn missing_proxyfier_returns_install_action_status() {
#[test]
fn detects_running_local_singbox_from_default_install_root_and_service() {
let host = MockHost::new()
.with_path(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe")
.with_path(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe")
.with_service("ProxyWardenSingBox");
let detected =
@@ -81,7 +116,7 @@ fn detects_running_local_singbox_from_default_install_root_and_service() {
assert_eq!(
detected.executable_path,
PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe")
PathBuf::from(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe")
);
assert_eq!(detected.service_name, "ProxyWardenSingBox");
assert!(detected.running);
@@ -92,7 +127,7 @@ fn detects_running_local_singbox_from_default_install_root_and_service() {
assert!(component.running);
assert_eq!(
component.path,
Some(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe".to_string())
Some(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe".to_string())
);
assert!(component.problems.is_empty());
}
@@ -113,6 +148,11 @@ fn detects_stopped_local_singbox_from_env_override() {
assert_eq!(component.state, ComponentState::Stopped);
assert!(component.installed);
assert!(!component.running);
assert_eq!(
component.service_name,
Some("ProxyWardenSingBox".to_string())
);
assert_eq!(component.service_status, Some("stopped".to_string()));
assert!(component
.problems
.iter()
@@ -135,7 +175,7 @@ struct MockHost {
env: HashMap<String, String>,
paths: HashSet<String>,
processes: HashSet<String>,
services: HashSet<String>,
services: HashMap<String, String>,
registry: Vec<RegistryInstallEntry>,
}
@@ -160,7 +200,14 @@ impl MockHost {
}
fn with_service(mut self, service: &str) -> Self {
self.services.insert(service.to_ascii_lowercase());
self.services
.insert(service.to_ascii_lowercase(), "running".to_string());
self
}
fn with_stopped_service(mut self, service: &str) -> Self {
self.services
.insert(service.to_ascii_lowercase(), "stopped".to_string());
self
}
@@ -188,8 +235,10 @@ impl ProxyfierDetectionHost for MockHost {
self.processes.contains(&process_name.to_ascii_lowercase())
}
fn service_running(&self, service_name: &str) -> bool {
self.services.contains(&service_name.to_ascii_lowercase())
fn service_status(&self, service_name: &str) -> Option<String> {
self.services
.get(&service_name.to_ascii_lowercase())
.cloned()
}
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
+5 -1
View File
@@ -184,6 +184,8 @@ fn missing_singbox_component() -> ComponentStatus {
running: false,
version: None,
path: None,
service_name: Some("ProxyWardenSingBox".to_string()),
service_status: None,
problems: vec!["Local sing-box is not installed".to_string()],
actions: vec!["Install Local sing-box".to_string()],
}
@@ -197,7 +199,9 @@ fn running_singbox_component() -> ComponentStatus {
installed: true,
running: true,
version: Some("1.11.0".to_string()),
path: Some(r"C:\Tools\ProxyWarden\sing-box\sing-box.exe".to_string()),
path: Some(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe".to_string()),
service_name: Some("ProxyWardenSingBox".to_string()),
service_status: Some("running".to_string()),
problems: Vec::new(),
actions: vec!["Restart".to_string(), "Stop".to_string()],
}
+4 -2
View File
@@ -21,7 +21,7 @@ fn generates_selected_outbound_config_and_runs_check_when_binary_path_is_supplie
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\ProxyWarden\sing-box\sing-box.exe");
let binary_path = Path::new(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe");
let generated = adapter
.generate_config(
@@ -211,7 +211,7 @@ fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig {
listen_host: "127.0.0.1".to_string(),
listen_port: 1080,
service_name: "ProxyWardenSingBox".to_string(),
install_root: r"C:\Program Files\ProxyWarden\sing-box".to_string(),
install_root: r"C:\Program Files\ProxyWarden\components\sing-box".to_string(),
updated_at: Some("2026-07-07T10:00:00Z".to_string()),
}
}
@@ -280,6 +280,8 @@ fn missing_singbox_component() -> ComponentStatus {
running: false,
version: None,
path: None,
service_name: Some("ProxyWardenSingBox".to_string()),
service_status: None,
problems: vec!["Local sing-box is not installed".to_string()],
actions: vec!["Install Local sing-box".to_string()],
}
+13 -11
View File
@@ -47,10 +47,10 @@ noise
#[test]
fn safe_install_dir_allows_only_proxywarden_singbox_folder() {
assert!(
ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\ProxyWarden\sing-box"))
.is_ok()
);
assert!(ensure_safe_singbox_install_dir(Path::new(
r"C:\Program Files\ProxyWarden\components\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());
}
@@ -72,7 +72,7 @@ fn service_control_script_targets_named_service_and_action() {
#[test]
fn service_control_script_syncs_generated_config_before_start() {
let source = Path::new(r"C:\ProgramData\ProxyWarden\generated\sing-box-config.json");
let target = Path::new(r"C:\Program Files\ProxyWarden\sing-box\config.json");
let target = Path::new(r"C:\Program Files\ProxyWarden\components\sing-box\config.json");
let script = service_control_script(
SingBoxServiceAction::Start,
"ProxyWardenSingBox",
@@ -83,9 +83,9 @@ fn service_control_script_syncs_generated_config_before_start() {
assert!(script.contains(
"$configSource = 'C:\\ProgramData\\ProxyWarden\\generated\\sing-box-config.json'"
));
assert!(
script.contains("$configTarget = 'C:\\Program Files\\ProxyWarden\\sing-box\\config.json'")
);
assert!(script.contains(
"$configTarget = 'C:\\Program Files\\ProxyWarden\\components\\sing-box\\config.json'"
));
assert!(script.contains("Copy-Item -LiteralPath $configSource"));
assert!(script.contains("'config_sync_failed'"));
}
@@ -116,10 +116,12 @@ fn install_singbox_script_parses_as_powershell() {
fn detected_singbox(binary_exists: bool, wrapper_exists: bool, running: bool) -> DetectedSingBox {
DetectedSingBox {
install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box"),
executable_path: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe"),
install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\components\sing-box"),
executable_path: PathBuf::from(
r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe",
),
wrapper_path: PathBuf::from(
r"C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe",
r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe",
),
binary_exists,
wrapper_exists,
+3 -1
View File
@@ -57,7 +57,7 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
listen_host: "127.0.0.1".to_string(),
listen_port: 1080,
service_name: "ProxyWardenSingBox".to_string(),
install_root: r"C:\Program Files\ProxyWarden\sing-box".to_string(),
install_root: r"C:\Program Files\ProxyWarden\components\sing-box".to_string(),
updated_at: Some("2026-07-07T10:00:00Z".to_string()),
};
let cache = sample_subscription_cache();
@@ -370,6 +370,8 @@ fn sample_component() -> ComponentStatus {
running: false,
version: None,
path: None,
service_name: Some("ProxiFyreService".to_string()),
service_status: None,
problems: vec!["ProxiFyre не установлен".to_string()],
actions: vec!["Установить ProxiFyre".to_string()],
}