Files
claude-proxy/claude-proxy.lib.ps1
T
gusadmin ab2d3ed8cd 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>
2026-07-10 13:46:23 +03:00

547 lines
24 KiB
PowerShell
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
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
}