<# claude-proxy.lib.ps1 - shared logic for the CLI and the GUI. Dot-source this file; it only defines functions (no side effects at load). #> Set-StrictMode -Off $script:FW_GROUP = 'ClaudeProxyLock' $script:ConfigDir = Join-Path $env:APPDATA 'ClaudeProxy' $script:ConfigPath = Join-Path $script:ConfigDir 'config.json' $script:BinDir = Join-Path $script:ConfigDir 'bin' $script:XrayExe = Join-Path $script:BinDir 'xray.exe' $script:XrayCfgPath = Join-Path $script:ConfigDir 'xray-config.json' $script:XrayVersion = 'v26.3.27' # pinned Xray-core release $script:XrayEmbedB64 = $null # build.ps1 injects the embedded Xray zip (base64) here # Baked-in defaults (used on a fresh machine before config.json exists / is edited in the GUI). $script:DefaultProxy = 'socks5://127.0.0.1:1080' # ---------------------------------------------------------------------------- config function Get-Config { if (-not (Test-Path $script:ConfigPath)) { return [PSCustomObject]@{ mode = 'proxy'; proxy = $script:DefaultProxy; vless = ''; localPort = 10808 failClosed = $true; killExisting = $true; extraArgs = @() } } $c = Get-Content $script:ConfigPath -Raw | ConvertFrom-Json # backfill new fields on older config files if (-not $c.PSObject.Properties['mode']) { $c | Add-Member mode 'proxy' } if (-not $c.PSObject.Properties['vless']) { $c | Add-Member vless '' } if (-not $c.PSObject.Properties['localPort']) { $c | Add-Member localPort 10808 } $c } function Save-Config($cfg) { if (-not (Test-Path $script:ConfigDir)) { New-Item -ItemType Directory -Path $script:ConfigDir -Force | Out-Null } ($cfg | ConvertTo-Json -Depth 5) | Set-Content -Path $script:ConfigPath -Encoding UTF8 } function Parse-Proxy([string]$uri) { if ($uri -notmatch '^(?socks5|socks5h|socks4|http|https)://(?[^:/]+):(?\d+)/?$') { throw "Bad proxy '$uri'. Use scheme://host:port, e.g. socks5://192.168.1.10:1080" } [PSCustomObject]@{ Scheme = $Matches.scheme PHost = $Matches.host Port = [int]$Matches.port Arg = "{0}://{1}:{2}" -f $Matches.scheme, $Matches.host, $Matches.port } } function Resolve-ProxyIPs([string]$hostname) { $addrs = [System.Net.Dns]::GetHostAddresses($hostname) [PSCustomObject]@{ V4 = @($addrs | Where-Object AddressFamily -eq 'InterNetwork' | ForEach-Object { $_.ToString() } | Sort-Object -Unique) V6 = @($addrs | Where-Object AddressFamily -eq 'InterNetworkV6' | ForEach-Object { $_.ToString() } | Sort-Object -Unique) } } function Test-ProxyReachable($proxy) { try { $c = New-Object System.Net.Sockets.TcpClient $iar = $c.BeginConnect($proxy.PHost, $proxy.Port, $null, $null) $ok = $iar.AsyncWaitHandle.WaitOne(1500, $false) if ($ok -and $c.Connected) { $c.EndConnect($iar); $c.Close(); return $true } $c.Close(); return $false } catch { return $false } } # ---------------------------------------------------------------------------- MSIX app function Get-ClaudeApp { $pkg = Get-AppxPackage -Name 'Claude' -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $pkg) { throw "Claude MSIX package not found (Get-AppxPackage -Name Claude)." } [xml]$m = Get-Content (Join-Path $pkg.InstallLocation 'AppxManifest.xml') $appNode = $m.Package.Applications.Application | Select-Object -First 1 [PSCustomObject]@{ InstallLocation = $pkg.InstallLocation Exe = (Join-Path $pkg.InstallLocation $appNode.Executable) Aumid = "$($pkg.PackageFamilyName)!$($appNode.Id)" PackageFullName = $pkg.PackageFullName } } # ---------------------------------------------------------------------------- firewall function ConvertTo-UInt32([string]$ip) { $b = [System.Net.IPAddress]::Parse($ip).GetAddressBytes(); [Array]::Reverse($b) [uint32][System.BitConverter]::ToUInt32($b, 0) } function ConvertFrom-UInt32([uint64]$v) { $b = [System.BitConverter]::GetBytes([uint32]$v); [Array]::Reverse($b) ([System.Net.IPAddress]::new($b)).ToString() } function Get-Ipv4Complement([string[]]$ips) { $MAX = [uint64]4294967295 $vals = @($ips | ForEach-Object { [uint64](ConvertTo-UInt32 $_) } | Sort-Object -Unique) $ranges = @(); $cursor = [uint64]0 foreach ($v in $vals) { if ($v -gt $cursor) { $ranges += ("{0}-{1}" -f (ConvertFrom-UInt32 $cursor), (ConvertFrom-UInt32 ($v - 1))) } $cursor = $v + 1 } if ($cursor -le $MAX) { $ranges += ("{0}-{1}" -f (ConvertFrom-UInt32 $cursor), (ConvertFrom-UInt32 $MAX)) } $ranges } # Full IPv6 space as an explicit range (Windows Firewall rejects the "::/0" CIDR form). $script:V6_ALL = '::-ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' function New-BlockRule([string]$name, [string]$exe, [string[]]$remote) { try { New-NetFirewallRule -DisplayName $name -Group $script:FW_GROUP ` -Direction Outbound -Action Block -Program $exe -RemoteAddress $remote -Profile Any -Enabled True | Out-Null } catch { throw "Правило '$name' отклонено (RemoteAddress = $($remote -join '; ')): $($_.Exception.Message)" } } function Install-Firewall($App, $ProxyIPs) { Remove-Firewall -Quiet $exe = $App.Exe if ($ProxyIPs.V4.Count -gt 0) { New-BlockRule 'Claude Proxy Lock (IPv4)' $exe (Get-Ipv4Complement $ProxyIPs.V4) } if ($ProxyIPs.V6.Count -eq 0) { New-BlockRule 'Claude Proxy Lock (IPv6 all)' $exe @($script:V6_ALL) } else { try { New-NetFirewallRule -DisplayName 'Claude Proxy Lock (IPv6 allow proxy)' -Group $script:FW_GROUP ` -Direction Outbound -Action Allow -Program $exe -RemoteAddress $ProxyIPs.V6 -Profile Any -Enabled True | Out-Null } catch { throw "IPv6 allow-rule отклонено ($($ProxyIPs.V6 -join '; ')): $($_.Exception.Message)" } New-BlockRule 'Claude Proxy Lock (IPv6 block rest)' $exe @($script:V6_ALL) } } # VLESS/tunnel mode: block ALL external traffic from claude.exe. It may only reach the local # SOCKS (127.0.0.1) — loopback is exempt from Windows Firewall filtering, so it still works. # xray.exe is intentionally NOT restricted; it carries the tunnel to the VLESS server. function Install-FirewallTunnel($App) { Remove-Firewall -Quiet New-NetFirewallRule -DisplayName 'Claude Proxy Lock (tunnel, all external)' -Group $script:FW_GROUP ` -Direction Outbound -Action Block -Program $App.Exe -Profile Any -Enabled True | Out-Null } function Remove-Firewall([switch]$Quiet) { $rules = Get-NetFirewallRule -Group $script:FW_GROUP -ErrorAction SilentlyContinue if ($rules) { $rules | Remove-NetFirewallRule } } # Install the firewall lock appropriate for the current config mode. Needs admin. # Used by the elevated firewall-helper entry point (GUI runs de-elevated). function Apply-FirewallForConfig { $cfg = Get-Config $app = Get-ClaudeApp $mode = if ($cfg.mode) { $cfg.mode } else { 'proxy' } if ($mode -eq 'vless') { Install-FirewallTunnel $app } else { $proxy = Parse-Proxy $cfg.proxy Install-Firewall $app (Resolve-ProxyIPs $proxy.PHost) } } function Get-FirewallState($App) { $rules = @(Get-NetFirewallRule -Group $script:FW_GROUP -ErrorAction SilentlyContinue) if ($rules.Count -eq 0) { return 'absent' } foreach ($r in $rules) { $af = $r | Get-NetFirewallApplicationFilter -ErrorAction SilentlyContinue if ($af -and $af.Program -and ($af.Program -ne $App.Exe)) { return 'stale' } } 'ok' } function Test-Admin { $id = [System.Security.Principal.WindowsIdentity]::GetCurrent() (New-Object System.Security.Principal.WindowsPrincipal($id)).IsInRole( [System.Security.Principal.WindowsBuiltInRole]::Administrator) } # ---------------------------------------------------------------------------- launch $script:ActivationType = @" using System; using System.Runtime.InteropServices; namespace ClaudeProxy { [ComImport, Guid("2e941141-7f97-4756-ba1d-9decde894a3d"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] public interface IApplicationActivationManager { int ActivateApplication( [In, MarshalAs(UnmanagedType.LPWStr)] string appUserModelId, [In, MarshalAs(UnmanagedType.LPWStr)] string arguments, [In] int options, [Out] out uint processId); int ActivateForFile(); int ActivateForProtocol(); } [ComImport, Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C")] public class ApplicationActivationManager { } // Do the COM QueryInterface in C# (reliable) instead of via a PowerShell cast. public static class Launcher { public static uint Activate(string aumid, string args) { IApplicationActivationManager mgr = (IApplicationActivationManager)(new ApplicationActivationManager()); uint pid; int hr = mgr.ActivateApplication(aumid, args ?? "", 0, out pid); if (hr < 0) { throw new System.ComponentModel.Win32Exception(hr, "ActivateApplication HRESULT 0x" + hr.ToString("X8") + " for AUMID " + aumid); } return pid; } } } "@ function Initialize-ActivationType { if (-not ([System.Management.Automation.PSTypeName]'ClaudeProxy.Launcher').Type) { Add-Type -TypeDefinition $script:ActivationType -Language CSharp | Out-Null } } function Start-ClaudeApp($App, [string]$ArgLine) { Initialize-ActivationType [ClaudeProxy.Launcher]::Activate($App.Aumid, $ArgLine) } function Stop-ClaudeInstances($App) { $procs = Get-Process -Name 'claude' -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith($App.InstallLocation, [StringComparison]::OrdinalIgnoreCase) } if ($procs) { $procs | Stop-Process -Force -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 900 } } # Is Claude running, and was it launched with --proxy-server? function Get-ClaudeStatus($App) { $procs = @(Get-CimInstance Win32_Process -Filter "Name='claude.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($App.InstallLocation, [StringComparison]::OrdinalIgnoreCase) }) [PSCustomObject]@{ Running = ($procs.Count -gt 0) Proxied = [bool]($procs | Where-Object { $_.CommandLine -and $_.CommandLine -match '--proxy-server' }) } } # ---------------------------------------------------------------------------- VLESS / Xray # Parse a vless:// share link (as exported by 3x-ui) into its parts. function ConvertFrom-VlessLink([string]$link) { $link = $link.Trim() if ($link -notmatch '^vless://') { throw "Ожидается vless://... ссылка (share-link из 3x-ui)." } $u = [System.Uri]$link if (-not $u.UserInfo) { throw "В ссылке нет UUID." } $q = @{} foreach ($pair in ($u.Query.TrimStart('?') -split '&')) { if (-not $pair) { continue } $kv = $pair -split '=', 2 $q[$kv[0]] = if ($kv.Count -gt 1) { [System.Uri]::UnescapeDataString($kv[1]) } else { '' } } [PSCustomObject]@{ Uuid = $u.UserInfo VHost = $u.Host Port = $u.Port Type = if ($q['type']) { $q['type'] } else { 'tcp' } Security = if ($q['security']) { $q['security'] } else { 'none' } Sni = $q['sni'] Fp = if ($q['fp']) { $q['fp'] } else { 'chrome' } Pbk = $q['pbk'] Sid = $q['sid'] Spx = if ($q['spx']) { $q['spx'] } else { '' } Flow = $q['flow'] ServiceName = $q['serviceName'] Path = $q['path'] HostHdr = $q['host'] Remark = [System.Uri]::UnescapeDataString($u.Fragment.TrimStart('#')) } } # Build an Xray config: local SOCKS inbound -> VLESS(+Reality) outbound. function New-XrayConfig([string]$vlessLink, [int]$localPort) { $v = ConvertFrom-VlessLink $vlessLink $user = @{ id = $v.Uuid; encryption = 'none' } if ($v.Flow) { $user.flow = $v.Flow } $stream = @{ network = $v.Type; security = $v.Security } if ($v.Security -eq 'reality') { if (-not $v.Pbk) { throw "Reality: в ссылке нет publicKey (pbk)." } $stream.realitySettings = @{ serverName = $v.Sni; fingerprint = $v.Fp; publicKey = $v.Pbk; shortId = $v.Sid; spiderX = $v.Spx } } elseif ($v.Security -eq 'tls') { $stream.tlsSettings = @{ serverName = $v.Sni; fingerprint = $v.Fp } } switch ($v.Type) { 'grpc' { $stream.grpcSettings = @{ serviceName = $v.ServiceName } } 'ws' { $stream.wsSettings = @{ path = $v.Path; headers = @{ Host = $v.HostHdr } } } } $cfg = @{ log = @{ loglevel = 'warning' } inbounds = @(@{ tag = 'socks-in'; listen = '127.0.0.1'; port = $localPort; protocol = 'socks'; settings = @{ udp = $true; auth = 'noauth' } }) outbounds = @(@{ tag = 'proxy'; protocol = 'vless'; settings = @{ vnext = @(@{ address = $v.VHost; port = [int]$v.Port; users = @($user) }) }; streamSettings = $stream }) } $cfg | ConvertTo-Json -Depth 12 } function Test-XrayPresent { Test-Path $script:XrayExe } # Download the pinned Xray-core, verify SHA256 against the published .dgst, extract to BinDir. function Install-Xray([scriptblock]$Log) { function say($m) { if ($Log) { & $Log $m } } $ver = $script:XrayVersion $base = "https://github.com/XTLS/Xray-core/releases/download/$ver" $zipUrl = "$base/Xray-windows-64.zip"; $dgstUrl = "$zipUrl.dgst" New-Item -ItemType Directory -Force -Path $script:BinDir | Out-Null $tmpZip = Join-Path $env:TEMP "xray-$ver.zip"; $tmpDg = "$tmpZip.dgst" [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 say "скачиваю Xray-core $ver..." Invoke-WebRequest -Uri $zipUrl -OutFile $tmpZip -UseBasicParsing Invoke-WebRequest -Uri $dgstUrl -OutFile $tmpDg -UseBasicParsing # parse expected SHA256 (a 64-hex token on a line mentioning 256 but not 512) $expected = $null foreach ($line in (Get-Content $tmpDg)) { if ($line -match '512') { continue } if ($line -match '256' -and $line -match '([0-9a-fA-F]{64})') { $expected = $Matches[1].ToLower(); break } } $actual = (Get-FileHash $tmpZip -Algorithm SHA256).Hash.ToLower() if ($expected -and $actual -ne $expected) { Remove-Item $tmpZip, $tmpDg -ErrorAction SilentlyContinue throw "SHA256 не совпал (ожидался $expected, получен $actual) — файл не установлен." } say ("SHA256 ок: " + $actual.Substring(0, 16) + "...") Expand-Archive -Path $tmpZip -DestinationPath $script:BinDir -Force Remove-Item $tmpZip, $tmpDg -ErrorAction SilentlyContinue if (-not (Test-XrayPresent)) { throw "xray.exe не найден после распаковки." } say "Xray установлен." } # Make sure xray.exe exists: prefer the embedded copy (baked into the exe), else download. function Ensure-Xray([scriptblock]$Log) { function say($m) { if ($Log) { & $Log $m } } if (Test-XrayPresent) { return } if ($script:XrayEmbedB64) { say 'распаковываю встроенный Xray...' New-Item -ItemType Directory -Force -Path $script:BinDir | Out-Null $tmp = Join-Path $env:TEMP 'xray-embed.zip' [System.IO.File]::WriteAllBytes($tmp, [System.Convert]::FromBase64String($script:XrayEmbedB64)) Expand-Archive -Path $tmp -DestinationPath $script:BinDir -Force Remove-Item $tmp -ErrorAction SilentlyContinue if (Test-XrayPresent) { say 'Xray готов (встроенный).'; return } } Install-Xray $Log # fallback: download the pinned release } function Test-LocalSocks([int]$port) { try { $c = New-Object System.Net.Sockets.TcpClient $iar = $c.BeginConnect('127.0.0.1', $port, $null, $null) $ok = $iar.AsyncWaitHandle.WaitOne(500, $false) if ($ok -and $c.Connected) { $c.EndConnect($iar); $c.Close(); return $true } $c.Close(); return $false } catch { return $false } } function Get-XrayProcs { Get-Process -Name 'xray' -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith($script:BinDir, [StringComparison]::OrdinalIgnoreCase) } } function Stop-Xray { $p = Get-XrayProcs if ($p) { $p | Stop-Process -Force -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 300 } } function Start-Xray([string]$vlessLink, [int]$localPort) { Ensure-Xray # extract the embedded copy if needed Stop-Xray (New-XrayConfig $vlessLink $localPort) | Set-Content -Path $script:XrayCfgPath -Encoding UTF8 Start-Process -FilePath $script:XrayExe -ArgumentList @('run', '-config', "`"$($script:XrayCfgPath)`"") -WindowStyle Hidden | Out-Null for ($i = 0; $i -lt 30; $i++) { if (Test-LocalSocks $localPort) { return } Start-Sleep -Milliseconds 200 } Stop-Xray throw "Xray не поднял локальный SOCKS на 127.0.0.1:$localPort. Проверь VLESS-ссылку и доступность сервера." } # ---------------------------------------------------------------------------- high level # Returns the overall switch state without changing anything. function Get-ProxyState { $cfg = Get-Config $app = Get-ClaudeApp $fw = Get-FirewallState $app $cl = Get-ClaudeStatus $app $mode = if ($cfg.mode) { $cfg.mode } else { 'proxy' } # reachability = the upstream we actually rely on for this mode $reach = $false; $endpoint = '' if ($mode -eq 'vless') { try { $v = ConvertFrom-VlessLink $cfg.vless; $endpoint = "$($v.VHost):$($v.Port)"; $reach = (Test-ProxyReachable ([PSCustomObject]@{ PHost = $v.VHost; Port = [int]$v.Port })) } catch {} } else { try { $p = Parse-Proxy $cfg.proxy; $endpoint = "$($p.PHost):$($p.Port)"; $reach = (Test-ProxyReachable $p) } catch {} } [PSCustomObject]@{ Config = $cfg Mode = $mode App = $app Firewall = $fw # absent | stale | ok Endpoint = $endpoint Reachable = $reach XrayPresent = (Test-XrayPresent) XrayRunning = ([bool](Get-XrayProcs)) Running = $cl.Running Proxied = $cl.Proxied On = ($fw -eq 'ok') } } # Turn the switch ON: install lock (if failClosed) + relaunch Claude proxied. function Enable-Proxy([switch]$RestartClaude) { $cfg = Get-Config $app = Get-ClaudeApp $mode = if ($cfg.mode) { $cfg.mode } else { 'proxy' } if ($mode -eq 'vless') { if ([string]::IsNullOrWhiteSpace([string]$cfg.vless)) { throw "Не задана VLESS-ссылка." } $localPort = if ($cfg.localPort) { [int]$cfg.localPort } else { 10808 } Start-Xray $cfg.vless $localPort # throws if the tunnel can't come up if ($cfg.failClosed) { Install-FirewallTunnel $app } $proxyArg = "socks5://127.0.0.1:$localPort" } else { $proxy = Parse-Proxy $cfg.proxy $ips = Resolve-ProxyIPs $proxy.PHost if ($cfg.failClosed) { Install-Firewall $app $ips } $proxyArg = $proxy.Arg } if ($RestartClaude) { try { Stop-ClaudeInstances $app $args = @("--proxy-server=$proxyArg") if ($cfg.extraArgs) { $args += $cfg.extraArgs } Start-ClaudeApp $app ($args -join ' ') | Out-Null } catch { # Never leave a lock (or a dangling tunnel) without a proxied Claude: roll back. if ($cfg.failClosed) { Remove-Firewall -Quiet } if ($mode -eq 'vless') { Stop-Xray } throw } } } # Turn the switch OFF: remove lock, stop the tunnel, relaunch Claude normally. function Disable-Proxy([switch]$RestartClaude) { $app = Get-ClaudeApp Remove-Firewall -Quiet Stop-Xray # no-op in plain-proxy mode / if not running if ($RestartClaude) { Stop-ClaudeInstances $app Start-ClaudeApp $app '' | Out-Null } } # ---------------------------------------------------------------------------- verify # Enumerate every TCP connection owned by the Claude desktop processes and classify # each remote endpoint: through the proxy, local, a blocked bypass attempt, or a LEAK. function Get-ProxyConnections { $cfg = Get-Config $app = Get-ClaudeApp $mode = if ($cfg.mode) { $cfg.mode } else { 'proxy' } if ($mode -eq 'vless') { $localPort = if ($cfg.localPort) { [int]$cfg.localPort } else { 10808 } $proxySet = @('127.0.0.1', '::1') # claude may only reach the local tunnel entry $endpoint = "127.0.0.1:$localPort (VLESS+Reality)" $loopIsProxy = $true } else { $proxy = Parse-Proxy $cfg.proxy $ips = Resolve-ProxyIPs $proxy.PHost $proxySet = @($ips.V4 + $ips.V6) $endpoint = "$($proxy.PHost):$($proxy.Port)" $loopIsProxy = $false } $procs = @(Get-CimInstance Win32_Process -Filter "Name='claude.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($app.InstallLocation, [StringComparison]::OrdinalIgnoreCase) }) $procIds = @($procs | ForEach-Object { $_.ProcessId }) $res = [PSCustomObject]@{ Mode = $mode; ProxyEndpoint = $endpoint; ProxyIPs = $proxySet; Procs = $procIds.Count ViaProxy = @(); Local = @(); Blocked = @(); Leaks = @(); Tunnel = '' } if ($procIds.Count -gt 0) { $conns = Get-NetTCPConnection -OwningProcess $procIds -ErrorAction SilentlyContinue | Where-Object { $_.State -in 'Established', 'SynSent' } foreach ($c in $conns) { $ra = [string]$c.RemoteAddress $entry = "{0}:{1} [{2}]" -f $ra, $c.RemotePort, $c.State $isLoop = ($ra -in @('127.0.0.1', '::1')) $isOtherLocal = ($ra -in @('0.0.0.0', '::')) -or ($ra -like 'fe80*') if ($isLoop) { if ($loopIsProxy) { $res.ViaProxy += $entry } else { $res.Local += $entry }; continue } if ($isOtherLocal) { $res.Local += $entry; continue } if (-not $loopIsProxy -and ($proxySet -contains $ra)) { $res.ViaProxy += $entry; continue } if ($c.State -eq 'SynSent') { $res.Blocked += $entry; continue } $res.Leaks += $entry } } if ($mode -eq 'vless') { $xp = @(Get-XrayProcs) if ($xp.Count -eq 0) { $res.Tunnel = 'xray НЕ запущен' } else { $xc = Get-NetTCPConnection -OwningProcess @($xp | ForEach-Object { $_.Id }) -State Established -ErrorAction SilentlyContinue | Where-Object { [string]$_.RemoteAddress -notin @('127.0.0.1', '::1') } | Select-Object -First 1 $res.Tunnel = if ($xc) { "xray -> $($xc.RemoteAddress):$($xc.RemotePort)" } else { 'xray запущен, внешнего коннекта пока нет' } } } $res } # Human-readable report lines + overall verdict; also written to a log file. function Format-VerifyReport($c) { $lines = @() $modeLabel = if ($c.Mode -eq 'vless') { 'VLESS+Reality' } else { 'прямой прокси' } $lines += "Проверка трафика Claude ($modeLabel)" $lines += " вход : $($c.ProxyEndpoint)" if ($c.Mode -eq 'vless') { $lines += " тоннель : $($c.Tunnel)" } $lines += " процессов Claude: $($c.Procs)" $lines += " через прокси : $($c.ViaProxy.Count)" $lines += " локальные : $($c.Local.Count)" $lines += " заблокировано : $($c.Blocked.Count) (firewall отбил обход)" $lines += " УТЕЧКИ : $($c.Leaks.Count)" if ($c.Leaks.Count -gt 0) { $c.Leaks | ForEach-Object { $lines += " ! прямой коннект: $_" } } if ($c.Blocked.Count -gt 0) { $c.Blocked | ForEach-Object { $lines += " ~ отбито: $_" } } $tunnelDown = ($c.Mode -eq 'vless' -and $c.Tunnel -eq 'xray НЕ запущен') $verdict = if ($c.Procs -eq 0) { 'НЕТ ДАННЫХ: Claude не запущен' } elseif ($c.Leaks.Count -gt 0) { 'ПРОВАЛ: есть прямой трафик мимо прокси' } elseif ($tunnelDown) { 'ПРОВАЛ: тоннель (xray) не запущен' } elseif ($c.ViaProxy.Count -eq 0) { 'НЕОПРЕДЕЛЁННО: нет активных коннектов (подожди, пока Claude загрузится)' } elseif ($c.Mode -eq 'vless') { 'OK: весь трафик Claude идёт в VLESS+Reality тоннель' } else { 'OK: весь внешний трафик Claude идёт через прокси' } $lines += " ВЕРДИКТ : $verdict" [PSCustomObject]@{ Lines = $lines; Verdict = $verdict; Ok = ($c.Leaks.Count -eq 0 -and $c.ViaProxy.Count -gt 0 -and -not $tunnelDown) } } function Invoke-Verify { $conns = Get-ProxyConnections $rep = Format-VerifyReport $conns if (-not (Test-Path $script:ConfigDir)) { New-Item -ItemType Directory -Path $script:ConfigDir -Force | Out-Null } $stamp = (Get-Date).ToString('yyyyMMdd-HHmmss') $logPath = Join-Path $script:ConfigDir "verify-$stamp.log" $rep.Lines | Set-Content -Path $logPath -Encoding UTF8 $rep | Add-Member -NotePropertyName LogPath -NotePropertyValue $logPath -PassThru }