418 lines
15 KiB
PowerShell
418 lines
15 KiB
PowerShell
# ==========================================
|
||
# 📦 SING-BOX NATIVE INSTALLER
|
||
# ==========================================
|
||
|
||
param(
|
||
[switch]$Force,
|
||
[switch]$Debug,
|
||
[string]$SubscriptionUrl = ""
|
||
)
|
||
|
||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||
. "$ScriptDir\lib\Common.ps1"
|
||
. "$ScriptDir\lib\Net.ps1"
|
||
. "$ScriptDir\lib\System.ps1"
|
||
|
||
# --- CONFIG ---
|
||
$SingboxVersion = "1.11.4"
|
||
$InstallDir = "C:\Tools\sing-box"
|
||
$LocalProxyPort = 1080
|
||
$SingboxUrl = "https://github.com/SagerNet/sing-box/releases/download/v$SingboxVersion/sing-box-$SingboxVersion-windows-amd64.zip"
|
||
$TaskName = "SingBoxProxy"
|
||
|
||
Ensure-Admin
|
||
|
||
# --- LOGIC ---
|
||
|
||
function Select-Server {
|
||
param($Config)
|
||
|
||
$outbounds = $Config.outbounds
|
||
$servers = @()
|
||
|
||
foreach ($outbound in $outbounds) {
|
||
if ($outbound.type -in @("vless", "vmess", "trojan", "shadowsocks", "hysteria2")) {
|
||
$servers += @{
|
||
tag = $outbound.tag
|
||
type = $outbound.type
|
||
server = $outbound.server
|
||
server_port = $outbound.server_port
|
||
outbound = $outbound
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($servers.Count -eq 0) {
|
||
Write-Error "Серверы не найдены в подписке!"
|
||
return $null
|
||
}
|
||
|
||
$options = [Ordered]@{}
|
||
for ($i = 0; $i -lt $servers.Count; $i++) {
|
||
$s = $servers[$i]
|
||
$options["$($i+1)"] = "$($s.tag) ($($s.server):$($s.server_port))"
|
||
}
|
||
|
||
$choice = Show-Menu -Title "🌐 Доступные серверы" -Options $options -Prompt "👉 Выберите сервер (номер)"
|
||
$index = [int]$choice - 1
|
||
|
||
if ($index -lt 0 -or $index -ge $servers.Count) {
|
||
Write-Error "Неверный выбор!"
|
||
return $null
|
||
}
|
||
|
||
return $servers[$index]
|
||
}
|
||
|
||
function New-SingboxConfig {
|
||
param($Outbound, $Port)
|
||
|
||
return @{
|
||
log = @{ level = "info"; timestamp = $true; output = "$InstallDir\singbox.log" }
|
||
dns = @{ independent_cache = $true }
|
||
inbounds = @(
|
||
@{
|
||
type = "socks"
|
||
tag = "socks-in"
|
||
listen = "0.0.0.0"
|
||
listen_port = $Port
|
||
}
|
||
)
|
||
outbounds = @(
|
||
$Outbound,
|
||
@{ type = "direct"; tag = "direct" }
|
||
)
|
||
route = @{
|
||
final = $Outbound.tag
|
||
auto_detect_interface = $true
|
||
}
|
||
}
|
||
}
|
||
|
||
function Parse-VlessUrl {
|
||
param([string]$Url)
|
||
|
||
if (-not $Url.StartsWith("vless://")) { throw "URL должен начинаться с vless://" }
|
||
|
||
# Remove scheme
|
||
$raw = $Url.Substring(8)
|
||
|
||
# Split fragment
|
||
$tag = "reality"
|
||
if ($raw -match "#(.*)$") {
|
||
$tag = [System.Web.HttpUtility]::UrlDecode($matches[1])
|
||
$raw = $raw -replace "#.*$", ""
|
||
}
|
||
|
||
# Split query
|
||
$queryStr = ""
|
||
if ($raw -match "\?(.*)$") {
|
||
$queryStr = $matches[1]
|
||
$raw = $raw -replace "\?.*$", ""
|
||
}
|
||
|
||
# Parse UUID@HOST:PORT
|
||
if ($raw -notmatch "([^@]+)@([^:]+):(\d+)") { throw "Неверный формат vless (ожидается uuid@host:port)" }
|
||
$uuid = $matches[1][0]
|
||
$serverHost = $matches[2][0]
|
||
$port = [int]$matches[3][0] # Fix for regex object access in PS
|
||
|
||
if (-not $uuid) {
|
||
# Fallback if regex returns match info differently in different PS versions
|
||
$uuid = $matches[1]
|
||
$serverHost = $matches[2]
|
||
$port = [int]$matches[3]
|
||
}
|
||
|
||
|
||
# Parse Query
|
||
$params = @{}
|
||
if ($queryStr) {
|
||
$parts = $queryStr -split "&"
|
||
foreach ($p in $parts) {
|
||
$kv = $p -split "="
|
||
if ($kv.Count -eq 2) {
|
||
$params[[System.Web.HttpUtility]::UrlDecode($kv[0])] = [System.Web.HttpUtility]::UrlDecode($kv[1])
|
||
}
|
||
}
|
||
}
|
||
|
||
# Extract
|
||
$pbk = if ($params["pbk"]) { $params["pbk"] } else { throw "Отсутствует параметр pbk (Public Key)" }
|
||
$sid = if ($params["sid"]) { $params["sid"] } else { throw "Отсутствует параметр sid (Short ID)" }
|
||
$sni = if ($params["sni"]) { $params["sni"] } else { $serverHost }
|
||
$fp = if ($params["fp"]) { $params["fp"] } else { "chrome" }
|
||
$flow = if ($params["flow"]) { $params["flow"] } else { "" }
|
||
|
||
return @{
|
||
uuid = $uuid
|
||
server = $serverHost
|
||
server_port = $port
|
||
tag = $tag
|
||
public_key = $pbk
|
||
short_id = $sid
|
||
server_name = $sni
|
||
fingerprint = $fp
|
||
flow = $flow
|
||
}
|
||
}
|
||
|
||
# --- MAIN ---
|
||
|
||
if ($Debug) { Set-DebugMode -Enabled $true }
|
||
|
||
Write-Header "NATIVE SING-BOX (UDP ПОДДЕРЖКА)" -ClearScreen
|
||
|
||
$taskStatus = Get-TaskStatus -Name $TaskName
|
||
|
||
if ($taskStatus -and -not $Force) {
|
||
Write-Info "Sing-box уже установлен."
|
||
Write-Host " Статус: $taskStatus" -ForegroundColor ($taskStatus -eq "Running" ? "Green" : "Red")
|
||
Write-Host ""
|
||
|
||
$opts = [Ordered]@{
|
||
"1" = "Сменить сервер (из подписки)"
|
||
"2" = "Ввести новую ссылку на подписку"
|
||
"3" = "Перезапустить службу"
|
||
"4" = "Остановить службу"
|
||
"5" = "Показать конфиг"
|
||
"6" = "Переустановить"
|
||
"b" = "Назад"
|
||
}
|
||
|
||
$act = Show-Menu -Options $opts
|
||
|
||
switch ($act) {
|
||
"1" {
|
||
# Reload existing sub logic could be added here, currently just re-runs install flow partially
|
||
# Simplification: treat as new setup but try to load saved sub url
|
||
$Force = $true
|
||
}
|
||
"2" { $SubscriptionUrl = ""; $Force = $true }
|
||
"3" { Manage-ScheduledTask -Name $TaskName -Action "Start"; Write-Success "Запущено!"; exit }
|
||
"4" { Manage-ScheduledTask -Name $TaskName -Action "Stop"; Write-Success "Остановлено!"; exit }
|
||
"5" { Get-Content "$InstallDir\config.json"; exit }
|
||
"6" { $Force = $true }
|
||
"b" { exit }
|
||
}
|
||
}
|
||
|
||
if ($Force -or -not $taskStatus) {
|
||
# 1. Загрузка
|
||
Write-Step "Установка Sing-box..."
|
||
if (!(Test-Path "$InstallDir\sing-box.exe")) {
|
||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||
$zipCtx = "$env:TEMP\sing-box.zip"
|
||
if (Download-File -Url $SingboxUrl -Destination $zipCtx) {
|
||
Expand-Archive -Path $zipCtx -DestinationPath $env:TEMP -Force
|
||
$extracted = Get-ChildItem "$env:TEMP\sing-box-*" -Directory | Select -First 1
|
||
Copy-Item "$($extracted.FullName)\sing-box.exe" "$InstallDir\sing-box.exe" -Force
|
||
Remove-Item $zipCtx; Remove-Item $extracted.FullName -Recurse -Force
|
||
Write-Success "Sing-box скачан"
|
||
}
|
||
else {
|
||
Read-Host "Нажмите Enter для выхода..."
|
||
exit 1
|
||
}
|
||
}
|
||
|
||
# 2. Подписка
|
||
if ([string]::IsNullOrWhiteSpace($SubscriptionUrl)) {
|
||
# Try load saved
|
||
$savedSub = "$InstallDir\sub_info.json"
|
||
if (Test-Path $savedSub) {
|
||
try {
|
||
$json = Get-Content $savedSub -Raw | ConvertFrom-Json
|
||
if ($json.url) {
|
||
Write-Info "Найдена сохраненная подписка: $($json.url)"
|
||
if ((Read-Host "Использовать? (y/n)") -eq 'y') { $SubscriptionUrl = $json.url }
|
||
}
|
||
}
|
||
catch {}
|
||
}
|
||
}
|
||
|
||
if ([string]::IsNullOrWhiteSpace($SubscriptionUrl)) {
|
||
$SubscriptionUrl = Read-Host "`n🔗 Введите URL подписки (VLESS)"
|
||
}
|
||
|
||
if ([string]::IsNullOrWhiteSpace($SubscriptionUrl)) {
|
||
Write-Error "Url не указан"
|
||
Read-Host "Нажмите Enter для выхода..."
|
||
exit
|
||
}
|
||
|
||
# --- PARSING ---
|
||
$data = @{ success = $false; config = $null; error = "" }
|
||
|
||
if ($SubscriptionUrl.StartsWith("vless://")) {
|
||
try {
|
||
$p = Parse-VlessUrl -Url $SubscriptionUrl
|
||
$outbound = [Ordered]@{
|
||
type = "vless"
|
||
tag = $p.tag
|
||
server = $p.server
|
||
server_port = $p.server_port
|
||
uuid = $p.uuid
|
||
flow = $p.flow
|
||
tls = @{
|
||
enabled = $true
|
||
server_name = $p.server_name
|
||
utls = @{ enabled = $true; fingerprint = $p.fingerprint }
|
||
reality = @{
|
||
enabled = $true
|
||
public_key = $p.public_key
|
||
short_id = $p.short_id
|
||
}
|
||
}
|
||
packet_encoding = "xudp"
|
||
}
|
||
$data.success = $true
|
||
$data.config = @{ outbounds = @($outbound) }
|
||
}
|
||
catch {
|
||
$data.error = $_.Exception.Message
|
||
}
|
||
}
|
||
else {
|
||
$data = Get-SubscriptionData -Url $SubscriptionUrl -Headers (Get-SubscriptionHeaders)
|
||
}
|
||
|
||
|
||
# --- PARSING LOGIC ENHANCEMENT ---
|
||
if (-not $data.success) {
|
||
# Fallback: Try to handle non-JSON body (Base64 or Plain Text)
|
||
try {
|
||
Write-Info "JSON парсинг не удался, пробую как список ссылок..."
|
||
$content = $data.rawContent
|
||
|
||
# Base64 decode if needed
|
||
if ($content -match "^[A-Za-z0-9+/=]+$") {
|
||
try {
|
||
$bytes = [System.Convert]::FromBase64String($content)
|
||
$content = [System.Text.Encoding]::UTF8.GetString($bytes)
|
||
}
|
||
catch {}
|
||
}
|
||
|
||
# Try to find vless:// links
|
||
$links = $content -split "[\r\n]+" | Where-Object { $_ -match "^vless://" }
|
||
|
||
if ($links.Count -gt 0) {
|
||
Write-Success "Найдено ссылок: $($links.Count)"
|
||
|
||
# Mock a config object with these links as "outbounds"
|
||
# Note: We can't fully parsing VLESS query params in pure PS easily without a lot of regex
|
||
# So we will try a simpler approach: Let sing-box do it? No, sing-box needs config.
|
||
|
||
# WORKAROUND: Create a minimal outbound for each link
|
||
# Parsing `vless://UUID@HOST:PORT?security=reality&...#NAME`
|
||
$parsedOutbounds = @()
|
||
|
||
foreach ($link in $links) {
|
||
if ($link -match "vless://([^@]+)@([^:]+):(\d+)(\?.*)?(#.*)?") {
|
||
$uuid = $matches[1]
|
||
$server = $matches[2]
|
||
$port = [int]$matches[3]
|
||
$query = $matches[4]
|
||
$hash = $matches[5]
|
||
|
||
$tag = if ($hash) { $hash.Substring(1) } else { "${server}:${port}" }
|
||
$tag = [System.Web.HttpUtility]::UrlDecode($tag)
|
||
|
||
# Parse Query Params
|
||
$flow = ""; $fp = ""; $pbk = ""; $sid = ""; $sni = ""; $serviceName = ""
|
||
|
||
if ($query) {
|
||
if ($query -match "flow=([^&]+)") { $flow = $matches[1] }
|
||
if ($query -match "fp=([^&]+)") { $fp = $matches[1] }
|
||
if ($query -match "pbk=([^&]+)") { $pbk = $matches[1] }
|
||
if ($query -match "sid=([^&]+)") { $sid = $matches[1] }
|
||
if ($query -match "sni=([^&]+)") { $sni = $matches[1] }
|
||
if ($query -match "serviceName=([^&]+)") { $serviceName = $matches[1] }
|
||
}
|
||
|
||
# Construct Sing-box outbound (REALITY based assumption for modern vless)
|
||
$out = [Ordered]@{
|
||
type = "vless"
|
||
tag = $tag
|
||
server = $server
|
||
server_port = $port
|
||
uuid = $uuid
|
||
flow = $flow
|
||
tls = @{
|
||
enabled = $true
|
||
server_name = $sni
|
||
utls = @{ enabled = $true; fingerprint = $fp }
|
||
reality = @{
|
||
enabled = $true
|
||
public_key = $pbk
|
||
short_id = $sid
|
||
}
|
||
}
|
||
packet_encoding = "xudp"
|
||
}
|
||
$parsedOutbounds += $out
|
||
}
|
||
}
|
||
|
||
if ($parsedOutbounds.Count -gt 0) {
|
||
$data.success = $true
|
||
$data.config = @{ outbounds = $parsedOutbounds }
|
||
$data.error = $null
|
||
}
|
||
else {
|
||
throw "Не удалось распарсить VLESS ссылки"
|
||
}
|
||
|
||
}
|
||
else {
|
||
throw $data.error
|
||
}
|
||
}
|
||
catch {
|
||
Write-Error "Ошибка обработки подписки: $_"
|
||
Write-Host " Скрипт поддерживает: SIP008 (JSON) или список VLESS+Reality ссылок." -ForegroundColor Yellow
|
||
Read-Host "Нажмите Enter для выхода..."
|
||
exit
|
||
}
|
||
}
|
||
|
||
|
||
# Save sub info
|
||
@{ url = $SubscriptionUrl } | ConvertTo-Json | Set-Content "$InstallDir\sub_info.json"
|
||
|
||
# 3. Выбор сервера
|
||
$server = Select-Server -Config $data.config
|
||
if (!$server) {
|
||
Read-Host "Нажмите Enter для выхода..."
|
||
exit
|
||
}
|
||
|
||
# 4. Конфиг
|
||
$cfg = New-SingboxConfig -Outbound $server.outbound -Port $LocalProxyPort
|
||
$cfg | ConvertTo-Json -Depth 10 | Set-Content "$InstallDir\config.json" -Encoding UTF8
|
||
|
||
# 5. Задача
|
||
Manage-ScheduledTask -Name $TaskName -ExePath "$InstallDir\sing-box.exe" -Arguments "run -c `"$InstallDir\config.json`"" -WorkDir $InstallDir -Action "Install"
|
||
Manage-ScheduledTask -Name $TaskName -Action "Start"
|
||
|
||
# 6. Firewall
|
||
if (Ensure-FirewallPort -Port $LocalProxyPort -Name "SingBox-Proxy-Port") {
|
||
Write-Success "Правило Firewall создано (порт $LocalProxyPort)"
|
||
}
|
||
|
||
Write-Success "Успешно установлено и запущено!"
|
||
Write-Info "Локальный прокси: 127.0.0.1:$LocalProxyPort"
|
||
|
||
$ips = Get-LocalIPs
|
||
if ($ips) {
|
||
Write-Info "Доступно из сети по адресам:"
|
||
foreach ($ip in $ips) {
|
||
Write-Host " ${ip}:$LocalProxyPort" -ForegroundColor Gray
|
||
}
|
||
}
|
||
|
||
Start-Sleep -Seconds 3
|
||
}
|