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