diff --git a/apps/windows-client/README.md b/apps/windows-client/README.md index 80d1849..043a3bd 100644 --- a/apps/windows-client/README.md +++ b/apps/windows-client/README.md @@ -21,6 +21,8 @@ Source configuration is owned by Rust domain models and JSON files under: C:\ProgramData\VpnProxy\config\profiles.json C:\ProgramData\VpnProxy\config\targets.json C:\ProgramData\VpnProxy\config\components.json +C:\ProgramData\VpnProxy\config\local-singbox.json +C:\ProgramData\VpnProxy\state\singbox-subscription-cache.json C:\ProgramData\VpnProxy\state\activity.json ``` @@ -78,6 +80,25 @@ Installers must be launched intentionally by the user or by a future narrow helper permission. Profile apply must not silently install Control App, Proxyfier, or Local sing-box. +Local sing-box install creates this optional runtime: + +```text +C:\Program Files\VpnProxy\sing-box\sing-box.exe +C:\Program Files\VpnProxy\sing-box\VpnProxySingBox.exe +Windows service: VpnProxySingBox +``` + +The install flow downloads `sing-box` from `SagerNet/sing-box` releases and the +WinSW service wrapper from `winsw/winsw` releases, then writes a service config +that points at `C:\ProgramData\VpnProxy\generated\sing-box-config.json`. It +requires UAC confirmation. Uninstall is scoped to the configured +`VpnProxy\sing-box` install root. + +The external proxy route remains independent from Local sing-box. Choosing +Local sing-box in the UI generates `sing-box-config.json`, ensures the +`local-singbox` SOCKS5 target at `127.0.0.1:1080`, and then applies ProxiFyre +to that local target. + ## Existing Proxyfier Detection The app detects an already installed Proxyfier layer before showing component @@ -113,4 +134,5 @@ different profile format. 7. Install and start Local sing-box only when using a local target. Task evidence is recorded in -`docs/goals/windows-modular-client/EVIDENCE.md`. +`docs/goals/windows-modular-client/EVIDENCE.md` and +`docs/goals/windows-local-singbox/EVIDENCE.md`. diff --git a/apps/windows-client/scripts/install-singbox.ps1 b/apps/windows-client/scripts/install-singbox.ps1 index 14c54f8..f69eefe 100644 --- a/apps/windows-client/scripts/install-singbox.ps1 +++ b/apps/windows-client/scripts/install-singbox.ps1 @@ -1,13 +1,19 @@ param( [string]$InstallRoot = "C:\Program Files\VpnProxy\sing-box", - [string]$BinaryPath = "", [string]$ServiceName = "VpnProxySingBox", + [string]$ConfigSource = "C:\ProgramData\VpnProxy\generated\sing-box-config.json", [switch]$PlanOnly, - [switch]$Force + [switch]$Force, + [switch]$Uninstall ) $ErrorActionPreference = "Stop" +$SingBoxReleaseApi = "https://api.github.com/repos/SagerNet/sing-box/releases/latest" +$WinSwReleaseApi = "https://api.github.com/repos/winsw/winsw/releases/latest" +$WrapperFile = "$ServiceName.exe" +$ConfigFile = "config.json" + function New-Result { param( [bool]$Success, @@ -23,7 +29,7 @@ function New-Result { changed = $Changed message = $Message details = $Details - } | ConvertTo-Json -Depth 6 + } | ConvertTo-Json -Depth 8 } function Test-IsAdministrator { @@ -32,6 +38,48 @@ function Test-IsAdministrator { $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } +function Get-NativeArchitecture { + $processor = Get-CimInstance Win32_Processor | Select-Object -First 1 + if ($null -ne $processor -and $processor.Architecture -eq 12) { return "arm64" } + if ([Environment]::Is64BitOperatingSystem) { return "amd64" } + return "386" +} + +function Get-WinSwArchitecture { + param([string]$Arch) + if ($Arch -eq "arm64") { return "arm64" } + if ($Arch -eq "386") { return "x86" } + return "x64" +} + +function Invoke-Download { + param([string]$Uri, [string]$Path) + Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $Path -Headers @{ "User-Agent" = "vpn-proxy-windows-client" } +} + +function Select-Asset { + param( + [object[]]$Assets, + [string]$Pattern, + [string]$Label + ) + + $asset = $Assets | Where-Object { $_.name -match $Pattern } | Select-Object -First 1 + if ($null -eq $asset) { + throw "Не найден release asset для $Label по шаблону $Pattern." + } + return $asset +} + +function Test-SafeInstallRoot { + param([string]$Path) + $full = [System.IO.Path]::GetFullPath($Path).TrimEnd("\") + $leaf = Split-Path -Leaf $full + $parent = Split-Path -Parent $full + if ($leaf -ne "sing-box") { return $false } + return $parent -match "\\VpnProxy$|\\vpn-proxy$" +} + function Backup-File { param([string]$Path) if (Test-Path -LiteralPath $Path) { @@ -42,16 +90,92 @@ function Backup-File { return $null } +function Write-Utf8NoBomFile { + param( + [string]$Path, + [string]$Value + ) + + $encoding = New-Object System.Text.UTF8Encoding $false + [System.IO.File]::WriteAllText($Path, $Value, $encoding) +} + +function Write-WinSwConfig { + param( + [string]$Root, + [string]$Name + ) + + $xmlPath = Join-Path $Root "$Name.xml" + $logDir = Join-Path $Root "logs" + New-Item -ItemType Directory -Path $logDir -Force | Out-Null + $xml = @" + + $Name + VPN Proxy Local sing-box + Local sing-box runtime managed by VPN Proxy Windows client. + %BASE%\sing-box.exe + run -c "%BASE%\config.json" + %BASE%\logs + + 10485760 + 4 + + + +"@ + Write-Utf8NoBomFile -Path $xmlPath -Value $xml + return $xmlPath +} + +function Stop-And-Uninstall-Service { + param( + [string]$Root, + [string]$Name + ) + + $wrapper = Join-Path $Root "$Name.exe" + $service = Get-Service -Name $Name -ErrorAction SilentlyContinue + if ($null -ne $service -and $service.Status -ne "Stopped") { + Stop-Service -Name $Name -Force -ErrorAction SilentlyContinue + $service = Get-Service -Name $Name -ErrorAction SilentlyContinue + if ($null -ne $service) { + try { $service.WaitForStatus("Stopped", [TimeSpan]::FromSeconds(15)) } catch {} + } + } + + if (Test-Path -LiteralPath $wrapper) { + Push-Location $Root + try { & $wrapper uninstall | Out-Null } finally { Pop-Location } + } + + $service = Get-Service -Name $Name -ErrorAction SilentlyContinue + if ($null -ne $service) { + sc.exe delete $Name | Out-Null + } +} + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $installRootFull = [System.IO.Path]::GetFullPath($InstallRoot) $details = @{ - installRoot = $InstallRoot - binaryPath = $BinaryPath + installRoot = $installRootFull serviceName = $ServiceName + configSource = $ConfigSource + singboxReleaseApi = $SingBoxReleaseApi + winswReleaseApi = $WinSwReleaseApi planOnly = [bool]$PlanOnly + uninstall = [bool]$Uninstall } if ($PlanOnly) { - New-Result -Success $true -Action "install-singbox" -Changed $false -Message "Local sing-box install plan is ready." -Details $details + $details.items = @( + @{ id = "sing-box-binary"; name = "sing-box.exe"; source = $SingBoxReleaseApi; target = (Join-Path $installRootFull "sing-box.exe") }, + @{ id = "winsw-wrapper"; name = $WrapperFile; source = $WinSwReleaseApi; target = (Join-Path $installRootFull $WrapperFile) }, + @{ id = "windows-service"; name = $ServiceName; target = "Windows Service" }, + @{ id = "config"; name = $ConfigFile; source = $ConfigSource; target = (Join-Path $installRootFull $ConfigFile) } + ) + New-Result -Success $true -Action "install-singbox.plan" -Changed $false -Message "Local sing-box install plan is ready." -Details $details exit 0 } @@ -60,36 +184,86 @@ try { exit 1 } - if ([string]::IsNullOrWhiteSpace($BinaryPath) -or -not (Test-Path -LiteralPath $BinaryPath)) { - New-Result -Success $false -Action "install-singbox" -Changed $false -Message "BinaryPath is required and must point to sing-box.exe." -Details $details - exit 2 + if ($Uninstall) { + if (-not (Test-SafeInstallRoot -Path $installRootFull)) { + New-Result -Success $false -Action "uninstall-singbox" -Changed $false -Message "Unsafe InstallRoot for recursive uninstall." -Details $details + exit 2 + } + + Stop-And-Uninstall-Service -Root $installRootFull -Name $ServiceName + if (Test-Path -LiteralPath $installRootFull) { + Remove-Item -LiteralPath $installRootFull -Recurse -Force + } + New-Result -Success $true -Action "uninstall-singbox" -Changed $true -Message "Local sing-box service and install folder were removed." -Details $details + exit 0 } $changed = $false - if (-not (Test-Path -LiteralPath $InstallRoot)) { - New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null + New-Item -ItemType Directory -Path $installRootFull -Force | Out-Null + $workDir = Join-Path ([System.IO.Path]::GetTempPath()) ("vpn-proxy-singbox-" + [guid]::NewGuid().ToString("N")) + $extractDir = Join-Path $workDir "extract" + New-Item -ItemType Directory -Path $extractDir -Force | Out-Null + + try { + $arch = Get-NativeArchitecture + $winswArch = Get-WinSwArchitecture -Arch $arch + $details.architecture = $arch + $details.winswArchitecture = $winswArch + + $singboxRelease = Invoke-RestMethod -Uri $SingBoxReleaseApi -Headers @{ "User-Agent" = "vpn-proxy-windows-client" } + $singboxAsset = Select-Asset $singboxRelease.assets "windows-$arch\.zip$" "sing-box" + $singboxZip = Join-Path $workDir $singboxAsset.name + Invoke-Download $singboxAsset.browser_download_url $singboxZip + Expand-Archive -LiteralPath $singboxZip -DestinationPath $extractDir -Force + $singboxExe = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter "sing-box.exe" | Select-Object -First 1 + if ($null -eq $singboxExe) { throw "В архиве sing-box не найден sing-box.exe." } + Copy-Item -LiteralPath $singboxExe.FullName -Destination (Join-Path $installRootFull "sing-box.exe") -Force $changed = $true - } - $configPath = Join-Path $InstallRoot "config.json" - $backupPath = Backup-File -Path $configPath - if ($backupPath) { - $details.backupPath = $backupPath - } - - $markerPath = Join-Path $InstallRoot "install-singbox.marker.json" - if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) { - @{ - component = "singbox" - binaryPath = $BinaryPath - serviceName = $ServiceName - installedAt = (Get-Date).ToString("o") - } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8 + $winswRelease = Invoke-RestMethod -Uri $WinSwReleaseApi -Headers @{ "User-Agent" = "vpn-proxy-windows-client" } + $winswAsset = Select-Asset $winswRelease.assets "WinSW-$winswArch\.exe$" "WinSW" + Invoke-Download $winswAsset.browser_download_url (Join-Path $installRootFull $WrapperFile) $changed = $true + + $configTarget = Join-Path $installRootFull $ConfigFile + $backupPath = Backup-File -Path $configTarget + if ($backupPath) { $details.backupPath = $backupPath } + if (Test-Path -LiteralPath $ConfigSource) { + Copy-Item -LiteralPath $ConfigSource -Destination $configTarget -Force + } elseif (-not (Test-Path -LiteralPath $configTarget)) { + Write-Utf8NoBomFile -Path $configTarget -Value '{"log":{"level":"info","timestamp":true},"inbounds":[],"outbounds":[{"type":"direct","tag":"direct"}],"route":{"final":"direct"}}' + } + + $xmlPath = Write-WinSwConfig -Root $installRootFull -Name $ServiceName + $details.configPath = $configTarget + $details.wrapperConfigPath = $xmlPath + + if ($Force) { + Stop-And-Uninstall-Service -Root $installRootFull -Name $ServiceName + } + + Push-Location $installRootFull + try { + $service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue + if ($null -eq $service) { + & ".\$WrapperFile" install + if ($LASTEXITCODE -ne 0) { throw "WinSW install завершился с кодом $LASTEXITCODE." } + $changed = $true + } + & ".\$WrapperFile" start + if ($LASTEXITCODE -ne 0) { + Start-Service -Name $ServiceName -ErrorAction Stop + } + } finally { + Pop-Location + } + } finally { + if (Test-Path -LiteralPath $workDir) { + Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue + } } - $details.markerPath = $markerPath - New-Result -Success $true -Action "install-singbox" -Changed $changed -Message "Local sing-box install boundary completed." -Details $details + New-Result -Success $true -Action "install-singbox" -Changed $changed -Message "Local sing-box service is installed and started." -Details $details } catch { New-Result -Success $false -Action "install-singbox" -Changed $false -Message $_.Exception.Message exit 1 diff --git a/apps/windows-client/src-tauri/Cargo.lock b/apps/windows-client/src-tauri/Cargo.lock index ce4a889..e13342c 100644 --- a/apps/windows-client/src-tauri/Cargo.lock +++ b/apps/windows-client/src-tauri/Cargo.lock @@ -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" diff --git a/apps/windows-client/src-tauri/Cargo.toml b/apps/windows-client/src-tauri/Cargo.toml index 42c840e..3eed604 100644 --- a/apps/windows-client/src-tauri/Cargo.toml +++ b/apps/windows-client/src-tauri/Cargo.toml @@ -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" diff --git a/apps/windows-client/src-tauri/src/adapters/singbox.rs b/apps/windows-client/src-tauri/src/adapters/singbox.rs index 85aa19a..117d3b0 100644 --- a/apps/windows-client/src-tauri/src/adapters/singbox.rs +++ b/apps/windows-client/src-tauri/src/adapters/singbox.rs @@ -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, inbound_tag: impl Into, - outbound_tag: impl Into, + vpn_outbound_tag: impl Into, ) -> 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, @@ -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, - pub outbounds: Vec, - 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, - #[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 { + 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 { diff --git a/apps/windows-client/src-tauri/src/commands.rs b/apps/windows-client/src-tauri/src/commands.rs index 15a2e5a..ab21240 100644 --- a/apps/windows-client/src-tauri/src/commands.rs +++ b/apps/windows-client/src-tauri/src/commands.rs @@ -5,15 +5,20 @@ use crate::adapters::proxy_router::{ ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig, ProxyRouterRequest, }; +#[cfg(not(test))] +use crate::adapters::singbox::{ + SingBoxAdapter, SingBoxCheckResult, SingBoxCommandChecker, SingBoxConfigChecker, + SingBoxConfigError, SingBoxConfigErrorKind, SingBoxGeneratedConfig, SingBoxGenerationRequest, +}; use crate::component_detection::{ - detect_proxyfier_install, detect_proxyfier_install_with_host, - proxyfier_component_from_detection, DetectedProxyfier, ProxyfierDetectionHost, - SystemProxyfierDetectionHost, + detect_proxyfier_install, detect_proxyfier_install_with_host, detect_singbox_install, + proxyfier_component_from_detection, singbox_component_from_detection, DetectedProxyfier, + DetectedSingBox, ProxyfierDetectionHost, SystemProxyfierDetectionHost, }; use crate::models::{ - ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, Profile, - ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol, Target, - TargetInput, TargetKind, + ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, + Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol, + SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind, }; #[cfg(test)] use crate::proxifyre::ProxiFyreAdapter; @@ -22,13 +27,26 @@ use crate::proxy_router::{ ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig, ProxyRouterRequest, }; +#[cfg(test)] +use crate::singbox::{ + SingBoxAdapter, SingBoxCheckResult, SingBoxCommandChecker, SingBoxConfigChecker, + SingBoxConfigError, SingBoxConfigErrorKind, SingBoxGeneratedConfig, SingBoxGenerationRequest, +}; +use crate::singbox_service::{ + build_singbox_setup_status, ensure_safe_singbox_install_dir, + parse_service_command_output as parse_singbox_service_command_output, service_control_script, + ServiceCommandOutput as SingBoxServiceCommandOutput, SingBoxServiceAction, SingBoxSetupStatus, +}; use crate::storage::{default_config_root, JsonStorage}; +use crate::subscription; use crate::validation::{normalize_profile, normalize_target, ValidationError}; use serde::{Deserialize, Serialize}; use std::env; use std::fs; +use std::net::{IpAddr, TcpStream, ToSocketAddrs, UdpSocket}; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; +use std::time::{Duration, Instant}; use std::time::{SystemTime, UNIX_EPOCH}; const PROXIFYRE_INSTALL_DIR: &str = r"C:\Tools\ProxiFyre"; @@ -136,6 +154,96 @@ pub struct ProxiFyreSetupItemDto { pub details: String, } +pub type SingBoxSetupStatusDto = SingBoxSetupStatus; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalSingBoxStatusResponse { + pub config: LocalSingBoxConfigDto, + pub cache: Option, + pub component: ComponentStatusDto, + pub generated_config_path: String, + pub lan_listen_host: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalSingBoxConfigDto { + pub subscription_display_url: Option, + pub has_subscription: bool, + pub selected_server_tag: Option, + pub listen_host: String, + pub listen_port: u16, + pub service_name: String, + pub install_root: String, + pub updated_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionCacheDto { + pub servers: Vec, + pub user_info: serde_json::Map, + pub fetched_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionServerDto { + pub tag: String, + #[serde(rename = "type")] + pub server_type: String, + pub server: String, + pub server_port: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveSingBoxSubscriptionInputDto { + pub subscription_url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SelectSingBoxServerInputDto { + pub tag: String, + #[serde(default)] + pub server: Option, + #[serde(default)] + pub server_port: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PingSingBoxServerInputDto { + pub tag: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PingServerResponse { + pub tag: String, + pub server: String, + pub server_port: u16, + pub ok: bool, + pub latency: Option, + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GenerateSingBoxConfigResponse { + pub success: bool, + pub message: String, + pub adapter_id: String, + pub generated_config_path: String, + pub selected_server_tag: String, + pub listen_host: String, + pub listen_port: u16, + pub check: Option, + pub activity: ActivityEntryDto, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProfileInputDto { @@ -286,6 +394,24 @@ pub trait ProxyApplyHelper { ) -> Result; } +pub trait SubscriptionFetcher { + fn fetch_subscription( + &self, + url: &str, + ) -> Result; +} + +pub struct SystemSubscriptionFetcher; + +impl SubscriptionFetcher for SystemSubscriptionFetcher { + fn fetch_subscription( + &self, + url: &str, + ) -> Result { + subscription::fetch_subscription(url) + } +} + pub trait Clock { fn now(&self) -> String; } @@ -417,6 +543,25 @@ pub async fn get_proxifyre_setup_status() -> Result, +) -> Result { + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || read_singbox_status(&storage)) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn get_singbox_setup_status() -> Result { + tauri::async_runtime::spawn_blocking(|| { + build_singbox_setup_status(detect_singbox_install().as_ref()) + }) + .await + .map_err(background_task_error) +} + #[tauri::command] pub fn resolve_profile_preview( input: ProfileInputDto, @@ -424,6 +569,73 @@ pub fn resolve_profile_preview( resolve_preview(input) } +#[tauri::command] +pub fn save_singbox_subscription( + state: tauri::State<'_, CommandState>, + input: SaveSingBoxSubscriptionInputDto, +) -> Result { + save_singbox_subscription_to_storage(&state.storage(), input, &SystemClock) +} + +#[tauri::command] +pub async fn fetch_singbox_subscription( + state: tauri::State<'_, CommandState>, +) -> Result { + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || { + fetch_singbox_subscription_with_fetcher(&storage, &SystemSubscriptionFetcher, &SystemClock) + }) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub fn forget_singbox_subscription( + state: tauri::State<'_, CommandState>, +) -> Result { + forget_singbox_subscription_in_storage(&state.storage(), &SystemClock) +} + +#[tauri::command] +pub fn select_singbox_server( + state: tauri::State<'_, CommandState>, + input: SelectSingBoxServerInputDto, +) -> Result { + select_singbox_server_in_storage(&state.storage(), input, &SystemClock) +} + +#[tauri::command] +pub fn ping_singbox_server( + state: tauri::State<'_, CommandState>, + input: PingSingBoxServerInputDto, +) -> Result { + ping_singbox_server_in_storage(&state.storage(), input) +} + +#[tauri::command] +pub fn ping_all_singbox_servers( + state: tauri::State<'_, CommandState>, +) -> Result, CommandError> { + ping_all_singbox_servers_in_storage(&state.storage()) +} + +#[tauri::command] +pub fn generate_singbox_config( + state: tauri::State<'_, CommandState>, +) -> Result { + let detected = detect_singbox_install(); + let binary_path = detected + .as_ref() + .map(|detected| detected.executable_path.as_path()); + generate_singbox_config_with_services( + &state.storage(), + &SingBoxAdapter::default(), + &SingBoxCommandChecker, + &SystemClock, + binary_path, + ) +} + #[tauri::command] pub fn apply_profiles( state: tauri::State<'_, CommandState>, @@ -494,6 +706,54 @@ pub async fn uninstall_proxifyre() -> Result { .map_err(background_task_error)? } +#[tauri::command] +pub async fn start_singbox_service( + state: tauri::State<'_, CommandState>, +) -> Result { + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || { + let detected = detect_singbox_install(); + let binary_path = detected + .as_ref() + .map(|detected| detected.executable_path.as_path()); + let generated = generate_singbox_config_with_services( + &storage, + &SingBoxAdapter::default(), + &SingBoxCommandChecker, + &SystemClock, + binary_path, + )?; + let generated_path = PathBuf::from(generated.generated_config_path); + control_singbox_service(SingBoxServiceAction::Start, Some(generated_path.as_path())) + }) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn stop_singbox_service() -> Result { + tauri::async_runtime::spawn_blocking(|| control_singbox_service(SingBoxServiceAction::Stop, None)) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn install_singbox( + state: tauri::State<'_, CommandState>, +) -> Result { + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || install_singbox_component(&storage)) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn uninstall_singbox() -> Result { + tauri::async_runtime::spawn_blocking(uninstall_singbox_component) + .await + .map_err(background_task_error)? +} + pub fn build_status(storage: &JsonStorage) -> Result { let profiles = storage.read_profiles().map_err(storage_error)?; let targets = storage.read_targets().map_err(storage_error)?; @@ -677,17 +937,965 @@ pub fn apply_profiles_with_services( }) } +pub fn read_singbox_status( + storage: &JsonStorage, +) -> Result { + let config = storage.read_local_singbox_config().map_err(storage_error)?; + let cache = storage + .read_singbox_subscription_cache() + .map_err(storage_error)?; + let detected = detect_singbox_install(); + let component = singbox_component_from_detection(detected.as_ref()); + + Ok(LocalSingBoxStatusResponse { + config: LocalSingBoxConfigDto::from(&config), + cache: cache.as_ref().map(SubscriptionCacheDto::from), + component: ComponentStatusDto::from(&component), + generated_config_path: storage + .paths() + .generated_dir + .join("sing-box-config.json") + .display() + .to_string(), + lan_listen_host: local_lan_ipv4(), + }) +} + +pub fn save_singbox_subscription_to_storage( + storage: &JsonStorage, + input: SaveSingBoxSubscriptionInputDto, + clock: &impl Clock, +) -> Result { + let subscription_url = input.subscription_url.trim().to_string(); + validate_subscription_url(&subscription_url)?; + + let mut config = storage.read_local_singbox_config().map_err(storage_error)?; + config.subscription_url = Some(subscription_url); + config.updated_at = Some(clock.now()); + storage + .write_local_singbox_config(&config) + .map_err(storage_error)?; + + read_singbox_status(storage) +} + +pub fn fetch_singbox_subscription_with_fetcher( + storage: &JsonStorage, + fetcher: &impl SubscriptionFetcher, + clock: &impl Clock, +) -> Result { + let mut config = storage.read_local_singbox_config().map_err(storage_error)?; + let subscription_url = config + .subscription_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + CommandError::new( + "singbox_subscription_missing", + "Ссылка на подписку Local sing-box не сохранена.", + ) + })?; + + let cache = fetcher + .fetch_subscription(&subscription_url) + .map_err(|error| CommandError::new("singbox_subscription_fetch_failed", error.message))?; + let selected_tag = config + .selected_server_tag + .as_deref() + .filter(|tag| cache.servers.iter().any(|server| server.tag == *tag)) + .map(str::to_string) + .or_else(|| cache.servers.first().map(|server| server.tag.clone())); + + config.selected_server_tag = selected_tag; + config.updated_at = Some(clock.now()); + storage + .write_singbox_subscription_cache(&cache) + .map_err(storage_error)?; + storage + .write_local_singbox_config(&config) + .map_err(storage_error)?; + storage + .append_activity(ActivityEntry { + id: "singbox-subscription-fetched".to_string(), + at: clock.now(), + level: ActivityLevel::Success, + title: "Подписка Local sing-box обновлена".to_string(), + message: format!("Серверов найдено: {}", cache.servers.len()), + }) + .map_err(storage_error)?; + + read_singbox_status(storage) +} + +pub fn forget_singbox_subscription_in_storage( + storage: &JsonStorage, + clock: &impl Clock, +) -> Result { + let mut config = storage.read_local_singbox_config().map_err(storage_error)?; + config.subscription_url = None; + config.selected_server_tag = None; + config.updated_at = Some(clock.now()); + storage + .write_local_singbox_config(&config) + .map_err(storage_error)?; + storage + .remove_singbox_subscription_cache() + .map_err(storage_error)?; + + read_singbox_status(storage) +} + +pub fn select_singbox_server_in_storage( + storage: &JsonStorage, + input: SelectSingBoxServerInputDto, + clock: &impl Clock, +) -> Result { + let requested_tag = input.tag.trim().to_string(); + if requested_tag.is_empty() { + return Err(CommandError::new( + "singbox_server_tag_missing", + "Сервер Local sing-box не выбран.", + )); + } + + let cache = storage + .read_singbox_subscription_cache() + .map_err(storage_error)? + .ok_or_else(|| { + CommandError::new( + "singbox_subscription_cache_missing", + "Сначала нужно загрузить подписку Local sing-box.", + ) + })?; + let Some(server) = find_subscription_server( + &cache, + &requested_tag, + input.server.as_deref(), + input.server_port, + ) else { + return Err(CommandError::new( + "singbox_server_not_found", + format!("Сервер Local sing-box '{requested_tag}' не найден в текущей подписке."), + )); + }; + let selected_tag = server.tag.clone(); + + let mut config = storage.read_local_singbox_config().map_err(storage_error)?; + config.selected_server_tag = Some(selected_tag); + config.updated_at = Some(clock.now()); + storage + .write_local_singbox_config(&config) + .map_err(storage_error)?; + + read_singbox_status(storage) +} + +pub fn ping_singbox_server_in_storage( + storage: &JsonStorage, + input: PingSingBoxServerInputDto, +) -> Result { + let tag = input.tag.trim(); + let cache = read_required_singbox_cache(storage)?; + let server = find_subscription_server(&cache, tag, None, None).ok_or_else(|| { + CommandError::new( + "singbox_server_not_found", + format!("Сервер Local sing-box '{tag}' не найден в текущей подписке."), + ) + })?; + + Ok(ping_subscription_server(server)) +} + +pub fn ping_all_singbox_servers_in_storage( + storage: &JsonStorage, +) -> Result, CommandError> { + let cache = read_required_singbox_cache(storage)?; + Ok(cache.servers.iter().map(ping_subscription_server).collect()) +} + +pub fn generate_singbox_config_with_services( + storage: &JsonStorage, + adapter: &SingBoxAdapter, + checker: &C, + clock: &impl Clock, + binary_path: Option<&Path>, +) -> Result +where + C: SingBoxConfigChecker, +{ + let config = storage.read_local_singbox_config().map_err(storage_error)?; + let cache = read_required_singbox_cache(storage)?; + let generated = adapter + .generate_config( + SingBoxGenerationRequest::new(&config, &cache, binary_path), + checker, + ) + .map_err(singbox_adapter_error)?; + let generated_path = storage + .paths() + .generated_dir + .join(generated.output_file_name.as_str()); + + write_generated_config(&generated_path, &generated.contents)?; + ensure_local_singbox_target(storage, &config)?; + + let activity = activity_for_singbox_generate(clock, &generated, &generated_path); + storage + .append_activity(activity.clone()) + .map_err(storage_error)?; + + Ok(GenerateSingBoxConfigResponse { + success: true, + message: "Конфиг Local sing-box создан".to_string(), + adapter_id: generated.adapter_id, + generated_config_path: generated_path.display().to_string(), + selected_server_tag: generated.selected_server_tag, + listen_host: generated.listen, + listen_port: generated.listen_port, + check: generated.check, + activity: ActivityEntryDto::from(&activity), + }) +} + +fn read_required_singbox_cache(storage: &JsonStorage) -> Result { + storage + .read_singbox_subscription_cache() + .map_err(storage_error)? + .ok_or_else(|| { + CommandError::new( + "singbox_subscription_cache_missing", + "Сначала нужно загрузить подписку Local sing-box.", + ) + }) +} + +fn validate_subscription_url(subscription_url: &str) -> Result<(), CommandError> { + if subscription_url.is_empty() { + return Err(CommandError::new( + "singbox_subscription_url_missing", + "Ссылка на подписку Local sing-box не указана.", + )); + } + + let parsed = url::Url::parse(subscription_url).map_err(|_| { + CommandError::new( + "singbox_subscription_url_invalid", + "Ссылка на подписку Local sing-box должна быть корректным URL.", + ) + })?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(CommandError::new( + "singbox_subscription_url_invalid", + "Ссылка на подписку Local sing-box должна начинаться с http:// или https://.", + )); + } + + Ok(()) +} + +fn ping_subscription_server(server: &SubscriptionServer) -> PingServerResponse { + let started = Instant::now(); + let addresses = match (server.server.as_str(), server.server_port).to_socket_addrs() { + Ok(addresses) => addresses.collect::>(), + Err(error) => { + return PingServerResponse { + tag: server.tag.clone(), + server: server.server.clone(), + server_port: server.server_port, + ok: false, + latency: None, + error: Some(format!("DNS/адрес недоступен: {error}")), + }; + } + }; + + if addresses.is_empty() { + return PingServerResponse { + tag: server.tag.clone(), + server: server.server.clone(), + server_port: server.server_port, + ok: false, + latency: None, + error: Some("DNS не вернул адреса".to_string()), + }; + } + + let timeout = Duration::from_secs(2); + let mut last_error = None; + for address in addresses { + match TcpStream::connect_timeout(&address, timeout) { + Ok(_) => { + return PingServerResponse { + tag: server.tag.clone(), + server: server.server.clone(), + server_port: server.server_port, + ok: true, + latency: Some(started.elapsed().as_millis()), + error: None, + }; + } + Err(error) => last_error = Some(error.to_string()), + } + } + + PingServerResponse { + tag: server.tag.clone(), + server: server.server.clone(), + server_port: server.server_port, + ok: false, + latency: None, + error: last_error, + } +} + +fn local_lan_ipv4() -> Option { + let socket = UdpSocket::bind("0.0.0.0:0").ok()?; + socket.connect("8.8.8.8:80").ok()?; + let IpAddr::V4(address) = socket.local_addr().ok()?.ip() else { + return None; + }; + if address.is_loopback() || address.is_link_local() || address.is_unspecified() { + return None; + } + Some(address.to_string()) +} + +fn find_subscription_server<'a>( + cache: &'a SubscriptionCache, + requested_tag: &str, + requested_server: Option<&str>, + requested_port: Option, +) -> Option<&'a SubscriptionServer> { + cache + .servers + .iter() + .find(|server| server.tag == requested_tag) + .or_else(|| { + let requested = comparable_server_tag(requested_tag); + cache + .servers + .iter() + .find(|server| comparable_server_tag(&server.tag) == requested) + }) + .or_else(|| { + let server_name = requested_server?.trim(); + let server_port = requested_port?; + cache.servers.iter().find(|server| { + server.server.eq_ignore_ascii_case(server_name) && server.server_port == server_port + }) + }) +} + +fn comparable_server_tag(value: &str) -> String { + value + .chars() + .filter(|ch| !matches!(ch, '\u{fe0e}' | '\u{fe0f}' | '\u{200d}')) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn ensure_local_singbox_target( + storage: &JsonStorage, + config: &LocalSingBoxConfig, +) -> Result<(), CommandError> { + let mut targets = storage.read_targets().map_err(storage_error)?; + let target = Target { + id: "local-singbox".to_string(), + name: "Локальный sing-box".to_string(), + kind: TargetKind::Local, + protocol: ProxyProtocol::Socks5, + host: config.listen_host.clone(), + port: config.listen_port, + requires_component: Some(ComponentId::Singbox), + }; + + match targets.iter().position(|existing| existing.id == target.id) { + Some(index) => targets[index] = target, + None => targets.push(target), + } + + storage.write_targets(&targets).map_err(storage_error) +} + +fn activity_for_singbox_generate( + clock: &impl Clock, + generated: &SingBoxGeneratedConfig, + generated_path: &Path, +) -> ActivityEntry { + ActivityEntry { + id: "singbox-config-generated".to_string(), + at: clock.now(), + level: ActivityLevel::Success, + title: "Конфиг Local sing-box создан".to_string(), + message: format!( + "Сервер: {}, listen: {}:{}, конфиг: {}", + generated.selected_server_tag, + generated.listen, + generated.listen_port, + generated_path.display() + ), + } +} + +fn singbox_adapter_error(error: SingBoxConfigError) -> CommandError { + let code = match error.kind { + SingBoxConfigErrorKind::MissingSelectedServer => "singbox_server_not_selected", + SingBoxConfigErrorKind::MissingSelectedOutbound => "singbox_selected_server_missing", + SingBoxConfigErrorKind::UnsupportedSelectedOutbound => { + "singbox_selected_server_unsupported" + } + SingBoxConfigErrorKind::Serialization => "serialization_error", + SingBoxConfigErrorKind::CheckFailed => "singbox_check_failed", + }; + + CommandError::new(code, error.message) +} + +fn control_singbox_service( + action: SingBoxServiceAction, + config_source: Option<&Path>, +) -> Result { + let Some(detected) = detect_singbox_install() else { + return Err(CommandError::new( + "singbox_not_found", + "Local sing-box не найден на компьютере.", + )); + }; + + let config_target = config_source.map(|_| detected.install_dir.join("config.json")); + let script = service_control_script( + action, + &detected.service_name, + config_source, + config_target.as_deref(), + ); + let output = Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script.as_str(), + ]) + .output() + .map_err(|error| { + CommandError::new( + singbox_service_error_code(action), + format!( + "Не удалось {} службу Local sing-box: {error}", + action.label() + ), + ) + })?; + let result = parse_singbox_service_command_output(&output.stdout).ok_or_else(|| { + CommandError::new( + singbox_service_error_code(action), + singbox_service_script_failed_message(action, output.status.code()), + ) + })?; + + if result.success { + let refreshed = detect_singbox_install(); + let component = singbox_component_from_detection(refreshed.as_ref()); + return Ok(ComponentStatusDto::from(&component)); + } + + if matches!( + result.code.as_str(), + "start_failed" | "stop_failed" | "config_sync_failed" + ) { + run_elevated_singbox_service_command( + action, + &detected.service_name, + config_source, + config_target.as_deref(), + &result, + )?; + let refreshed = detect_singbox_install(); + let component = singbox_component_from_detection(refreshed.as_ref()); + return Ok(ComponentStatusDto::from(&component)); + } + + Err(CommandError::new( + singbox_service_error_code(action), + singbox_service_command_failed_message(action, &result), + )) +} + +fn run_elevated_singbox_service_command( + action: SingBoxServiceAction, + service_name: &str, + config_source: Option<&Path>, + config_target: Option<&Path>, + direct_result: &SingBoxServiceCommandOutput, +) -> Result<(), CommandError> { + let script_path = + write_elevated_singbox_service_script(action, service_name, config_source, config_target)?; + let launch_script = format!( + "$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode", + escape_powershell_single(&script_path.display().to_string()) + ); + let output = Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + launch_script.as_str(), + ]) + .output(); + + let _ = fs::remove_file(&script_path); + + match output { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => Err(CommandError::new( + singbox_service_error_code(action), + elevated_singbox_service_failed_message(action, direct_result, output.status.code()), + )), + Err(error) => Err(CommandError::new( + singbox_service_error_code(action), + format!( + "Не удалось запросить права администратора, чтобы {} службу Local sing-box: {error}", + action.label() + ), + )), + } +} + +fn write_elevated_singbox_service_script( + action: SingBoxServiceAction, + service_name: &str, + config_source: Option<&Path>, + config_target: Option<&Path>, +) -> Result { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + let script_path = env::temp_dir().join(format!("vpn-proxy-singbox-service-{nonce}.ps1")); + let script = elevated_singbox_service_script(action, service_name, config_source, config_target); + + write_powershell_script(&script_path, &script).map_err(|error| { + CommandError::new( + singbox_service_error_code(action), + format!( + "Не удалось подготовить временный скрипт для управления Local sing-box '{}': {error}", + script_path.display() + ), + ) + })?; + + Ok(script_path) +} + +fn elevated_singbox_service_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 = 'SilentlyContinue' +$serviceName = '{escaped_service_name}' +$action = '{action_name}' +$configSource = '{escaped_config_source}' +$configTarget = '{escaped_config_target}' + +if ($action -eq 'start') {{ + if (-not [string]::IsNullOrWhiteSpace($configSource)) {{ + if (-not (Test-Path -LiteralPath $configSource)) {{ exit 5 }} + if (-not [string]::IsNullOrWhiteSpace($configTarget)) {{ + try {{ + Copy-Item -LiteralPath $configSource -Destination $configTarget -Force -ErrorAction Stop + }} catch {{ + exit 6 + }} + }} + }} + + $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue + if ($null -eq $service) {{ exit 2 }} + if ($service.Status -eq 'Running') {{ exit 0 }} + + Start-Service -Name $serviceName -ErrorAction SilentlyContinue + $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue + if ($null -ne $service) {{ + try {{ $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15)) }} catch {{}} + if ($service.Status -eq 'Running') {{ exit 0 }} + }} + + exit 3 +}} + +$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue +if ($null -eq $service) {{ exit 2 }} +if ($service.Status -eq 'Stopped') {{ exit 0 }} + +Stop-Service -Name $serviceName -Force -ErrorAction SilentlyContinue +$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue +if ($null -ne $service) {{ + try {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(15)) }} catch {{}} + if ($service.Status -eq 'Stopped') {{ exit 0 }} +}} + +exit 4 +"# + ) +} + +fn install_singbox_component(storage: &JsonStorage) -> Result { + let generated_config_path = storage.paths().generated_dir.join("sing-box-config.json"); + run_elevated_singbox_package_script( + SingBoxPackageAction::Install, + include_str!("../../scripts/install-singbox.ps1"), + vec![ + "-ConfigSource".to_string(), + generated_config_path.display().to_string(), + ], + &storage.paths().state_dir, + )?; + + let refreshed = detect_singbox_install(); + let Some(detected) = refreshed.as_ref() else { + return Err(CommandError::new( + SingBoxPackageAction::Install.error_code(), + "Установка Local sing-box завершилась, но приложение не найдено после проверки.", + )); + }; + + Ok(ComponentStatusDto::from(&singbox_component_from_detection( + Some(detected), + ))) +} + +fn uninstall_singbox_component() -> Result { + let Some(detected) = detect_singbox_install() else { + let component = singbox_component_from_detection(None); + return Ok(ComponentStatusDto::from(&component)); + }; + + ensure_safe_singbox_install_dir(&detected.install_dir).map_err(|message| { + CommandError::new(SingBoxPackageAction::Uninstall.error_code(), message) + })?; + let artifact_dir = default_config_root().join("state"); + run_elevated_singbox_package_script( + SingBoxPackageAction::Uninstall, + include_str!("../../scripts/install-singbox.ps1"), + vec![ + "-InstallRoot".to_string(), + detected.install_dir.display().to_string(), + "-ServiceName".to_string(), + detected.service_name, + "-Uninstall".to_string(), + ], + &artifact_dir, + )?; + + let refreshed = detect_singbox_install(); + if refreshed.is_some() { + return Err(CommandError::new( + SingBoxPackageAction::Uninstall.error_code(), + "Удаление Local sing-box завершилось, но приложение все еще найдено на компьютере.", + )); + } + + let component = singbox_component_from_detection(None); + Ok(ComponentStatusDto::from(&component)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SingBoxPackageAction { + Install, + Uninstall, +} + +impl SingBoxPackageAction { + fn error_code(self) -> &'static str { + match self { + SingBoxPackageAction::Install => "singbox_install_failed", + SingBoxPackageAction::Uninstall => "singbox_uninstall_failed", + } + } + + fn label(self) -> &'static str { + match self { + SingBoxPackageAction::Install => "установить", + SingBoxPackageAction::Uninstall => "удалить", + } + } + + fn file_label(self) -> &'static str { + match self { + SingBoxPackageAction::Install => "install", + SingBoxPackageAction::Uninstall => "uninstall", + } + } +} + +fn run_elevated_singbox_package_script( + action: SingBoxPackageAction, + installer_body: &str, + installer_args: Vec, + artifact_dir: &Path, +) -> Result<(), CommandError> { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + fs::create_dir_all(artifact_dir).map_err(|error| { + CommandError::new( + action.error_code(), + format!( + "Не удалось создать папку для временных файлов Local sing-box '{}': {error}", + artifact_dir.display() + ), + ) + })?; + + let installer_path = artifact_dir.join(format!( + "vpn-proxy-singbox-{}-{nonce}.ps1", + action.file_label() + )); + let runner_path = artifact_dir.join(format!( + "vpn-proxy-singbox-{}-{nonce}.runner.ps1", + action.file_label() + )); + let result_path = artifact_dir.join(format!( + "vpn-proxy-singbox-{}-{nonce}.log", + action.file_label() + )); + + write_powershell_script(&installer_path, installer_body).map_err(|error| { + CommandError::new( + action.error_code(), + format!( + "Не удалось подготовить установщик Local sing-box '{}': {error}", + installer_path.display() + ), + ) + })?; + write_powershell_script( + &runner_path, + &singbox_installer_runner_script(&installer_path, &result_path, &installer_args), + ) + .map_err(|error| { + CommandError::new( + action.error_code(), + format!( + "Не удалось подготовить runner Local sing-box '{}': {error}", + runner_path.display() + ), + ) + })?; + + let launch_script = format!( + r#" +$ErrorActionPreference = 'Stop' +$resultPath = '{}' +try {{ + $p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}') + if ($null -eq $p) {{ + Set-Content -LiteralPath $resultPath -Value 'Elevated PowerShell не был запущен.' -Encoding UTF8 + exit 1 + }} + exit $p.ExitCode +}} catch {{ + Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8 + exit 1 +}} +"#, + escape_powershell_single(&result_path.display().to_string()), + escape_powershell_single(&runner_path.display().to_string()) + ); + let output = Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + launch_script.as_str(), + ]) + .output(); + + let _ = fs::remove_file(&installer_path); + let _ = fs::remove_file(&runner_path); + + match output { + Ok(output) if output.status.success() => { + let _ = fs::remove_file(&result_path); + Ok(()) + } + Ok(output) => { + let details = package_failure_details(&result_path, &output); + let _ = fs::remove_file(&result_path); + Err(CommandError::new( + action.error_code(), + format!( + "Не удалось {} Local sing-box. Код elevated-команды: {}. {details}", + action.label(), + output.status.code().unwrap_or(-1), + ), + )) + } + Err(error) => Err(CommandError::new( + action.error_code(), + format!( + "Не удалось запросить права администратора, чтобы {} Local sing-box: {error}", + action.label() + ), + )), + } +} + +pub(crate) fn singbox_installer_runner_script( + installer_path: &Path, + result_path: &Path, + installer_args: &[String], +) -> String { + let args = installer_args + .iter() + .map(|arg| format!("'{}'", escape_powershell_single(arg))) + .collect::>() + .join(", "); + + format!( + r#" +$ErrorActionPreference = 'Stop' +$installerPath = '{}' +$resultPath = '{}' +$stdoutPath = "$resultPath.stdout.log" +$stderrPath = "$resultPath.stderr.log" +$installerArgs = @({args}) +try {{ + $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs 2>&1 + $exitCode = $LASTEXITCODE + Set-Content -LiteralPath $stdoutPath -Value ($output | Out-String) -Encoding UTF8 + if ($exitCode -ne 0) {{ + $stdout = if (Test-Path -LiteralPath $stdoutPath) {{ Get-Content -LiteralPath $stdoutPath -Raw }} else {{ '' }} + $stderr = if (Test-Path -LiteralPath $stderrPath) {{ Get-Content -LiteralPath $stderrPath -Raw }} else {{ '' }} + throw "install-singbox.ps1 завершился с кодом $exitCode. stdout: $stdout stderr: $stderr" + }} + Set-Content -LiteralPath $resultPath -Value 'ok' -Encoding UTF8 + exit 0 +}} catch {{ + Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8 + exit 1 +}} finally {{ + Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue +}} +"#, + escape_powershell_single(&installer_path.display().to_string()), + escape_powershell_single(&result_path.display().to_string()) + ) +} + +fn singbox_service_error_code(action: SingBoxServiceAction) -> &'static str { + match action { + SingBoxServiceAction::Start => "singbox_service_start_failed", + SingBoxServiceAction::Stop => "singbox_service_stop_failed", + } +} + +fn singbox_service_script_failed_message( + action: SingBoxServiceAction, + exit_code: Option, +) -> String { + let exit_code = exit_code + .map(|code| format!(" Код выхода PowerShell: {code}.")) + .unwrap_or_default(); + + format!( + "Не удалось {} службу Local sing-box: команда управления службой не вернула корректный результат.{exit_code}", + action.label() + ) +} + +fn singbox_service_command_failed_message( + action: SingBoxServiceAction, + result: &SingBoxServiceCommandOutput, +) -> String { + let service_name = result + .service_name + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("VpnProxySingBox"); + let status = result + .status + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("неизвестен"); + let pid = result + .process_id + .filter(|value| *value > 0) + .map(|value| format!(", PID: {value}")) + .unwrap_or_default(); + + match result.code.as_str() { + "service_not_found" => "Служба Local sing-box не найдена.".to_string(), + "config_source_missing" => { + "Сгенерированный конфиг Local sing-box не найден перед запуском службы.".to_string() + } + "config_sync_failed" => { + "Не удалось обновить config.json службы Local sing-box перед запуском. Попробуй запустить приложение от имени администратора.".to_string() + } + "start_failed" => format!( + "Не удалось запустить службу {service_name}. Текущий статус: {status}{pid}. Попробуй запустить приложение от имени администратора." + ), + "stop_failed" => format!( + "Не удалось остановить службу {service_name}. Текущий статус: {status}{pid}. Запусти приложение от имени администратора или останови службу вручную в services.msc." + ), + _ => format!( + "Не удалось {} службу {service_name}. Текущий статус: {status}{pid}.", + action.label() + ), + } +} + +fn elevated_singbox_service_failed_message( + action: SingBoxServiceAction, + direct_result: &SingBoxServiceCommandOutput, + exit_code: Option, +) -> String { + let exit_code = exit_code + .map(|code| format!(" Код выхода elevated PowerShell: {code}.")) + .unwrap_or_default(); + format!( + "{} Попытка с правами администратора тоже не сработала.{exit_code}", + singbox_service_command_failed_message(action, direct_result) + ) +} + fn components_or_defaults(storage: &JsonStorage) -> Result, CommandError> { let components = storage.read_components().map_err(storage_error)?; Ok(resolve_component_statuses( components, detect_proxyfier_install(), + detect_singbox_install(), )) } pub fn resolve_component_statuses( stored_components: Vec, detected_proxyfier: Option, + detected_singbox: Option, ) -> Vec { let mut components = default_components(); @@ -701,6 +1909,12 @@ pub fn resolve_component_statuses( proxyfier_component_from_detection(detected_proxyfier.as_ref()), ); } + if detected_singbox.is_some() { + upsert_component( + &mut components, + singbox_component_from_detection(detected_singbox.as_ref()), + ); + } components } @@ -2241,3 +3455,46 @@ impl From<&ActivityEntry> for ActivityEntryDto { } } } + +impl From<&LocalSingBoxConfig> for LocalSingBoxConfigDto { + fn from(config: &LocalSingBoxConfig) -> Self { + Self { + subscription_display_url: config.subscription_display_url(), + has_subscription: config + .subscription_url + .as_deref() + .is_some_and(|value| !value.trim().is_empty()), + selected_server_tag: config.selected_server_tag.clone(), + listen_host: config.listen_host.clone(), + listen_port: config.listen_port, + service_name: config.service_name.clone(), + install_root: config.install_root.clone(), + updated_at: config.updated_at.clone(), + } + } +} + +impl From<&SubscriptionCache> for SubscriptionCacheDto { + fn from(cache: &SubscriptionCache) -> Self { + Self { + servers: cache + .servers + .iter() + .map(SubscriptionServerDto::from) + .collect(), + user_info: cache.user_info.clone(), + fetched_at: cache.fetched_at.clone(), + } + } +} + +impl From<&SubscriptionServer> for SubscriptionServerDto { + fn from(server: &SubscriptionServer) -> Self { + Self { + tag: server.tag.clone(), + server_type: server.server_type.clone(), + server: server.server.clone(), + server_port: server.server_port, + } + } +} diff --git a/apps/windows-client/src-tauri/src/component_detection.rs b/apps/windows-client/src-tauri/src/component_detection.rs index cad06b4..878dbe5 100644 --- a/apps/windows-client/src-tauri/src/component_detection.rs +++ b/apps/windows-client/src-tauri/src/component_detection.rs @@ -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, } +#[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 { + detect_singbox_install_with_host(&SystemProxyfierDetectionHost) +} + +pub fn detect_singbox_install_with_host( + host: &impl ProxyfierDetectionHost, +) -> Option { + 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 { + 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, 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 { + 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", diff --git a/apps/windows-client/src-tauri/src/main.rs b/apps/windows-client/src-tauri/src/main.rs index 292c677..b7a834a 100644 --- a/apps/windows-client/src-tauri/src/main.rs +++ b/apps/windows-client/src-tauri/src/main.rs @@ -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"); diff --git a/apps/windows-client/src-tauri/src/models.rs b/apps/windows-client/src-tauri/src/models.rs index 368a67c..2ff7211 100644 --- a/apps/windows-client/src-tauri/src/models.rs +++ b/apps/windows-client/src-tauri/src/models.rs @@ -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, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalSingBoxConfig { + #[serde(default)] + pub subscription_url: Option, + #[serde(default)] + pub selected_server_tag: Option, + #[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, +} + +impl LocalSingBoxConfig { + pub fn subscription_display_url(&self) -> Option { + 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, + #[serde(default)] + pub user_info: serde_json::Map, + 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::(); + if trimmed.chars().count() <= 18 { + "***".to_string() + } else { + format!("{visible}...") + } + } + } +} diff --git a/apps/windows-client/src-tauri/src/singbox_service.rs b/apps/windows-client/src-tauri/src/singbox_service.rs new file mode 100644 index 0000000..d5a42c8 --- /dev/null +++ b/apps/windows-client/src-tauri/src/singbox_service.rs @@ -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, +} + +#[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, + 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, + pub status: Option, + pub process_id: Option, +} + +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 { + 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('\'', "''") +} diff --git a/apps/windows-client/src-tauri/src/storage.rs b/apps/windows-client/src-tauri/src/storage.rs index 9b088ae..d5b902c 100644 --- a/apps/windows-client/src-tauri/src/storage.rs +++ b/apps/windows-client/src-tauri/src/storage.rs @@ -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 { + 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> { + 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> { 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(&self, path: &Path) -> io::Result> + 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 { diff --git a/apps/windows-client/src-tauri/src/subscription.rs b/apps/windows-client/src-tauri/src/subscription.rs new file mode 100644 index 0000000..98fa70a --- /dev/null +++ b/apps/windows-client/src-tauri/src/subscription.rs @@ -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) -> 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, +} + +pub fn parse_subscription_body(body: &str) -> Result { + let config = match serde_json::from_str::(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 { + 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::() { + result.insert(key.to_string(), Value::Number(parsed.into())); + } + } + + result +} + +pub fn fetch_subscription(url: &str) -> Result { + 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 { + let decoded = maybe_decode_base64(body); + let links = decoded + .lines() + .map(str::trim) + .filter(|line| line.starts_with("vless://")) + .collect::>(); + + 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::, _>>()?; + + Ok(json!({ "outbounds": outbounds })) +} + +fn parse_vless_url(raw_url: &str) -> Result { + 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, SubscriptionError> { + let servers = config + .get("outbounds") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(server_from_outbound) + .collect::>(); + + if servers.is_empty() { + return Err(SubscriptionError::new( + "No supported proxy outbounds found in subscription", + )); + } + + Ok(servers) +} + +fn server_from_outbound(outbound: &Value) -> Option { + 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::(); + 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 { + 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}") +} diff --git a/apps/windows-client/src-tauri/tests/command_tests.rs b/apps/windows-client/src-tauri/tests/command_tests.rs index d73c12e..60ae4b8 100644 --- a/apps/windows-client/src-tauri/tests/command_tests.rs +++ b/apps/windows-client/src-tauri/tests/command_tests.rs @@ -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() diff --git a/apps/windows-client/src-tauri/tests/component_detection_tests.rs b/apps/windows-client/src-tauri/tests/component_detection_tests.rs index 7459fde..f4c6abb 100644 --- a/apps/windows-client/src-tauri/tests/component_detection_tests.rs +++ b/apps/windows-client/src-tauri/tests/component_detection_tests.rs @@ -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, diff --git a/apps/windows-client/src-tauri/tests/singbox_adapter_tests.rs b/apps/windows-client/src-tauri/tests/singbox_adapter_tests.rs index 874acca..9020d62 100644 --- a/apps/windows-client/src-tauri/tests/singbox_adapter_tests.rs +++ b/apps/windows-client/src-tauri/tests/singbox_adapter_tests.rs @@ -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, diff --git a/apps/windows-client/src-tauri/tests/singbox_command_tests.rs b/apps/windows-client/src-tauri/tests/singbox_command_tests.rs new file mode 100644 index 0000000..6154274 --- /dev/null +++ b/apps/windows-client/src-tauri/tests/singbox_command_tests.rs @@ -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 { + 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 { + 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); +} diff --git a/apps/windows-client/src-tauri/tests/singbox_service_tests.rs b/apps/windows-client/src-tauri/tests/singbox_service_tests.rs new file mode 100644 index 0000000..d5ac1e6 --- /dev/null +++ b/apps/windows-client/src-tauri/tests/singbox_service_tests.rs @@ -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(), + } +} diff --git a/apps/windows-client/src-tauri/tests/storage_tests.rs b/apps/windows-client/src-tauri/tests/storage_tests.rs index c7ef420..3fe369d 100644 --- a/apps/windows-client/src-tauri/tests/storage_tests.rs +++ b/apps/windows-client/src-tauri/tests/storage_tests.rs @@ -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(), diff --git a/apps/windows-client/src-tauri/tests/subscription_tests.rs b/apps/windows-client/src-tauri/tests/subscription_tests.rs new file mode 100644 index 0000000..3ee69b5 --- /dev/null +++ b/apps/windows-client/src-tauri/tests/subscription_tests.rs @@ -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 + ) +} diff --git a/apps/windows-client/src/api/tauriCommands.ts b/apps/windows-client/src/api/tauriCommands.ts index 7a106e5..ff4a036 100644 --- a/apps/windows-client/src/api/tauriCommands.ts +++ b/apps/windows-client/src/api/tauriCommands.ts @@ -2,8 +2,11 @@ import { invoke } from '@tauri-apps/api/core'; import type { ActivityEntry, ComponentStatus, + LocalSingBoxConfig, Profile, ProfileInput, + SubscriptionCache, + SubscriptionServer, Target, TargetInput, } from '../domain/types'; @@ -47,6 +50,47 @@ export interface ProxiFyreSetupStatus { items: ProxiFyreSetupItem[]; } +export type SingBoxSetupItem = ProxiFyreSetupItem; + +export interface SingBoxSetupStatus { + ready: boolean; + missingCount: number; + items: SingBoxSetupItem[]; +} + +export interface LocalSingBoxStatusResponse { + config: LocalSingBoxConfig; + cache?: SubscriptionCache; + component: ComponentStatus; + generatedConfigPath: string; + lanListenHost?: string; +} + +export interface PingServerResponse { + tag: string; + server: string; + serverPort: number; + ok: boolean; + latency?: number; + error?: string; +} + +export interface GenerateSingBoxConfigResponse { + success: boolean; + message: string; + adapterId: string; + generatedConfigPath: string; + selectedServerTag: string; + listenHost: string; + listenPort: number; + check?: { + checked: boolean; + success: boolean; + message: string; + }; + activity: ActivityEntry; +} + export interface HelperApplyResult { success: boolean; changed: boolean; @@ -98,6 +142,52 @@ export function getProxiFyreSetupStatus(): Promise { return invoke('get_proxifyre_setup_status'); } +export function getSingBoxStatus(): Promise { + return invoke('get_singbox_status'); +} + +export function getSingBoxSetupStatus(): Promise { + return invoke('get_singbox_setup_status'); +} + +export function saveSingBoxSubscription(subscriptionUrl: string): Promise { + return invoke('save_singbox_subscription', { + input: { subscriptionUrl }, + }); +} + +export function fetchSingBoxSubscription(): Promise { + return invoke('fetch_singbox_subscription'); +} + +export function forgetSingBoxSubscription(): Promise { + return invoke('forget_singbox_subscription'); +} + +export function selectSingBoxServer(server: SubscriptionServer): Promise { + return invoke('select_singbox_server', { + input: { + tag: server.tag, + server: server.server, + serverPort: server.serverPort, + }, + }); +} + +export function pingSingBoxServer(tag: string): Promise { + return invoke('ping_singbox_server', { + input: { tag }, + }); +} + +export function pingAllSingBoxServers(): Promise { + return invoke('ping_all_singbox_servers'); +} + +export function generateSingBoxConfig(): Promise { + return invoke('generate_singbox_config'); +} + export function applyProfiles(): Promise { return invoke('apply_profiles'); } @@ -121,3 +211,19 @@ export function installProxiFyre(): Promise { export function uninstallProxiFyre(): Promise { return invoke('uninstall_proxifyre'); } + +export function startSingBoxService(): Promise { + return invoke('start_singbox_service'); +} + +export function stopSingBoxService(): Promise { + return invoke('stop_singbox_service'); +} + +export function installSingBox(): Promise { + return invoke('install_singbox'); +} + +export function uninstallSingBox(): Promise { + return invoke('uninstall_singbox'); +} diff --git a/apps/windows-client/src/app/App.tsx b/apps/windows-client/src/app/App.tsx index 2f67f63..88d1d0e 100644 --- a/apps/windows-client/src/app/App.tsx +++ b/apps/windows-client/src/app/App.tsx @@ -1,25 +1,42 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { open } from '@tauri-apps/plugin-dialog'; -import { Cpu, FileCode2, FolderOpen, MoreHorizontal } from 'lucide-react'; +import { Cpu, FileCode2, FolderOpen, Gauge, Info, Link2, MoreHorizontal, Trash2, Wand2 } from 'lucide-react'; import { applyProfiles, + fetchSingBoxSubscription, + forgetSingBoxSubscription, + generateSingBoxConfig, getComponents, getProxiFyreSetupStatus, getSavedState, + getSingBoxSetupStatus, + getSingBoxStatus, installProxiFyre, + installSingBox, openConfigLocation, + pingAllSingBoxServers, saveProfile, + saveSingBoxSubscription, saveTarget, + selectSingBoxServer, startProxiFyreService, + startSingBoxService, stopProxiFyreService, + stopSingBoxService, uninstallProxiFyre, + uninstallSingBox, type ApplyProfilesResponse, + type LocalSingBoxStatusResponse, + type PingServerResponse, type ProxiFyreSetupStatus, + type SingBoxSetupStatus, } from '../api/tauriCommands'; -import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, Target } from '../domain/types'; +import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types'; type DraftItemType = Extract; type ProxiFyreAction = 'start' | 'stop' | 'install' | 'uninstall'; +type SingBoxAction = 'start' | 'stop' | 'install' | 'uninstall' | 'fetch' | 'forget' | 'generate' | 'ping'; +type RouteMode = 'external' | 'local-singbox'; type ServiceVisualState = 'active' | 'settling' | null; interface DraftItem { @@ -41,6 +58,7 @@ interface LogEntry extends Notice { const MAIN_TARGET_ID = 'main-proxy'; const MAIN_PROFILE_ID = 'main-profile'; +const LOCAL_SINGBOX_TARGET_ID = 'local-singbox'; const LOG_VISIBLE_MS = 6500; const fallbackComponents: ComponentStatus[] = [ @@ -53,20 +71,37 @@ const fallbackComponents: ComponentStatus[] = [ problems: ['ProxiFyre не найден'], actions: [], }, + { + id: 'singbox', + name: 'Локальный sing-box', + state: 'missing', + installed: false, + running: false, + problems: [], + actions: [], + }, ]; export function App() { const [proxyInput, setProxyInput] = useState(''); + const [routeMode, setRouteMode] = useState('external'); const [profileId, setProfileId] = useState(MAIN_PROFILE_ID); const [targetId, setTargetId] = useState(MAIN_TARGET_ID); const [items, setItems] = useState([]); + const [hasUnappliedChanges, setHasUnappliedChanges] = useState(false); const [loadedProfiles, setLoadedProfiles] = useState([]); const [isProcessInputOpen, setIsProcessInputOpen] = useState(false); const [processInput, setProcessInput] = useState(''); const [pickerAction, setPickerAction] = useState<'exe' | 'folder' | null>(null); const [components, setComponents] = useState(fallbackComponents); const [setupStatus, setSetupStatus] = useState(null); + const [singBoxStatus, setSingBoxStatus] = useState(null); + const [singBoxSetupStatus, setSingBoxSetupStatus] = useState(null); + const [subscriptionInput, setSubscriptionInput] = useState(''); + const [serverPings, setServerPings] = useState>({}); const [isSetupOpen, setIsSetupOpen] = useState(false); + const [isSingBoxSetupOpen, setIsSingBoxSetupOpen] = useState(false); + const [isSingBoxInfoOpen, setIsSingBoxInfoOpen] = useState(false); const [generatedConfigPath, setGeneratedConfigPath] = useState(''); const [logEntries, setLogEntries] = useState([]); const [activeLogId, setActiveLogId] = useState(null); @@ -76,7 +111,9 @@ export function App() { const [isApplying, setIsApplying] = useState(false); const [isOpeningConfig, setIsOpeningConfig] = useState(false); const [serviceAction, setServiceAction] = useState(null); + const [singBoxAction, setSingBoxAction] = useState(null); const [isServiceMenuOpen, setIsServiceMenuOpen] = useState(false); + const [isSingBoxMenuOpen, setIsSingBoxMenuOpen] = useState(false); const [serviceVisualState, setServiceVisualState] = useState(null); const serviceVisualTimerRef = useRef(null); @@ -84,6 +121,10 @@ export function App() { () => components.find((component) => component.id === 'proxyfier'), [components], ); + const singbox = useMemo( + () => singBoxStatus?.component ?? components.find((component) => component.id === 'singbox'), + [components, singBoxStatus], + ); const activeLog = useMemo( () => logEntries.find((entry) => entry.id === activeLogId) ?? null, [activeLogId, logEntries], @@ -91,6 +132,9 @@ export function App() { const finderStateClass = isDetectingComponents ? 'checking' : proxyfier?.installed ? 'found' : 'missing'; const finderVisualClass = serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : ''; + const isSingBoxInstalled = Boolean(singbox?.installed); + const singBoxStateClass = isDetectingComponents ? 'checking' : singbox?.installed ? 'found' : 'missing'; + const singBoxVisualClass = singBoxAction ? 'working' : ''; useEffect(() => { void refresh(); @@ -135,16 +179,20 @@ export function App() { async function refreshComponents() { setIsDetectingComponents(true); try { - const [detectedComponents, detectedSetupStatus] = await Promise.all([ + const [detectedComponents, detectedSetupStatus, detectedSingBoxStatus, detectedSingBoxSetupStatus] = await Promise.all([ getComponents(), getProxiFyreSetupStatus(), + getSingBoxStatus(), + getSingBoxSetupStatus(), ]); setComponents(detectedComponents); setSetupStatus(detectedSetupStatus); + setSingBoxStatus(detectedSingBoxStatus); + setSingBoxSetupStatus(detectedSingBoxSetupStatus); } catch (error) { showNotice({ kind: 'error', - title: 'ProxiFyre не проверен', + title: 'Компоненты не проверены', text: errorMessage(error), }); } finally { @@ -157,14 +205,21 @@ export function App() { const mainProfile = profiles.find((profile) => profile.id === MAIN_PROFILE_ID); const activeProfile = mainProfile ?? activeProfiles[0]; const activeTarget = targetForUi(targets, activeProfile); + const externalTarget = targetForExternalProxy(targets); const editableProfiles = mainProfile ? [mainProfile] : activeProfiles; - if (activeTarget) setProxyInput(formatProxy(activeTarget)); + if (externalTarget) setProxyInput(formatProxy(externalTarget)); setItems(itemsForProfiles(editableProfiles)); setLoadedProfiles(profiles); setProfileId(mainProfile?.id ?? MAIN_PROFILE_ID); - setTargetId(activeTarget?.id ?? activeProfile?.targetId ?? MAIN_TARGET_ID); + setTargetId(externalTarget?.id ?? MAIN_TARGET_ID); + setRouteMode( + activeTarget?.id === LOCAL_SINGBOX_TARGET_ID || activeProfile?.targetId === LOCAL_SINGBOX_TARGET_ID + ? 'local-singbox' + : 'external', + ); setGeneratedConfigPath(generatedPath); + setHasUnappliedChanges(false); } function addItem(type: DraftItemType, rawValue: string) { @@ -195,6 +250,7 @@ export function App() { value, }, ]); + setHasUnappliedChanges(true); return true; } @@ -207,6 +263,19 @@ export function App() { function removeItem(id: string) { setItems((current) => current.filter((item) => item.id !== id)); + setHasUnappliedChanges(true); + } + + function changeRouteMode(nextMode: RouteMode) { + setRouteMode((current) => { + if (current !== nextMode) setHasUnappliedChanges(true); + return nextMode; + }); + } + + function changeProxyInput(nextValue: string) { + setProxyInput(nextValue); + setHasUnappliedChanges(true); } async function pickAndAddItem(type: Extract) { @@ -228,10 +297,16 @@ export function App() { } async function updateConfig() { - let parsedProxy: ParsedProxy; + let parsedProxy: ParsedProxy | null = null; try { - parsedProxy = parseProxy(proxyInput); if (!items.length) throw new Error('Добавь хотя бы один процесс, EXE-файл или папку.'); + if (routeMode === 'external') { + parsedProxy = parseProxy(proxyInput); + } else if (!isSingBoxInstalled) { + throw new Error('Сначала установи Local sing-box.'); + } else if (!singBoxStatus?.config.selectedServerTag) { + throw new Error('Выбери сервер Local sing-box.'); + } } catch (error) { showNotice({ kind: 'error', @@ -243,19 +318,28 @@ export function App() { setIsApplying(true); try { - await saveTarget({ - id: targetId, - name: 'Основной прокси', - kind: 'external', - protocol: parsedProxy.protocol, - host: parsedProxy.host, - port: parsedProxy.port, - }); + let singBoxGeneratedPath = ''; + if (routeMode === 'external') { + if (!parsedProxy) throw new Error('Прокси не разобран.'); + await saveTarget({ + id: targetId, + name: 'Основной прокси', + kind: 'external', + protocol: parsedProxy.protocol, + host: parsedProxy.host, + port: parsedProxy.port, + }); + } else { + const singBoxResult = await generateSingBoxConfig(); + singBoxGeneratedPath = singBoxResult.generatedConfigPath; + await ensureSingBoxRunningForApply(); + } + await saveProfile({ id: profileId, name: 'Приложения через прокси', enabled: true, - targetId, + targetId: routeMode === 'local-singbox' ? LOCAL_SINGBOX_TARGET_ID : targetId, protocols: ['TCP', 'UDP'], items: items.map(profileItemInput), }); @@ -266,16 +350,21 @@ export function App() { ); const result = await applyProfiles(); - const [saved, detectedComponents, detectedSetupStatus] = await Promise.all([ + const [saved, detectedComponents, detectedSetupStatus, detectedSingBoxStatus, detectedSingBoxSetupStatus] = await Promise.all([ getSavedState(), getComponents(), getProxiFyreSetupStatus(), + getSingBoxStatus(), + getSingBoxSetupStatus(), ]); applySavedState(saved.profiles, saved.targets, result.generatedConfigPath); setComponents(detectedComponents); setSetupStatus(detectedSetupStatus); - showNotice(noticeFromApply(result)); + setSingBoxStatus(detectedSingBoxStatus); + setSingBoxSetupStatus(detectedSingBoxSetupStatus); + setHasUnappliedChanges(false); + showNotice(routeMode === 'local-singbox' ? noticeFromLocalApply(result, singBoxGeneratedPath) : noticeFromApply(result)); } catch (error) { showNotice({ kind: 'error', @@ -287,6 +376,30 @@ export function App() { } } + async function ensureSingBoxRunningForApply() { + if (routeMode !== 'local-singbox' || !singbox?.installed) return; + + setIsSingBoxMenuOpen(false); + try { + await nextFrame(); + if (singbox.running) { + setSingBoxAction('stop'); + const stopped = await stopSingBoxService(); + setComponents((current) => upsertComponent(current, stopped)); + } + + setSingBoxAction('start'); + const component = await startSingBoxService(); + setComponents((current) => upsertComponent(current, component)); + const status = await refreshSingBoxState(); + if (!status.component.running) { + throw new Error('Local sing-box установлен, но служба не запустилась.'); + } + } finally { + setSingBoxAction(null); + } + } + async function openConfig() { setIsOpeningConfig(true); try { @@ -395,6 +508,216 @@ export function App() { } } + async function refreshSingBoxState() { + const [detectedSingBoxStatus, detectedSingBoxSetupStatus, detectedComponents] = await Promise.all([ + getSingBoxStatus(), + getSingBoxSetupStatus(), + getComponents(), + ]); + setSingBoxStatus(detectedSingBoxStatus); + setSingBoxSetupStatus(detectedSingBoxSetupStatus); + setComponents(detectedComponents); + return detectedSingBoxStatus; + } + + async function setSingBoxServiceRunning(shouldRun: boolean) { + const action: SingBoxAction = shouldRun ? 'start' : 'stop'; + setSingBoxAction(action); + setIsSingBoxMenuOpen(false); + try { + await nextFrame(); + const component = shouldRun ? await startSingBoxService() : await stopSingBoxService(); + setComponents((current) => upsertComponent(current, component)); + await refreshSingBoxState(); + showNotice({ + kind: 'success', + title: shouldRun ? 'sing-box запущен' : 'sing-box остановлен', + text: componentDetails(component, false), + }); + } catch (error) { + showNotice({ + kind: 'error', + title: shouldRun ? 'sing-box не запущен' : 'sing-box не остановлен', + text: errorMessage(error), + }); + } finally { + setSingBoxAction(null); + } + } + + async function installSingBoxPackage() { + setSingBoxAction('install'); + setIsSingBoxMenuOpen(false); + try { + await nextFrame(); + const component = await installSingBox(); + setComponents((current) => upsertComponent(current, component)); + await refreshSingBoxState(); + showNotice({ + kind: 'success', + title: 'Local sing-box установлен', + text: componentDetails(component, false), + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Local sing-box не установлен', + text: errorMessage(error), + }); + } finally { + setSingBoxAction(null); + } + } + + async function uninstallSingBoxPackage() { + const confirmed = window.confirm( + 'Удалить Local sing-box с компьютера? Будет удалена служба и папка установки sing-box.', + ); + if (!confirmed) return; + + setSingBoxAction('uninstall'); + setIsSingBoxMenuOpen(false); + try { + await nextFrame(); + const component = await uninstallSingBox(); + setComponents((current) => upsertComponent(current, component)); + await refreshSingBoxState(); + showNotice({ + kind: 'success', + title: 'Local sing-box удален', + text: 'Служба и папка установки Local sing-box удалены.', + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Local sing-box не удален', + text: errorMessage(error), + }); + } finally { + setSingBoxAction(null); + } + } + + async function syncSingBoxSubscription() { + const subscriptionUrl = subscriptionInput.trim(); + if (!subscriptionUrl && !singBoxStatus?.config.hasSubscription) { + showNotice({ + kind: 'error', + title: 'Ссылка не указана', + text: 'Вставь ссылку подписки Local sing-box.', + }); + return; + } + + setSingBoxAction('fetch'); + try { + if (subscriptionUrl) { + await saveSingBoxSubscription(subscriptionUrl); + } + const status = await fetchSingBoxSubscription(); + setSingBoxStatus(status); + setComponents((current) => upsertComponent(current, status.component)); + setSubscriptionInput(''); + setServerPings({}); + setHasUnappliedChanges(true); + showNotice({ + kind: 'success', + title: 'Подписка обновлена', + text: `Серверов: ${status.cache?.servers.length ?? 0}`, + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Подписка не обновлена', + text: errorMessage(error), + }); + } finally { + setSingBoxAction(null); + } + } + + async function forgetSingBoxSubscriptionData() { + setSingBoxAction('forget'); + setIsSingBoxMenuOpen(false); + try { + const status = await forgetSingBoxSubscription(); + setSingBoxStatus(status); + setComponents((current) => upsertComponent(current, status.component)); + setServerPings({}); + setHasUnappliedChanges(true); + showNotice({ + kind: 'info', + title: 'Подписка очищена', + text: 'Ссылка, cache и выбранный сервер Local sing-box удалены.', + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Подписка не очищена', + text: errorMessage(error), + }); + } finally { + setSingBoxAction(null); + } + } + + async function chooseSingBoxServer(server: SubscriptionServer) { + try { + const status = await selectSingBoxServer(server); + setSingBoxStatus(status); + setComponents((current) => upsertComponent(current, status.component)); + setHasUnappliedChanges(true); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Сервер не выбран', + text: errorMessage(error), + }); + } + } + + async function pingSingBoxServers() { + setSingBoxAction('ping'); + try { + const results = await pingAllSingBoxServers(); + setServerPings(Object.fromEntries(results.map((result) => [result.tag, result]))); + showNotice({ + kind: 'info', + title: 'Ping завершен', + text: pingSummary(results), + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Ping не выполнен', + text: errorMessage(error), + }); + } finally { + setSingBoxAction(null); + } + } + + async function generateSingBoxNow() { + setSingBoxAction('generate'); + try { + const result = await generateSingBoxConfig(); + await refreshSingBoxState(); + showNotice({ + kind: 'success', + title: 'Конфиг sing-box создан', + text: result.generatedConfigPath, + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'Конфиг sing-box не создан', + text: errorMessage(error), + }); + } finally { + setSingBoxAction(null); + } + } + function startServiceVisual() { if (serviceVisualTimerRef.current !== null) { window.clearTimeout(serviceVisualTimerRef.current); @@ -541,15 +864,245 @@ export function App() { ) : null} - +
+
+ + +
+ + {routeMode === 'external' ? ( + + ) : null} +
+ + {routeMode === 'local-singbox' ? ( +
+
+ ) : null}
@@ -653,9 +1206,24 @@ export function App() {
+ {hasUnappliedChanges ? ( +
+ Изменения еще не применены в ProxiFyre + {applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))} +
+ ) : null} +