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:
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# build output
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
# Xray binary (fetched by build-native.ps1, verified by SHA256) - not stored in git
|
||||||
|
xray.zip
|
||||||
|
xray-embed.b64
|
||||||
|
|
||||||
|
# preview / debug artifacts
|
||||||
|
*-preview.png
|
||||||
|
preview.png
|
||||||
|
ui-preview*.png
|
||||||
|
native-preview.png
|
||||||
|
debug.log
|
||||||
|
|
||||||
|
# generated icon (regenerated by make-icon.ps1)
|
||||||
|
icon.ico
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@echo off
|
||||||
|
REM Visible-console launcher for the GUI (for troubleshooting). Normal use: "Claude Proxy.vbs".
|
||||||
|
set "HERE=%~dp0"
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -STA -File "%HERE%claude-proxy-gui.ps1"
|
||||||
|
pause
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
' Double-click launcher for the Claude Proxy GUI (no console window).
|
||||||
|
' The GUI self-elevates (one UAC prompt) so firewall toggling needs no repeated prompts.
|
||||||
|
Dim sh, here
|
||||||
|
Set sh = CreateObject("WScript.Shell")
|
||||||
|
here = Left(WScript.ScriptFullName, InStrRev(WScript.ScriptFullName, "\"))
|
||||||
|
sh.Run "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -STA -File """ & here & "claude-proxy-gui.ps1""", 0, False
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@echo off
|
||||||
|
REM Double-click entry point: launch Claude with all traffic forced through the proxy.
|
||||||
|
set "HERE=%~dp0"
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%HERE%claude-proxy.ps1" -Action run
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo Launch failed. See the message above.
|
||||||
|
pause
|
||||||
|
)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# claude-proxy
|
||||||
|
|
||||||
|
GUI-«рубильник» для десктопного **Claude** (MSIX/Store, Windows): включил — **весь** трафик
|
||||||
|
приложения идёт только через прокси, выключил — напрямую. Два режима: прямой SOCKS5/HTTP-прокси
|
||||||
|
и **VLESS + Reality** тоннель (встроенный Xray-core).
|
||||||
|
|
||||||
|
Нативный C#/.NET, один exe (~20 МБ). Ничего доустанавливать не нужно.
|
||||||
|
|
||||||
|
## Как это работает
|
||||||
|
|
||||||
|
Два слоя защиты:
|
||||||
|
|
||||||
|
1. **Функциональный** — Claude запускается через MSIX-активацию с аргументом
|
||||||
|
`--proxy-server="socks5://host:port"`. Chromium-стек аппы (в т.ч. запросы к API) уходит в
|
||||||
|
прокси. SOCKS5 обрабатывается нативно, с удалённым DNS (без DNS-утечки), без HTTP→SOCKS-моста.
|
||||||
|
|
||||||
|
2. **Fail-closed (гарантия)** — правило Windows Firewall на `claude.exe`: блок **любого**
|
||||||
|
исходящего, кроме адреса прокси. Если что-то полезет мимо прокси — оно не получит сеть
|
||||||
|
(заблокируется), а не утечёт напрямую. «Весь трафик через прокси или никак».
|
||||||
|
|
||||||
|
Приложение работает **без прав администратора** — поэтому запущенный им Claude это твой обычный
|
||||||
|
инстанс (тот, что в трее), а не отдельный админский. UAC запрашивается только на смену
|
||||||
|
firewall-правила (при вкл/выкл рубильника).
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
Двойной клик по **`ClaudeProxy.exe`** — откроется окно с рубильником.
|
||||||
|
|
||||||
|
- **Рубильник ВКЛ** — ставит firewall-лок и запускает Claude через прокси. Если Claude закрыт —
|
||||||
|
просто стартует его уже завёрнутым; если открыт — предложит перезапуск.
|
||||||
|
- **Рубильник ВЫКЛ** — снимает лок, останавливает тоннель, перезапускает Claude напрямую.
|
||||||
|
- **«Запустить Claude»** — старт Claude через прокси по требованию (ставит лок при необходимости).
|
||||||
|
- **«Проверить»** — самопроверка: перечисляет TCP-коннекты Claude и сверяет, что всё идёт через
|
||||||
|
прокси/тоннель; вердикт пишется в `%APPDATA%\ClaudeProxy\verify-<время>.log`.
|
||||||
|
- Переключение режима заблокировано, пока рубильник ВКЛ.
|
||||||
|
|
||||||
|
## Режим VLESS + Reality
|
||||||
|
|
||||||
|
Переключатель режима в шапке. В режиме VLESS поднимается локальный **Xray-core** (вход — SOCKS5
|
||||||
|
на `127.0.0.1:10808`, выход — твой VLESS+Reality сервер), Claude направляется на локальный SOCKS,
|
||||||
|
наружу-трафик шифрует и тоннелирует Xray. Fail-closed тут строже: `claude.exe` блокируется от
|
||||||
|
**всего** внешнего (loopback firewall не трогает) — то есть только в тоннель.
|
||||||
|
|
||||||
|
1. Переключи режим на **VLESS + Reality**.
|
||||||
|
2. Вставь `vless://...` share-link из 3x-ui в поле (оно маскируется — ключи не светятся). «Сохранить».
|
||||||
|
3. Рубильник **ВКЛ** — Xray распакуется из встроенного (или кнопкой «Распаковать»), поднимется
|
||||||
|
тоннель, Claude уйдёт в него.
|
||||||
|
|
||||||
|
Парсинг ссылки: Reality (pbk/sni/sid/spx/fp/flow) и транспорты tcp/grpc/ws.
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Windows 10/11 (нужен встроенный .NET Framework 4.x — есть из коробки).
|
||||||
|
- Установленный десктопный **Claude** (MSIX/Store) — детектится автоматически.
|
||||||
|
|
||||||
|
Настройки хранятся per-user в `%APPDATA%\ClaudeProxy\config.ini`; Xray встроен в exe, ставится в
|
||||||
|
`%APPDATA%\ClaudeProxy\bin`. Пинится Xray-core `v26.3.27`.
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File build-native.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Компилит через `csc` из .NET Framework (`C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe`,
|
||||||
|
есть на любой Windows — SDK не нужен). При первой сборке скачивает Xray-core (пинованную версию,
|
||||||
|
с проверкой SHA256) и встраивает как ресурс; иконку генерит `make-icon.ps1`; манифест — asInvoker.
|
||||||
|
Результат — `dist\ClaudeProxy.exe`.
|
||||||
|
|
||||||
|
Исходники (`native/`):
|
||||||
|
- `Core.cs` — firewall (Windows Firewall COM API), MSIX-активация, Xray, детект приложения.
|
||||||
|
- `Support.cs` — конфиг, VLESS-парсер + генератор Xray-конфига, verify (через `netstat`), `Main`.
|
||||||
|
- `MainForm.cs` — WinForms UI.
|
||||||
|
|
||||||
|
Код на **C# 5** (csc из .NET Framework не поддерживает C# 6+).
|
||||||
|
|
||||||
|
> В репо остались PowerShell/ps2exe-исходники первой версии (`claude-proxy*.ps1`, `build.ps1`) —
|
||||||
|
> deprecated. От ps2exe отказались: упакованный exe ловил ложное срабатывание антивируса
|
||||||
|
> (эвристика на «PowerShell внутри exe»). Нативный managed-exe этим не страдает.
|
||||||
|
|
||||||
|
## Как оно себя проверяет
|
||||||
|
|
||||||
|
Кнопка «Проверить» перечисляет TCP-коннекты процессов Claude и классифицирует каждый удалённый
|
||||||
|
адрес: через прокси/тоннель, локальный, заблокированный обход (`SYN_SENT` в никуда) или **утечка**
|
||||||
|
(прямой установленный коннект). Вердикт + детали пишутся в лог-файл, который переживает разрыв
|
||||||
|
связи. Независимая земля-истина — логи самого прокси/сервера.
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Слой 1 зависит от того, что Electron уважает `--proxy-server` (Chromium — да). Если часть
|
||||||
|
трафика пойдёт мимо Chromium, при fail-closed она **заблокируется** (не утечёт), но функция
|
||||||
|
аппы может не работать.
|
||||||
|
- Firewall-правило пинит IP прокси (резолв при включении). Сменился IP — переключи рубильник.
|
||||||
|
- Лок переживает перезагрузку; снимается выключением рубильника.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<#
|
||||||
|
build-native.ps1 - compile the native C# app (no ps2exe) to dist\ClaudeProxy.exe.
|
||||||
|
Uses the .NET Framework C# compiler (csc), which is present on every Windows machine.
|
||||||
|
#>
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$dist = Join-Path $root 'dist'
|
||||||
|
New-Item -ItemType Directory -Force -Path $dist | Out-Null
|
||||||
|
|
||||||
|
$csc = 'C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe'
|
||||||
|
if (-not (Test-Path $csc)) { throw "csc not found: $csc" }
|
||||||
|
|
||||||
|
# make sure the icon and the embedded Xray zip exist
|
||||||
|
if (-not (Test-Path (Join-Path $root 'icon.ico'))) {
|
||||||
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $root 'make-icon.ps1') | Out-Null
|
||||||
|
}
|
||||||
|
# Xray binary (embedded as a resource). Not stored in git - fetch the pinned release if missing.
|
||||||
|
$xrayZip = Join-Path $root 'xray.zip'
|
||||||
|
if (-not (Test-Path $xrayZip)) {
|
||||||
|
$ver = 'v26.3.27'
|
||||||
|
$base = "https://github.com/XTLS/Xray-core/releases/download/$ver"
|
||||||
|
Write-Host "Fetching Xray-core $ver..." -ForegroundColor Cyan
|
||||||
|
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
|
||||||
|
Invoke-WebRequest "$base/Xray-windows-64.zip" -OutFile $xrayZip -UseBasicParsing
|
||||||
|
Invoke-WebRequest "$base/Xray-windows-64.zip.dgst" -OutFile "$xrayZip.dgst" -UseBasicParsing
|
||||||
|
$expected = $null
|
||||||
|
foreach ($l in (Get-Content "$xrayZip.dgst")) {
|
||||||
|
if ($l -match '512') { continue }
|
||||||
|
if ($l -match '256' -and $l -match '([0-9a-fA-F]{64})') { $expected = $Matches[1].ToLower(); break }
|
||||||
|
}
|
||||||
|
$actual = (Get-FileHash $xrayZip -Algorithm SHA256).Hash.ToLower()
|
||||||
|
Remove-Item "$xrayZip.dgst" -ErrorAction SilentlyContinue
|
||||||
|
if ($expected -and $actual -ne $expected) { Remove-Item $xrayZip -ErrorAction SilentlyContinue; throw "Xray SHA256 mismatch." }
|
||||||
|
Write-Host " Xray SHA256 ok." -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = Join-Path $dist 'ClaudeProxy.exe'
|
||||||
|
$refs = @('System.dll', 'System.Core.dll', 'System.Windows.Forms.dll', 'System.Drawing.dll',
|
||||||
|
'System.IO.Compression.dll', 'System.IO.Compression.FileSystem.dll', 'Microsoft.CSharp.dll')
|
||||||
|
|
||||||
|
$args = @(
|
||||||
|
'/nologo', '/target:winexe', '/platform:x64', "/out:$out",
|
||||||
|
"/win32icon:$(Join-Path $root 'icon.ico')",
|
||||||
|
"/win32manifest:$(Join-Path $root 'native\app.manifest')",
|
||||||
|
"/resource:$(Join-Path $root 'xray.zip'),ClaudeProxy.xray.zip"
|
||||||
|
)
|
||||||
|
foreach ($r in $refs) { $args += "/reference:$r" }
|
||||||
|
$args += (Join-Path $root 'native\Core.cs')
|
||||||
|
$args += (Join-Path $root 'native\Support.cs')
|
||||||
|
$args += (Join-Path $root 'native\MainForm.cs')
|
||||||
|
|
||||||
|
Write-Host "Compiling native C# -> $out" -ForegroundColor Cyan
|
||||||
|
& $csc @args
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "csc failed (exit $LASTEXITCODE)" }
|
||||||
|
|
||||||
|
$sz = [math]::Round((Get-Item $out).Length / 1MB, 1)
|
||||||
|
Write-Host "OK: $out ($sz MB)" -ForegroundColor Green
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<#
|
||||||
|
build.ps1 - inline lib+gui into one script and compile to dist\ClaudeProxy.exe via ps2exe.
|
||||||
|
Usage: powershell -ExecutionPolicy Bypass -File build.ps1 [-Smoke]
|
||||||
|
-Smoke : after inlining, run the combined script with a 0.7s auto-close to catch runtime errors.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param([switch]$Smoke)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$dist = Join-Path $root 'dist'
|
||||||
|
New-Item -ItemType Directory -Force -Path $dist | Out-Null
|
||||||
|
|
||||||
|
$rd = New-Object System.Text.UTF8Encoding($false)
|
||||||
|
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||||
|
|
||||||
|
$lib = [System.IO.File]::ReadAllText((Join-Path $root 'claude-proxy.lib.ps1'), $rd)
|
||||||
|
$guiLines = [System.IO.File]::ReadAllLines((Join-Path $root 'claude-proxy-gui.ps1'), $rd)
|
||||||
|
|
||||||
|
# Drop the dot-source line and the $Root assignment: in a single-file exe there is no
|
||||||
|
# external lib to source, and $MyInvocation.MyCommand.Path is null under ps2exe.
|
||||||
|
$guiFiltered = $guiLines | Where-Object {
|
||||||
|
($_ -notmatch 'claude-proxy\.lib\.ps1') -and ($_ -notmatch '^\s*\$Root\s*=')
|
||||||
|
}
|
||||||
|
|
||||||
|
# embed the Xray binary (base64) so the exe is self-contained (no download needed)
|
||||||
|
$embed = ''
|
||||||
|
$embedFile = Join-Path $root 'xray-embed.b64'
|
||||||
|
if (Test-Path $embedFile) {
|
||||||
|
$b64 = ([System.IO.File]::ReadAllText($embedFile)).Trim()
|
||||||
|
$embed = "`r`n`$script:XrayEmbedB64 = @'`r`n$b64`r`n'@`r`n"
|
||||||
|
Write-Host ("Embedding Xray: {0} KB base64" -f [math]::Round($b64.Length / 1KB))
|
||||||
|
} else {
|
||||||
|
Write-Host "xray-embed.b64 not found - exe will download Xray on first use." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
$combined = $lib + $embed + "`r`n# ===== inlined GUI =====`r`n" + ($guiFiltered -join "`r`n")
|
||||||
|
$combinedPath = Join-Path $dist 'claude-proxy.combined.ps1'
|
||||||
|
[System.IO.File]::WriteAllText($combinedPath, $combined, $enc)
|
||||||
|
Write-Host "Combined script -> $combinedPath"
|
||||||
|
|
||||||
|
# parse-check
|
||||||
|
$errs = $null; $tok = $null
|
||||||
|
[System.Management.Automation.Language.Parser]::ParseFile($combinedPath, [ref]$tok, [ref]$errs) | Out-Null
|
||||||
|
if ($errs) {
|
||||||
|
Write-Host "PARSE ERRORS in combined script:" -ForegroundColor Red
|
||||||
|
$errs | ForEach-Object { ' {0}:{1} {2}' -f $_.Extent.StartLineNumber, $_.Extent.StartColumnNumber, $_.Message }
|
||||||
|
throw "Aborting build."
|
||||||
|
}
|
||||||
|
Write-Host "Parse: OK" -ForegroundColor Green
|
||||||
|
|
||||||
|
if ($Smoke) {
|
||||||
|
Write-Host "Smoke test (window will flash briefly)..." -ForegroundColor Cyan
|
||||||
|
$env:CLAUDEPROXY_SMOKE = '1'
|
||||||
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -STA -File $combinedPath
|
||||||
|
Remove-Item Env:\CLAUDEPROXY_SMOKE -ErrorAction SilentlyContinue
|
||||||
|
Write-Host "Smoke exit: $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# regenerate the icon if the generator changed / icon missing
|
||||||
|
$icon = Join-Path $root 'icon.ico'
|
||||||
|
if (-not (Test-Path $icon)) {
|
||||||
|
Write-Host "Generating icon..." -ForegroundColor Cyan
|
||||||
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $root 'make-icon.ps1') | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
Import-Module ps2exe -ErrorAction Stop
|
||||||
|
$exe = Join-Path $dist 'ClaudeProxy.exe'
|
||||||
|
Write-Host "Compiling -> $exe" -ForegroundColor Cyan
|
||||||
|
# NOTE: asInvoker (NOT requireAdmin). The GUI runs de-elevated so the Claude it launches is the
|
||||||
|
# user's normal instance (matches the taskbar/tray pin). Firewall changes elevate on demand.
|
||||||
|
$p2e = @{
|
||||||
|
inputFile = $combinedPath; outputFile = $exe
|
||||||
|
noConsole = $true; STA = $true
|
||||||
|
title = 'Claude Proxy'; product = 'Claude Proxy'; company = 'gusik'
|
||||||
|
version = '1.0.0.0'; description = 'Force Claude desktop traffic through a proxy'
|
||||||
|
}
|
||||||
|
if (Test-Path $icon) { $p2e.iconFile = $icon }
|
||||||
|
Invoke-ps2exe @p2e | Out-Null
|
||||||
|
|
||||||
|
if (Test-Path $exe) {
|
||||||
|
$sz = [math]::Round((Get-Item $exe).Length / 1KB)
|
||||||
|
Write-Host "OK: $exe ($sz KB)" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
throw "ps2exe did not produce the exe."
|
||||||
|
}
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
<#
|
||||||
|
claude-proxy-gui.ps1 - GUI "рубильник" for forcing Claude desktop traffic through a proxy.
|
||||||
|
Claude-styled: warm cream surface, coral accent, rounded cards/buttons, spark mark.
|
||||||
|
Self-elevates once at startup so firewall toggling needs no repeated UAC prompts.
|
||||||
|
#>
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
. (Join-Path $Root 'claude-proxy.lib.ps1')
|
||||||
|
|
||||||
|
# ---- elevated firewall helper -------------------------------------------------
|
||||||
|
# The GUI itself runs DE-ELEVATED, so the Claude it launches is the user's normal instance
|
||||||
|
# (the one pinned in the tray) and not a separate admin instance. Firewall changes need admin,
|
||||||
|
# so the GUI relaunches this exe with --fw-apply / --fw-remove via RunAs; those do only the
|
||||||
|
# firewall op (no GUI) and exit.
|
||||||
|
# NB: use dash-less tokens ('fwapply'/'fwremove'). ps2exe mangles '--fw-apply' into a bound
|
||||||
|
# parameter, so it would never reach $args as a plain string.
|
||||||
|
if ($args -contains 'fwapply' -or $args -contains 'fwremove') {
|
||||||
|
if (Test-Admin) {
|
||||||
|
try {
|
||||||
|
if ($args -contains 'fwapply') { Apply-FirewallForConfig } else { Remove-Firewall -Quiet }
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
[System.Windows.Forms.Application]::EnableVisualStyles()
|
||||||
|
[System.Windows.Forms.Application]::SetCompatibleTextRenderingDefault($false)
|
||||||
|
# Keep the app alive on a stray UI-thread error and show a tidy message (no raw .NET crash dialog).
|
||||||
|
[System.Windows.Forms.Application]::SetUnhandledExceptionMode([System.Windows.Forms.UnhandledExceptionMode]::CatchException)
|
||||||
|
[System.Windows.Forms.Application]::add_ThreadException({ param($s, $e)
|
||||||
|
try { [System.Windows.Forms.MessageBox]::Show($e.Exception.Message, 'Ошибка', 'OK', 'Warning') | Out-Null } catch {} })
|
||||||
|
|
||||||
|
# ---- Claude palette ----------------------------------------------------------
|
||||||
|
function C([int]$r, [int]$g, [int]$b) { [System.Drawing.Color]::FromArgb($r, $g, $b) }
|
||||||
|
$cCream = C 250 249 245
|
||||||
|
$cInk = C 41 40 36
|
||||||
|
$cHead = C 26 25 21
|
||||||
|
$cMuted = C 131 129 123
|
||||||
|
$cCard = C 255 255 255
|
||||||
|
$cBorder = C 232 229 220
|
||||||
|
$cCoral = C 193 95 60
|
||||||
|
$cCoralLo = C 224 137 101
|
||||||
|
$cCoralHi = C 168 79 48
|
||||||
|
$cTrackOff = C 206 202 192
|
||||||
|
$cOk = C 74 122 80
|
||||||
|
$cRed = C 196 74 61
|
||||||
|
$cWhite = C 255 255 255
|
||||||
|
$fReg = New-Object System.Drawing.Font('Segoe UI', 9.5)
|
||||||
|
$fSemi = New-Object System.Drawing.Font('Segoe UI Semibold', 9.5)
|
||||||
|
$fHead = New-Object System.Drawing.Font('Segoe UI Semibold', 15)
|
||||||
|
$fSub = New-Object System.Drawing.Font('Segoe UI', 9)
|
||||||
|
$fPill = New-Object System.Drawing.Font('Segoe UI Semibold', 12)
|
||||||
|
|
||||||
|
# ---- helpers -----------------------------------------------------------------
|
||||||
|
function RoundPath($w, $h, $r) {
|
||||||
|
$d = $r * 2
|
||||||
|
$p = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||||
|
$p.AddArc(0, 0, $d, $d, 180, 90)
|
||||||
|
$p.AddArc($w - $d, 0, $d, $d, 270, 90)
|
||||||
|
$p.AddArc($w - $d, $h - $d, $d, $d, 0, 90)
|
||||||
|
$p.AddArc(0, $h - $d, $d, $d, 90, 90)
|
||||||
|
$p.CloseFigure(); $p
|
||||||
|
}
|
||||||
|
function Set-Round($ctl, $r) { $ctl.Region = New-Object System.Drawing.Region((RoundPath $ctl.Width $ctl.Height $r)) }
|
||||||
|
|
||||||
|
# Enable double buffering on any control (kills repaint flicker on custom-painted panels).
|
||||||
|
$script:DBProp = [System.Windows.Forms.Control].GetProperty('DoubleBuffered',
|
||||||
|
[System.Reflection.BindingFlags]([System.Reflection.BindingFlags]::Instance -bor [System.Reflection.BindingFlags]::NonPublic))
|
||||||
|
function Set-DoubleBuffered($ctl) { $script:DBProp.SetValue($ctl, $true, $null) }
|
||||||
|
|
||||||
|
function Draw-Spark($g, $cx, $cy, $inner, $outer, $penW, $col) {
|
||||||
|
$pen = New-Object System.Drawing.Pen($col, $penW)
|
||||||
|
$pen.StartCap = 'Round'; $pen.EndCap = 'Round'
|
||||||
|
for ($i = 0; $i -lt 12; $i++) {
|
||||||
|
$a = [math]::PI * 2 * $i / 12
|
||||||
|
$len = $outer * (0.82 + 0.18 * [math]::Abs([math]::Cos($a * 1.5)))
|
||||||
|
$g.DrawLine($pen, ($cx + [math]::Cos($a) * $inner), ($cy + [math]::Sin($a) * $inner),
|
||||||
|
($cx + [math]::Cos($a) * $len), ($cy + [math]::Sin($a) * $len))
|
||||||
|
}
|
||||||
|
$cd = $penW * 1.35
|
||||||
|
$db = New-Object System.Drawing.SolidBrush($col)
|
||||||
|
$g.FillEllipse($db, ($cx - $cd), ($cy - $cd), ($cd * 2), ($cd * 2))
|
||||||
|
$pen.Dispose(); $db.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
# card factory (white rounded panel with a hairline border)
|
||||||
|
function New-Card($x, $y, $w, $h) {
|
||||||
|
$p = New-Object System.Windows.Forms.Panel
|
||||||
|
$p.Location = New-Object System.Drawing.Point($x, $y)
|
||||||
|
$p.Size = New-Object System.Drawing.Size($w, $h)
|
||||||
|
$p.BackColor = $cCream
|
||||||
|
Set-DoubleBuffered $p
|
||||||
|
$p.Add_Paint({
|
||||||
|
param($s, $e)
|
||||||
|
$e.Graphics.SmoothingMode = 'AntiAlias'
|
||||||
|
$path = RoundPath ($s.Width - 1) ($s.Height - 1) 14
|
||||||
|
$b = New-Object System.Drawing.SolidBrush($cCard)
|
||||||
|
$pen = New-Object System.Drawing.Pen($cBorder, 1)
|
||||||
|
$e.Graphics.FillPath($b, $path); $e.Graphics.DrawPath($pen, $path)
|
||||||
|
$b.Dispose(); $pen.Dispose(); $path.Dispose()
|
||||||
|
})
|
||||||
|
$p
|
||||||
|
}
|
||||||
|
|
||||||
|
function Style-Primary($btn) {
|
||||||
|
$btn.FlatStyle = 'Flat'; $btn.FlatAppearance.BorderSize = 0
|
||||||
|
$btn.BackColor = $cCoral; $btn.ForeColor = $cWhite; $btn.Font = $fSemi
|
||||||
|
$btn.FlatAppearance.MouseOverBackColor = $cCoralHi
|
||||||
|
$btn.Cursor = [System.Windows.Forms.Cursors]::Hand
|
||||||
|
# re-round after a handle recreation; $this is the button (do NOT capture $btn - it is null here)
|
||||||
|
$btn.Add_HandleCreated({ if ($this) { $this.Region = New-Object System.Drawing.Region((RoundPath $this.Width $this.Height 9)) } })
|
||||||
|
Set-Round $btn 9
|
||||||
|
}
|
||||||
|
function Style-Ghost($btn) {
|
||||||
|
$btn.FlatStyle = 'Flat'; $btn.FlatAppearance.BorderColor = $cBorder; $btn.FlatAppearance.BorderSize = 1
|
||||||
|
$btn.BackColor = $cCard; $btn.ForeColor = $cInk; $btn.Font = $fReg
|
||||||
|
$btn.FlatAppearance.MouseOverBackColor = $cCream
|
||||||
|
$btn.Cursor = [System.Windows.Forms.Cursors]::Hand
|
||||||
|
Set-Round $btn 9
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- form --------------------------------------------------------------------
|
||||||
|
$form = New-Object System.Windows.Forms.Form
|
||||||
|
$form.Text = 'Claude Proxy'
|
||||||
|
$form.ClientSize = New-Object System.Drawing.Size(452, 512)
|
||||||
|
$form.StartPosition = 'CenterScreen'
|
||||||
|
$form.FormBorderStyle = 'FixedSingle'
|
||||||
|
$form.MaximizeBox = $false
|
||||||
|
$form.BackColor = $cCream
|
||||||
|
$form.Font = $fReg
|
||||||
|
$form.ForeColor = $cInk
|
||||||
|
Set-DoubleBuffered $form
|
||||||
|
try {
|
||||||
|
$entry = [System.Reflection.Assembly]::GetEntryAssembly()
|
||||||
|
if ($entry -and $entry.Location -like '*.exe') { $form.Icon = [System.Drawing.Icon]::ExtractAssociatedIcon($entry.Location) }
|
||||||
|
elseif (Test-Path (Join-Path $Root 'icon.ico')) { $form.Icon = New-Object System.Drawing.Icon((Join-Path $Root 'icon.ico')) }
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
# header mark (coral tile + spark)
|
||||||
|
$mark = New-Object System.Windows.Forms.Panel
|
||||||
|
$mark.Size = New-Object System.Drawing.Size(46, 46)
|
||||||
|
$mark.Location = New-Object System.Drawing.Point(24, 22)
|
||||||
|
$mark.BackColor = $cCream
|
||||||
|
Set-DoubleBuffered $mark
|
||||||
|
$mark.Add_Paint({
|
||||||
|
param($s, $e)
|
||||||
|
$g = $e.Graphics; $g.SmoothingMode = 'AntiAlias'
|
||||||
|
$path = RoundPath ($s.Width - 1) ($s.Height - 1) 12
|
||||||
|
$rect = New-Object System.Drawing.Rectangle(0, 0, $s.Width, $s.Height)
|
||||||
|
$br = New-Object System.Drawing.Drawing2D.LinearGradientBrush($rect, $cCoralLo, $cCoralHi, 90)
|
||||||
|
$g.FillPath($br, $path)
|
||||||
|
# toggle glyph (matches the app icon)
|
||||||
|
$S = $s.Width
|
||||||
|
$tw = $S * 0.58; $th = $S * 0.34; $tx = ($S - $tw) / 2.0; $ty = ($S - $th) / 2.0
|
||||||
|
$tp = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||||
|
$tp.AddArc($tx, $ty, $th, $th, 90, 180); $tp.AddArc($tx + $tw - $th, $ty, $th, $th, 270, 180); $tp.CloseFigure()
|
||||||
|
$tb = New-Object System.Drawing.SolidBrush((C 248 246 239)); $g.FillPath($tb, $tp)
|
||||||
|
$kd = $th - ($S * 0.10); $kx = $tx + $tw - $kd - ($S * 0.05); $ky = $ty + (($th - $kd) / 2.0)
|
||||||
|
$kb = New-Object System.Drawing.SolidBrush($cCoralHi); $g.FillEllipse($kb, $kx, $ky, $kd, $kd)
|
||||||
|
$tb.Dispose(); $kb.Dispose(); $tp.Dispose(); $br.Dispose(); $path.Dispose()
|
||||||
|
})
|
||||||
|
$form.Controls.Add($mark)
|
||||||
|
|
||||||
|
$title = New-Object System.Windows.Forms.Label
|
||||||
|
$title.Text = 'Claude Proxy'; $title.Font = $fHead; $title.ForeColor = $cHead
|
||||||
|
$title.AutoSize = $true; $title.Location = New-Object System.Drawing.Point(82, 22)
|
||||||
|
$form.Controls.Add($title)
|
||||||
|
|
||||||
|
$subtitle = New-Object System.Windows.Forms.Label
|
||||||
|
$subtitle.Text = 'весь трафик Claude через прокси'; $subtitle.Font = $fSub; $subtitle.ForeColor = $cMuted
|
||||||
|
$subtitle.AutoSize = $false; $subtitle.Location = New-Object System.Drawing.Point(84, 48)
|
||||||
|
$subtitle.Size = New-Object System.Drawing.Size(196, 18) # keep clear of the mode radios on the right
|
||||||
|
$form.Controls.Add($subtitle)
|
||||||
|
|
||||||
|
# mode selector (top-right of the header) - right-aligned so it never overlaps the subtitle
|
||||||
|
$rbProxy = New-Object System.Windows.Forms.RadioButton
|
||||||
|
$rbProxy.Text = 'Прямой прокси'; $rbProxy.ForeColor = $cInk; $rbProxy.Font = $fReg; $rbProxy.AutoSize = $false
|
||||||
|
$rbProxy.TextAlign = 'MiddleLeft'; $rbProxy.BackColor = $cCream
|
||||||
|
$rbProxy.Size = New-Object System.Drawing.Size(150, 22); $rbProxy.Location = New-Object System.Drawing.Point(292, 18)
|
||||||
|
$form.Controls.Add($rbProxy)
|
||||||
|
|
||||||
|
$rbVless = New-Object System.Windows.Forms.RadioButton
|
||||||
|
$rbVless.Text = 'VLESS + Reality'; $rbVless.ForeColor = $cInk; $rbVless.Font = $fReg; $rbVless.AutoSize = $false
|
||||||
|
$rbVless.TextAlign = 'MiddleLeft'; $rbVless.BackColor = $cCream
|
||||||
|
$rbVless.Size = New-Object System.Drawing.Size(150, 22); $rbVless.Location = New-Object System.Drawing.Point(292, 42)
|
||||||
|
$form.Controls.Add($rbVless)
|
||||||
|
|
||||||
|
# ---- painted toggle ----------------------------------------------------------
|
||||||
|
$script:SwitchOn = $false
|
||||||
|
$script:Busy = $false
|
||||||
|
$tgl = New-Object System.Windows.Forms.Panel
|
||||||
|
$tgl.Size = New-Object System.Drawing.Size(172, 58)
|
||||||
|
$tgl.Location = New-Object System.Drawing.Point(24, 88)
|
||||||
|
$tgl.BackColor = $cCream
|
||||||
|
$tgl.Cursor = [System.Windows.Forms.Cursors]::Hand
|
||||||
|
Set-DoubleBuffered $tgl
|
||||||
|
$form.Controls.Add($tgl)
|
||||||
|
$tgl.Add_Paint({
|
||||||
|
param($s, $e)
|
||||||
|
$g = $e.Graphics; $g.SmoothingMode = 'AntiAlias'
|
||||||
|
$w = $s.Width; $h = $s.Height
|
||||||
|
$path = RoundPath $w $h ($h / 2)
|
||||||
|
if ($script:SwitchOn) {
|
||||||
|
$rect = New-Object System.Drawing.Rectangle(0, 0, $w, $h)
|
||||||
|
$br = New-Object System.Drawing.Drawing2D.LinearGradientBrush($rect, $cCoralLo, $cCoral, 0)
|
||||||
|
} else {
|
||||||
|
$br = New-Object System.Drawing.SolidBrush($cTrackOff)
|
||||||
|
}
|
||||||
|
$g.FillPath($br, $path)
|
||||||
|
$knobD = $h - 12
|
||||||
|
$knobX = if ($script:SwitchOn) { $w - $knobD - 6 } else { 6 }
|
||||||
|
$shadow = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(40, 0, 0, 0))
|
||||||
|
$g.FillEllipse($shadow, ($knobX + 1), 7, $knobD, $knobD)
|
||||||
|
$kb = New-Object System.Drawing.SolidBrush($cWhite)
|
||||||
|
$g.FillEllipse($kb, $knobX, 6, $knobD, $knobD)
|
||||||
|
$txt = if ($script:SwitchOn) { 'ВКЛ' } else { 'ВЫКЛ' }
|
||||||
|
$tcol = if ($script:SwitchOn) { $cWhite } else { C 106 104 98 }
|
||||||
|
$tb = New-Object System.Drawing.SolidBrush($tcol)
|
||||||
|
$tx = if ($script:SwitchOn) { 22 } else { 66 }
|
||||||
|
$g.DrawString($txt, $fPill, $tb, $tx, 16)
|
||||||
|
$br.Dispose(); $kb.Dispose(); $tb.Dispose(); $shadow.Dispose(); $path.Dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
$hint = New-Object System.Windows.Forms.Label
|
||||||
|
$hint.Text = 'Включено = наружу только через прокси, всё остальное заблокировано'
|
||||||
|
$hint.ForeColor = $cMuted; $hint.Font = $fSub; $hint.AutoSize = $false
|
||||||
|
$hint.Location = New-Object System.Drawing.Point(208, 92)
|
||||||
|
$hint.Size = New-Object System.Drawing.Size(224, 52)
|
||||||
|
$form.Controls.Add($hint)
|
||||||
|
|
||||||
|
# ---- status card -------------------------------------------------------------
|
||||||
|
$card = New-Card 20 162 412 158
|
||||||
|
$form.Controls.Add($card)
|
||||||
|
|
||||||
|
$lblProxyCap = New-Object System.Windows.Forms.Label
|
||||||
|
$lblProxyCap.Text = 'Прокси'; $lblProxyCap.ForeColor = $cMuted; $lblProxyCap.AutoSize = $true
|
||||||
|
$lblProxyCap.Location = New-Object System.Drawing.Point(18, 18); $card.Controls.Add($lblProxyCap)
|
||||||
|
|
||||||
|
$txtProxy = New-Object System.Windows.Forms.TextBox
|
||||||
|
$txtProxy.Location = New-Object System.Drawing.Point(84, 15); $txtProxy.Size = New-Object System.Drawing.Size(214, 24)
|
||||||
|
$txtProxy.BorderStyle = 'FixedSingle'; $txtProxy.BackColor = $cCream; $txtProxy.ForeColor = $cInk
|
||||||
|
$card.Controls.Add($txtProxy)
|
||||||
|
|
||||||
|
$btnSave = New-Object System.Windows.Forms.Button
|
||||||
|
$btnSave.Text = 'Сохранить'; $btnSave.Location = New-Object System.Drawing.Point(308, 13); $btnSave.Size = New-Object System.Drawing.Size(88, 28)
|
||||||
|
Style-Ghost $btnSave; $card.Controls.Add($btnSave)
|
||||||
|
|
||||||
|
$rProxyDot = New-Object System.Windows.Forms.Label
|
||||||
|
$rProxyDot.Text = [char]0x25CF; $rProxyDot.AutoSize = $true; $rProxyDot.Font = New-Object System.Drawing.Font('Segoe UI', 11)
|
||||||
|
$rProxyDot.ForeColor = $cMuted; $rProxyDot.Location = New-Object System.Drawing.Point(17, 54); $card.Controls.Add($rProxyDot)
|
||||||
|
$lblReach = New-Object System.Windows.Forms.Label
|
||||||
|
$lblReach.AutoSize = $true; $lblReach.ForeColor = $cInk; $lblReach.Font = $fReg
|
||||||
|
$lblReach.Location = New-Object System.Drawing.Point(38, 56); $card.Controls.Add($lblReach)
|
||||||
|
|
||||||
|
$sep = New-Object System.Windows.Forms.Label
|
||||||
|
$sep.AutoSize = $false; $sep.Location = New-Object System.Drawing.Point(18, 86); $sep.Size = New-Object System.Drawing.Size(378, 1)
|
||||||
|
$sep.BackColor = $cBorder; $card.Controls.Add($sep)
|
||||||
|
|
||||||
|
$lblFwCap = New-Object System.Windows.Forms.Label
|
||||||
|
$lblFwCap.Text = 'Firewall-лок'; $lblFwCap.ForeColor = $cMuted; $lblFwCap.AutoSize = $true
|
||||||
|
$lblFwCap.Location = New-Object System.Drawing.Point(18, 98); $card.Controls.Add($lblFwCap)
|
||||||
|
$lblFw = New-Object System.Windows.Forms.Label
|
||||||
|
$lblFw.AutoSize = $true; $lblFw.ForeColor = $cInk; $lblFw.Font = $fSemi
|
||||||
|
$lblFw.Location = New-Object System.Drawing.Point(120, 98); $card.Controls.Add($lblFw)
|
||||||
|
|
||||||
|
$lblClCap = New-Object System.Windows.Forms.Label
|
||||||
|
$lblClCap.Text = 'Claude'; $lblClCap.ForeColor = $cMuted; $lblClCap.AutoSize = $true
|
||||||
|
$lblClCap.Location = New-Object System.Drawing.Point(18, 122); $card.Controls.Add($lblClCap)
|
||||||
|
$lblCl = New-Object System.Windows.Forms.Label
|
||||||
|
$lblCl.AutoSize = $true; $lblCl.ForeColor = $cInk; $lblCl.Font = $fSemi
|
||||||
|
$lblCl.Location = New-Object System.Drawing.Point(120, 122); $card.Controls.Add($lblCl)
|
||||||
|
|
||||||
|
# VLESS-only: Xray status row (positioned by Apply-ModeLayout)
|
||||||
|
$lblXrayCap = New-Object System.Windows.Forms.Label
|
||||||
|
$lblXrayCap.Text = 'Xray-core'; $lblXrayCap.ForeColor = $cMuted; $lblXrayCap.AutoSize = $true
|
||||||
|
$lblXrayCap.Location = New-Object System.Drawing.Point(18, 68); $card.Controls.Add($lblXrayCap)
|
||||||
|
$lblXray = New-Object System.Windows.Forms.Label
|
||||||
|
$lblXray.AutoSize = $true; $lblXray.Font = $fSemi
|
||||||
|
$lblXray.Location = New-Object System.Drawing.Point(120, 68); $card.Controls.Add($lblXray)
|
||||||
|
$btnXray = New-Object System.Windows.Forms.Button
|
||||||
|
$btnXray.Text = 'Установить'; $btnXray.Location = New-Object System.Drawing.Point(300, 64); $btnXray.Size = New-Object System.Drawing.Size(96, 26)
|
||||||
|
Style-Ghost $btnXray; $card.Controls.Add($btnXray)
|
||||||
|
|
||||||
|
# ---- options -----------------------------------------------------------------
|
||||||
|
$chkRestart = New-Object System.Windows.Forms.CheckBox
|
||||||
|
$chkRestart.Text = 'Перезапускать Claude при переключении'
|
||||||
|
$chkRestart.ForeColor = $cInk; $chkRestart.AutoSize = $true; $chkRestart.Checked = $true; $chkRestart.Font = $fReg
|
||||||
|
$chkRestart.Location = New-Object System.Drawing.Point(22, 324); $form.Controls.Add($chkRestart)
|
||||||
|
|
||||||
|
$chkFailClosed = New-Object System.Windows.Forms.CheckBox
|
||||||
|
$chkFailClosed.Text = 'Fail-closed: блокировать любой трафик мимо прокси (firewall)'
|
||||||
|
$chkFailClosed.ForeColor = $cInk; $chkFailClosed.AutoSize = $true; $chkFailClosed.Checked = $true; $chkFailClosed.Font = $fReg
|
||||||
|
$chkFailClosed.Location = New-Object System.Drawing.Point(22, 348); $form.Controls.Add($chkFailClosed)
|
||||||
|
|
||||||
|
# ---- action buttons ----------------------------------------------------------
|
||||||
|
$btnLaunch = New-Object System.Windows.Forms.Button
|
||||||
|
$btnLaunch.Text = 'Запустить Claude'; $btnLaunch.Location = New-Object System.Drawing.Point(20, 384); $btnLaunch.Size = New-Object System.Drawing.Size(170, 36)
|
||||||
|
Style-Primary $btnLaunch; $form.Controls.Add($btnLaunch)
|
||||||
|
|
||||||
|
$btnVerify = New-Object System.Windows.Forms.Button
|
||||||
|
$btnVerify.Text = 'Проверить'; $btnVerify.Location = New-Object System.Drawing.Point(198, 386); $btnVerify.Size = New-Object System.Drawing.Size(142, 32)
|
||||||
|
Style-Ghost $btnVerify; $form.Controls.Add($btnVerify)
|
||||||
|
|
||||||
|
$btnRefresh = New-Object System.Windows.Forms.Button
|
||||||
|
$btnRefresh.Text = 'Обновить'; $btnRefresh.Location = New-Object System.Drawing.Point(348, 386); $btnRefresh.Size = New-Object System.Drawing.Size(84, 32)
|
||||||
|
Style-Ghost $btnRefresh; $form.Controls.Add($btnRefresh)
|
||||||
|
|
||||||
|
# ---- log ---------------------------------------------------------------------
|
||||||
|
$log = New-Object System.Windows.Forms.TextBox
|
||||||
|
$log.Multiline = $true; $log.ReadOnly = $true; $log.ScrollBars = 'Vertical'
|
||||||
|
$log.Location = New-Object System.Drawing.Point(20, 430); $log.Size = New-Object System.Drawing.Size(412, 66)
|
||||||
|
$log.BorderStyle = 'FixedSingle'; $log.BackColor = $cCard; $log.ForeColor = $cMuted; $log.Font = New-Object System.Drawing.Font('Consolas', 8.5)
|
||||||
|
$form.Controls.Add($log)
|
||||||
|
|
||||||
|
function Write-Log([string]$msg) { $log.AppendText(((Get-Date).ToString('HH:mm:ss') + ' ' + $msg + "`r`n")) }
|
||||||
|
|
||||||
|
function Get-CurrentMode { if ($rbVless.Checked) { 'vless' } else { 'proxy' } }
|
||||||
|
|
||||||
|
function Save-CurrentConfig {
|
||||||
|
$cfg = Get-Config
|
||||||
|
$cfg.mode = Get-CurrentMode
|
||||||
|
if ($cfg.mode -eq 'vless') { $cfg.vless = $txtProxy.Text } else { $cfg.proxy = $txtProxy.Text }
|
||||||
|
$cfg.failClosed = $chkFailClosed.Checked
|
||||||
|
Save-Config $cfg
|
||||||
|
$cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
function Update-XrayStatus {
|
||||||
|
if (Test-XrayPresent) { $lblXray.Text = "готов ($script:XrayVersion)"; $lblXray.ForeColor = $cOk; $btnXray.Text = 'Переустановить' }
|
||||||
|
elseif ($script:XrayEmbedB64) { $lblXray.Text = "встроен ($script:XrayVersion)"; $lblXray.ForeColor = $cOk; $btnXray.Text = 'Распаковать' }
|
||||||
|
else { $lblXray.Text = 'не установлен'; $lblXray.ForeColor = $cRed; $btnXray.Text = 'Установить' }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Swap the card between proxy and VLESS layouts.
|
||||||
|
function Apply-ModeLayout($mode) {
|
||||||
|
$cfg = Get-Config
|
||||||
|
if ($mode -eq 'vless') {
|
||||||
|
$lblProxyCap.Text = 'VLESS'; $subtitle.Text = 'через VLESS + Reality тоннель'
|
||||||
|
$txtProxy.UseSystemPasswordChar = $true; $txtProxy.Text = [string]$cfg.vless
|
||||||
|
$lblXrayCap.Visible = $true; $lblXray.Visible = $true; $btnXray.Visible = $true
|
||||||
|
$sep.Top = 100; $lblFwCap.Top = 112; $lblFw.Top = 112; $lblClCap.Top = 134; $lblCl.Top = 134
|
||||||
|
Update-XrayStatus
|
||||||
|
} else {
|
||||||
|
$lblProxyCap.Text = 'Прокси'; $subtitle.Text = 'весь трафик Claude через прокси'
|
||||||
|
$txtProxy.UseSystemPasswordChar = $false; $txtProxy.Text = [string]$cfg.proxy
|
||||||
|
$lblXrayCap.Visible = $false; $lblXray.Visible = $false; $btnXray.Visible = $false
|
||||||
|
$sep.Top = 74; $lblFwCap.Top = 86; $lblFw.Top = 86; $lblClCap.Top = 108; $lblCl.Top = 108
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mode-aware pre-flight before enabling: validate input, ensure Xray, check reachability, save.
|
||||||
|
function Preflight-Enable {
|
||||||
|
if ((Get-CurrentMode) -eq 'vless') {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($txtProxy.Text)) {
|
||||||
|
[System.Windows.Forms.MessageBox]::Show('Вставь VLESS-ссылку из 3x-ui.', 'Нужна ссылка', 'OK', 'Warning') | Out-Null; return $false
|
||||||
|
}
|
||||||
|
try { $v = ConvertFrom-VlessLink $txtProxy.Text }
|
||||||
|
catch { [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Некорректная ссылка', 'OK', 'Error') | Out-Null; return $false }
|
||||||
|
if (-not (Test-XrayPresent)) {
|
||||||
|
Set-Busy $true
|
||||||
|
try { Ensure-Xray ${function:Write-Log}; Update-XrayStatus }
|
||||||
|
catch { Write-Log ('ОШИБКА подготовки Xray: ' + $_.Exception.Message); [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Ошибка Xray', 'OK', 'Error') | Out-Null; Set-Busy $false; return $false }
|
||||||
|
Set-Busy $false
|
||||||
|
}
|
||||||
|
if (-not (Test-ProxyReachable ([PSCustomObject]@{ PHost = $v.VHost; Port = [int]$v.Port }))) {
|
||||||
|
$r = [System.Windows.Forms.MessageBox]::Show("Сервер $($v.VHost):$($v.Port) не отвечает. Всё равно продолжить?", 'Сервер недоступен', 'YesNo', 'Warning')
|
||||||
|
if ($r -ne 'Yes') { return $false }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try { $p = Parse-Proxy $txtProxy.Text }
|
||||||
|
catch { [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Некорректный адрес', 'OK', 'Error') | Out-Null; return $false }
|
||||||
|
if (-not (Test-ProxyReachable $p)) {
|
||||||
|
$r = [System.Windows.Forms.MessageBox]::Show("Прокси $($p.PHost):$($p.Port) не отвечает. Всё равно включить?", 'Прокси недоступен', 'YesNo', 'Warning')
|
||||||
|
if ($r -ne 'Yes') { return $false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Save-CurrentConfig | Out-Null
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Relaunch this exe (or script in dev) elevated to apply/remove the firewall. Returns $false if
|
||||||
|
# the user cancels the UAC prompt.
|
||||||
|
function Invoke-ElevatedSelf([string]$fwArg) {
|
||||||
|
try {
|
||||||
|
$self = ([System.Diagnostics.Process]::GetCurrentProcess()).MainModule.FileName
|
||||||
|
if ($self -like '*powershell*' -or $self -like '*pwsh*') {
|
||||||
|
Start-Process -FilePath $self -Verb RunAs -Wait -WindowStyle Hidden -ArgumentList @(
|
||||||
|
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-STA', '-File', "`"$PSCommandPath`"", $fwArg)
|
||||||
|
} else {
|
||||||
|
Start-Process -FilePath $self -Verb RunAs -Wait -ArgumentList $fwArg
|
||||||
|
}
|
||||||
|
return $true
|
||||||
|
} catch { return $false } # UAC cancelled / error
|
||||||
|
}
|
||||||
|
|
||||||
|
# Enable: start tunnel (vless) + firewall (elevated helper) + launch Claude (de-elevated).
|
||||||
|
function Do-Enable([bool]$restart) {
|
||||||
|
$cfg = Get-Config
|
||||||
|
$mode = if ($cfg.mode) { $cfg.mode } else { 'proxy' }
|
||||||
|
if ($mode -eq 'vless') {
|
||||||
|
$localPort = if ($cfg.localPort) { [int]$cfg.localPort } else { 10808 }
|
||||||
|
Ensure-Xray ${function:Write-Log}
|
||||||
|
Start-Xray $cfg.vless $localPort
|
||||||
|
$proxyArg = "socks5://127.0.0.1:$localPort"
|
||||||
|
} else {
|
||||||
|
$proxyArg = (Parse-Proxy $cfg.proxy).Arg
|
||||||
|
}
|
||||||
|
if ($cfg.failClosed) {
|
||||||
|
if (-not (Invoke-ElevatedSelf 'fwapply')) {
|
||||||
|
if ($mode -eq 'vless') { Stop-Xray }
|
||||||
|
throw 'Установка firewall отменена в UAC.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($restart) {
|
||||||
|
$app = Get-ClaudeApp
|
||||||
|
Stop-ClaudeInstances $app
|
||||||
|
$a = @("--proxy-server=$proxyArg"); if ($cfg.extraArgs) { $a += $cfg.extraArgs }
|
||||||
|
Start-ClaudeApp $app ($a -join ' ') | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Disable: remove firewall (elevated helper) + stop tunnel + relaunch Claude direct.
|
||||||
|
function Do-Disable([bool]$restart) {
|
||||||
|
$app = Get-ClaudeApp
|
||||||
|
if ((Get-FirewallState $app) -ne 'absent') {
|
||||||
|
if (-not (Invoke-ElevatedSelf 'fwremove')) { throw 'Снятие firewall отменено в UAC (лок остался).' }
|
||||||
|
}
|
||||||
|
Stop-Xray
|
||||||
|
if ($restart) { Stop-ClaudeInstances $app; Start-ClaudeApp $app '' | Out-Null }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- state / UI update -------------------------------------------------------
|
||||||
|
function Update-Ui([switch]$CheckReachable) {
|
||||||
|
try {
|
||||||
|
$app = Get-ClaudeApp
|
||||||
|
$fw = Get-FirewallState $app
|
||||||
|
$cl = Get-ClaudeStatus $app
|
||||||
|
$newOn = ($fw -eq 'ok')
|
||||||
|
if ($newOn -ne $script:SwitchOn) { $script:SwitchOn = $newOn; $tgl.Invalidate() }
|
||||||
|
|
||||||
|
$lblFw.Text = switch ($fw) { 'ok' { 'установлен' } 'stale' { 'устарел (путь изменился)' } default { 'нет' } }
|
||||||
|
$lblFw.ForeColor = if ($fw -eq 'ok') { $cCoral } elseif ($fw -eq 'stale') { $cRed } else { $cMuted }
|
||||||
|
|
||||||
|
if (-not $cl.Running) { $lblCl.Text = 'не запущен'; $lblCl.ForeColor = $cMuted }
|
||||||
|
elseif ($cl.Proxied) { $lblCl.Text = 'через прокси'; $lblCl.ForeColor = $cOk }
|
||||||
|
else { $lblCl.Text = 'напрямую'; $lblCl.ForeColor = $cRed }
|
||||||
|
|
||||||
|
if ($CheckReachable) {
|
||||||
|
$ok = $false; $ep = ''
|
||||||
|
if ((Get-CurrentMode) -eq 'vless') {
|
||||||
|
try { $v = ConvertFrom-VlessLink $txtProxy.Text; $ep = "$($v.VHost):$($v.Port)"; $ok = Test-ProxyReachable ([PSCustomObject]@{ PHost = $v.VHost; Port = [int]$v.Port }) }
|
||||||
|
catch { $ep = 'некорректная ссылка' }
|
||||||
|
} else {
|
||||||
|
try { $p = Parse-Proxy $txtProxy.Text; $ep = "$($p.PHost):$($p.Port)"; $ok = Test-ProxyReachable $p }
|
||||||
|
catch { $ep = 'некорректный адрес' }
|
||||||
|
}
|
||||||
|
$rProxyDot.ForeColor = if ($ok) { $cOk } else { $cRed }
|
||||||
|
$lblReach.Text = if ($ok) { "сервер доступен $ep" } else { "не отвечает $ep" }
|
||||||
|
}
|
||||||
|
} catch { Write-Log ('ошибка обновления: ' + $_.Exception.Message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-Busy([bool]$b) {
|
||||||
|
# Do NOT toggle $tgl.Enabled here: that forces a repaint and causes the flicker.
|
||||||
|
# The $script:Busy flag already guards re-entry.
|
||||||
|
$script:Busy = $b
|
||||||
|
$btnSave.Enabled = -not $b; $btnRefresh.Enabled = -not $b; $btnVerify.Enabled = -not $b; $btnLaunch.Enabled = -not $b
|
||||||
|
$form.Cursor = if ($b) { [System.Windows.Forms.Cursors]::WaitCursor } else { [System.Windows.Forms.Cursors]::Default }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- actions -----------------------------------------------------------------
|
||||||
|
function Toggle-Switch {
|
||||||
|
if ($script:Busy) { return }
|
||||||
|
$cfg = Get-Config
|
||||||
|
$restart = $chkRestart.Checked
|
||||||
|
try {
|
||||||
|
if (-not $script:SwitchOn) {
|
||||||
|
if (-not (Preflight-Enable)) { return }
|
||||||
|
$running = (Get-ClaudeStatus (Get-ClaudeApp)).Running
|
||||||
|
# Only restart (kill+relaunch) a running Claude — and only if the user allows it.
|
||||||
|
$doRestart = $false
|
||||||
|
if ($running) {
|
||||||
|
if ($restart) {
|
||||||
|
$r = [System.Windows.Forms.MessageBox]::Show(
|
||||||
|
'Claude запущен. Перезапустить его через прокси? (текущее окно закроется)',
|
||||||
|
'Перезапуск Claude', 'OKCancel', 'Question')
|
||||||
|
if ($r -ne 'OK') { return }
|
||||||
|
$doRestart = $true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
# Claude closed: just launch it already-proxied — no reboot.
|
||||||
|
$doRestart = $true
|
||||||
|
}
|
||||||
|
Set-Busy $true
|
||||||
|
Write-Log 'включаю...'
|
||||||
|
Do-Enable $doRestart
|
||||||
|
$note = if (-not $running) { 'Claude запущен через тоннель/прокси.' }
|
||||||
|
elseif ($doRestart) { 'Claude перезапущен.' }
|
||||||
|
else { 'лок установлен. Claude ещё напрямую — перезапусти, чтобы применить.' }
|
||||||
|
Write-Log ('ВКЛ. ' + $note)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$restartOff = $restart
|
||||||
|
if ($restartOff) {
|
||||||
|
$r = [System.Windows.Forms.MessageBox]::Show(
|
||||||
|
'Выключение перезапустит Claude напрямую (текущее окно закроется). Продолжить?',
|
||||||
|
'Перезапуск Claude', 'OKCancel', 'Question')
|
||||||
|
if ($r -ne 'OK') { $restartOff = $false }
|
||||||
|
}
|
||||||
|
Set-Busy $true
|
||||||
|
Write-Log 'выключаю прокси...'
|
||||||
|
Do-Disable $restartOff
|
||||||
|
Write-Log 'прокси ВЫКЛ. firewall-лок снят.'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Log ('ОШИБКА: ' + $_.Exception.Message)
|
||||||
|
[System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Ошибка', 'OK', 'Error') | Out-Null
|
||||||
|
} finally {
|
||||||
|
Set-Busy $false
|
||||||
|
Update-Ui -CheckReachable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Launch Claude through the proxy on demand — installs the lock (if fail-closed) and
|
||||||
|
# starts Claude already-proxied. Works whether Claude is open (restart) or closed (fresh start).
|
||||||
|
function Launch-Proxied {
|
||||||
|
if ($script:Busy) { return }
|
||||||
|
try {
|
||||||
|
if (-not (Preflight-Enable)) { return }
|
||||||
|
if ((Get-ClaudeStatus (Get-ClaudeApp)).Running) {
|
||||||
|
$r = [System.Windows.Forms.MessageBox]::Show(
|
||||||
|
'Claude уже запущен. Перезапустить его через прокси? (текущее окно закроется)',
|
||||||
|
'Перезапуск Claude', 'OKCancel', 'Question')
|
||||||
|
if ($r -ne 'OK') { return }
|
||||||
|
}
|
||||||
|
Set-Busy $true
|
||||||
|
Write-Log 'запускаю Claude через прокси...'
|
||||||
|
Do-Enable $true
|
||||||
|
Write-Log 'Claude запущен через прокси.'
|
||||||
|
} catch {
|
||||||
|
Write-Log ('ОШИБКА: ' + $_.Exception.Message)
|
||||||
|
[System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Ошибка', 'OK', 'Error') | Out-Null
|
||||||
|
} finally {
|
||||||
|
Set-Busy $false; Update-Ui -CheckReachable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tgl.Add_Click({ Toggle-Switch })
|
||||||
|
$btnLaunch.Add_Click({ Launch-Proxied })
|
||||||
|
$btnRefresh.Add_Click({ Update-Ui -CheckReachable; Write-Log 'статус обновлён.' })
|
||||||
|
$btnVerify.Add_Click({
|
||||||
|
try {
|
||||||
|
$r = Invoke-Verify
|
||||||
|
$r.Lines | ForEach-Object { Write-Log $_ }
|
||||||
|
Write-Log "лог: $($r.LogPath)"
|
||||||
|
$ico = if ($r.Ok) { 'Information' } elseif ($r.Verdict -like 'ПРОВАЛ*') { 'Error' } else { 'Warning' }
|
||||||
|
[System.Windows.Forms.MessageBox]::Show($r.Verdict, 'Результат проверки', 'OK', $ico) | Out-Null
|
||||||
|
} catch { [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Ошибка', 'OK', 'Error') | Out-Null }
|
||||||
|
})
|
||||||
|
$btnSave.Add_Click({
|
||||||
|
try {
|
||||||
|
if ((Get-CurrentMode) -eq 'vless') {
|
||||||
|
$v = ConvertFrom-VlessLink $txtProxy.Text
|
||||||
|
Save-CurrentConfig | Out-Null
|
||||||
|
Write-Log "VLESS сохранён: $($v.VHost):$($v.Port) [$($v.Remark)]"
|
||||||
|
} else {
|
||||||
|
$p = Parse-Proxy $txtProxy.Text
|
||||||
|
Save-CurrentConfig | Out-Null
|
||||||
|
Write-Log "прокси сохранён: $($p.Arg)"
|
||||||
|
}
|
||||||
|
if ($script:SwitchOn) { Write-Log 'ВНИМАНИЕ: режим активен со старой настройкой — переключи рубильник, чтобы применить.' }
|
||||||
|
Update-Ui -CheckReachable
|
||||||
|
} catch { [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Ошибка', 'OK', 'Error') | Out-Null }
|
||||||
|
})
|
||||||
|
|
||||||
|
$rbVless.Add_CheckedChanged({
|
||||||
|
$mode = Get-CurrentMode
|
||||||
|
Apply-ModeLayout $mode
|
||||||
|
$c = Get-Config; $c.mode = $mode; Save-Config $c
|
||||||
|
Update-Ui -CheckReachable
|
||||||
|
})
|
||||||
|
|
||||||
|
$btnXray.Add_Click({
|
||||||
|
if ($script:Busy) { return }
|
||||||
|
try {
|
||||||
|
Set-Busy $true
|
||||||
|
Stop-Xray
|
||||||
|
if (Test-XrayPresent) { Remove-Item $script:XrayExe -Force -ErrorAction SilentlyContinue }
|
||||||
|
Write-Log 'готовлю Xray-core...'
|
||||||
|
Ensure-Xray ${function:Write-Log}
|
||||||
|
Update-XrayStatus
|
||||||
|
Write-Log 'Xray готов.'
|
||||||
|
} catch {
|
||||||
|
Write-Log ('ОШИБКА: ' + $_.Exception.Message)
|
||||||
|
[System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Ошибка Xray', 'OK', 'Error') | Out-Null
|
||||||
|
} finally { Set-Busy $false }
|
||||||
|
})
|
||||||
|
|
||||||
|
$timer = New-Object System.Windows.Forms.Timer
|
||||||
|
$timer.Interval = 4000
|
||||||
|
$timer.Add_Tick({ if (-not $script:Busy) { Update-Ui } })
|
||||||
|
$timer.Start()
|
||||||
|
|
||||||
|
$form.Add_Shown({
|
||||||
|
$cfg = Get-Config
|
||||||
|
if ($null -ne $cfg.failClosed) { $chkFailClosed.Checked = [bool]$cfg.failClosed }
|
||||||
|
if ($cfg.mode -eq 'vless') { $rbVless.Checked = $true } else { $rbProxy.Checked = $true }
|
||||||
|
Apply-ModeLayout (Get-CurrentMode)
|
||||||
|
Write-Log 'готово. рубильник управляет проксированием Claude.'
|
||||||
|
Update-Ui -CheckReachable
|
||||||
|
if ($env:CLAUDEPROXY_SMOKE -eq '2') {
|
||||||
|
$bmp = New-Object System.Drawing.Bitmap($form.ClientSize.Width, $form.ClientSize.Height)
|
||||||
|
$form.DrawToBitmap($bmp, (New-Object System.Drawing.Rectangle(0, 0, $bmp.Width, $bmp.Height)))
|
||||||
|
$bmp.Save((Join-Path $Root 'ui-preview.png'), [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
$form.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if ($env:CLAUDEPROXY_SMOKE -eq '1') {
|
||||||
|
$smoke = New-Object System.Windows.Forms.Timer
|
||||||
|
$smoke.Interval = 700
|
||||||
|
$smoke.Add_Tick({ $smoke.Stop(); $form.Close() })
|
||||||
|
$smoke.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.Windows.Forms.Application]::Run($form)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<#
|
||||||
|
claude-proxy.ps1 - CLI. See README.md. GUI is claude-proxy-gui.ps1.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[ValidateSet('run', 'on', 'off', 'install-firewall', 'remove-firewall', 'status', 'verify')]
|
||||||
|
[string]$Action = 'status',
|
||||||
|
[switch]$NoRestart
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
. (Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) 'claude-proxy.lib.ps1')
|
||||||
|
|
||||||
|
function Elevate([string]$action) {
|
||||||
|
Start-Process -FilePath (Get-Process -Id $PID).Path -Verb RunAs -Wait -ArgumentList @(
|
||||||
|
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$PSCommandPath`"", '-Action', $action)
|
||||||
|
}
|
||||||
|
|
||||||
|
$restart = -not $NoRestart
|
||||||
|
|
||||||
|
switch ($Action) {
|
||||||
|
'status' {
|
||||||
|
$s = Get-ProxyState
|
||||||
|
Write-Host "Claude proxy - status" -ForegroundColor Cyan
|
||||||
|
Write-Host (" switch : {0}" -f ($(if ($s.On) { 'ON (locked to proxy)' } else { 'OFF' })))
|
||||||
|
Write-Host " proxy : $($s.Config.proxy)"
|
||||||
|
Write-Host (" reachable : {0}" -f $s.Reachable)
|
||||||
|
Write-Host " exe : $($s.App.Exe)"
|
||||||
|
Write-Host " firewall : $($s.Firewall)"
|
||||||
|
Write-Host (" claude : {0}" -f ($(if (-not $s.Running) { 'not running' } elseif ($s.Proxied) { 'running (proxied)' } else { 'running (direct)' })))
|
||||||
|
}
|
||||||
|
{ $_ -in 'on', 'run' } {
|
||||||
|
if (-not (Test-Admin)) { Elevate 'on'; break }
|
||||||
|
Write-Host "Turning proxy ON..." -ForegroundColor Cyan
|
||||||
|
if ($restart) { Enable-Proxy -RestartClaude } else { Enable-Proxy }
|
||||||
|
Write-Host "Done. Claude routed through proxy." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
'off' {
|
||||||
|
if (-not (Test-Admin)) { Elevate 'off'; break }
|
||||||
|
Write-Host "Turning proxy OFF..." -ForegroundColor Yellow
|
||||||
|
if ($restart) { Disable-Proxy -RestartClaude } else { Disable-Proxy }
|
||||||
|
Write-Host "Done. Firewall lock removed." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
'install-firewall' {
|
||||||
|
if (-not (Test-Admin)) { Elevate 'install-firewall'; break }
|
||||||
|
Enable-Proxy; Write-Host "Firewall lock installed." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
'remove-firewall' {
|
||||||
|
if (-not (Test-Admin)) { Elevate 'remove-firewall'; break }
|
||||||
|
Disable-Proxy; Write-Host "Firewall lock removed." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
'verify' {
|
||||||
|
$r = Invoke-Verify
|
||||||
|
$color = if ($r.Ok) { 'Green' } elseif ($r.Verdict -like 'ПРОВАЛ*') { 'Red' } else { 'Yellow' }
|
||||||
|
$r.Lines | ForEach-Object { Write-Host $_ -ForegroundColor $color }
|
||||||
|
Write-Host " лог : $($r.LogPath)" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"proxy": "socks5://127.0.0.1:1080",
|
||||||
|
"failClosed": true,
|
||||||
|
"killExisting": true,
|
||||||
|
"extraArgs": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<#
|
||||||
|
make-icon.ps1 - generate a Claude-styled multi-size app icon (icon.ico) + preview.png.
|
||||||
|
Coral rounded tile with a white radial "spark" mark.
|
||||||
|
#>
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
|
||||||
|
function New-Tile([int]$S) {
|
||||||
|
$bmp = New-Object System.Drawing.Bitmap($S, $S, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
||||||
|
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||||
|
$g.SmoothingMode = 'AntiAlias'
|
||||||
|
$g.InterpolationMode = 'HighQualityBicubic'
|
||||||
|
$g.PixelOffsetMode = 'HighQuality'
|
||||||
|
$g.Clear([System.Drawing.Color]::Transparent)
|
||||||
|
|
||||||
|
# rounded tile
|
||||||
|
$pad = [math]::Max(1, [int]($S * 0.06))
|
||||||
|
$rect = New-Object System.Drawing.Rectangle($pad, $pad, ($S - 2 * $pad), ($S - 2 * $pad))
|
||||||
|
$rad = [int]($S * 0.225)
|
||||||
|
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||||
|
$d = $rad * 2
|
||||||
|
$path.AddArc($rect.X, $rect.Y, $d, $d, 180, 90)
|
||||||
|
$path.AddArc($rect.Right - $d, $rect.Y, $d, $d, 270, 90)
|
||||||
|
$path.AddArc($rect.Right - $d, $rect.Bottom - $d, $d, $d, 0, 90)
|
||||||
|
$path.AddArc($rect.X, $rect.Bottom - $d, $d, $d, 90, 90)
|
||||||
|
$path.CloseFigure()
|
||||||
|
|
||||||
|
# coral gradient
|
||||||
|
$c1 = [System.Drawing.Color]::FromArgb(226, 138, 101) # lighter coral
|
||||||
|
$c2 = [System.Drawing.Color]::FromArgb(193, 95, 60) # deeper terracotta
|
||||||
|
$brush = New-Object System.Drawing.Drawing2D.LinearGradientBrush($rect, $c1, $c2, 90)
|
||||||
|
$g.FillPath($brush, $path)
|
||||||
|
|
||||||
|
# subtle top sheen
|
||||||
|
$sheen = New-Object System.Drawing.Drawing2D.LinearGradientBrush(
|
||||||
|
$rect, [System.Drawing.Color]::FromArgb(38, 255, 255, 255), [System.Drawing.Color]::FromArgb(0, 255, 255, 255), 90)
|
||||||
|
$g.FillPath($sheen, $path)
|
||||||
|
|
||||||
|
# toggle-switch glyph (white track + coral knob) - the "рубильник", distinct from Claude's spark
|
||||||
|
$tw = $S * 0.54; $th = $S * 0.30
|
||||||
|
$tx = ($S - $tw) / 2.0; $ty = ($S - $th) / 2.0
|
||||||
|
$tp = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||||
|
$tp.AddArc($tx, $ty, $th, $th, 90, 180)
|
||||||
|
$tp.AddArc($tx + $tw - $th, $ty, $th, $th, 270, 180)
|
||||||
|
$tp.CloseFigure()
|
||||||
|
$trackBrush = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(240, 248, 246, 239))
|
||||||
|
$g.FillPath($trackBrush, $tp)
|
||||||
|
# knob on the ON (right) side, coral so it reads as cut out of the white track
|
||||||
|
$knobD = $th - ($S * 0.09)
|
||||||
|
$knobX = $tx + $tw - $knobD - ($S * 0.045)
|
||||||
|
$knobY = $ty + (($th - $knobD) / 2.0)
|
||||||
|
$knobBrush = New-Object System.Drawing.SolidBrush($c2)
|
||||||
|
$g.FillEllipse($knobBrush, $knobX, $knobY, $knobD, $knobD)
|
||||||
|
|
||||||
|
$g.Dispose(); $brush.Dispose(); $sheen.Dispose(); $trackBrush.Dispose(); $knobBrush.Dispose(); $tp.Dispose()
|
||||||
|
$bmp
|
||||||
|
}
|
||||||
|
|
||||||
|
# preview
|
||||||
|
$preview = New-Tile 256
|
||||||
|
$preview.Save((Join-Path $root 'preview.png'), [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
|
||||||
|
# build .ico (PNG-compressed entries, Vista+)
|
||||||
|
$sizes = @(16, 24, 32, 48, 64, 128, 256)
|
||||||
|
$pngs = @()
|
||||||
|
foreach ($s in $sizes) {
|
||||||
|
$b = New-Tile $s
|
||||||
|
$ms = New-Object System.IO.MemoryStream
|
||||||
|
$b.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
$pngs += , @{ size = $s; bytes = $ms.ToArray() }
|
||||||
|
$b.Dispose(); $ms.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
$icoPath = Join-Path $root 'icon.ico'
|
||||||
|
$fs = New-Object System.IO.FileStream($icoPath, [System.IO.FileMode]::Create)
|
||||||
|
$bw = New-Object System.IO.BinaryWriter($fs)
|
||||||
|
# ICONDIR
|
||||||
|
$bw.Write([uint16]0) # reserved
|
||||||
|
$bw.Write([uint16]1) # type = icon
|
||||||
|
$bw.Write([uint16]$pngs.Count) # count
|
||||||
|
$offset = 6 + 16 * $pngs.Count
|
||||||
|
foreach ($p in $pngs) {
|
||||||
|
$wh = if ($p.size -ge 256) { 0 } else { $p.size }
|
||||||
|
$bw.Write([byte]$wh) # width
|
||||||
|
$bw.Write([byte]$wh) # height
|
||||||
|
$bw.Write([byte]0) # palette
|
||||||
|
$bw.Write([byte]0) # reserved
|
||||||
|
$bw.Write([uint16]1) # planes
|
||||||
|
$bw.Write([uint16]32) # bpp
|
||||||
|
$bw.Write([uint32]$p.bytes.Length)
|
||||||
|
$bw.Write([uint32]$offset)
|
||||||
|
$offset += $p.bytes.Length
|
||||||
|
}
|
||||||
|
foreach ($p in $pngs) { $bw.Write($p.bytes) }
|
||||||
|
$bw.Flush(); $bw.Close(); $fs.Close()
|
||||||
|
|
||||||
|
Write-Host "icon.ico : $icoPath ($([math]::Round((Get-Item $icoPath).Length/1KB)) KB, sizes: $($sizes -join ','))"
|
||||||
|
Write-Host "preview : $(Join-Path $root 'preview.png')"
|
||||||
+393
@@ -0,0 +1,393 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.Principal;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace ClaudeProxy
|
||||||
|
{
|
||||||
|
// ---- MSIX activation (launch a packaged app with arguments) -------------------
|
||||||
|
[ComImport, Guid("2e941141-7f97-4756-ba1d-9decde894a3d"),
|
||||||
|
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
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")]
|
||||||
|
class ApplicationActivationManager { }
|
||||||
|
|
||||||
|
class ClaudeApp { public string InstallLocation; public string Exe; public string Aumid; }
|
||||||
|
|
||||||
|
static class Core
|
||||||
|
{
|
||||||
|
public const string XrayVersion = "v26.3.27";
|
||||||
|
public const string PackageFamily = "Claude_pzs8sxrjxfjjc";
|
||||||
|
public const string Aumid = "Claude_pzs8sxrjxfjjc!Claude";
|
||||||
|
public const string FwGroup = "ClaudeProxyLock";
|
||||||
|
public const string DefaultProxy = "socks5://127.0.0.1:1080";
|
||||||
|
|
||||||
|
public static string ConfigDir { get { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ClaudeProxy"); } }
|
||||||
|
public static string ConfigPath { get { return Path.Combine(ConfigDir, "config.ini"); } }
|
||||||
|
public static string BinDir { get { return Path.Combine(ConfigDir, "bin"); } }
|
||||||
|
public static string XrayExe { get { return Path.Combine(BinDir, "xray.exe"); } }
|
||||||
|
public static string XrayCfgPath { get { return Path.Combine(ConfigDir, "xray-config.json"); } }
|
||||||
|
|
||||||
|
// ---- admin ----------------------------------------------------------------
|
||||||
|
public static bool IsAdmin()
|
||||||
|
{
|
||||||
|
using (var id = WindowsIdentity.GetCurrent())
|
||||||
|
return new WindowsPrincipal(id).IsInRole(WindowsBuiltInRole.Administrator);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relaunch self elevated with a single token argument; wait for it. Returns false if cancelled.
|
||||||
|
public static bool ElevateSelf(string arg)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = Process.GetCurrentProcess().MainModule.FileName,
|
||||||
|
Arguments = arg,
|
||||||
|
Verb = "runas",
|
||||||
|
UseShellExecute = true,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
var p = Process.Start(psi);
|
||||||
|
p.WaitForExit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch { return false; } // UAC cancelled / error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- config ---------------------------------------------------------------
|
||||||
|
public static Config LoadConfig()
|
||||||
|
{
|
||||||
|
var c = new Config();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(ConfigPath))
|
||||||
|
foreach (var line in File.ReadAllLines(ConfigPath))
|
||||||
|
{
|
||||||
|
int i = line.IndexOf('=');
|
||||||
|
if (i <= 0) continue;
|
||||||
|
string k = line.Substring(0, i).Trim(), v = line.Substring(i + 1);
|
||||||
|
switch (k)
|
||||||
|
{
|
||||||
|
case "mode": c.Mode = v.Trim(); break;
|
||||||
|
case "proxy": c.Proxy = v.Trim(); break;
|
||||||
|
case "vless": c.Vless = v; break;
|
||||||
|
case "localPort": int.TryParse(v.Trim(), out c.LocalPort); break;
|
||||||
|
case "failClosed": c.FailClosed = v.Trim() == "1" || v.Trim().ToLower() == "true"; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
if (c.LocalPort <= 0) c.LocalPort = 10808;
|
||||||
|
if (string.IsNullOrEmpty(c.Mode)) c.Mode = "proxy";
|
||||||
|
if (string.IsNullOrEmpty(c.Proxy)) c.Proxy = DefaultProxy;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SaveConfig(Config c)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(ConfigDir);
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("mode=" + c.Mode);
|
||||||
|
sb.AppendLine("proxy=" + c.Proxy);
|
||||||
|
sb.AppendLine("vless=" + (c.Vless ?? ""));
|
||||||
|
sb.AppendLine("localPort=" + c.LocalPort);
|
||||||
|
sb.AppendLine("failClosed=" + (c.FailClosed ? "1" : "0"));
|
||||||
|
File.WriteAllText(ConfigPath, sb.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- proxy parsing --------------------------------------------------------
|
||||||
|
public static bool TryParseProxy(string uri, out string host, out int port)
|
||||||
|
{
|
||||||
|
host = null; port = 0;
|
||||||
|
if (string.IsNullOrWhiteSpace(uri)) return false;
|
||||||
|
var m = System.Text.RegularExpressions.Regex.Match(uri.Trim(),
|
||||||
|
@"^(socks5|socks5h|socks4|http|https)://([^:/]+):(\d+)/?$");
|
||||||
|
if (!m.Success) return false;
|
||||||
|
host = m.Groups[2].Value; port = int.Parse(m.Groups[3].Value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
public static string ProxyArg(string uri) { return uri.Trim().TrimEnd('/'); }
|
||||||
|
|
||||||
|
public static bool TcpReachable(string host, int port, int timeoutMs)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var c = new TcpClient())
|
||||||
|
{
|
||||||
|
var ar = c.BeginConnect(host, port, null, null);
|
||||||
|
if (ar.AsyncWaitHandle.WaitOne(timeoutMs) && c.Connected) { c.EndConnect(ar); return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Claude app discovery -------------------------------------------------
|
||||||
|
public static ClaudeApp GetClaudeApp()
|
||||||
|
{
|
||||||
|
string exe = null;
|
||||||
|
// 1. from a running instance (works de-elevated)
|
||||||
|
foreach (var p in Process.GetProcessesByName("claude"))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string fn = p.MainModule.FileName;
|
||||||
|
if (fn.IndexOf("WindowsApps", StringComparison.OrdinalIgnoreCase) >= 0 &&
|
||||||
|
fn.IndexOf(PackageFamily.Split('_')[1], StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
|
{ exe = fn; break; }
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
// 2. enumerate WindowsApps (works when elevated)
|
||||||
|
if (exe == null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string wa = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "WindowsApps");
|
||||||
|
foreach (var dir in Directory.GetDirectories(wa, "Claude_*__" + PackageFamily.Split('_')[1]))
|
||||||
|
{
|
||||||
|
string cand = Path.Combine(dir, "app", "Claude.exe");
|
||||||
|
if (File.Exists(cand)) { exe = cand; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
if (exe == null) throw new Exception("Не найден Claude (десктопный MSIX-пакет).");
|
||||||
|
string install = Directory.GetParent(Directory.GetParent(exe).FullName).FullName;
|
||||||
|
return new ClaudeApp { Exe = exe, InstallLocation = install, Aumid = Aumid };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ClaudeRunning()
|
||||||
|
{
|
||||||
|
foreach (var p in Process.GetProcessesByName("claude"))
|
||||||
|
try { var fn = p.MainModule.FileName; if (fn.IndexOf("WindowsApps", StringComparison.OrdinalIgnoreCase) >= 0) return true; }
|
||||||
|
catch { }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void StopClaude()
|
||||||
|
{
|
||||||
|
foreach (var p in Process.GetProcessesByName("claude"))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string fn = p.MainModule.FileName;
|
||||||
|
if (fn.IndexOf("WindowsApps", StringComparison.OrdinalIgnoreCase) >= 0) { p.Kill(); }
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
Thread.Sleep(900);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static uint LaunchClaude(string argLine)
|
||||||
|
{
|
||||||
|
var mgr = (IApplicationActivationManager)(new ApplicationActivationManager());
|
||||||
|
uint pid;
|
||||||
|
int hr = mgr.ActivateApplication(Aumid, argLine ?? "", 0, out pid);
|
||||||
|
if (hr < 0) throw new Exception("Не удалось запустить Claude (HRESULT 0x" + hr.ToString("X8") + ").");
|
||||||
|
return pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- firewall (Windows Firewall COM API via dynamic late binding) ---------
|
||||||
|
// Returns all IPv4 addresses EXCEPT the given ones, as "a-b" range strings.
|
||||||
|
public static List<string> Ipv4Complement(IEnumerable<string> ips)
|
||||||
|
{
|
||||||
|
var vals = ips.Select(IpToUInt).Distinct().OrderBy(x => x).ToList();
|
||||||
|
var res = new List<string>();
|
||||||
|
ulong cursor = 0, max = 4294967295UL;
|
||||||
|
foreach (var v in vals)
|
||||||
|
{
|
||||||
|
if (v > cursor) res.Add(UIntToIp(cursor) + "-" + UIntToIp(v - 1));
|
||||||
|
cursor = v + 1;
|
||||||
|
}
|
||||||
|
if (cursor <= max) res.Add(UIntToIp(cursor) + "-" + UIntToIp(max));
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
static ulong IpToUInt(string ip)
|
||||||
|
{
|
||||||
|
var b = IPAddress.Parse(ip).GetAddressBytes();
|
||||||
|
return ((ulong)b[0] << 24) | ((ulong)b[1] << 16) | ((ulong)b[2] << 8) | b[3];
|
||||||
|
}
|
||||||
|
static string UIntToIp(ulong v)
|
||||||
|
{
|
||||||
|
return string.Format("{0}.{1}.{2}.{3}", (v >> 24) & 255, (v >> 16) & 255, (v >> 8) & 255, v & 255);
|
||||||
|
}
|
||||||
|
const string V6All = "::-ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff";
|
||||||
|
|
||||||
|
static dynamic FwPolicy()
|
||||||
|
{
|
||||||
|
return Activator.CreateInstance(Type.GetTypeFromProgID("HNetCfg.FwPolicy2"));
|
||||||
|
}
|
||||||
|
static void AddBlockRule(dynamic policy, string name, string exe, string remote)
|
||||||
|
{
|
||||||
|
dynamic r = Activator.CreateInstance(Type.GetTypeFromProgID("HNetCfg.FWRule"));
|
||||||
|
r.Name = name;
|
||||||
|
r.Grouping = FwGroup;
|
||||||
|
r.Direction = 2; // NET_FW_RULE_DIR_OUT
|
||||||
|
r.Action = 0; // NET_FW_ACTION_BLOCK
|
||||||
|
r.ApplicationName = exe;
|
||||||
|
if (!string.IsNullOrEmpty(remote)) r.RemoteAddresses = remote;
|
||||||
|
r.Profiles = 0x7FFFFFFF; // all profiles
|
||||||
|
r.Enabled = true;
|
||||||
|
policy.Rules.Add(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RemoveFirewall()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
dynamic policy = FwPolicy();
|
||||||
|
var names = new List<string>();
|
||||||
|
foreach (dynamic r in policy.Rules)
|
||||||
|
{
|
||||||
|
try { if ((string)r.Grouping == FwGroup) names.Add((string)r.Name); } catch { }
|
||||||
|
}
|
||||||
|
foreach (var n in names) { try { policy.Rules.Remove(n); } catch { } }
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ApplyFirewallForConfig()
|
||||||
|
{
|
||||||
|
var cfg = LoadConfig();
|
||||||
|
var app = GetClaudeApp();
|
||||||
|
RemoveFirewall();
|
||||||
|
dynamic policy = FwPolicy();
|
||||||
|
if (cfg.Mode == "vless")
|
||||||
|
{
|
||||||
|
// tunnel: block ALL external (loopback is exempt from firewall filtering)
|
||||||
|
AddBlockRule(policy, "ClaudeProxyLock: tunnel", app.Exe, null);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string host; int port;
|
||||||
|
if (!TryParseProxy(cfg.Proxy, out host, out port)) throw new Exception("Некорректный прокси.");
|
||||||
|
var addrs = Dns.GetHostAddresses(host);
|
||||||
|
var v4 = addrs.Where(a => a.AddressFamily == AddressFamily.InterNetwork).Select(a => a.ToString()).Distinct().ToList();
|
||||||
|
var v6 = addrs.Where(a => a.AddressFamily == AddressFamily.InterNetworkV6).Select(a => a.ToString()).Distinct().ToList();
|
||||||
|
if (v4.Count > 0)
|
||||||
|
AddBlockRule(policy, "ClaudeProxyLock: IPv4", app.Exe, string.Join(",", Ipv4Complement(v4)));
|
||||||
|
if (v6.Count == 0)
|
||||||
|
AddBlockRule(policy, "ClaudeProxyLock: IPv6", app.Exe, V6All);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// allow proxy v6, block the rest
|
||||||
|
dynamic allow = Activator.CreateInstance(Type.GetTypeFromProgID("HNetCfg.FWRule"));
|
||||||
|
allow.Name = "ClaudeProxyLock: IPv6 allow"; allow.Grouping = FwGroup;
|
||||||
|
allow.Direction = 2; allow.Action = 1; allow.ApplicationName = app.Exe;
|
||||||
|
allow.RemoteAddresses = string.Join(",", v6); allow.Profiles = 0x7FFFFFFF; allow.Enabled = true;
|
||||||
|
policy.Rules.Add(allow);
|
||||||
|
AddBlockRule(policy, "ClaudeProxyLock: IPv6 block", app.Exe, V6All);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// absent | stale | ok (path mismatch -> stale)
|
||||||
|
public static string FirewallState(string exe)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
dynamic policy = FwPolicy();
|
||||||
|
int count = 0; bool stale = false;
|
||||||
|
foreach (dynamic r in policy.Rules)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if ((string)r.Grouping != FwGroup) continue;
|
||||||
|
count++;
|
||||||
|
string prog = null; try { prog = (string)r.ApplicationName; } catch { }
|
||||||
|
if (!string.IsNullOrEmpty(prog) && exe != null &&
|
||||||
|
!prog.Equals(exe, StringComparison.OrdinalIgnoreCase)) stale = true;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
if (count == 0) return "absent";
|
||||||
|
return stale ? "stale" : "ok";
|
||||||
|
}
|
||||||
|
catch { return "absent"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Xray -----------------------------------------------------------------
|
||||||
|
public static bool XrayPresent() { return File.Exists(XrayExe); }
|
||||||
|
|
||||||
|
public static void EnsureXray()
|
||||||
|
{
|
||||||
|
if (XrayPresent()) return;
|
||||||
|
Directory.CreateDirectory(BinDir);
|
||||||
|
// embedded resource "ClaudeProxy.xray.zip"
|
||||||
|
var asm = Assembly.GetExecutingAssembly();
|
||||||
|
string resName = asm.GetManifestResourceNames().FirstOrDefault(n => n.EndsWith("xray.zip", StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (resName != null)
|
||||||
|
{
|
||||||
|
string tmp = Path.Combine(Path.GetTempPath(), "claudeproxy-xray.zip");
|
||||||
|
using (var rs = asm.GetManifestResourceStream(resName))
|
||||||
|
using (var fs = File.Create(tmp))
|
||||||
|
rs.CopyTo(fs);
|
||||||
|
using (var z = ZipFile.OpenRead(tmp))
|
||||||
|
foreach (var e in z.Entries)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(e.Name)) continue;
|
||||||
|
string dest = Path.Combine(BinDir, e.Name);
|
||||||
|
e.ExtractToFile(dest, true);
|
||||||
|
}
|
||||||
|
try { File.Delete(tmp); } catch { }
|
||||||
|
}
|
||||||
|
if (!XrayPresent()) throw new Exception("Не удалось распаковать встроенный Xray.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void StopXray()
|
||||||
|
{
|
||||||
|
foreach (var p in Process.GetProcessesByName("xray"))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (p.MainModule.FileName.StartsWith(BinDir, StringComparison.OrdinalIgnoreCase)) p.Kill();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
Thread.Sleep(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool LocalSocksUp(int port) { return TcpReachable("127.0.0.1", port, 500); }
|
||||||
|
|
||||||
|
public static void StartXray(string vlessLink, int localPort)
|
||||||
|
{
|
||||||
|
EnsureXray();
|
||||||
|
StopXray();
|
||||||
|
File.WriteAllText(XrayCfgPath, Vless.BuildXrayConfig(vlessLink, localPort));
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = XrayExe,
|
||||||
|
Arguments = "run -config \"" + XrayCfgPath + "\"",
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
Process.Start(psi);
|
||||||
|
for (int i = 0; i < 30; i++) { if (LocalSocksUp(localPort)) return; Thread.Sleep(200); }
|
||||||
|
StopXray();
|
||||||
|
throw new Exception("Xray не поднял локальный SOCKS на 127.0.0.1:" + localPort + ". Проверь ссылку и сервер.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool XrayRunning() { return Process.GetProcessesByName("xray").Any(p =>
|
||||||
|
{ try { return p.MainModule.FileName.StartsWith(BinDir, StringComparison.OrdinalIgnoreCase); } catch { return false; } }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
using System;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Drawing2D;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace ClaudeProxy
|
||||||
|
{
|
||||||
|
class BufferedPanel : Panel { public BufferedPanel() { DoubleBuffered = true; } }
|
||||||
|
|
||||||
|
class MainForm : Form
|
||||||
|
{
|
||||||
|
// palette
|
||||||
|
static Color Cream = Color.FromArgb(250, 249, 245), Ink = Color.FromArgb(41, 40, 36),
|
||||||
|
Head = Color.FromArgb(26, 25, 21), Muted = Color.FromArgb(131, 129, 123),
|
||||||
|
Card = Color.FromArgb(255, 255, 255), Border = Color.FromArgb(232, 229, 220),
|
||||||
|
Coral = Color.FromArgb(193, 95, 60), CoralLo = Color.FromArgb(224, 137, 101),
|
||||||
|
CoralHi = Color.FromArgb(168, 79, 48), TrackOff = Color.FromArgb(206, 202, 192),
|
||||||
|
OkC = Color.FromArgb(74, 122, 80), RedC = Color.FromArgb(196, 74, 61),
|
||||||
|
Glyph = Color.FromArgb(248, 246, 239);
|
||||||
|
Font fReg = new Font("Segoe UI", 9.5f), fSemi = new Font("Segoe UI Semibold", 9.5f),
|
||||||
|
fHead = new Font("Segoe UI Semibold", 15f), fSub = new Font("Segoe UI", 9f),
|
||||||
|
fPill = new Font("Segoe UI Semibold", 12f), fMono = new Font("Consolas", 8.5f);
|
||||||
|
|
||||||
|
bool switchOn = false, busy = false;
|
||||||
|
RadioButton rbProxy, rbVless;
|
||||||
|
BufferedPanel mark, tgl, card;
|
||||||
|
Label title, subtitle, lblFieldCap, lblReach, rDot, lblFwCap, lblFw, lblClCap, lblCl, lblXrayCap, lblXray;
|
||||||
|
TextBox txtField, log;
|
||||||
|
CheckBox chkRestart, chkFail;
|
||||||
|
Button btnSave, btnLaunch, btnVerify, btnRefresh, btnXray;
|
||||||
|
Panel sep;
|
||||||
|
Timer timer;
|
||||||
|
|
||||||
|
string Mode { get { return rbVless.Checked ? "vless" : "proxy"; } }
|
||||||
|
|
||||||
|
public MainForm()
|
||||||
|
{
|
||||||
|
Text = "Claude Proxy";
|
||||||
|
AutoScaleMode = AutoScaleMode.None;
|
||||||
|
ClientSize = new Size(452, 512);
|
||||||
|
StartPosition = FormStartPosition.CenterScreen;
|
||||||
|
FormBorderStyle = FormBorderStyle.FixedSingle;
|
||||||
|
MaximizeBox = false;
|
||||||
|
BackColor = Cream; ForeColor = Ink; Font = fReg;
|
||||||
|
try { Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath); } catch { }
|
||||||
|
|
||||||
|
mark = new BufferedPanel { Size = new Size(46, 46), Location = new Point(24, 22), BackColor = Cream };
|
||||||
|
mark.Paint += (s, e) => PaintTile(e.Graphics, 46);
|
||||||
|
Controls.Add(mark);
|
||||||
|
|
||||||
|
title = MkLabel("Claude Proxy", fHead, Head, 82, 22); title.AutoSize = true; Controls.Add(title);
|
||||||
|
subtitle = MkLabel("весь трафик Claude через прокси", fSub, Muted, 84, 48);
|
||||||
|
subtitle.AutoSize = false; subtitle.Size = new Size(196, 18); Controls.Add(subtitle);
|
||||||
|
|
||||||
|
rbProxy = MkRadio("Прямой прокси", 292, 18);
|
||||||
|
rbVless = MkRadio("VLESS + Reality", 292, 42);
|
||||||
|
Controls.Add(rbProxy); Controls.Add(rbVless);
|
||||||
|
|
||||||
|
tgl = new BufferedPanel { Size = new Size(172, 58), Location = new Point(24, 88), BackColor = Cream, Cursor = Cursors.Hand };
|
||||||
|
tgl.Paint += (s, e) => PaintToggle(e.Graphics, tgl.Width, tgl.Height);
|
||||||
|
tgl.Click += (s, e) => ToggleSwitch();
|
||||||
|
Controls.Add(tgl);
|
||||||
|
|
||||||
|
var hint = MkLabel("Включено = наружу только через прокси, всё остальное заблокировано", fSub, Muted, 208, 92);
|
||||||
|
hint.AutoSize = false; hint.Size = new Size(224, 52); Controls.Add(hint);
|
||||||
|
|
||||||
|
// status card
|
||||||
|
card = new BufferedPanel { Size = new Size(412, 158), Location = new Point(20, 162), BackColor = Cream };
|
||||||
|
card.Paint += (s, e) => { var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||||
|
using (var path = Round(card.Width - 1, card.Height - 1, 14))
|
||||||
|
using (var b = new SolidBrush(Card)) using (var pen = new Pen(Border, 1))
|
||||||
|
{ g.FillPath(b, path); g.DrawPath(pen, path); } };
|
||||||
|
Controls.Add(card);
|
||||||
|
|
||||||
|
lblFieldCap = MkLabel("Прокси", fReg, Muted, 18, 18); lblFieldCap.AutoSize = true; card.Controls.Add(lblFieldCap);
|
||||||
|
txtField = new TextBox { Location = new Point(84, 15), Size = new Size(214, 24), BorderStyle = BorderStyle.FixedSingle, BackColor = Cream, ForeColor = Ink };
|
||||||
|
card.Controls.Add(txtField);
|
||||||
|
btnSave = MkButton("Сохранить", 308, 13, 88, 28, false); btnSave.Click += (s, e) => OnSave(); card.Controls.Add(btnSave);
|
||||||
|
|
||||||
|
rDot = MkLabel("●", new Font("Segoe UI", 11f), Muted, 17, 54); rDot.AutoSize = true; card.Controls.Add(rDot);
|
||||||
|
lblReach = MkLabel("", fReg, Ink, 38, 56); lblReach.AutoSize = true; card.Controls.Add(lblReach);
|
||||||
|
|
||||||
|
lblXrayCap = MkLabel("Xray-core", fReg, Muted, 18, 68); lblXrayCap.AutoSize = true; card.Controls.Add(lblXrayCap);
|
||||||
|
lblXray = MkLabel("", fSemi, Ink, 120, 68); lblXray.AutoSize = true; card.Controls.Add(lblXray);
|
||||||
|
btnXray = MkButton("Распаковать", 300, 64, 96, 26, false); btnXray.Click += (s, e) => OnXray(); card.Controls.Add(btnXray);
|
||||||
|
|
||||||
|
sep = new Panel { Location = new Point(18, 86), Size = new Size(378, 1), BackColor = Border }; card.Controls.Add(sep);
|
||||||
|
lblFwCap = MkLabel("Firewall-лок", fReg, Muted, 18, 98); lblFwCap.AutoSize = true; card.Controls.Add(lblFwCap);
|
||||||
|
lblFw = MkLabel("", fSemi, Ink, 120, 98); lblFw.AutoSize = true; card.Controls.Add(lblFw);
|
||||||
|
lblClCap = MkLabel("Claude", fReg, Muted, 18, 122); lblClCap.AutoSize = true; card.Controls.Add(lblClCap);
|
||||||
|
lblCl = MkLabel("", fSemi, Ink, 120, 122); lblCl.AutoSize = true; card.Controls.Add(lblCl);
|
||||||
|
|
||||||
|
chkRestart = new CheckBox { Text = "Перезапускать Claude при переключении", ForeColor = Ink, AutoSize = true, Checked = true, Location = new Point(22, 324), Font = fReg };
|
||||||
|
chkFail = new CheckBox { Text = "Fail-closed: блокировать любой трафик мимо прокси (firewall)", ForeColor = Ink, AutoSize = true, Checked = true, Location = new Point(22, 348), Font = fReg };
|
||||||
|
Controls.Add(chkRestart); Controls.Add(chkFail);
|
||||||
|
|
||||||
|
btnLaunch = MkButton("Запустить Claude", 20, 384, 170, 36, true); btnLaunch.Click += (s, e) => LaunchProxied(); Controls.Add(btnLaunch);
|
||||||
|
btnVerify = MkButton("Проверить", 198, 386, 142, 32, false); btnVerify.Click += (s, e) => OnVerify(); Controls.Add(btnVerify);
|
||||||
|
btnRefresh = MkButton("Обновить", 348, 386, 84, 32, false); btnRefresh.Click += (s, e) => { UpdateUi(true); Log("статус обновлён."); }; Controls.Add(btnRefresh);
|
||||||
|
|
||||||
|
log = new TextBox { Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Vertical, Location = new Point(20, 430), Size = new Size(412, 66), BorderStyle = BorderStyle.FixedSingle, BackColor = Card, ForeColor = Muted, Font = fMono };
|
||||||
|
Controls.Add(log);
|
||||||
|
|
||||||
|
rbVless.CheckedChanged += (s, e) => { ApplyModeLayout(); var c = Core.LoadConfig(); c.Mode = Mode; Core.SaveConfig(c); UpdateUi(true); };
|
||||||
|
|
||||||
|
timer = new Timer { Interval = 4000 };
|
||||||
|
timer.Tick += (s, e) => { if (!busy) UpdateUi(false); };
|
||||||
|
timer.Start();
|
||||||
|
|
||||||
|
Load += (s, e) => {
|
||||||
|
var cfg = Core.LoadConfig();
|
||||||
|
chkFail.Checked = cfg.FailClosed;
|
||||||
|
if (cfg.Mode == "vless") rbVless.Checked = true; else rbProxy.Checked = true;
|
||||||
|
ApplyModeLayout();
|
||||||
|
Log("готово. рубильник управляет проксированием Claude.");
|
||||||
|
UpdateUi(true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- factories ----
|
||||||
|
Label MkLabel(string t, Font f, Color c, int x, int y)
|
||||||
|
{ return new Label { Text = t, Font = f, ForeColor = c, Location = new Point(x, y), BackColor = Color.Transparent }; }
|
||||||
|
RadioButton MkRadio(string t, int x, int y)
|
||||||
|
{ return new RadioButton { Text = t, ForeColor = Ink, Font = fReg, AutoSize = false, TextAlign = ContentAlignment.MiddleLeft, BackColor = Cream, Size = new Size(150, 22), Location = new Point(x, y) }; }
|
||||||
|
Button MkButton(string t, int x, int y, int w, int h, bool primary)
|
||||||
|
{
|
||||||
|
var b = new Button { Text = t, Location = new Point(x, y), Size = new Size(w, h), FlatStyle = FlatStyle.Flat, Cursor = Cursors.Hand };
|
||||||
|
b.FlatAppearance.BorderSize = primary ? 0 : 1;
|
||||||
|
if (primary) { b.BackColor = Coral; b.ForeColor = Color.White; b.Font = fSemi; b.FlatAppearance.MouseOverBackColor = CoralHi; }
|
||||||
|
else { b.BackColor = Card; b.ForeColor = Ink; b.Font = fReg; b.FlatAppearance.BorderColor = Border; b.FlatAppearance.MouseOverBackColor = Cream; }
|
||||||
|
b.Region = new Region(Round(w, h, 9));
|
||||||
|
b.SizeChanged += (s, e) => { try { b.Region = new Region(Round(b.Width, b.Height, 9)); } catch { } };
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
static GraphicsPath Round(int w, int h, int r)
|
||||||
|
{
|
||||||
|
int d = r * 2; var p = new GraphicsPath();
|
||||||
|
p.AddArc(0, 0, d, d, 180, 90); p.AddArc(w - d, 0, d, d, 270, 90);
|
||||||
|
p.AddArc(w - d, h - d, d, d, 0, 90); p.AddArc(0, h - d, d, d, 90, 90);
|
||||||
|
p.CloseFigure(); return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- painting ----
|
||||||
|
void PaintTile(Graphics g, int S)
|
||||||
|
{
|
||||||
|
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||||
|
using (var path = Round(S - 1, S - 1, 12))
|
||||||
|
using (var br = new LinearGradientBrush(new Rectangle(0, 0, S, S), CoralLo, CoralHi, 90f))
|
||||||
|
g.FillPath(br, path);
|
||||||
|
DrawToggleGlyph(g, S);
|
||||||
|
}
|
||||||
|
void DrawToggleGlyph(Graphics g, float S)
|
||||||
|
{
|
||||||
|
float tw = S * 0.58f, th = S * 0.34f, tx = (S - tw) / 2f, ty = (S - th) / 2f;
|
||||||
|
using (var tp = new GraphicsPath())
|
||||||
|
{
|
||||||
|
tp.AddArc(tx, ty, th, th, 90, 180); tp.AddArc(tx + tw - th, ty, th, th, 270, 180); tp.CloseFigure();
|
||||||
|
using (var tb = new SolidBrush(Glyph)) g.FillPath(tb, tp);
|
||||||
|
}
|
||||||
|
float kd = th - S * 0.10f, kx = tx + tw - kd - S * 0.05f, ky = ty + (th - kd) / 2f;
|
||||||
|
using (var kb = new SolidBrush(CoralHi)) g.FillEllipse(kb, kx, ky, kd, kd);
|
||||||
|
}
|
||||||
|
void PaintToggle(Graphics g, int w, int h)
|
||||||
|
{
|
||||||
|
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||||
|
using (var path = Round(w, h, h / 2))
|
||||||
|
{
|
||||||
|
if (switchOn) using (var br = new LinearGradientBrush(new Rectangle(0, 0, w, h), CoralLo, Coral, 0f)) g.FillPath(br, path);
|
||||||
|
else using (var br = new SolidBrush(TrackOff)) g.FillPath(br, path);
|
||||||
|
}
|
||||||
|
int knobD = h - 12, knobX = switchOn ? w - knobD - 6 : 6;
|
||||||
|
using (var sh = new SolidBrush(Color.FromArgb(40, 0, 0, 0))) g.FillEllipse(sh, knobX + 1, 7, knobD, knobD);
|
||||||
|
using (var kb = new SolidBrush(Color.White)) g.FillEllipse(kb, knobX, 6, knobD, knobD);
|
||||||
|
string txt = switchOn ? "ВКЛ" : "ВЫКЛ";
|
||||||
|
using (var tb = new SolidBrush(switchOn ? Color.White : Color.FromArgb(106, 104, 98)))
|
||||||
|
g.DrawString(txt, fPill, tb, switchOn ? 22 : 66, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- mode layout ----
|
||||||
|
void ApplyModeLayout()
|
||||||
|
{
|
||||||
|
var cfg = Core.LoadConfig();
|
||||||
|
if (Mode == "vless")
|
||||||
|
{
|
||||||
|
lblFieldCap.Text = "VLESS"; subtitle.Text = "через VLESS + Reality тоннель";
|
||||||
|
txtField.UseSystemPasswordChar = true; txtField.Text = cfg.Vless ?? "";
|
||||||
|
lblXrayCap.Visible = lblXray.Visible = btnXray.Visible = true;
|
||||||
|
sep.Top = 100; lblFwCap.Top = lblFw.Top = 112; lblClCap.Top = lblCl.Top = 134;
|
||||||
|
UpdateXrayStatus();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lblFieldCap.Text = "Прокси"; subtitle.Text = "весь трафик Claude через прокси";
|
||||||
|
txtField.UseSystemPasswordChar = false; txtField.Text = cfg.Proxy ?? "";
|
||||||
|
lblXrayCap.Visible = lblXray.Visible = btnXray.Visible = false;
|
||||||
|
sep.Top = 74; lblFwCap.Top = lblFw.Top = 86; lblClCap.Top = lblCl.Top = 108;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void UpdateXrayStatus()
|
||||||
|
{
|
||||||
|
if (Core.XrayPresent()) { lblXray.Text = "готов (" + Core.XrayVersion + ")"; lblXray.ForeColor = OkC; btnXray.Text = "Переустановить"; }
|
||||||
|
else { lblXray.Text = "встроен (" + Core.XrayVersion + ")"; lblXray.ForeColor = OkC; btnXray.Text = "Распаковать"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
void Log(string m) { log.AppendText(DateTime.Now.ToString("HH:mm:ss") + " " + m + "\r\n"); }
|
||||||
|
void SetBusy(bool b)
|
||||||
|
{
|
||||||
|
busy = b;
|
||||||
|
btnSave.Enabled = btnRefresh.Enabled = btnVerify.Enabled = btnLaunch.Enabled = btnXray.Enabled = !b;
|
||||||
|
UpdateModeLock();
|
||||||
|
Cursor = b ? Cursors.WaitCursor : Cursors.Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode can't be switched while the lock is active (ON) or during an operation.
|
||||||
|
void UpdateModeLock() { rbProxy.Enabled = rbVless.Enabled = !switchOn && !busy; }
|
||||||
|
|
||||||
|
Config CaptureConfig()
|
||||||
|
{
|
||||||
|
var c = Core.LoadConfig();
|
||||||
|
c.Mode = Mode; c.FailClosed = chkFail.Checked;
|
||||||
|
if (Mode == "vless") c.Vless = txtField.Text; else c.Proxy = txtField.Text;
|
||||||
|
Core.SaveConfig(c); return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
void UpdateUi(bool checkReach)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ClaudeApp app = null;
|
||||||
|
try { app = Core.GetClaudeApp(); } catch { }
|
||||||
|
string fw = app != null ? Core.FirewallState(app.Exe) : Core.FirewallState(null);
|
||||||
|
bool newOn = fw == "ok";
|
||||||
|
if (newOn != switchOn) { switchOn = newOn; tgl.Invalidate(); }
|
||||||
|
UpdateModeLock();
|
||||||
|
|
||||||
|
lblFw.Text = fw == "ok" ? "установлен" : (fw == "stale" ? "устарел (путь изменился)" : "нет");
|
||||||
|
lblFw.ForeColor = fw == "ok" ? Coral : (fw == "stale" ? RedC : Muted);
|
||||||
|
|
||||||
|
bool running = Core.ClaudeRunning();
|
||||||
|
if (!running) { lblCl.Text = "не запущен"; lblCl.ForeColor = Muted; }
|
||||||
|
else { lblCl.Text = "запущен"; lblCl.ForeColor = Ink; }
|
||||||
|
|
||||||
|
if (checkReach)
|
||||||
|
{
|
||||||
|
bool ok; string ep;
|
||||||
|
if (Mode == "vless") ok = Vless.ServerReachable(txtField.Text, out ep);
|
||||||
|
else { string h; int p; if (Core.TryParseProxy(txtField.Text, out h, out p)) { ep = h + ":" + p; ok = Core.TcpReachable(h, p, 1500); } else { ep = "некорректный адрес"; ok = false; } }
|
||||||
|
rDot.ForeColor = ok ? OkC : RedC;
|
||||||
|
lblReach.Text = (ok ? "сервер доступен " : "не отвечает ") + ep;
|
||||||
|
}
|
||||||
|
if (Mode == "vless") UpdateXrayStatus();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Log("ошибка обновления: " + ex.Message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- orchestration ----
|
||||||
|
bool Preflight()
|
||||||
|
{
|
||||||
|
if (Mode == "vless")
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(txtField.Text)) { MessageBox.Show("Вставь VLESS-ссылку из 3x-ui.", "Нужна ссылка", MessageBoxButtons.OK, MessageBoxIcon.Warning); return false; }
|
||||||
|
try { string h, u, r; int p; Vless.Parse(txtField.Text, out h, out p, out u, out r); }
|
||||||
|
catch (Exception ex) { MessageBox.Show(ex.Message, "Некорректная ссылка", MessageBoxButtons.OK, MessageBoxIcon.Error); return false; }
|
||||||
|
if (!Core.XrayPresent())
|
||||||
|
{
|
||||||
|
SetBusy(true);
|
||||||
|
try { Core.EnsureXray(); UpdateXrayStatus(); }
|
||||||
|
catch (Exception ex) { Log("ОШИБКА Xray: " + ex.Message); MessageBox.Show(ex.Message, "Ошибка Xray", MessageBoxButtons.OK, MessageBoxIcon.Error); SetBusy(false); return false; }
|
||||||
|
SetBusy(false);
|
||||||
|
}
|
||||||
|
string ep; if (!Vless.ServerReachable(txtField.Text, out ep))
|
||||||
|
if (MessageBox.Show("Сервер " + ep + " не отвечает. Всё равно продолжить?", "Сервер недоступен", MessageBoxButtons.YesNo, MessageBoxIcon.Warning) != DialogResult.Yes) return false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string h; int p;
|
||||||
|
if (!Core.TryParseProxy(txtField.Text, out h, out p)) { MessageBox.Show("Некорректный адрес прокси.", "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error); return false; }
|
||||||
|
if (!Core.TcpReachable(h, p, 1500))
|
||||||
|
if (MessageBox.Show("Прокси " + h + ":" + p + " не отвечает. Всё равно включить?", "Прокси недоступен", MessageBoxButtons.YesNo, MessageBoxIcon.Warning) != DialogResult.Yes) return false;
|
||||||
|
}
|
||||||
|
CaptureConfig();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DoEnable(bool restart)
|
||||||
|
{
|
||||||
|
var cfg = Core.LoadConfig();
|
||||||
|
string proxyArg;
|
||||||
|
if (cfg.Mode == "vless") { Core.EnsureXray(); Core.StartXray(cfg.Vless, cfg.LocalPort); proxyArg = "socks5://127.0.0.1:" + cfg.LocalPort; }
|
||||||
|
else proxyArg = Core.ProxyArg(cfg.Proxy);
|
||||||
|
|
||||||
|
if (cfg.FailClosed)
|
||||||
|
if (!Core.ElevateSelf("fwapply")) { if (cfg.Mode == "vless") Core.StopXray(); throw new Exception("Установка firewall отменена в UAC."); }
|
||||||
|
|
||||||
|
if (restart) { Core.StopClaude(); string a = "--proxy-server=" + proxyArg; Core.LaunchClaude(a); }
|
||||||
|
}
|
||||||
|
|
||||||
|
void DoDisable(bool restart)
|
||||||
|
{
|
||||||
|
string exe = null; try { exe = Core.GetClaudeApp().Exe; } catch { }
|
||||||
|
if (Core.FirewallState(exe) != "absent")
|
||||||
|
if (!Core.ElevateSelf("fwremove")) throw new Exception("Снятие firewall отменено в UAC (лок остался).");
|
||||||
|
Core.StopXray();
|
||||||
|
if (restart) { Core.StopClaude(); Core.LaunchClaude(""); }
|
||||||
|
}
|
||||||
|
|
||||||
|
void ToggleSwitch()
|
||||||
|
{
|
||||||
|
if (busy) return;
|
||||||
|
bool restart = chkRestart.Checked;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!switchOn)
|
||||||
|
{
|
||||||
|
if (!Preflight()) return;
|
||||||
|
bool running = Core.ClaudeRunning();
|
||||||
|
bool doRestart = false;
|
||||||
|
if (running) { if (restart) { if (MessageBox.Show("Claude запущен. Перезапустить его через прокси? (текущее окно закроется)", "Перезапуск Claude", MessageBoxButtons.OKCancel, MessageBoxIcon.Question) != DialogResult.OK) return; doRestart = true; } }
|
||||||
|
else doRestart = true;
|
||||||
|
SetBusy(true); Log("включаю..."); DoEnable(doRestart);
|
||||||
|
Log("ВКЛ. " + (!running ? "Claude запущен через прокси." : (doRestart ? "Claude перезапущен." : "лок установлен; перезапусти Claude, чтобы применить.")));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bool restartOff = restart;
|
||||||
|
if (restartOff && MessageBox.Show("Выключение перезапустит Claude напрямую (текущее окно закроется). Продолжить?", "Перезапуск Claude", MessageBoxButtons.OKCancel, MessageBoxIcon.Question) != DialogResult.OK) restartOff = false;
|
||||||
|
SetBusy(true); Log("выключаю прокси..."); DoDisable(restartOff); Log("прокси ВЫКЛ. firewall-лок снят.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Log("ОШИБКА: " + ex.Message); MessageBox.Show(ex.Message, "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error); }
|
||||||
|
finally { SetBusy(false); UpdateUi(true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
void LaunchProxied()
|
||||||
|
{
|
||||||
|
if (busy) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Preflight()) return;
|
||||||
|
if (Core.ClaudeRunning() && MessageBox.Show("Claude уже запущен. Перезапустить его через прокси? (текущее окно закроется)", "Перезапуск Claude", MessageBoxButtons.OKCancel, MessageBoxIcon.Question) != DialogResult.OK) return;
|
||||||
|
SetBusy(true); Log("запускаю Claude через прокси..."); DoEnable(true); Log("Claude запущен через прокси.");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Log("ОШИБКА: " + ex.Message); MessageBox.Show(ex.Message, "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error); }
|
||||||
|
finally { SetBusy(false); UpdateUi(true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnVerify()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var r = Verify.Run(Core.LoadConfig());
|
||||||
|
foreach (var l in r.Lines) Log(l);
|
||||||
|
string stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss");
|
||||||
|
string p = Verify.WriteLog(r, stamp);
|
||||||
|
if (p.Length > 0) Log("лог: " + p);
|
||||||
|
MessageBox.Show(r.Verdict, "Результат проверки", MessageBoxButtons.OK,
|
||||||
|
r.Ok ? MessageBoxIcon.Information : (r.Verdict.StartsWith("ПРОВАЛ") ? MessageBoxIcon.Error : MessageBoxIcon.Warning));
|
||||||
|
}
|
||||||
|
catch (Exception ex) { MessageBox.Show(ex.Message, "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnSave()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Mode == "vless") { string h, u, rm; int p; Vless.Parse(txtField.Text, out h, out p, out u, out rm); CaptureConfig(); Log("VLESS сохранён: " + h + ":" + p + " [" + rm + "]"); }
|
||||||
|
else { string h; int p; if (!Core.TryParseProxy(txtField.Text, out h, out p)) throw new Exception("Некорректный адрес."); CaptureConfig(); Log("прокси сохранён: " + Core.ProxyArg(txtField.Text)); }
|
||||||
|
if (switchOn) Log("ВНИМАНИЕ: режим активен со старой настройкой — переключи рубильник, чтобы применить.");
|
||||||
|
UpdateUi(true);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { MessageBox.Show(ex.Message, "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnXray()
|
||||||
|
{
|
||||||
|
if (busy) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SetBusy(true); Core.StopXray();
|
||||||
|
try { if (Core.XrayPresent()) System.IO.File.Delete(Core.XrayExe); } catch { }
|
||||||
|
Log("готовлю Xray-core..."); Core.EnsureXray(); UpdateXrayStatus(); Log("Xray готов.");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Log("ОШИБКА: " + ex.Message); MessageBox.Show(ex.Message, "Ошибка Xray", MessageBoxButtons.OK, MessageBoxIcon.Error); }
|
||||||
|
finally { SetBusy(false); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace ClaudeProxy
|
||||||
|
{
|
||||||
|
class Config
|
||||||
|
{
|
||||||
|
public string Mode = "proxy"; // proxy | vless
|
||||||
|
public string Proxy = Core.DefaultProxy;
|
||||||
|
public string Vless = "";
|
||||||
|
public int LocalPort = 10808;
|
||||||
|
public bool FailClosed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- VLESS share-link (from 3x-ui) -> Xray config -----------------------------
|
||||||
|
static class Vless
|
||||||
|
{
|
||||||
|
public static Dictionary<string, string> Parse(string link, out string host, out int port, out string uuid, out string remark)
|
||||||
|
{
|
||||||
|
link = (link ?? "").Trim();
|
||||||
|
if (!link.StartsWith("vless://", StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new Exception("Ожидается vless://... ссылка (share-link из 3x-ui).");
|
||||||
|
var uri = new Uri(link);
|
||||||
|
uuid = uri.UserInfo;
|
||||||
|
if (string.IsNullOrEmpty(uuid)) throw new Exception("В ссылке нет UUID.");
|
||||||
|
host = uri.Host; port = uri.Port;
|
||||||
|
remark = Uri.UnescapeDataString(uri.Fragment.TrimStart('#'));
|
||||||
|
var q = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var pair in uri.Query.TrimStart('?').Split('&'))
|
||||||
|
{
|
||||||
|
if (pair.Length == 0) continue;
|
||||||
|
int i = pair.IndexOf('=');
|
||||||
|
if (i < 0) q[pair] = "";
|
||||||
|
else q[pair.Substring(0, i)] = Uri.UnescapeDataString(pair.Substring(i + 1));
|
||||||
|
}
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string J(string s) { return "\"" + (s ?? "").Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; }
|
||||||
|
static string GetQ(Dictionary<string, string> q, string k, string d) { return q.ContainsKey(k) && q[k].Length > 0 ? q[k] : d; }
|
||||||
|
|
||||||
|
public static string BuildXrayConfig(string link, int localPort)
|
||||||
|
{
|
||||||
|
string host, uuid, remark; int port;
|
||||||
|
var q = Parse(link, out host, out port, out uuid, out remark);
|
||||||
|
string type = GetQ(q, "type", "tcp");
|
||||||
|
string security = GetQ(q, "security", "none");
|
||||||
|
string flow = GetQ(q, "flow", "");
|
||||||
|
|
||||||
|
var stream = new StringBuilder();
|
||||||
|
stream.Append("\"network\":" + J(type));
|
||||||
|
stream.Append(",\"security\":" + J(security));
|
||||||
|
if (security == "reality")
|
||||||
|
{
|
||||||
|
if (!q.ContainsKey("pbk")) throw new Exception("Reality: в ссылке нет publicKey (pbk).");
|
||||||
|
stream.Append(",\"realitySettings\":{");
|
||||||
|
stream.Append("\"serverName\":" + J(GetQ(q, "sni", "")));
|
||||||
|
stream.Append(",\"fingerprint\":" + J(GetQ(q, "fp", "chrome")));
|
||||||
|
stream.Append(",\"publicKey\":" + J(GetQ(q, "pbk", "")));
|
||||||
|
stream.Append(",\"shortId\":" + J(GetQ(q, "sid", "")));
|
||||||
|
stream.Append(",\"spiderX\":" + J(GetQ(q, "spx", "")));
|
||||||
|
stream.Append("}");
|
||||||
|
}
|
||||||
|
else if (security == "tls")
|
||||||
|
{
|
||||||
|
stream.Append(",\"tlsSettings\":{\"serverName\":" + J(GetQ(q, "sni", "")) + ",\"fingerprint\":" + J(GetQ(q, "fp", "chrome")) + "}");
|
||||||
|
}
|
||||||
|
if (type == "grpc")
|
||||||
|
stream.Append(",\"grpcSettings\":{\"serviceName\":" + J(GetQ(q, "serviceName", "")) + "}");
|
||||||
|
else if (type == "ws")
|
||||||
|
stream.Append(",\"wsSettings\":{\"path\":" + J(GetQ(q, "path", "/")) + ",\"headers\":{\"Host\":" + J(GetQ(q, "host", "")) + "}}");
|
||||||
|
|
||||||
|
var user = new StringBuilder();
|
||||||
|
user.Append("{\"id\":" + J(uuid) + ",\"encryption\":\"none\"");
|
||||||
|
if (flow.Length > 0) user.Append(",\"flow\":" + J(flow));
|
||||||
|
user.Append("}");
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append("{");
|
||||||
|
sb.Append("\"log\":{\"loglevel\":\"warning\"},");
|
||||||
|
sb.Append("\"inbounds\":[{\"tag\":\"socks-in\",\"listen\":\"127.0.0.1\",\"port\":" + localPort +
|
||||||
|
",\"protocol\":\"socks\",\"settings\":{\"udp\":true,\"auth\":\"noauth\"}}],");
|
||||||
|
sb.Append("\"outbounds\":[{\"tag\":\"proxy\",\"protocol\":\"vless\",\"settings\":{\"vnext\":[{\"address\":" +
|
||||||
|
J(host) + ",\"port\":" + port + ",\"users\":[" + user + "]}]},\"streamSettings\":{" + stream + "}}]");
|
||||||
|
sb.Append("}");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// reachability of the VLESS server host:port
|
||||||
|
public static bool ServerReachable(string link, out string endpoint)
|
||||||
|
{
|
||||||
|
endpoint = "";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string host, uuid, remark; int port;
|
||||||
|
Parse(link, out host, out port, out uuid, out remark);
|
||||||
|
endpoint = host + ":" + port;
|
||||||
|
return Core.TcpReachable(host, port, 1500);
|
||||||
|
}
|
||||||
|
catch { endpoint = "некорректная ссылка"; return false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class VerifyResult
|
||||||
|
{
|
||||||
|
public string Mode, Endpoint, Tunnel = "";
|
||||||
|
public int Procs;
|
||||||
|
public List<string> ViaProxy = new List<string>();
|
||||||
|
public List<string> Local = new List<string>();
|
||||||
|
public List<string> Leaks = new List<string>();
|
||||||
|
public List<string> Blocked = new List<string>();
|
||||||
|
public List<string> Lines = new List<string>();
|
||||||
|
public string Verdict; public bool Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
static class Verify
|
||||||
|
{
|
||||||
|
class Conn { public string Remote; public int RemotePort; public string State; public int Pid; }
|
||||||
|
|
||||||
|
// Parse `netstat -ano` for TCP connections owned by the given PIDs.
|
||||||
|
static List<Conn> TcpConns(HashSet<int> pids)
|
||||||
|
{
|
||||||
|
var res = new List<Conn>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo("netstat", "-ano")
|
||||||
|
{ UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true, StandardOutputEncoding = Encoding.ASCII };
|
||||||
|
var p = Process.Start(psi);
|
||||||
|
string outp = p.StandardOutput.ReadToEnd();
|
||||||
|
p.WaitForExit();
|
||||||
|
foreach (var raw in outp.Split('\n'))
|
||||||
|
{
|
||||||
|
var line = raw.Trim();
|
||||||
|
if (!line.StartsWith("TCP", StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
var t = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (t.Length < 5) continue;
|
||||||
|
int pid; if (!int.TryParse(t[4], out pid)) continue;
|
||||||
|
if (!pids.Contains(pid)) continue;
|
||||||
|
string state = t[3];
|
||||||
|
if (state != "ESTABLISHED" && state != "SYN_SENT") continue;
|
||||||
|
string remote = t[2];
|
||||||
|
int rp = 0; string rip = remote;
|
||||||
|
int c = remote.LastIndexOf(':');
|
||||||
|
if (c > 0) { rip = remote.Substring(0, c); int.TryParse(remote.Substring(c + 1), out rp); }
|
||||||
|
rip = rip.Trim('[', ']');
|
||||||
|
res.Add(new Conn { Remote = rip, RemotePort = rp, State = state == "SYN_SENT" ? "SynSent" : "Established", Pid = pid });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
static HashSet<int> ClaudePids()
|
||||||
|
{
|
||||||
|
var set = new HashSet<int>();
|
||||||
|
foreach (var p in Process.GetProcessesByName("claude"))
|
||||||
|
try { if (p.MainModule.FileName.IndexOf("WindowsApps", StringComparison.OrdinalIgnoreCase) >= 0) set.Add(p.Id); }
|
||||||
|
catch { }
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
static HashSet<int> XrayPids()
|
||||||
|
{
|
||||||
|
var set = new HashSet<int>();
|
||||||
|
foreach (var p in Process.GetProcessesByName("xray"))
|
||||||
|
try { if (p.MainModule.FileName.StartsWith(Core.BinDir, StringComparison.OrdinalIgnoreCase)) set.Add(p.Id); }
|
||||||
|
catch { }
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static VerifyResult Run(Config cfg)
|
||||||
|
{
|
||||||
|
var r = new VerifyResult { Mode = cfg.Mode };
|
||||||
|
HashSet<string> proxySet;
|
||||||
|
bool loopIsProxy;
|
||||||
|
if (cfg.Mode == "vless")
|
||||||
|
{
|
||||||
|
proxySet = new HashSet<string> { "127.0.0.1", "::1" };
|
||||||
|
r.Endpoint = "127.0.0.1:" + cfg.LocalPort + " (VLESS+Reality)";
|
||||||
|
loopIsProxy = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string host; int port; Core.TryParseProxy(cfg.Proxy, out host, out port);
|
||||||
|
var ips = new HashSet<string>();
|
||||||
|
try { foreach (var a in System.Net.Dns.GetHostAddresses(host)) ips.Add(a.ToString()); } catch { }
|
||||||
|
proxySet = ips; r.Endpoint = host + ":" + port; loopIsProxy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pids = ClaudePids();
|
||||||
|
r.Procs = pids.Count;
|
||||||
|
foreach (var c in TcpConns(pids))
|
||||||
|
{
|
||||||
|
string entry = c.Remote + ":" + c.RemotePort + " [" + c.State + "]";
|
||||||
|
bool isLoop = c.Remote == "127.0.0.1" || c.Remote == "::1";
|
||||||
|
bool otherLocal = c.Remote == "0.0.0.0" || c.Remote == "::" || c.Remote.StartsWith("fe80");
|
||||||
|
if (isLoop) { if (loopIsProxy) r.ViaProxy.Add(entry); else r.Local.Add(entry); continue; }
|
||||||
|
if (otherLocal) { r.Local.Add(entry); continue; }
|
||||||
|
if (!loopIsProxy && proxySet.Contains(c.Remote)) { r.ViaProxy.Add(entry); continue; }
|
||||||
|
if (c.State == "SynSent") { r.Blocked.Add(entry); continue; }
|
||||||
|
r.Leaks.Add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.Mode == "vless")
|
||||||
|
{
|
||||||
|
var xp = XrayPids();
|
||||||
|
if (xp.Count == 0) r.Tunnel = "xray НЕ запущен";
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var xc = TcpConns(xp).FirstOrDefault(c => c.State == "Established" && c.Remote != "127.0.0.1" && c.Remote != "::1");
|
||||||
|
r.Tunnel = xc != null ? "xray -> " + xc.Remote + ":" + xc.RemotePort : "xray запущен, внешнего коннекта пока нет";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool tunnelDown = cfg.Mode == "vless" && r.Tunnel == "xray НЕ запущен";
|
||||||
|
string modeLabel = cfg.Mode == "vless" ? "VLESS+Reality" : "прямой прокси";
|
||||||
|
r.Lines.Add("Проверка трафика Claude (" + modeLabel + ")");
|
||||||
|
r.Lines.Add(" вход : " + r.Endpoint);
|
||||||
|
if (cfg.Mode == "vless") r.Lines.Add(" тоннель : " + r.Tunnel);
|
||||||
|
r.Lines.Add(" процессов Claude: " + r.Procs);
|
||||||
|
r.Lines.Add(" через прокси : " + r.ViaProxy.Count);
|
||||||
|
r.Lines.Add(" локальные : " + r.Local.Count);
|
||||||
|
r.Lines.Add(" заблокировано : " + r.Blocked.Count + " (firewall отбил обход)");
|
||||||
|
r.Lines.Add(" УТЕЧКИ : " + r.Leaks.Count);
|
||||||
|
foreach (var l in r.Leaks) r.Lines.Add(" ! прямой коннект: " + l);
|
||||||
|
foreach (var b in r.Blocked) r.Lines.Add(" ~ отбито: " + b);
|
||||||
|
|
||||||
|
if (r.Procs == 0) r.Verdict = "НЕТ ДАННЫХ: Claude не запущен";
|
||||||
|
else if (r.Leaks.Count > 0) r.Verdict = "ПРОВАЛ: есть прямой трафик мимо прокси";
|
||||||
|
else if (tunnelDown) r.Verdict = "ПРОВАЛ: тоннель (xray) не запущен";
|
||||||
|
else if (r.ViaProxy.Count == 0) r.Verdict = "НЕОПРЕДЕЛЁННО: нет активных коннектов (подожди загрузки Claude)";
|
||||||
|
else if (cfg.Mode == "vless") r.Verdict = "OK: весь трафик Claude идёт в VLESS+Reality тоннель";
|
||||||
|
else r.Verdict = "OK: весь внешний трафик Claude идёт через прокси";
|
||||||
|
r.Lines.Add(" ВЕРДИКТ : " + r.Verdict);
|
||||||
|
r.Ok = r.Leaks.Count == 0 && r.ViaProxy.Count > 0 && !tunnelDown;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string WriteLog(VerifyResult r, string stamp)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.IO.Directory.CreateDirectory(Core.ConfigDir);
|
||||||
|
string path = System.IO.Path.Combine(Core.ConfigDir, "verify-" + stamp + ".log");
|
||||||
|
System.IO.File.WriteAllLines(path, r.Lines.ToArray());
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
catch { return ""; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static class Program
|
||||||
|
{
|
||||||
|
[STAThread]
|
||||||
|
static void Main(string[] args)
|
||||||
|
{
|
||||||
|
// elevated firewall-helper mode (invoked by the GUI via runas). Native args arrive clean.
|
||||||
|
if (args.Contains("fwapply") || args.Contains("fwremove"))
|
||||||
|
{
|
||||||
|
if (Core.IsAdmin())
|
||||||
|
{
|
||||||
|
try { if (args.Contains("fwapply")) Core.ApplyFirewallForConfig(); else Core.RemoveFirewall(); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Application.EnableVisualStyles();
|
||||||
|
Application.SetCompatibleTextRenderingDefault(false);
|
||||||
|
if (args.Contains("smoketest"))
|
||||||
|
{
|
||||||
|
var f = new MainForm();
|
||||||
|
f.StartPosition = FormStartPosition.Manual;
|
||||||
|
f.Location = new System.Drawing.Point(-3000, -3000);
|
||||||
|
f.Show();
|
||||||
|
Application.DoEvents();
|
||||||
|
System.Threading.Thread.Sleep(600);
|
||||||
|
Application.DoEvents();
|
||||||
|
using (var bmp = new System.Drawing.Bitmap(f.ClientSize.Width, f.ClientSize.Height))
|
||||||
|
{
|
||||||
|
f.DrawToBitmap(bmp, new System.Drawing.Rectangle(0, 0, bmp.Width, bmp.Height));
|
||||||
|
bmp.Save(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "claudeproxy-native-preview.png"));
|
||||||
|
}
|
||||||
|
f.Close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Application.Run(new MainForm());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges>
|
||||||
|
<!-- asInvoker: run de-elevated so the Claude we launch is the user's normal instance -->
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
<dependency>
|
||||||
|
<dependentAssembly>
|
||||||
|
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0"
|
||||||
|
processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*" />
|
||||||
|
</dependentAssembly>
|
||||||
|
</dependency>
|
||||||
|
</assembly>
|
||||||
Reference in New Issue
Block a user