From d5559867bdfe73a53a5f12b8522421870752aa7d Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Tue, 19 May 2026 14:28:41 +0300 Subject: [PATCH] =?UTF-8?q?feat(proxy):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20pclaude/pcodex=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=8F=D0=B2=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D0=BA=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=BA=D1=81=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - claude/codex должны ходить через hproxy.local, а ручной proxy_on легко забыть; нужны явные команды, которые не стартуют молча при недоступном прокси. - Что: - bash: добавлены _proxy_export/_proxy_reachable/_proxy_run и pclaude/pcodex; proxy_on рефакторен на единый источник правды без смены поведения. - powershell: добавлены Get-ProxyEndpoint/Set-ProxyEnv/Test-ProxyReachable/Invoke-WithProxy и pclaude/pcodex; proxy_on рефакторен аналогично. - таймаут pre-flight вынесен в PROXY_PREFLIGHT_TIMEOUT (дефолт 2 c). - добавлен tests/test_proxy_launchers.sh (fail-fast, изоляция env, проброс кода, аргументы, регресс proxy_on). - Проверка: - bash tests/test_proxy_launchers.sh. --- bash/.bash_functions | 60 +++++++++-- chezmoi/dot_config/powershell/profile.ps1 | 87 ++++++++++++++-- tests/test_proxy_launchers.sh | 120 ++++++++++++++++++++++ 3 files changed, 249 insertions(+), 18 deletions(-) create mode 100755 tests/test_proxy_launchers.sh diff --git a/bash/.bash_functions b/bash/.bash_functions index 61da15c..165bb8a 100644 --- a/bash/.bash_functions +++ b/bash/.bash_functions @@ -23,20 +23,58 @@ update_hproxy() { echo "✅ hproxy.local -> $(getent hosts hproxy.local 2>/dev/null | awk '{print $1}')" } -# Удобные функции-переключатели для терминала -# NO_PROXY: ::1 и [::1] нужны, т.к. localhost в dual-stack Linux резолвится в IPv6, -# и без них локальные MCP/loopback-соединения (chroma-mcp, claude-mem worker и т.п.) -# уходят в SOCKS5 через hproxy.local. Дублируем в lower-case no_proxy для Python-стека. -proxy_on() { - local no_proxy_list="hproxy.local,localhost,127.0.0.1,::1,[::1],.concept-software.ru" - export HTTP_PROXY="http://hproxy.local:12334" - export HTTPS_PROXY="http://hproxy.local:12334" - export ALL_PROXY="socks5://hproxy.local:12334" - export NO_PROXY="$no_proxy_list" +# Единый источник правды для прокси-эндпоинта (не экспортируется; идемпотентно +# при повторном source). NO_PROXY: ::1 и [::1] нужны, т.к. localhost в dual-stack +# Linux резолвится в IPv6, и без них локальные MCP/loopback-соединения +# (chroma-mcp, claude-mem worker и т.п.) уходят в SOCKS5 через hproxy.local. +: "${_PROXY_HOST:=hproxy.local}" +: "${_PROXY_PORT:=12334}" +: "${_PROXY_NO_PROXY:=hproxy.local,localhost,127.0.0.1,::1,[::1],.concept-software.ru}" + +# Внутренний: экспортирует proxy-env (+ lower-case дубли для Python-стека). +# Единственное место, где формируются значения переменных. +_proxy_export() { + export HTTP_PROXY="http://${_PROXY_HOST}:${_PROXY_PORT}" + export HTTPS_PROXY="http://${_PROXY_HOST}:${_PROXY_PORT}" + export ALL_PROXY="socks5://${_PROXY_HOST}:${_PROXY_PORT}" + export NO_PROXY="$_PROXY_NO_PROXY" export http_proxy="$HTTP_PROXY" export https_proxy="$HTTPS_PROXY" export all_proxy="$ALL_PROXY" - export no_proxy="$no_proxy_list" + export no_proxy="$_PROXY_NO_PROXY" +} + +# Внутренний: fail-fast проверка доступности прокси (только reachability — +# не доказательство, что трафик реально проксируется). Таймаут покрывает и +# медленный DNS-резолв; при зависании — отказ запуска (безопасный отказ). +_proxy_reachable() { + timeout "${PROXY_PREFLIGHT_TIMEOUT:-2}" \ + bash -c "exec 3<>/dev/tcp/${_PROXY_HOST}/${_PROXY_PORT}" 2>/dev/null +} + +# Внутренний: запуск команды с proxy-env. Pre-flight, затем subshell+exec — +# env виден только дочернему процессу, не протекает в родительский shell, +# код возврата пробрасывается. +_proxy_run() { + local cmd="$1"; shift + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "❌ Команда не найдена в PATH: $cmd" >&2 + return 127 + fi + if ! _proxy_reachable; then + echo "❌ Прокси ${_PROXY_HOST}:${_PROXY_PORT} недоступен — запуск '$cmd' отменён." >&2 + return 1 + fi + # exec и так резолвит через PATH в обход функций/алиасов. + ( _proxy_export; exec "$cmd" "$@" ) +} + +pclaude() { _proxy_run claude "$@"; } +pcodex() { _proxy_run codex "$@"; } + +# Удобные функции-переключатели для терминала. +proxy_on() { + _proxy_export echo "✅ Proxy enabled. Checking route..." # Подставляем результат выполнения curl прямо в строку echo "🌍 Current IP: $(curl -s --max-time 3 https://ifconfig.me)" diff --git a/chezmoi/dot_config/powershell/profile.ps1 b/chezmoi/dot_config/powershell/profile.ps1 index 04db936..ee72e94 100644 --- a/chezmoi/dot_config/powershell/profile.ps1 +++ b/chezmoi/dot_config/powershell/profile.ps1 @@ -75,15 +75,46 @@ function Get-PublicIp { catch { return $null } } +# Единый источник правды для прокси-эндпоинта. +function Get-ProxyEndpoint { + @{ + ProxyHost = 'hproxy.local' + Port = 12334 + NoProxy = 'hproxy.local,localhost,127.0.0.1,.concept-software.ru' + } +} + +# Внутренний: выставить proxy-env в текущей области. Единственное место, +# где формируются значения переменных. +function Set-ProxyEnv { + $e = Get-ProxyEndpoint + $env:HTTP_PROXY = "http://$($e.ProxyHost):$($e.Port)" + $env:HTTPS_PROXY = "http://$($e.ProxyHost):$($e.Port)" + $env:ALL_PROXY = "socks5://$($e.ProxyHost):$($e.Port)" + $env:NO_PROXY = $e.NoProxy +} + +# Внутренний: fail-fast проверка доступности прокси (только reachability). +# Таймаут — $env:PROXY_PREFLIGHT_TIMEOUT (дефолт 2 c), покрывает и медленный DNS. +function Test-ProxyReachable { + $e = Get-ProxyEndpoint + $timeout = if ($env:PROXY_PREFLIGHT_TIMEOUT) { [int]$env:PROXY_PREFLIGHT_TIMEOUT } else { 2 } + $client = [System.Net.Sockets.TcpClient]::new() + try { + $iar = $client.BeginConnect($e.ProxyHost, [int]$e.Port, $null, $null) + if ($iar.AsyncWaitHandle.WaitOne([TimeSpan]::FromSeconds($timeout))) { + $client.EndConnect($iar) + return $true + } + return $false + } + catch { return $false } + finally { $client.Dispose() } +} + # Включить прокси для текущей сессии (hproxy.local). function proxy_on { - $Host_ = 'hproxy.local'; $Port = 12334 - - $env:HTTP_PROXY = "http://${Host_}:${Port}" - $env:HTTPS_PROXY = "http://${Host_}:${Port}" - $env:ALL_PROXY = "socks5://${Host_}:${Port}" - $env:NO_PROXY = "${Host_},localhost,127.0.0.1,.concept-software.ru" - + Set-ProxyEnv $Ip = Get-PublicIp "Proxy ON. IP: $(if ($Ip) { $Ip } else { 'unavailable' })" } @@ -103,6 +134,48 @@ function myip { "IP: $(if ($Ip) { $Ip } else { 'unavailable' })" } +# Внутренний: запуск команды через прокси. Pre-flight, затем env только на +# время дочернего процесса (в finally восстанавливаем прежние значения — +# сессия не остаётся с включённым прокси). +function Invoke-WithProxy { + param( + [Parameter(Mandatory)][string]$Exe, + [Parameter(ValueFromRemainingArguments)][string[]]$CmdArgs + ) + + if (-not (Get-Command $Exe -ErrorAction SilentlyContinue)) { + Write-Error "Команда не найдена в PATH: $Exe" + return + } + if (-not (Test-ProxyReachable)) { + $e = Get-ProxyEndpoint + Write-Error "Прокси $($e.ProxyHost):$($e.Port) недоступен — запуск '$Exe' отменён." + return + } + + $names = 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY' + $saved = @{} + foreach ($n in $names) { $saved[$n] = [Environment]::GetEnvironmentVariable($n) } + try { + Set-ProxyEnv + & $Exe @CmdArgs + $global:LASTEXITCODE = $LASTEXITCODE + } + finally { + foreach ($n in $names) { + if ($null -eq $saved[$n]) { + Remove-Item -Path "Env:$n" -ErrorAction SilentlyContinue + } else { + Set-Item -Path "Env:$n" -Value $saved[$n] + } + } + } +} + +# Явный запуск claude/codex гарантированно через прокси. +function pclaude { Invoke-WithProxy claude @args } +function pcodex { Invoke-WithProxy codex @args } + # ── Шорткаты Windows Terminal ──────────────────────────────────────── if (Get-Command wt -ErrorAction SilentlyContinue) { diff --git a/tests/test_proxy_launchers.sh b/tests/test_proxy_launchers.sh new file mode 100755 index 0000000..4b0d4a8 --- /dev/null +++ b/tests/test_proxy_launchers.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FUNCTIONS_FILE="$REPO_ROOT/bash/.bash_functions" + +pass_count=0 + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +ok() { + pass_count=$((pass_count + 1)) + echo "ok - $*" +} + +assert_eq() { + local expected="$1" actual="$2" msg="$3" + [ "$expected" = "$actual" ] || fail "$msg (ожидалось '$expected', получено '$actual')" +} + +# Изолированный fake-CLI на PATH: печатает env и аргументы, отдаёт заданный код. +make_fake_bin() { + local dir + dir="$(mktemp -d /tmp/proxy-launchers-test.XXXXXX)" + cat >"$dir/fakecli" <<'EOF' +#!/usr/bin/env bash +echo "HTTP_PROXY=${HTTP_PROXY:-}" +echo "NO_PROXY=${NO_PROXY:-}" +for a in "$@"; do echo "ARG=$a"; done +exit "${FAKE_EXIT:-0}" +EOF + chmod +x "$dir/fakecli" + echo "$dir" +} + +# --- Каждый кейс в отдельном subshell, чтобы env/функции не протекали --- + +# 1. Прокси недоступен → бинарник НЕ вызывается, ненулевой код. +( + source "$FUNCTIONS_FILE" + _proxy_reachable() { return 1; } + bindir="$(make_fake_bin)"; export PATH="$bindir:$PATH" + out="$(_proxy_run fakecli 2>&1)"; rc=$? + [ "$rc" -ne 0 ] || fail "недоступный прокси: ожидался ненулевой код" + case "$out" in *ARG=*) fail "недоступный прокси: бинарник не должен запускаться";; esac + case "$out" in *"недоступен"*) ;; *) fail "недоступный прокси: нет понятного сообщения";; esac +) && ok "недоступный прокси → fail-fast без запуска бинарника" + +# 2. proxy-env виден дочернему процессу и НЕ протекает в родительский shell. +( + source "$FUNCTIONS_FILE" + _proxy_reachable() { return 0; } + bindir="$(make_fake_bin)"; export PATH="$bindir:$PATH" + unset HTTP_PROXY NO_PROXY || true + out="$(_proxy_run fakecli)" + case "$out" in *"HTTP_PROXY=http://hproxy.local:12334"*) ;; *) fail "child: HTTP_PROXY не выставлен";; esac + case "$out" in *"NO_PROXY=hproxy.local,localhost,127.0.0.1,::1,[::1],.concept-software.ru"*) ;; *) fail "child: NO_PROXY неверен";; esac + [ -z "${HTTP_PROXY:-}" ] || fail "leak: HTTP_PROXY протёк в родительский shell" + [ -z "${NO_PROXY:-}" ] || fail "leak: NO_PROXY протёк в родительский shell" +) && ok "proxy-env в дочернем процессе, без протечки в shell" + +# 3. Проброс кода возврата дочернего процесса. +( + source "$FUNCTIONS_FILE" + _proxy_reachable() { return 0; } + bindir="$(make_fake_bin)"; export PATH="$bindir:$PATH" + FAKE_EXIT=42 _proxy_run fakecli >/dev/null; rc=$? + assert_eq 42 "$rc" "код возврата дочернего процесса не проброшен" +) && ok "проброс кода возврата дочернего процесса" + +# 4. Аргументы с пробелами и `--` доходят без искажений. +( + source "$FUNCTIONS_FILE" + _proxy_reachable() { return 0; } + bindir="$(make_fake_bin)"; export PATH="$bindir:$PATH" + out="$(_proxy_run fakecli "a b" -- --flag)" + case "$out" in *$'ARG=a b\n'*) ;; *) fail "arg с пробелом потерян";; esac + case "$out" in *"ARG=--"*) ;; *) fail "разделитель -- потерян";; esac + case "$out" in *"ARG=--flag"*) ;; *) fail "флаг после -- потерян";; esac +) && ok "аргументы с пробелами и -- проброшены без искажений" + +# 5. Команда не в PATH → отдельное сообщение, код 127. +( + source "$FUNCTIONS_FILE" + _proxy_reachable() { return 0; } + out="$(_proxy_run definitely-no-such-cli-xyz 2>&1)"; rc=$? + assert_eq 127 "$rc" "отсутствующая команда: ожидался код 127" + case "$out" in *"не найдена"*) ;; *) fail "отсутствующая команда: нет понятного сообщения";; esac +) && ok "команда не в PATH → код 127 с сообщением" + +# 6. Регресс proxy_on: точный контракт экспортируемых переменных. +( + source "$FUNCTIONS_FILE" + curl() { echo "1.2.3.4"; } # без сети + unset HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY \ + http_proxy https_proxy all_proxy no_proxy || true + proxy_on >/dev/null + np="hproxy.local,localhost,127.0.0.1,::1,[::1],.concept-software.ru" + assert_eq "http://hproxy.local:12334" "${HTTP_PROXY}" "proxy_on HTTP_PROXY" + assert_eq "http://hproxy.local:12334" "${HTTPS_PROXY}" "proxy_on HTTPS_PROXY" + assert_eq "socks5://hproxy.local:12334" "${ALL_PROXY}" "proxy_on ALL_PROXY" + assert_eq "$np" "${NO_PROXY}" "proxy_on NO_PROXY" + assert_eq "${HTTP_PROXY}" "${http_proxy}" "proxy_on http_proxy дубль" + assert_eq "${HTTPS_PROXY}" "${https_proxy}" "proxy_on https_proxy дубль" + assert_eq "${ALL_PROXY}" "${all_proxy}" "proxy_on all_proxy дубль" + assert_eq "$np" "${no_proxy}" "proxy_on no_proxy дубль" +) && ok "регресс proxy_on: контракт переменных не изменился" + +# 7. shellcheck (если доступен). +if command -v shellcheck >/dev/null 2>&1; then + shellcheck -s bash "$FUNCTIONS_FILE" || fail "shellcheck нашёл проблемы в bash/.bash_functions" + ok "shellcheck чист" +else + echo "skip - shellcheck не установлен" +fi + +echo "PASS: $pass_count проверок пройдено"