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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user