claude-proxy: native C# GUI to force Claude desktop traffic through a proxy

Windows tray "switch" that routes all Claude MSIX-app traffic through a
SOCKS5/HTTP proxy or a VLESS+Reality tunnel (embedded Xray-core), with a
fail-closed Windows Firewall lock. Runs de-elevated so it launches the
user's normal Claude instance; elevates only for firewall changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 13:46:23 +03:00
commit ab2d3ed8cd
16 changed files with 2707 additions and 0 deletions
+546
View File
@@ -0,0 +1,546 @@
<#
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 '^(?<scheme>socks5|socks5h|socks4|http|https)://(?<host>[^:/]+):(?<port>\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
}