271 lines
9.3 KiB
PowerShell
271 lines
9.3 KiB
PowerShell
param(
|
||
[string]$InstallRoot = "C:\Program Files\VpnProxy\sing-box",
|
||
[string]$ServiceName = "VpnProxySingBox",
|
||
[string]$ConfigSource = "C:\ProgramData\VpnProxy\generated\sing-box-config.json",
|
||
[switch]$PlanOnly,
|
||
[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,
|
||
[string]$Action,
|
||
[bool]$Changed,
|
||
[string]$Message,
|
||
[hashtable]$Details = @{}
|
||
)
|
||
|
||
[ordered]@{
|
||
success = $Success
|
||
action = $Action
|
||
changed = $Changed
|
||
message = $Message
|
||
details = $Details
|
||
} | ConvertTo-Json -Depth 8
|
||
}
|
||
|
||
function Test-IsAdministrator {
|
||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||
$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) {
|
||
$backup = "$Path.bak"
|
||
Copy-Item -LiteralPath $Path -Destination $backup -Force
|
||
return $backup
|
||
}
|
||
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 = @"
|
||
<service>
|
||
<id>$Name</id>
|
||
<name>VPN Proxy Local sing-box</name>
|
||
<description>Local sing-box runtime managed by VPN Proxy Windows client.</description>
|
||
<executable>%BASE%\sing-box.exe</executable>
|
||
<arguments>run -c "%BASE%\config.json"</arguments>
|
||
<logpath>%BASE%\logs</logpath>
|
||
<log mode="roll-by-size">
|
||
<sizeThreshold>10485760</sizeThreshold>
|
||
<keepFiles>4</keepFiles>
|
||
</log>
|
||
<onfailure action="restart" delay="5 sec"/>
|
||
</service>
|
||
"@
|
||
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 = $installRootFull
|
||
serviceName = $ServiceName
|
||
configSource = $ConfigSource
|
||
singboxReleaseApi = $SingBoxReleaseApi
|
||
winswReleaseApi = $WinSwReleaseApi
|
||
planOnly = [bool]$PlanOnly
|
||
uninstall = [bool]$Uninstall
|
||
}
|
||
|
||
if ($PlanOnly) {
|
||
$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
|
||
}
|
||
|
||
if (-not (Test-IsAdministrator)) {
|
||
New-Result -Success $false -Action "install-singbox" -Changed $false -Message "Administrator rights are required." -Details $details
|
||
exit 1
|
||
}
|
||
|
||
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
|
||
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
|
||
|
||
$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
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|