$ErrorActionPreference = 'Stop' try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {} try { $Host.UI.RawUI.WindowTitle = 'UnexVPN // router uplink' } catch {} try { $Host.UI.RawUI.BackgroundColor = 'Black'; $Host.UI.RawUI.ForegroundColor = 'Green' } catch {} [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $DEMO = ($env:UNEXVPN_DEMO -eq '1') $API = if ($env:UNEXVPN_API) { $env:UNEXVPN_API } else { 'https://unexvpn.com' } $TUN_DESC = 'UnexVPN-Germany' $PROFILE_NAME = 'HomeWG' $GROUP_PREFIX = 'HomeRU' $GROUP_SIZE = 250 $VPN_PRIORITY = 40000 $Stamp = Get-Date -Format 'yyyyMMdd-HHmm' $DesktopDir = [Environment]::GetFolderPath('Desktop') $LogFile = Join-Path $DesktopDir ($(if ($DEMO) { 'ДЕМО-' } else { '' }) + "UnexVPN-роутер-журнал-$Stamp.txt") function Log($m) { try { Add-Content -Path $LogFile -Value ("[{0}] {1}" -f (Get-Date -Format 'HH:mm:ss'), $m) -Encoding UTF8 } catch {} } $LOGO = @( '██╗ ██╗ ███╗ ██╗ ███████╗ ██╗ ██╗ ██╗ ██╗ ██████╗ ███╗ ██╗', '██║ ██║ ████╗ ██║ ██╔════╝ ╚██╗██╔╝ ██║ ██║ ██╔══██╗ ████╗ ██║', '██║ ██║ ██╔██╗ ██║ █████╗ ╚███╔╝ ██║ ██║ ██████╔╝ ██╔██╗ ██║', '██║ ██║ ██║╚██╗██║ ██╔══╝ ██╔██╗ ╚██╗ ██╔╝ ██╔═══╝ ██║╚██╗██║', '╚██████╔╝ ██║ ╚████║ ███████╗ ██╔╝ ██╗ ╚████╔╝ ██║ ██║ ╚████║', ' ╚═════╝ ╚═╝ ╚═══╝ ╚══════╝ ╚═╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═══╝' ) $GLYPHS = '01#$%&*+=<>?/\|{}[]~^:;ABCDEFXZ'.ToCharArray() $UIW = 78 $Rng = New-Object Random $StepNames = @( 'поиск роутера Keenetic', 'вход в роутер', 'проверка модели и прошивки', 'резервная копия настроек роутера', 'активация по коду', 'подключение к московскому шлюзу', 'создание защищённого туннеля', 'проверка канала Москва → Германия', 'передача данных на роутер', 'переключение интернета на Германию', 'проверка подключения' ) $StepState = @('wait') * $StepNames.Count $StepNote = @('') * $StepNames.Count $script:CurStep = -1 $script:CurFrac = 0.0 $script:SpinTick = 0 $script:TopRow = 0 $SPINNER = @('|', '/', '-', '\') function PadLine($text) { $text = [string]$text; if ($text.Length -ge $UIW) { return $text.Substring(0, $UIW - 1) }; return $text.PadRight($UIW - 1) } function MoveTo($row) { try { [Console]::SetCursorPosition(0, $row) } catch {} } function Scramble($line, $keep) { $chars = $line.ToCharArray() for ($k = 0; $k -lt $chars.Length; $k++) { if ($chars[$k] -ne ' ' -and $Rng.NextDouble() -gt $keep) { $chars[$k] = $GLYPHS[$Rng.Next($GLYPHS.Length)] } } return -join $chars } function Type-Line($text, $color = 'Green', $delay = 10) { foreach ($ch in $text.ToCharArray()) { Write-Host $ch -NoNewline -ForegroundColor $color; if ($delay) { Start-Sleep -Milliseconds $delay } } Write-Host '' } function Matrix-Rain($rows, $frames) { $top = try { [Console]::CursorTop } catch { 0 } for ($f = 0; $f -lt $frames; $f++) { MoveTo $top for ($r = 0; $r -lt $rows; $r++) { $sb = New-Object Text.StringBuilder for ($c = 0; $c -lt 72; $c++) { [void]$sb.Append($(if ($Rng.NextDouble() -lt 0.18) { $GLYPHS[$Rng.Next($GLYPHS.Length)] } else { ' ' })) } Write-Host (' ' + $sb.ToString()) -ForegroundColor $(if ($Rng.NextDouble() -lt 0.3) { 'Green' } else { 'DarkGreen' }) } Start-Sleep -Milliseconds 45 } MoveTo $top } function Draw-Logo([switch]$Animate) { Clear-Host Write-Host '' if ($Animate) { $top = try { [Console]::CursorTop } catch { 0 } Matrix-Rain $LOGO.Count 14 foreach ($keep in 0.15, 0.35, 0.55, 0.75, 0.9, 1.0) { MoveTo $top foreach ($line in $LOGO) { Write-Host (' ' + (Scramble $line $keep)) -ForegroundColor $(if ($keep -ge 1.0) { 'Green' } else { 'DarkGreen' }) } Start-Sleep -Milliseconds 90 } } else { foreach ($line in $LOGO) { Write-Host (' ' + $line) -ForegroundColor Green } } Write-Host '' $sub = ' root@unexvpn:~# uplink --route home>moscow>germany' if ($DEMO) { $sub += ' [DEMO]' } if ($Animate) { Type-Line $sub 'Green' 12 } else { Write-Host $sub -ForegroundColor Green } Write-Host (' ' + ('─' * 68)) -ForegroundColor DarkGreen } function Draw-Step($idx) { MoveTo ($script:TopRow + $idx) switch ($StepState[$idx]) { 'wait' { $tag = '[ ]'; $tc = 'DarkGreen' } 'run' { $tag = '[ ' + $SPINNER[$script:SpinTick % 4] + $SPINNER[($script:SpinTick + 2) % 4] + ' ]'; $tc = 'Green' } 'ok' { $tag = '[ OK ]'; $tc = 'Green' } 'warn' { $tag = '[WARN]'; $tc = 'Yellow' } 'fail' { $tag = '[FAIL]'; $tc = 'Red' } } Write-Host (' ' + $tag + ' ') -NoNewline -ForegroundColor $tc $name = $StepNames[$idx] $nc = if ($StepState[$idx] -eq 'wait') { 'DarkGreen' } elseif ($StepState[$idx] -eq 'run') { 'White' } else { 'Green' } Write-Host $name -NoNewline -ForegroundColor $nc $rest = $UIW - 11 - $name.Length $txt = [string]$StepNote[$idx] if ($txt -and $rest -gt 6) { if ($txt.Length -gt $rest - 4) { $txt = $txt.Substring(0, $rest - 5) + '…' } Write-Host (' // ' + $txt).PadRight($rest) -NoNewline -ForegroundColor DarkGreen } else { Write-Host (' ' * [Math]::Max(0, $rest)) -NoNewline } Write-Host '' } function Draw-Bar { $done = @($StepState | Where-Object { $_ -eq 'ok' -or $_ -eq 'warn' }).Count $p = ($done + $(if ($script:CurStep -ge 0 -and $StepState[$script:CurStep] -eq 'run') { $script:CurFrac } else { 0 })) / $StepNames.Count if ($p -gt 1) { $p = 1 } $len = 52 $fill = [int][Math]::Floor($p * $len) MoveTo ($script:TopRow + $StepNames.Count + 1) Write-Host ' [' -NoNewline -ForegroundColor DarkGreen Write-Host ('█' * $fill) -NoNewline -ForegroundColor Green Write-Host ('·' * ($len - $fill)) -NoNewline -ForegroundColor DarkGreen Write-Host ']' -NoNewline -ForegroundColor DarkGreen Write-Host (' {0,3}%' -f [int]($p * 100)).PadRight($UIW - $len - 6) -ForegroundColor Green } function Say($msg, $color = 'Green') { MoveTo ($script:TopRow + $StepNames.Count + 3) Write-Host (PadLine (' > ' + $msg)) -ForegroundColor $color Write-Host (PadLine '') Write-Host (PadLine '') MoveTo ($script:TopRow + $StepNames.Count + 4) } function Draw-All { Draw-Logo Write-Host '' $script:TopRow = try { [Console]::CursorTop } catch { 0 } for ($k = 0; $k -lt $StepNames.Count; $k++) { Draw-Step $k } Write-Host '' Draw-Bar Write-Host '' } function Clear-Below { MoveTo ($script:TopRow + $StepNames.Count + 3); for ($k = 0; $k -lt 9; $k++) { Write-Host (PadLine '') }; MoveTo ($script:TopRow + $StepNames.Count + 3) } function Begin-Step($idx, $txt = '') { $script:CurStep = $idx; $script:CurFrac = 0; $StepState[$idx] = 'run'; $StepNote[$idx] = $txt; Draw-Step $idx; Draw-Bar; Log "STEP $($StepNames[$idx])" } function Tick($txt = $null, $frac = $null) { $script:SpinTick++ if ($null -ne $txt) { $StepNote[$script:CurStep] = $txt } if ($null -ne $frac) { $script:CurFrac = $frac } Draw-Step $script:CurStep; Draw-Bar } function End-Step($st = 'ok', $txt = $null) { $StepState[$script:CurStep] = $st if ($null -ne $txt) { $StepNote[$script:CurStep] = $txt } Draw-Step $script:CurStep; $script:CurFrac = 0; Draw-Bar Log " -> $st $($StepNote[$script:CurStep])" } function Skip-Step($idx, $txt) { $script:CurStep = $idx; $StepState[$idx] = 'ok'; $StepNote[$idx] = $txt; Draw-Step $idx; Draw-Bar; Log "SKIP $($StepNames[$idx]) $txt" } function Fail($msg) { if ($script:CurStep -ge 0 -and $StepState[$script:CurStep] -eq 'run') { End-Step 'fail' } Say ('ошибка: ' + $msg) 'Red' Log "FAIL $msg" throw [Exception]::new('__shown__') } function Sleep-Spin($sec) { $end = (Get-Date).AddSeconds($sec); while ((Get-Date) -lt $end) { Tick; Start-Sleep -Milliseconds 200 } } $script:RouterHost = $null $script:Cookies = New-Object Net.CookieContainer function Http($target, $path, $body = $null, $timeout = 10) { if ($DEMO) { throw 'DEMO: network disabled' } $req = [Net.HttpWebRequest]::Create("http://$target$path") $req.Proxy = $null $req.CookieContainer = $script:Cookies $req.Timeout = $timeout * 1000 $req.ReadWriteTimeout = $timeout * 1000 $req.AllowAutoRedirect = $false if ($null -ne $body) { $bytes = [Text.Encoding]::UTF8.GetBytes($body) $req.Method = 'POST' $req.ContentType = 'application/json' $req.ContentLength = $bytes.Length $st = $req.GetRequestStream(); $st.Write($bytes, 0, $bytes.Length); $st.Close() } $resp = $null try { $resp = $req.GetResponse() } catch { $ex = $_.Exception while ($ex -and -not ($ex -is [Net.WebException])) { $ex = $ex.InnerException } if ($ex -and $ex.Response) { $resp = $ex.Response } else { throw } } $rd = New-Object IO.StreamReader($resp.GetResponseStream(), [Text.Encoding]::UTF8) $text = $rd.ReadToEnd(); $rd.Close() $r = [pscustomobject]@{ Status = [int]$resp.StatusCode; Headers = $resp.Headers; Body = $text } $resp.Close() return $r } function Api($method, $path, $payload = $null) { if ($DEMO) { throw 'DEMO: network disabled' } $reqArgs = @{ Uri = "$API$path"; Method = $method; UseBasicParsing = $true; TimeoutSec = 30 } if ($null -ne $payload) { $reqArgs.Body = [Text.Encoding]::UTF8.GetBytes((ConvertTo-Json $payload -Compress)); $reqArgs.ContentType = 'application/json; charset=utf-8' } try { $resp = Invoke-WebRequest @reqArgs return ([Text.Encoding]::UTF8.GetString($resp.RawContentStream.ToArray()) | ConvertFrom-Json) } catch { $code = 0; $detail = $_.Exception.Message $ex = $_.Exception while ($ex -and -not ($ex -is [Net.WebException])) { $ex = $ex.InnerException } if ($ex -and $ex.Response) { $code = [int]$ex.Response.StatusCode try { $rd = New-Object IO.StreamReader($ex.Response.GetResponseStream()); $detail = ($rd.ReadToEnd() | ConvertFrom-Json).detail } catch {} } throw [Exception]::new("API $code $detail") } } function Probe($target) { if ($DEMO) { Start-Sleep -Milliseconds 450; return ($target -eq '192.168.1.1') } try { $r = Http $target '/auth' $null 3 if ($r.Status -eq 401 -and $r.Headers['X-NDM-Realm']) { return $true } if ($r.Status -eq 200) { return $true } } catch {} return $false } function Prefix-Mask($bits) { $octets = foreach ($o in 0..3) { $n = [Math]::Max(0, [Math]::Min(8, $bits - 8 * $o)); 256 - [int][Math]::Pow(2, 8 - $n) } return ($octets -join '.') } function Hex($bytes) { -join ($bytes | ForEach-Object { $_.ToString('x2') }) } function Login($user, $secret) { if ($DEMO) { Start-Sleep -Milliseconds 700; return ([string]$secret).Length -gt 0 } $r = Http $script:RouterHost '/auth' if ($r.Status -eq 200) { return $true } $realm = $r.Headers['X-NDM-Realm']; $challenge = $r.Headers['X-NDM-Challenge'] $md5 = Hex ([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes("${user}:${realm}:${secret}"))) $sha = Hex ([Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($challenge + $md5))) $r = Http $script:RouterHost '/auth' ('{"login":' + (ConvertTo-Json $user) + ',"password":"' + $sha + '"}') return ($r.Status -eq 200) } function Rci($json, $timeout = 20) { $r = Http $script:RouterHost '/rci/' $json $timeout if ($r.Status -ne 200) { throw "роутер ответил $($r.Status)" } return ($r.Body | ConvertFrom-Json) } $script:DemoPolls = 0 $script:DemoTunnel = $false function Show($what) { if ($DEMO) { Start-Sleep -Milliseconds 300 if ($what -like '*version*') { return [pscustomobject]@{ version = [pscustomobject]@{ description = 'Keenetic Speedster (KN-3010)'; title = '4.3.6'; hw_id = 'KN-3010'; ndw = [pscustomobject]@{ components = 'base,wireguard,pingcheck' } } } } if ($what -like '*name*') { $script:DemoPolls++; return [pscustomobject]@{ interface = [pscustomobject]@{ wireguard = [pscustomobject]@{ 'public-key' = 'DEMOdemoDEMOdemoDEMOdemoDEMOdemoDEMOdemoDE0='; peer = @([pscustomobject]@{ online = ($script:DemoPolls -ge 5); 'last-handshake' = 3 }) } } } } $list = [ordered]@{ 'GigabitEthernet1' = [pscustomobject]@{ id = 'GigabitEthernet1'; 'interface-name' = 'ISP'; type = 'GigabitEthernet'; global = $true; connected = 'yes'; priority = 32767; description = 'Ethernet ISP' } 'Bridge0' = [pscustomobject]@{ id = 'Bridge0'; 'interface-name' = 'Home'; type = 'Bridge'; global = $false; connected = 'yes'; priority = 0; description = 'Home network' } } if ($script:DemoTunnel) { $list['Wireguard0'] = [pscustomobject]@{ id = 'Wireguard0'; 'interface-name' = 'Wireguard0'; type = 'Wireguard'; global = $true; connected = 'yes'; priority = 1; description = $TUN_DESC } } return [pscustomobject]@{ interface = [pscustomobject]$list } } return (Rci ('{"show":{' + $what + '}}')).show } function Run-Batch($cmds, $timeout = 120) { $errs = @() if (-not $cmds -or @($cmds).Count -eq 0) { return $errs } if ($DEMO) { Start-Sleep -Milliseconds (150 + 8 * @($cmds).Count); foreach ($c in @($cmds)) { if ($c -like 'interface Wireguard0*') { $script:DemoTunnel = $true }; Log "DEMO $c" }; return $errs } $cmds = @($cmds) $json = '[' + (($cmds | ForEach-Object { '{"parse":' + (ConvertTo-Json ([string]$_)) + '}' }) -join ',') + ']' $res = @(Rci $json $timeout) for ($k = 0; $k -lt $res.Count; $k++) { foreach ($s in @($res[$k].parse.status)) { if ($s -and $s.status -eq 'error') { $errs += ("{0}: {1}" -f $cmds[$k], $s.message) Log ("ERR {0}: {1}" -f $cmds[$k], $s.message) } } } return $errs } function Run-Cmds($cmds, $from = 0.0, $to = 1.0, $label = '', $chunk = 60) { $all = @() $cmds = @($cmds) $n = $cmds.Count for ($k = 0; $k -lt $n; $k += $chunk) { $part = $cmds[$k..([Math]::Min($k + $chunk, $n) - 1)] $all += Run-Batch $part $done = [Math]::Min($k + $chunk, $n) Tick ("{0} {1}/{2}" -f $label, $done, $n) ($from + ($to - $from) * $done / $n) } return $all } function Save-Config { return ((Run-Batch @('system configuration save') 180).Count -eq 0) } function Props($o) { if ($null -eq $o) { return @() }; return @($o.PSObject.Properties) } function Find-OurTunnel($ifs) { foreach ($pp in Props $ifs) { if ($pp.Value.type -eq 'Wireguard' -and $pp.Value.description -eq $TUN_DESC) { return $pp.Value.id } } return $null } function Free-TunnelName($ifs) { $used = @{}; foreach ($pp in Props $ifs) { $used[[string]$pp.Value.id] = $true; $used[$pp.Name] = $true } for ($k = 0; $k -lt 10; $k++) { if (-not $used["Wireguard$k"]) { return "Wireguard$k" } } return $null } function Find-Wan($ifs, $tunName) { $skip = 'Wireguard|OpenVPN|IKE|L2TP|PPTP|SSTP|Ipsec|Proxy|Zerotier|Wifi|Bridge' $best = $null foreach ($pp in Props $ifs) { $it = $pp.Value if ($it.id -eq $tunName -or -not $it.global -or $it.type -match $skip -or $it.connected -ne 'yes') { continue } if (-not $best -or [int]$it.priority -gt [int]$best.priority) { $best = $it } } return $best } function Tunnel-Info($tunName) { try { return (Show ('"interface":{"name":"' + $tunName + '"}')).interface.wireguard } catch { return $null } } function Read-Secret($prompt) { Write-Host (' > ' + $prompt) -NoNewline -ForegroundColor Green $sec = Read-Host -AsSecureString $b = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec) try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($b) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($b) } } function Ask($prompt, $default = '') { Write-Host (' > ' + $prompt) -NoNewline -ForegroundColor Green $a = Read-Host if (-not $a) { return $default } return $a.Trim() } function Exit-Trace { if ($DEMO) { Start-Sleep -Milliseconds 800; return @('93.152.223.226', 'DE') } try { $t = (Invoke-WebRequest -Uri 'https://1.1.1.1/cdn-cgi/trace' -UseBasicParsing -TimeoutSec 8).Content return @(([regex]::Match($t, 'ip=(\S+)')).Groups[1].Value, ([regex]::Match($t, 'loc=(\S+)')).Groups[1].Value) } catch { return @('', '') } } function Ping-Ms($target) { if ($DEMO) { Sleep-Spin 1.2; return 21 } try { $r = @(Test-Connection -ComputerName $target -Count 3 -ErrorAction SilentlyContinue) if ($r.Count) { return [int]($r | Measure-Object -Property ResponseTime -Average).Average } } catch {} return -1 } function Connect-Router { Begin-Step 0 'scan…' $cands = @() if (-not $DEMO) { try { $cands += @(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | ForEach-Object { $_.NextHop } | Where-Object { $_ -and $_ -ne '0.0.0.0' }) } catch {} } $cands = @($cands + @('192.168.1.1', '192.168.0.1', 'my.keenetic.net')) | Select-Object -Unique foreach ($h in $cands) { Tick "probe $h"; if (Probe $h) { $script:RouterHost = $h; break } } if (-not $script:RouterHost) { End-Step 'fail' 'не найден' Clear-Below $h = Ask 'роутер не найден автоматически, введите его адрес (например 192.168.1.1): ' Draw-All Begin-Step 0 "probe $h" if ($h -and (Probe $h)) { $script:RouterHost = $h } else { Fail 'роутер Keenetic не отвечает. Компьютер должен быть подключён к роутеру по Wi-Fi или кабелем.' } } End-Step 'ok' $script:RouterHost Begin-Step 1 'нужен пароль администратора роутера' Clear-Below Write-Host ' > логин и пароль от веб-интерфейса роутера (обычно логин admin)' -ForegroundColor DarkGreen $user = Ask 'login [admin]: ' 'admin' $ok = $false for ($try = 1; $try -le 3 -and -not $ok; $try++) { $secret = Read-Secret 'password: ' $ok = Login $user $secret if (-not $ok) { Write-Host ' > неверный логин или пароль, ещё раз' -ForegroundColor Yellow } } Draw-All $script:CurStep = 1 if (-not $ok) { Fail 'не удалось войти в роутер' } $script:RouterUser = $user; $script:RouterSecret = $secret End-Step 'ok' "вошли как $user" } function Ensure-Firmware { Begin-Step 2 'version…' $v = (Show '"version":{}').version $model = if ($v.description) { $v.description } else { $v.model } $ver = [string]$v.title $comps = [string]$v.ndw.components Log "MODEL $model $ver $($v.hw_id) comps=$comps" $mm = [regex]::Match($ver, '^(\d+)\.(\d+)') $verOk = $mm.Success -and (([int]$mm.Groups[1].Value -gt 4) -or ([int]$mm.Groups[1].Value -eq 4 -and [int]$mm.Groups[2].Value -ge 2)) $need = @() if ($comps -notmatch '(^|,)wireguard(,|$)') { $need += 'wireguard' } if ($comps -notmatch '(^|,)pingcheck(,|$)') { $need += 'pingcheck' } if (-not $verOk -or $need.Count -gt 0) { $why = @(); if (-not $verOk) { $why += "прошивка $ver устарела (нужна 4.2+)" }; if ($need) { $why += 'нет компонентов: ' + ($need -join ', ') } $catalog = $null for ($k = 0; $k -lt 30 -and -not $catalog; $k++) { Tick 'загружаю каталог компонентов Keenetic…' ($k / 30) $lst = (Rci '{"components":{"list":{}}}' 60).components.list if ($lst.component) { $catalog = $lst } else { Sleep-Spin 2 } } if (-not $catalog) { Fail 'роутер не получил каталог компонентов с сервера Keenetic. Проверьте, что на роутере есть интернет, и запустите ещё раз.' } $available = @($catalog.component.PSObject.Properties.Name) $missing = @($need | Where-Object { $available -notcontains $_ }) if ($missing.Count) { Fail ('для этой модели недоступны компоненты: ' + ($missing -join ', ')) } $target = [string]$catalog.firmware.title Tick ($why -join '; ') Clear-Below Write-Host (' > ' + ($why -join '; ')) -ForegroundColor Yellow Write-Host (" > роутер установит WireGuard и обновит прошивку до {0}, затем перезагрузится (3–7 минут)" -f $(if ($target) { $target } else { 'последней' })) -ForegroundColor DarkGreen Write-Host ' > интернет на это время пропадёт, программа дождётся роутера сама' -ForegroundColor DarkGreen $a = Ask 'обновить сейчас? [Y/n]: ' 'y' Draw-All $script:CurStep = 2 if ($a -notmatch '^[yYдД]') { Fail 'без обновления продолжить нельзя' } $uptime0 = [int](Show '"system":{}').system.uptime $e = Run-Batch (@($need | ForEach-Object { "components install $_" }) + @('components commit')) 300 if ($e.Count) { Fail ('обновление не запустилось: ' + ($e -join '; ')) } $rebooted = $false $deadline = (Get-Date).AddMinutes(20) while (-not $rebooted -and (Get-Date) -lt $deadline) { Sleep-Spin 10 Tick 'роутер скачивает обновление и перезагружается…' if (-not (Probe $script:RouterHost)) { continue } try { $script:Cookies = New-Object Net.CookieContainer if (Login $script:RouterUser $script:RouterSecret) { $up = [int](Show '"system":{}').system.uptime if ($up -lt $uptime0) { $rebooted = $true } } } catch {} } if (-not $rebooted) { Fail 'роутер не перезагрузился с обновлением за 20 минут. Проверьте в веб-интерфейсе: Параметры системы → Обновление.' } Sleep-Spin 15 $v = (Show '"version":{}').version $ver = [string]$v.title; $comps = [string]$v.ndw.components if ($comps -notmatch 'wireguard') { Fail "после обновления компонента WireGuard нет (версия $ver)" } } $script:RouterModel = $model; $script:RouterFw = $ver End-Step 'ok' "$model, KeeneticOS $ver" } function Backup-Config { Begin-Step 3 'download…' $r = if ($DEMO) { Start-Sleep -Milliseconds 900; [pscustomobject]@{ Status = 200; Body = "! DEMO`r`n" + ('! ' * 60) } } else { Http $script:RouterHost '/ci/startup-config.txt' $null 60 } if ($r.Status -ne 200 -or $r.Body.Length -lt 100) { Fail 'не удалось скачать настройки роутера' } $script:BackupFile = Join-Path $DesktopDir ($(if ($DEMO) { 'ДЕМО-' } else { '' }) + "Keenetic-бэкап-$Stamp.txt") [IO.File]::WriteAllText($script:BackupFile, $r.Body, (New-Object Text.UTF8Encoding($false))) End-Step 'ok' ("рабочий стол\" + (Split-Path $script:BackupFile -Leaf)) } function Activate($ifs) { Begin-Step 4 'нужен код активации' $tunName = Free-TunnelName $ifs if (-not $tunName) { Fail 'на роутере нет свободного имени для WireGuard-интерфейса' } Clear-Below Write-Host ' > код активации выдаёт администратор UnexVPN, формат UNX-XXXX-XXXX-XXXX' -ForegroundColor DarkGreen $code = Ask 'activation code: ' Draw-All $script:CurStep = 4 if (-not $code) { Fail 'код не введён' } Tick 'генерирую ключи на роутере…' 0.2 $e = Run-Batch @("interface $tunName", "interface $tunName description $TUN_DESC", "interface $tunName down") if ($e.Count) { Fail ('роутер не создал интерфейс: ' + ($e -join '; ')) } $wg = Tunnel-Info $tunName $pub = [string]$wg.'public-key' if (-not $pub) { Run-Batch @("no interface $tunName") | Out-Null; Fail 'роутер не выдал публичный ключ' } Tick 'проверяю код на сервере UnexVPN…' 0.5 try { $cfg = if ($DEMO) { Sleep-Spin 1.5 if ($code -notmatch 'UNX') { throw [Exception]::new('API 404 code not found') } [pscustomobject]@{ router_id = 0; label = 'demo'; tunnel_address = '10.9.0.10'; tunnel_netmask = '255.255.255.0'; mtu = 1380; server_public_key = 'DEMO'; preshared_key = 'DEMO'; endpoint = '77.110.104.223:51820'; asc = '4 40 70 47 70 1 2 3 4'; dns = @('1.1.1.1', '8.8.8.8'); exit_ip = '93.152.223.226'; keepalive = 25 } } else { Api 'POST' '/api/v1/router/activate' @{ code = $code; public_key = $pub; model = $script:RouterModel; firmware = $script:RouterFw } } } catch { Run-Batch @("no interface $tunName") | Out-Null $m = $_.Exception.Message $human = if ($m -match 'API 404') { 'код не найден, проверьте написание' } elseif ($m -match 'API 410.*used') { 'этот код уже использован' } elseif ($m -match 'API 410.*expired') { 'срок действия кода истёк' } elseif ($m -match 'API 410') { 'код отозван' } elseif ($m -match 'API 429') { 'слишком много попыток, подождите час' } elseif ($m -match 'API 503') { 'сервер UnexVPN временно недоступен, попробуйте позже' } else { $m } Fail $human } End-Step 'ok' ("адрес в туннеле {0}" -f $cfg.tunnel_address) return @($tunName, $cfg) } function Install-Mode { Draw-All Connect-Router Ensure-Firmware Backup-Config $ifs = (Show '"interface":{}').interface $tunName = Find-OurTunnel $ifs $cfg = $null if ($tunName) { Skip-Step 4 "уже активирован ($tunName), обновляю настройки" } else { $res = Activate $ifs $tunName = $res[0]; $cfg = $res[1] } $wan = Find-Wan ((Show '"interface":{}').interface) $tunName if (-not $wan) { Fail 'не нашёл подключение к провайдеру (интернет на роутере не подключён?)' } $wanId = [string]$wan.id $prio = [Math]::Max($VPN_PRIORITY, [int]$wan.priority + 1000) Log "TUN $tunName WAN $wanId ($($wan.'interface-name')) prio $($wan.priority)" $gw = if ($cfg) { ($cfg.endpoint -split ':')[0] } else { '77.110.104.223' } Begin-Step 5 $gw $ms = Ping-Ms $gw if ($ms -ge 0) { End-Step 'ok' "шлюз отвечает, $ms мс" } else { End-Step 'warn' 'ping закрыт, проверим через туннель' } Begin-Step 6 'configure…' if ($cfg) { $t = "interface $tunName"; $pk = $cfg.server_public_key $e = Run-Batch @( "$t description $TUN_DESC", "$t security-level public", "$t ip address $($cfg.tunnel_address) $($cfg.tunnel_netmask)", "$t ip mtu $($cfg.mtu)", "$t ip tcp adjust-mss pmtu", "$t ip global 1", "$t wireguard asc $($cfg.asc)", "$t wireguard peer $pk", "$t wireguard peer $pk endpoint $($cfg.endpoint)", "$t wireguard peer $pk keepalive-interval $($cfg.keepalive)", "$t wireguard peer $pk allow-ips 0.0.0.0 0.0.0.0", "$t wireguard peer $pk preshared-key $($cfg.preshared_key)", "$t wireguard peer $pk connect via $wanId", "$t up" ) if ($e.Count) { Fail ('роутер не принял настройки туннеля: ' + ($e -join '; ')) } End-Step 'ok' "$tunName через $($wan.'interface-name')" } else { Run-Batch @("interface $tunName up") | Out-Null End-Step 'ok' "$tunName уже настроен" } Begin-Step 7 'handshake…' $online = $false for ($k = 0; $k -lt 45 -and -not $online; $k++) { $peer = @((Tunnel-Info $tunName).peer)[0] if ($peer -and $peer.online -and [int]$peer.'last-handshake' -lt 180) { $online = $true; break } Sleep-Spin 2 Tick ("жду рукопожатие {0} с" -f (($k + 1) * 2)) ($k / 45) } if (-not $online) { Run-Batch @("interface $tunName ip global 1", "interface $tunName down") | Out-Null Save-Config | Out-Null Fail 'Германия не ответила через московский шлюз за 90 секунд. Туннель выключен, интернет работает как раньше.' } End-Step 'ok' 'Германия на связи' Begin-Step 8 'download lists…' $lists = if ($DEMO) { Start-Sleep -Milliseconds 700; [pscustomobject]@{ cidrs = @(1..900 | ForEach-Object { "10.$([int]($_ / 250)).$($_ % 250).0/24" }); domains = @(1..990 | ForEach-Object { "demo$_.ru" }) } } else { Api 'GET' '/api/v1/router/lists' } $cidrs = @($lists.cidrs); $domains = @($lists.domains) $rcmds = @($cidrs | ForEach-Object { $parts = $_ -split '/'; "ip route $($parts[0]) $(Prefix-Mask ([int]$parts[1])) $wanId auto" }) $e1 = Run-Cmds $rcmds 0.05 0.45 'российские сети' $groups = [int][Math]::Ceiling($domains.Count / $GROUP_SIZE) $dcmds = @() for ($g = 1; $g -le $groups; $g++) { $gname = "$GROUP_PREFIX$g" $dcmds += "object-group fqdn $gname" $dcmds += "object-group fqdn $gname description v2fly-ru-$g" $slice = $domains[(($g - 1) * $GROUP_SIZE)..([Math]::Min($g * $GROUP_SIZE, $domains.Count) - 1)] $dcmds += @($slice | ForEach-Object { "object-group fqdn $gname include $_" }) } $e2 = Run-Cmds $dcmds 0.45 0.8 'российские домены' $tail = @() for ($g = 1; $g -le $groups; $g++) { $tail += "dns-proxy route object-group $GROUP_PREFIX$g $wanId auto" } $tail += @( "ip route 1.1.1.1 255.255.255.255 $tunName auto", "ip route 8.8.8.8 255.255.255.255 $tunName auto", 'ip name-server 1.1.1.1', 'ip name-server 8.8.8.8', "ping-check profile $PROFILE_NAME", "ping-check profile $PROFILE_NAME host 8.8.8.8", "ping-check profile $PROFILE_NAME update-interval 12", "ping-check profile $PROFILE_NAME mode icmp", "ping-check profile $PROFILE_NAME min-success 2", "ping-check profile $PROFILE_NAME max-fails 10", "ping-check profile $PROFILE_NAME timeout 3", "interface $tunName ping-check profile $PROFILE_NAME", 'ip policy HomeWG', 'ip policy HomeWG description VPN', "ip policy HomeWG permit global $tunName", "ip policy HomeWG no permit global $wanId", 'ip policy HomeDirect', 'ip policy HomeDirect description Direct', "ip policy HomeDirect permit global $wanId", "ip policy HomeDirect no permit global $tunName" ) $e3 = Run-Cmds $tail 0.8 0.9 'DNS и резерв' Tick 'сохраняю настройки…' 0.92 $saved = Save-Config $chk = if ($DEMO) { Start-Sleep -Milliseconds 1200; [pscustomobject]@{ Status = 200; Body = "object-group fqdn ${GROUP_PREFIX}1`nip route 1.1.1.1" } } else { Http $script:RouterHost '/ci/startup-config.txt' $null 120 } $cfgOk = $saved -and $chk.Status -eq 200 -and $chk.Body -match "object-group fqdn ${GROUP_PREFIX}1" -and $chk.Body -match 'ip route 1\.1\.1\.1' $errs = @($e1) + @($e2) + @($e3) if (-not $cfgOk) { Tick 'роутеру не хватило памяти на домены, убираю их…' 0.95 $rm = @(); for ($g = 1; $g -le $groups; $g++) { $rm += "no dns-proxy route object-group $GROUP_PREFIX$g $wanId"; $rm += "no object-group fqdn $GROUP_PREFIX$g" } Run-Batch $rm | Out-Null if (-not (Save-Config)) { Fail 'роутер не смог сохранить настройки' } End-Step 'warn' ("{0} сетей; домены не влезли в память роутера" -f $cidrs.Count) } elseif ($errs.Count -gt 0) { End-Step 'warn' ("{0} сетей, {1} доменов; предупреждений {2}" -f $cidrs.Count, $domains.Count, $errs.Count) } else { End-Step 'ok' ("{0} сетей и {1} доменов идут напрямую" -f $cidrs.Count, $domains.Count) } Begin-Step 9 'switch…' $e = Run-Batch @("interface $tunName ip global $prio", "interface $tunName up") if ($e.Count) { Fail ('не удалось переключить: ' + ($e -join '; ')) } $dnsErr = Run-Batch @("interface $wanId ip no name-servers") Save-Config | Out-Null Sleep-Spin 4 if ($dnsErr.Count) { End-Step 'warn' 'DNS провайдера не отключился' } else { End-Step 'ok' 'весь дом идёт через Германию' } Begin-Step 10 'trace…' $ip = ''; $loc = '' for ($k = 0; $k -lt 10; $k++) { $tr = Exit-Trace; $ip = $tr[0]; $loc = $tr[1] Tick ("внешний IP {0} {1}" -f $ip, $loc) ($k / 10) if ($loc -eq 'DE') { break } Sleep-Spin 3 } $yt = $DEMO if (-not $DEMO) { try { $yt = ((Invoke-WebRequest -Uri 'https://www.youtube.com/generate_204' -UseBasicParsing -TimeoutSec 10).StatusCode -in 200, 204) } catch {} } if ($loc -eq 'DE') { End-Step 'ok' ("IP {0} (DE), YouTube {1}" -f $ip, $(if ($yt) { 'ok' } else { 'молчит' })) } else { End-Step 'warn' ("компьютер видит IP {0} {1}" -f $ip, $loc) } $script:CurStep = -1 Draw-Bar Clear-Below if ($StepState[10] -eq 'ok') { Type-Line ' > ACCESS GRANTED. роутер подключён к UnexVPN: дом → Москва → Германия' 'Green' 8 Write-Host ' > российские сайты (банки, госуслуги, маркетплейсы) открываются напрямую' -ForegroundColor DarkGreen Write-Host ' > если сервер недоступен, роутер сам вернёт интернет через провайдера примерно через 2 минуты' -ForegroundColor DarkGreen } else { Write-Host ' > роутер настроен, но этот компьютер пока выходит не через Германию' -ForegroundColor Yellow Write-Host ' > если на компьютере включён другой VPN или прокси, выключите его и запустите проверку снова' -ForegroundColor DarkGreen } Write-Host (" > бэкап старых настроек: {0}" -f $script:BackupFile) -ForegroundColor DarkGreen Write-Host (" > журнал: {0}" -f $LogFile) -ForegroundColor DarkGreen } function Toggle-Mode($turnOn) { $script:StepNames = @('поиск роутера Keenetic', 'вход в роутер', $(if ($turnOn) { 'включение VPN' } else { 'выключение VPN' }), 'проверка подключения') $script:StepState = @('wait') * $StepNames.Count; $script:StepNote = @('') * $StepNames.Count Draw-All Connect-Router Begin-Step 2 $ifs = (Show '"interface":{}').interface $tunName = Find-OurTunnel $ifs if (-not $tunName) { Fail 'на роутере нет туннеля UnexVPN, сначала выберите пункт 1' } $wan = Find-Wan $ifs $tunName $prio = [Math]::Max($VPN_PRIORITY, [int]$wan.priority + 1000) $cmds = if ($turnOn) { @("interface $tunName ip global $prio", "interface $tunName up") } else { @("interface $tunName down") } $e = Run-Batch $cmds if ($e.Count) { Fail ($e -join '; ') } Save-Config | Out-Null Sleep-Spin 5 End-Step 'ok' $(if ($turnOn) { 'туннель включён' } else { 'интернет напрямую через провайдера' }) Begin-Step 3 $ip = ''; $loc = '' for ($k = 0; $k -lt 6; $k++) { $tr = Exit-Trace; $ip = $tr[0]; $loc = $tr[1]; Tick "IP $ip $loc" ($k / 6); if (($turnOn -and $loc -eq 'DE') -or (-not $turnOn -and $loc -and $loc -ne 'DE')) { break }; Sleep-Spin 3 } End-Step 'ok' ("внешний IP {0} ({1})" -f $ip, $loc) $script:CurStep = -1; Draw-Bar; Clear-Below Write-Host ' > done.' -ForegroundColor Green } try { Draw-Logo -Animate Write-Host '' if ($DEMO) { Write-Host ' > DEMO: роутер, серверы и интернет не используются, все шаги имитируются' -ForegroundColor Yellow Write-Host ' > роутер найдётся на 192.168.1.1, подойдёт любой пароль и любой код с буквами UNX' -ForegroundColor Yellow Write-Host '' } Type-Line ' > подключение домашнего роутера Keenetic к UnexVPN' 'Green' 6 Write-Host ' > перед изменениями будет сохранена резервная копия настроек роутера' -ForegroundColor DarkGreen Write-Host '' Write-Host ' [1] ' -NoNewline -ForegroundColor Green; Write-Host 'подключить роутер / обновить настройки' -ForegroundColor White Write-Host ' [2] ' -NoNewline -ForegroundColor Green; Write-Host 'временно выключить VPN' -ForegroundColor White Write-Host ' [3] ' -NoNewline -ForegroundColor Green; Write-Host 'включить VPN обратно' -ForegroundColor White Write-Host ' [0] ' -NoNewline -ForegroundColor Green; Write-Host 'выход' -ForegroundColor White Write-Host '' $choice = Ask 'select [1]: ' '1' Log "START choice=$choice demo=$DEMO api=$API" switch ($choice) { '1' { Install-Mode } '2' { Toggle-Mode $false } '3' { Toggle-Mode $true } default { return } } } catch { if ($_.Exception.Message -ne '__shown__') { Log ("EXC " + $_.Exception.ToString() + "`n" + $_.ScriptStackTrace) try { Say ('непредвиденная ошибка: ' + $_.Exception.Message) 'Red' } catch { Write-Host $_ -ForegroundColor Red } } Write-Host '' Write-Host (" > подробности в журнале: {0}" -f $LogFile) -ForegroundColor DarkGreen Write-Host ' > пришлите этот файл в поддержку UnexVPN' -ForegroundColor DarkGreen } Write-Host '' Write-Host ' > нажмите Enter, чтобы закрыть' -ForegroundColor DarkGreen [void](Read-Host)