diff --git a/.scratch/.gitkeep b/.scratch/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.scratch/handoffs/20260720-0835-xray-proxy-after-03.md b/.scratch/handoffs/20260720-0835-xray-proxy-after-03.md deleted file mode 100644 index bcb2bba..0000000 --- a/.scratch/handoffs/20260720-0835-xray-proxy-after-03.md +++ /dev/null @@ -1,80 +0,0 @@ -# Handoff: конвейер xray-proxy после задачи 03 - -Дата: 2026-07-20. Для свежего агента, продолжающего работу в этом -репозитории с пользователем Дмитрием. Заменяет handoff «after-06». - -## Состояние проекта - -- Спека: `docs/specs/2026-07-15-headless-xray-proxy-design.md` (обновлена - задачей 03: модель идентичности процессов, lifetime-flock, три исхода - проверки state). -- Тикеты: `.scratch/headless-xray-proxy/issues/01..06`. 01, 02, 03, 06 — - done; 04 — ready-for-agent (следующая); 05 — замыкающая (blocked by - 03+04, включает ручную приёмку пользователем на целевых системах). -- Последний коммит работ: `5ace0cf` (feat 03, +890/−100). -- Тесты сейчас: `python3 -m unittest discover -s tests` → **49 OK** (~60 с); - `bash tests/test_proxy_launchers.sh` → **8 PASS**. -- Резолюции конвейера по 03 — в шапке тикета 03; расход токенов по этапам — - `.scratch/headless-xray-proxy/token-ledger.md`. - -## Решение пользователя по эксперименту (2026-07-20) - -Реализацию 04 гнать на `model_reasoning_effort="medium"` — добираем -статистику эксперимента (память `codex-effort-calibration`). Контекст -решения: 03 на medium дала 4 MAJOR от слепого CODE-ревью против 1 MAJOR у -06 на high (все — гонки конкурентности/восстановления; конвейер выловил и -починил, обе перепроверки FIXES_CONFIRMED). Оговорка: 03 богаче на гонки, -чем 06, чистым A/B не считать. Ревью постановок и кода, диагнозы — high. - -## Как вести работу (проверено прогонами 06 и 03) - -Скилл `claude-subagent-playbook`: постановка → spec-ревью (Codex, high; -для 03 это дало NEEDS_REWORK с 7 MAJOR и спасло реализацию — не -пропускать) → реализация Codex (medium) → саморевью (resume, medium) → -два слепых ревью (Claude TASK-субагент + Codex CODE свежий тред, high) → -триаж тредом исполнителя (resume, medium) → узкие перепроверки теми же -ревьюерами → приёмка оркестратором (обе сьюты локально) → коммит по -`conventional-commits`. - -Критично: - -- **thread_id Codex забирать сразу** из первой строки stdout-JSONL. - Рестарты сессии дважды убивали фоновые процессы/сторожа; тред - реанимируется `codex exec resume ` без потерь. -- **Синтаксис resume отличается**: `codex exec resume ` принимает - ТОЛЬКО `-c`-оверрайды (`-c 'sandbox_mode="workspace-write"'`, - `-c 'model="gpt-5.6-sol"'`, `-c 'model_reasoning_effort="…"'`), флаги - `-s`/`-m`/`-o` дают exit 2. Отчёты просить писать в файл через промпт. -- **Usage при убитом ходе теряется** (нет `turn.completed`); цифры - resume-хода покрывают тред по масштабу — фиксировать с оговоркой. -- Токен-леджер дополнять строкой на каждый этап; методика — в шапке - леджера. -- Один писатель в дереве; ревьюеров ограничивать read-only мандатом, после - ревью сверять `git status`. -- Чужие untracked-артефакты (`zed/.config/*`, `agents/.agents/skills/*`, - `scripts/setup-xray-proxy.sh`) не трогать и не коммитить. - -## Решения пользователя (не пересматривать без него) - -Память: `prefers-kiss`, `consultant-multi-distro`, -`codex-effort-calibration`. KISS с различением essential/accidental; -целевые системы: Ubuntu, РЕД ОС, возможно Astra/Alt (Python 3.8+ -stdlib-only, переносимость); строгий отказ парсера и безопасный kill не -упрощать; секреты (VLESS/UUID/ключи) — никогда в stdout/stderr/argv/ -state/репозиторий. - -## Suggested skills - -- `claude-subagent-playbook` — конвейер задач 04/05. -- `conventional-commits` — обязателен для каждого коммита. -- `resolving-merge-conflicts` — если merge с origin принесёт конфликты. - -## Ближайший шаг - -Задача 04: свежий обменный каталог (`mktemp -d`), spec-ревью постановки -`.scratch/headless-xray-proxy/issues/04-*.md` (Codex, high) → правка -постановки по находкам → реализация (**medium**) → далее по конвейеру. -После 04 — итоговое сравнение эксперимента и, при решении пользователя, -запись выводов в -`~/.claude/skills/claude-subagent-playbook/references/codex-exec.md`. -Затем 05 с ручной приёмкой пользователя на целевых системах. diff --git a/.scratch/headless-xray-proxy/issues/01-external-proxy-and-portable-shell.md b/.scratch/headless-xray-proxy/issues/01-external-proxy-and-portable-shell.md deleted file mode 100644 index a6fd35f..0000000 --- a/.scratch/headless-xray-proxy/issues/01-external-proxy-and-portable-shell.md +++ /dev/null @@ -1,70 +0,0 @@ -# 01 — Внешний прокси и переносимый Bash-интерфейс - -**What to build:** Самостоятельный пользовательский пакет прокси, через который -можно безопасно запускать команды с уже работающим внешним прокси, например -Hiddify. Привычные Bash-команды переходят на единый CLI-контракт, но прежний -сценарий без машинного конфига продолжает работать. - -**Blocked by:** None — can start immediately. - -**Status:** done - -Часть требований пересмотрена в 06 — см. 06-simplification-pass.md. - -- [x] CLI работает на Python 3.8+ без сторонних библиотек, ничего не устанавливает - и не запускает без явной подкоманды и выдаёт понятную ошибку на более старом - Python. -- [x] Машинный INI явно различает типы `external` и `xray`, проверяет имя, адрес, - порт и добавку к `NO_PROXY`; отсутствие файла сохраняет внешний прокси - `hproxy.local:12334` без неявного переключения на другой тип. -- [x] Канонический машинный INI содержит единственную секцию `[proxy]` и ровно - по одному ключу `type`, `name`, `address`, `port`, `no_proxy_extra`: - - ```ini - [proxy] - type = external - name = Hiddify - address = hproxy.local - port = 12334 - no_proxy_extra = .example.internal,git.example.internal - ``` - - Все ключи обязательны при наличии файла; `no_proxy_extra` может быть пустым. - Неизвестные секции и ключи, повторы, пропуски, многострочные или содержащие - NUL значения, пустые имя или адрес и порт вне диапазона 1–65535 отклоняются. - Добавки к `NO_PROXY` задаются через запятую, окружающие пробелы удаляются, - пустые элементы отклоняются. -- [x] `address` принимает IPv4, IPv6 без квадратных скобок либо ASCII DNS-имя - из корректных непустых меток; схема URL, встроенный порт, скобки, zone ID, - пробелы, `/`, `@` и другие URL-разделители отклоняются. При формировании - proxy-URL CLI сам заключает IPv6-литерал в квадратные скобки. -- [x] Для типа `external` команды `status`, `env` и `run -- COMMAND ARG...` - проверяют только настроенный адрес и порт; недоступный прокси запрещает запуск - дочерней команды с понятным сообщением. -- [x] До реализации управляемого процесса конфиг с `type=xray` обрабатывается - безопасным отказом: `status`, `env`, `run` и `start` не считают один открытый - порт доказательством управляемого Xray и не запускают дочернюю команду. -- [x] `env` при успехе печатает только восемь разрешённых proxy-переменных в двух - регистрах, а при ошибке не печатает частичный результат; базовый `NO_PROXY` - включает loopback и узел прокси, затем дополняется машинной настройкой. -- [x] Документированный `PROXY_PREFLIGHT_TIMEOUT` сохраняется как положительное - число секунд с прежним значением по умолчанию `2` для проверок доступности из - `status`, `env` и `run`; ошибочное значение даёт понятный отказ. -- [x] `run` без искажений сохраняет аргументы и код возврата команды, передаёт - proxy-переменные только дочернему процессу и возвращает отдельную понятную - ошибку, если команда отсутствует в `PATH`. -- [x] Переносимый Bash-модуль становится единственным владельцем `proxy_on`, - `proxy_off`, `pclaude`, `pcodex`, `myip` и внутренних proxy-помощников; - WSL-функция обновления `hproxy.local` остаётся независимой. -- [x] Обычный запуск dotfiles подключает общий Bash-файл и переносимый - proxy-модуль независимо: после установки доступны все пять proxy-команд и - `update_hproxy`; подключение одного переносимого модуля без остальных - dotfiles также предоставляет все пять proxy-команд. -- [x] Bash-модуль принимает вывод `env` атомарно, проверяет белый список и - наличие каждого из восьми ключей ровно один раз, не использует `eval` и при - любом неверном выводе оставляет прежние восемь переменных без изменений; - `proxy_on` и `proxy_off` больше не делают автоматический запрос внешнего IP. -- [x] Автоматические тесты подтверждают прежний внешний Hiddify-сценарий, - fail-fast, оба регистра переменных, настраиваемый `NO_PROXY`, отсутствие - протечки окружения, обычное и самостоятельное подключение Bash-модуля, - безопасный отказ для `type=xray` и атомарный разбор полного вывода `env`. diff --git a/.scratch/headless-xray-proxy/issues/02-managed-xray-happy-path.md b/.scratch/headless-xray-proxy/issues/02-managed-xray-happy-path.md deleted file mode 100644 index 91b5fb6..0000000 --- a/.scratch/headless-xray-proxy/issues/02-managed-xray-happy-path.md +++ /dev/null @@ -1,74 +0,0 @@ -# 02 — Управляемый Xray: полный штатный путь - -**What to build:** Пользователь с уже установленным поддерживаемым Xray кладёт -одну VLESS-ссылку в закрытый файл и получает полный штатный сценарий локального -прокси: проверку профиля, запуск на переднем плане, диагностику, запуск команд -через прокси и штатную остановку. - -**Blocked by:** 01 — Внешний прокси и переносимый Bash-интерфейс. - -**Status:** done - -Часть требований пересмотрена в 06 — см. 06-simplification-pass.md. - -- [x] `start` разрешён только при явном типе `xray`, требует loopback-адрес и - согласованный локальный порт, никогда не скачивает бинарник и выбирает первый - `xray` из `PATH`. Один разрешённый путь используется для `version`, - `run -test` и основного процесса и показывается перед запуском. -- [x] Успешный `xray version` разбирается только по первой строке в форме - `Xray VERSION` с концом строки либо ASCII-пробелом после полного токена - версии. Принимаются только `25.12.8` и `26.3.27`; ведущий мусор, похожий - префикс вроде `26.3.270`, пустой, неверный или завершившийся ошибкой вывод - запрещает преобразование и запуск. Запрошенная или указанная в профиле версия - не подменяет фактическую версию выбранного бинарника. -- [x] Профиль берётся из предусмотренного файла по умолчанию либо из явно - переданного `-f`, содержит ровно одну VLESS-ссылку, а конечный файл после - разрешения симлинка недоступен группе и остальным пользователям. -- [x] Строгий преобразователь поддерживает TCP/RAW с REALITY и XHTTP с TLS, - сохраняет все предусмотренные спецификацией поля, отклоняет повторы, - любой неизвестный query-параметр, отсутствующие обязательные поля и - неподдерживаемые сочетания до запуска Xray. -- [x] Для Xray 26.3.27 корректно обрабатываются `pcs`/`vcn`, а устаревшие - `allowInsecure`/`insecure` не переносятся и дают предупреждение; для явно - выбранного Xray 25.12.8 сохраняется совместимое insecure-поведение, а новые - поля отклоняются без автоматического отката версии. -- [x] `allowInsecure` и `insecure` принимают только точные значения `true` или - `false`; при одновременном присутствии значения должны совпадать. Для - 25.12.8 только согласованное истинное значение включает `allowInsecure` с - предупреждением, а для 26.3.27 поле никогда не попадает в JSON и истинное - значение предупреждает об обычной проверке сертификата. -- [x] Созданный клиентский JSON слушает только loopback, предоставляет HTTP и - SOCKS на одном порту, отключает журнал доступа и проходит `xray run -test` - до запуска основного процесса. -- [x] `start` остаётся на переднем плане как супервизор одного дочернего Xray; - после запуска процесс жив и порт принимает соединения, а сбой отдельной - проверки внешнего IP остаётся предупреждением. -- [x] Штатный `start` создаёт закрытое состояние с PID супервизора и дочернего - Xray, разрешённым путём к бинарнику, портом и безопасным отпечатком профиля. - `status`, `env`, `run` и `stop` требуют это состояние, живой супервизор, - ожидаемый дочерний процесс и настроенный порт; один чужой listener или PID - никогда не считается управляемым Xray и не затрагивается. -- [x] `status`, `env`, `run` и `stop` обеспечивают штатный путь управляемого - Xray; `run` никогда не запускает его скрыто, а `stop` может завершить - управляемый процесс после переключения машинного типа на `external`. - Успешный `stop` посылает сигнал супервизору, ограниченно ждёт завершения - супервизора и Xray, очищает штатное состояние и возвращает исходный - foreground-вызов `start` в терминал. -- [x] Диагностика внешнего IP действительно делает запрос через настроенный - локальный HTTP-прокси. Её сбой явно показан как предупреждение и не отменяет - уже успешные проверки процесса и порта. -- [x] Безопасная сводка показывает только полезные несекретные свойства - профиля; XHTTP `path` показывается только как скрытое значение с длиной, а - UUID, ключи, short ID и другие секреты — без фрагментов исходного значения. - Полная VLESS-ссылка и уникальные секреты отсутствуют в stdout, stderr и argv - дочерних процессов при успехе, любом отказе разбора и сбое `run -test`. -- [x] Модульные и интеграционные тесты покрывают обе семьи ссылок, варианты - ALPN, типичную ссылку Hiddify XHTTP/TLS, обе поддержанные версии Xray и - понятные отказы на неподдерживаемом вводе. -- [x] Чёрный ящик для каждой поддержанной версии доказывает полный порядок: - профиль по умолчанию и `-f`, `version`, успешный `run -test` до основного - запуска, живое управляемое состояние и порт, `status`, `env`, дочернюю - команду через `run`, проксированную IP-диагностику, `stop` и ограниченное - завершение исходного `start`. Через один настроенный локальный порт реально - завершаются отдельный HTTP-proxy-запрос и отдельный SOCKS-proxy-запрос. - Подмена порта или состояния чужим процессом даёт безопасный отказ. diff --git a/.scratch/headless-xray-proxy/issues/03-safe-process-lifecycle.md b/.scratch/headless-xray-proxy/issues/03-safe-process-lifecycle.md deleted file mode 100644 index 9433100..0000000 --- a/.scratch/headless-xray-proxy/issues/03-safe-process-lifecycle.md +++ /dev/null @@ -1,132 +0,0 @@ -# 03 — Безопасный жизненный цикл управляемого процесса - -**What to build:** Управляемый Xray предсказуемо переживает повторные команды, -сигналы, устаревшее состояние и аварийное исчезновение супервизора, не -затрагивая чужие процессы и не оставляя секретных данных вне закрытого -служебного каталога. - -**Blocked by:** 02 — Управляемый Xray: полный штатный путь; 06 — Упрощение. - -**Status:** done - -**Резолюция (2026-07-20):** реализовано и принято. Дельта: CLI 976→1235, -тесты 437→521 + 799→1112; сьюты: 49 unittest OK + 8 shell PASS (baseline -34/8). Конвейер: spec-ревью постановки (NEEDS_REWORK, 7 MAJOR + 3 MINOR — -постановка переписана до реализации) → реализация на medium → саморевью -(3 находки, починены) → слепые ревью: TASK 1 MINOR + 2 NIT, CODE 4 MAJOR + -1 MINOR (гонки идентичности/очистки) → триаж: FIXED 8, REJECTED 0 → обе -перепроверки FIXES_CONFIRMED. - -## Модель идентичности процессов - -Идентичность определяется отдельно для ребёнка-Xray и для супервизора; -«обход argv в поисках любого совпадения» (`verify_managed_state`) заменяется -этим механизмом. - -- **Общее для обоих:** PID + время старта. Время старта — поле 22 - (`starttime`) из `/proc/PID/stat`, целое число тиков с момента загрузки; - хранится в state как есть и сравнивается на точное равенство. Парсер - обязан сначала изолировать поле `comm` по последней `)` (comm может - содержать пробелы и скобки) и только потом индексировать поля; любая - ошибка чтения/разбора трактуется как несовпадение (fail-closed). -- **Ребёнок Xray:** канонический путь `/proc/PID/exe` (realpath) совпадает - с сохранённым `xray_path`. -- **Супервизор:** его exe — интерпретатор Python, поэтому проверяется - иначе: в state сохраняется канонический путь скрипта CLI - (`supervisor_path`, realpath на момент старта); при проверке операнд - скрипта из `/proc/PID/cmdline` резолвится (относительные пути — через - `/proc/PID/cwd`) и сравнивается с `supervisor_path`. Поддерживаемые формы - запуска (относительный путь, PATH, симлинк) не ломаются — сравнение - идёт по realpath. -- **Границы загрузки:** в state сохраняется `boot_id` - (`/proc/sys/kernel/random/boot_id`). Несовпадение с текущим означает - устаревшее состояние: записанные PID трактуются как мёртвые, сигналы не - посылаются. -- **Гонка check-then-kill:** непосредственно перед каждым `os.kill` - идентичность перечитывается заново; несовпадение — отказ без сигнала. - Остаточная гонка «процесс умер между перечиткой и kill» принята как риск - модели доверенных пользователей (pidfd не используем: Python 3.8+, - переносимость). - -## Чекбоксы - -- [x] Взаимное исключение: супервизор на всё время жизни держит - неблокирующий эксклюзивный `fcntl.flock` на lock-файле в служебном - каталоге. Второй одновременный `start` завершается понятным отказом, не - запуская процессов и не трогая чужие state/конфиг. `state.json` остаётся - данными для восстановления после сбоя, а не механизмом блокировки. - Последовательные повторный `start` и смена профиля при живом экземпляре - по-прежнему отвергаются. -- [x] Схема state (права 0600, каталоги 0700) — ровно эти ключи: - `supervisor_pid`, `xray_pid`, `xray_path`, `config_path`, `address`, - `port` (существующие) + `supervisor_path`, `supervisor_starttime`, - `xray_starttime`, `boot_id` (новые). Ссылка VLESS и её секреты в state - не попадают. -- [x] `status` и `stop` применяют модель идентичности выше; чужой, - устаревший или переиспользованный PID не признаётся управляемым - процессом (в пределах принятой остаточной гонки). -- [x] Три исхода проверки состояния, у каждого — своё поведение: - 1. **Здоровая пара** (супервизор и ребёнок живы, идентичность - подтверждена): `stop` сигналит супервизору, как сейчас. - 2. **Сирота** (супервизор исчез, ребёнок жив и подтверждён по - PID + starttime + boot_id + exe): `stop` сигналит ребёнку напрямую и - удаляет state и сгенерированный конфиг; доступность порта для этого - НЕ требуется. `status` — отдельная ненулевая диагностика «сирота»; - `env` и `run` — отказ. - 3. **Непроверяемое/устаревшее состояние:** если записанный PID жив, но - идентичность не подтверждена — никаких сигналов, state сохраняется, - диагностика с инструкцией ручного удаления; если записанные PID - мертвы (или boot_id не совпал) — безопасная очистка устаревших - файлов разрешена. -- [x] Конфликт локального порта: `start` при уже занятом порте завершается - ненулевым кодом, диагностика называет порт занятым/недоступным, чужой - слушатель не затронут, свои сгенерированные файлы убраны. Инспекция - владения слушателем (/proc/net/tcp) запрещена. -- [x] Секреты: полная VLESS-ссылка никуда не персистится; UUID, ключи, - секретоподобные значения отсутствуют в stdout, stderr, argv, - `state.json` и lock-файле. Единственное допустимое место — `client.json` - с правами 0600 внутри каталога 0700; он удаляется при штатном завершении - и при `stop` сироты. -- [x] Автоматические тесты (без root, на обычной dev-машине): - - блокировка: удержанный извне flock → `start` отказывает, ничего не - запустив и не изменив; - - переиспользованный PID моделируется правкой сохранённого `starttime` - у живого процесса (реальную гонку PID не воспроизводим); отдельно — - мёртвый PID, неверный исполняемый файл, несовпавший `boot_id`; - - SIGKILL супервизора → `stop` добивает подтверждённого сироту и чистит - state/конфиг; - - занятый порт (заранее открытый сокет) → контракт из чекбокса выше; - - юнит-тест парсера `/proc/PID/stat` на синтетических `comm` с - пробелами и `)`; - - фикстура фейкового Xray адаптирована под проверку `/proc/PID/exe` - (ребёнок должен быть настоящим исполняемым файлом — например, - `sys.executable` как «бинарник xray» со скриптами-операндами; детали - на усмотрение исполнителя); - - все сохраняемые ниже поведения остаются зелёными. - -## Сохранить, не переписывать - -Уже реализовано и покрыто тестами в 02/06 — не переделывать, только не -сломать: проброс SIGINT/SIGTERM/SIGHUP ребёнку; очистка state и конфига при -штатном завершении; права 0600/0700; последовательные отказы (повторный -start, смена профиля, несовпадение машинного адреса/порта со state); -loopback-only; секреты вне stdout/stderr/argv; foreground-супервизор; -fail-fast `pclaude`/`pcodex`; внешний путь без машинного конфига. - -Запрещено восстанавливать срезанное в 06: владение слушателем через -`/proc/net/tcp`, `profile_fingerprint`, инспекцию zombie-состояний, -SIGALRM-машинерию, поддержку второй версии Xray. - -## Не трогать - -- `bash/.bash_functions`, установщик, README — вне объёма. -- Никаких коммитов. - -## Отчёт исполнителя - -1. Изменённые файлы со строкой «зачем» на каждый. -2. Команды тестов и счётчики (`python3 -m unittest discover -s tests`, - `bash tests/test_proxy_launchers.sh`; baseline: 34 OK / 8 PASS — новых - станет больше, указать числа). -3. Чекбоксы: закрыт / не закрыт и почему. -4. Принятые по ходу решения и сомнения, если есть. diff --git a/.scratch/headless-xray-proxy/issues/04-verified-xray-installation.md b/.scratch/headless-xray-proxy/issues/04-verified-xray-installation.md deleted file mode 100644 index 6d14e1a..0000000 --- a/.scratch/headless-xray-proxy/issues/04-verified-xray-installation.md +++ /dev/null @@ -1,28 +0,0 @@ -# 04 — Проверяемая пользовательская установка Xray - -**What to build:** Пользователь без root устанавливает официальный Xray одной -явной командой и получает проверенный бинарник нужной версии без скрытой замены -существующей установки или записи за пределами пользовательских каталогов. - -**Blocked by:** 01 — Внешний прокси и переносимый Bash-интерфейс. - -**Status:** ready-for-agent - -- [ ] `install` поддерживает Linux x86_64 и aarch64, по умолчанию выбирает - зафиксированную версию 26.3.27 и принимает другую только через явный параметр - версии, не объявляя произвольный релиз поддержанным. -- [ ] Архив и файл контрольной суммы скачиваются из официального релиза, после - чего SHA-256 проверяется до распаковки и установки; несовпадение прекращает - операцию без изменения установленного бинарника. -- [ ] Работа ведётся во временном пользовательском каталоге, извлечённый бинарник - сначала проходит проверку `version`, а затем атомарно устанавливается в - пользовательский каталог исполняемых файлов. -- [ ] Существующий бинарник другой версии не перезаписывается без `--replace`; - ошибка, отказ или прерывание не оставляют частично установленный файл. -- [ ] После установки `command -v xray` обязан указывать на установленный файл; - иначе команда завершается с понятной подсказкой исправить `PATH`. -- [ ] Установщик не использует sudo, systemd или системные каталоги, а команды - `start` и запуск без подкоманды никогда не начинают скачивание. -- [ ] Автоматические тесты проверяют обе архитектуры, корректную и повреждённую - контрольную сумму, существующую версию, `--replace`, атомарность и отсутствие - записи вне изолированного пользовательского каталога. diff --git a/.scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md b/.scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md deleted file mode 100644 index 8f39488..0000000 --- a/.scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md +++ /dev/null @@ -1,36 +0,0 @@ -# 05 — Доставка пакета и итоговая приёмка - -**What to build:** Завершённая прокси-подсистема устанавливается отдельно или -вместе с dotfiles, снабжена безопасными шаблонами и понятной инструкцией, а её -заявленные сценарии подтверждены автоматическими и доступными системными -проверками. - -**Blocked by:** 03 — Безопасный жизненный цикл управляемого процесса; 04 — -Проверяемая пользовательская установка Xray. - -**Status:** ready-for-agent - -- [ ] Самостоятельный stow-совместимый пакет доставляет CLI, Bash-модуль и - вымышленные шаблоны машинного INI и VLESS-профиля; реальные пользовательские - настройки, UUID, ссылки и ключи не входят в репозиторий. -- [ ] Пакет можно подключить отдельно через stow, эквивалентные ручные симлинки - или копирование; общий установщик использует тот же пакет без отдельной - реализации и сохраняет повторяемость установки. -- [ ] Основная инструкция объясняет внешний Hiddify и управляемый Xray, - подготовку двух файлов настройки, все команды управления, `proxy_on/off`, - `pclaude/pcodex`, `NO_PROXY`, ограничения CIDR и диагностику. -- [ ] Инструкция фиксирует две поддержанные семьи VLESS, Xray 26.3.27, - современную проверку сертификата и отсутствие systemd, автозапуска и - автоматического перехода между типами прокси. -- [ ] Корневой обзор кратко описывает подсистему, показывает подключение пакета - и направляет к полной инструкции, не дублируя её. -- [ ] Полный автоматический прогон подтверждает Python 3.8-совместимый синтаксис, - внешний Hiddify-сценарий, обе семьи VLESS, Xray 26.3.27, установку, - жизненный цикл, Bash-интеграцию и отсутствие утечки секретов. -- [ ] На доступной Ubuntu с Python 3.10 проверены установка, `xray run -test` и - работа HTTP и SOCKS через один локальный вход для Xray 26.3.27. -- [ ] На целевой удалённой системе выполнен тот же пользовательский сценарий; - версия Python в РЕД ОС проверена до заявления поддержки, а недоступная среда - явно записана как неподтверждённая вместо неподкреплённого обещания. -- [ ] После приёмки статус исходной спецификации и документация соответствуют - фактически подтверждённому объёму, включая все оставшиеся ограничения. diff --git a/.scratch/headless-xray-proxy/issues/06-simplification-pass.md b/.scratch/headless-xray-proxy/issues/06-simplification-pass.md deleted file mode 100644 index 8766a60..0000000 --- a/.scratch/headless-xray-proxy/issues/06-simplification-pass.md +++ /dev/null @@ -1,180 +0,0 @@ -# 06 — Упрощение: срез accidental-сложности спеки и кода - -**What to build:** Один проход упрощения по готовым issue 01–02 и спеке: -убрать поддержку второй версии Xray, избыточные защитные проверки вне -принятой модели угроз («доверенные пользователи машины», единственный -пользователь редактирует свои файлы сам) и церемонии, не нужные личному -foreground-инструменту. Поведенческие контракты из раздела «Не трогать» -сохраняются без изменений. - -**Blocked by:** 02 — Управляемый Xray: полный штатный путь. - -**Status:** done - -**Резолюция (2026-07-20):** реализовано и принято. Дельта: CLI 1123→976, -shell.bash 116→91, тесты 1357→1236 строк; сьюты: 34 unittest OK + 8 shell -PASS. Ревью: 1 MAJOR (scoped IPv6 в упрощённом валидаторе — починен с -регрессионным тестом под Python 3.8), 1 NIT отклонён аргументированно. -Non-gating замечания ревью приняты как риски модели угроз. - -**Сначала прочитать:** - -1. Этот файл целиком, затем `/tmp/xray-simplify-Zeo5/06-simplify/sweep.md` — - там точный line-level blast radius каждого пункта (секция 2 — срезы A/B, - секция 3 — SIMP-N). Номера строк — ориентир, сверяй с фактическим кодом. -2. `docs/specs/2026-07-15-headless-xray-proxy-design.md` — исходная спека. -3. `xray-proxy/.local/bin/xray-proxy`, `xray-proxy/.config/xray-proxy/shell.bash`, - `tests/test_xray_proxy.py`, `tests/test_xray_proxy_managed.py`. - -Команды проверки (обе обязательны): - -- `python3 -m unittest discover -s tests` — baseline до изменений: 38 - тестов, OK, ~60 с; -- `bash tests/test_proxy_launchers.sh` — baseline: 9 проверок PASS. - -После изменений тестов станет меньше (удаляются тесты срезанных фич); все -оставшиеся зелёные, новые счётчики указать в отчёте. - -## A. Срез dual-version: остаётся только Xray 26.3.27 - -- [x] `VERSION_LINE` принимает только `26.3.27`; параметр `version` убран из - сигнатур `parse_vless`, `build_xray_config`, `safe_summary`, `warn_legacy` - и их вызовов; `xray_version` остаётся воротами «поддержанная версия или - отказ» и по-прежнему показывает фактическую версию в сводке. -- [x] Ветка эмиссии `allowInsecure` для 25.12.8 и запрет `pcs`/`vcn` для - 25.12.8 удалены. `parse_boolean_fields` (валидация точных `true`/`false`, - требование согласованности обоих полей, предупреждение при истинном - значении) сохраняется; `allowInsecure` никогда не попадает в JSON. -- [x] Тестовая ось версий схлопнута: lifecycle-тест гоняет один прогон - 26.3.27 (обе локации профиля — default и `-f` — сохраняются); - `test_old_version_preserves_explicit_legacy_insecure` и все - 25.12.8-строки табличных тестов удалены. - -## B. Срез проверки владения сокетом через /proc/net/tcp - -- [x] `process_owns_listener`, `process_socket_inodes`, `decode_proc_address` - и их вызовы (в `verify_managed_state` и readiness-цикле `command_start`) - удалены. Сохраняются: сверка PID/идентичности процесса, сверка - адрес/порт машинного конфига со state и `check_endpoint`-проверка - доступности порта. -- [x] `test_verified_child_must_own_configured_listener` удалён вместе с - веткой `FAKE_XRAY_LISTEN_PORT` в фейковом xray; - `test_substituted_machine_port_fails_closed...` сохраняется и зелёный. -- [x] Диагностические сообщения, упоминающие проверку владения слушателем - (в `verify_managed_state` и readiness-цикле `command_start`), обновлены: - после среза они описывают фактические проверки (идентичность процесса, - сверка адрес/порт, доступность порта), а не владение сокетом. - -## C. Диета входной валидации (файлы пишет сам пользователь) - -- [x] `validate_address`: сначала `ipaddress.ip_address`, иначе минимальная - проверка «непустое ASCII-имя без пробелов, схемы `://`, `@`, `/` и - квадратных скобок». RFC-строгость по меткам DNS, лимит 253 и - сопутствующие тесты удалены. Оборачивание IPv6 в `[]` при сборке URL - сохраняется. -- [x] `load_config`: сохраняется отказ на неизвестной секции/ключе и на - отсутствии обязательного ключа (ловит реальные опечатки) и диапазон - порта. Удалены: скан NUL, guard UTF-8-декодирования, отдельный запрет - `[DEFAULT]`, запрет CR/многострочных значений, case-sensitive - `optionxform`; соответствующие подкейсы тестов удалены. -- [x] `read_profile`: сохраняются разрешение симлинка, проверка прав - (group/other → ошибка с подсказкой `chmod 600`) и правило «ровно одна - непустая строка». Удалены скан NUL и guard UTF-8. -- [x] `parse_query`: сохраняются отказ на неизвестном и на повторном - query-параметре. Удалены regex на сырые `%`-последовательности и - посимвольный запрет управляющих символов. - -## D. Диета церемоний - -- [x] `write_private_json`: простая запись файла с правами 0600 внутри - уже-0700 каталога; O_EXCL/fsync/`os.replace`/PID-суффиксы удалены. -- [x] Валидация `PROXY_PREFLIGHT_TIMEOUT` и `XRAY_PROXY_STOP_TIMEOUT` — один - guard «`float()` успешен и > 0, иначе понятная ошибка»; `math` не - импортируется; тесты экстремальных значений (nan/inf/1e-7/1e308) - удалены. Для `PROXY_PREFLIGHT_TIMEOUT` оставить один тест «плохое - значение → понятный отказ»; для `XRAY_PROXY_STOP_TIMEOUT` отдельного - теста нет и добавлять его не нужно — достаточно guard'а. -- [x] `check_reachable` использует `socket.create_connection(timeout=...)` - без SIGALRM/setitimer-машинерии. Принятый риск (записан здесь): - зависший DNS-резолв на external-пути не ограничен таймаутом — для - личного инструмента пользователь прерывает вручную. -- [x] `extra` и `alpn` разбираются один раз в `parse_vless`, результат - сохраняется в профиле и переиспользуется в `build_xray_config` без - повторного разбора. `parse_json_object` сохраняет оба отказа — - на повторах ключей и на JSON-константах (`reject_constant` остаётся): - существующий тест требует отказ на уровне разбора с диагностикой про - `extra` и без запуска `run -test`, и это дешевле, чем переносить - ответственность на сериализацию. -- [x] `process_alive` — только `os.kill(pid, 0)`; чтение - `/proc/PID/stat` ради zombie-состояния удалено. -- [x] Отпечаток профиля (`profile_fingerprint`) удалён из state и кода: - он не участвует ни в одном решении. Смена профиля по-прежнему - блокируется существованием state. Вместе с ним обновлены `STATE_KEYS` и - все тестовые фикстуры state (включая точную сверку набора ключей в - managed-тестах): устаревший ключ в фикстуре не должен ронять сверку - ключей раньше, чем тест дойдёт до проверяемой идентичности процесса. - -## E. shell.bash: средняя форма приёма `env` - -- [x] `_proxy_apply_env_file`: при ненулевом коде возврата CLI окружение не - меняется вовсе; успешный вывод разбирается построчно по первому `=`; - применяются (через `printf -v`, без `eval`) только восемь известных - ключей, прочие строки — ошибка без частичного экспорта. Удалены сканы - NUL/CR, отдельный детектор повторов и проверка «ровно восемь строк». - Свойство «нет частичного изменения окружения при ошибке» сохраняется и - остаётся покрыто тестом. -- [x] `tests/test_proxy_launchers.sh` приведён к средней форме: проверки - повторов/CR/NUL/счётчика строк, соответствующие удалённой машинерии, - убраны или переписаны; проверки «ненулевой код возврата CLI → окружение - не тронуто», «неизвестная строка → ошибка без частичного экспорта» и - владения функциями остаются зелёными. - -## F. Спека и соседние issue приведены к новому объёму - -- [x] Из спеки удалены: ветка 25.12.8 (все места из секции 2 sweep-файла), - флаги `-v`/`--debug` (не реализованы), упоминание отпечатка профиля в - state; описания валидации/атомарности смягчены до фактического - поведения после этого issue. Формулировка идентичности процессов - («PID + время старта + исполняемый файл») в спеке не меняется. -- [x] Issue 03: добавлен явный мандат «идентичность = PID + время старта - (`/proc/PID/stat`) + исполняемый файл ребёнка / путь скрипта - супервизора; текущий argv-обход заменить этим механизмом»; убраны - унаследованные упоминания срезанных фич, если есть; строка Blocked by - дополнена: «06 — Упрощение». -- [x] Issue 05: приёмка сведена к одной версии Xray 26.3.27; упоминания - 25.12.8 удалены. Пункты про РЕД ОС сохранены (решение пользователя - 2026-07-20: целевые системы шире Ubuntu — РЕД ОС, возможно Astra Linux - и Alt Linux; инструмент могут использовать коллеги). -- [x] В спеке в раздел ручной проверки добавлены Astra Linux и Alt Linux - как кандидаты «по мере доступности» с тем же правилом, что для РЕД ОС: - недоступная среда явно записывается как неподтверждённая, поддержка не - заявляется без фактической проверки версии Python и прогона. -- [x] В issue 01 и 02 добавлена одна строка после Status: «Часть - требований пересмотрена в 06 — см. 06-simplification-pass.md»; чекбоксы - 01 и 02 не редактируются (issue 01 описывает строгую приёмку `env`, - которую этот issue смягчает, — без пометки он противоречил бы коду). - -## G. Не трогать (границы) - -- Строгий отказ на неизвестном/повторном query-параметре VLESS. -- Точечный `stop` только по проверенному PID; никакого pkill. -- Foreground-супервизор, проброс сигналов, очистка state. -- Изоляция окружения `run` (proxy-переменные только у ребёнка), сохранение - argv и кода возврата, `--`-разделитель. -- Fail-fast `pclaude`/`pcodex`, обратная совместимость: без машинного - конфига — внешний `hproxy.local:12334`. -- Проверка прав файла профиля (chmod 600) и loopback-only вход. -- Авто-диагностика внешнего IP на старте (несмертельная) — остаётся. -- Секреты: полная VLESS-ссылка, UUID, ключи не появляются в stdout/stderr/ - argv — все существующие тесты на это остаются зелёными. -- `bash/.bash_functions`, установщик, README — не входят в этот issue. -- Никаких коммитов. - -## Отчёт исполнителя - -1. Изменённые файлы со строкой «зачем» на каждый. -2. Команда тестов и счётчик (все зелёные, указать новое число). -3. Чекбоксы: закрыт / не закрыт и почему. -4. Итоговая дельта `wc -l` по CLI, shell.bash и обоим тест-файлам - против baseline (1123 / 116 / 467 / 890). -5. Сомнения, если есть. diff --git a/.scratch/headless-xray-proxy/token-ledger.md b/.scratch/headless-xray-proxy/token-ledger.md deleted file mode 100644 index 372e42d..0000000 --- a/.scratch/headless-xray-proxy/token-ledger.md +++ /dev/null @@ -1,57 +0,0 @@ -# Токен-леджер проекта headless-xray-proxy - -Затраты по этапам конвейера, для калибровки эффорта и планирования лимитов -(5-часовые / недельные окна подписок). Прямое сравнение корректно внутри -провайдера; между Claude и Codex метрики разные (у Claude-субагентов -харнесс даёт один суммарный счётчик, у Codex — раздельные input/cached/ -output/reasoning), кросс-сравнение — ориентировочное. - -## Задача 06 — упрощение (baseline: реализация на HIGH) - -| Этап | Агент / модель | Effort | Токены | Прочее | -|---|---|---|---|---| -| KISS-обход спеки и кода | Claude Opus (субагент) | default | 100 025 суммарно | 14 tool uses, 4.5 мин | -| Ревью постановки | Codex gpt-5.6-sol | high | in 1 263 557 (кэш 1 174 784 → свежих ~89k), out 16 862 (reasoning 12 817) | 82 команды | -| Реализация | Codex gpt-5.6-sol | high | in 6 680 053 (кэш 6 492 928 → свежих ~187k), out 29 064 (reasoning 8 280) | 84 команды, −331 строка | -| Саморевью | Codex (resume) | medium | инкремент: свежих in ~155k, out ~4.5k | 8 команд, found 2 fixed 2 | -| Слепое ревью «по задаче» | Claude Opus (субагент) | default | 75 216 суммарно | 26 tool uses, 4.3 мин; 1 NIT | -| Слепое ревью «по коду» | Codex gpt-5.6-sol (свежий тред) | high | in 2 336 162 (кэш 2 106 112 → свежих ~230k), out 12 434 (reasoning 7 033) | 48 команд; 1 MAJOR | -| Триаж/починка | Codex (resume) | medium | инкремент: свежих in ~26k, out ~7.3k | 18 команд; FIXED 1, REJECTED 1 | -| Перепроверка CODE-1 | Codex-ревьюер (resume) | medium | инкремент: свежих in ~137k (вкл. оборванный первый заход), out ~1.8k | FIXES_CONFIRMED | - -Итог 06 (high-baseline): свежий input ~735k + output ~97k на весь конвейер -(обход, ревью постановки, реализация, саморевью, 2 слепых ревью, триаж, -перепроверка); Claude-доля — 175k суммарно (обход 100k + ревью 75k). - -## Задача 03 / 04 (эксперимент: реализация на MEDIUM) - -_Заполняется по ходу; сравнивать построчно с 06._ - -| Этап | Агент / модель | Effort | Токены | Прочее | -|---|---|---|---|---| -| 03: ревью постановки | Codex gpt-5.6-sol | high | in 1 126 384 (кэш 1 044 480 → свежих ~82k), out 15 830 (reasoning 9 567) | NEEDS_REWORK: 7 MAJOR + 3 MINOR | -| 03: реализация | Codex gpt-5.6-sol | **medium** | in 5 925 714 (кэш 5 678 080 → свежих ~248k), out 29 512 (reasoning 11 251)* | 88+6 команд; +657/−89; 44 OK + 8 PASS; процесс убит рестартом сессии перед отчётом, дописан через resume | - -| 03: саморевью | Codex (resume) | medium | инкремент: свежих in ~53k, out ~11.6k (reasoning ~7k) | 38 команд; found 3 fixed 3; 45 OK + 8 PASS | -| 03: слепое ревью «по задаче» | Claude Opus (субагент) | default | 73 131 суммарно | 21 tool uses, 6.3 мин; 1 MINOR + 2 NIT | -| 03: слепое ревью «по коду» | Codex gpt-5.6-sol (свежий тред) | high | in 1 103 725 (кэш 1 019 136 → свежих ~85k), out 12 797 (reasoning 8 908) | 28 команд; 4 MAJOR + 1 MINOR | -| 03: триаж/починка | Codex (resume) | medium | инкремент: свежих in ~55.5k, out ~16.9k (reasoning ~7.8k) | 46 команд; FIXED 8, REJECTED 0; 49 OK + 8 PASS | -| 03: перепроверка TASK-1..3 | Claude Opus (резюм субагента) | default | инкремент ~7.4k (суммарно 80 560) | 8 tool uses, 1.7 мин; FIXES_CONFIRMED | -| 03: перепроверка CODE-1..5 | Codex-ревьюер (resume) | medium | инкремент: свежих in ~112k, out ~3.2k | 20 команд; FIXES_CONFIRMED | - -Итог 03 (реализация на MEDIUM): Codex — свежий input ~636k + output ~77k -(ревью постановки 82k/16k, реализация ~248k/29.5k, саморевью 53k/11.6k, -CODE-ревью 85k/12.8k, триаж 55.5k/16.9k, перепроверка 112k/3.2k); -Claude-доля — 80.5k суммарно (TASK-ревью + перепроверка). Против 06 -(high-baseline: ~735k + 97k Codex, 175k Claude) — дешевле, но качество -ниже: слепые ревью нашли 4 MAJOR (у 06 — 1), все — гонки -конкурентности/восстановления; конвейер их выловил и починил. - -\* usage первого (убитого) хода не был зафиксирован (нет `turn.completed`); цифры — из resume-хода, по масштабу покрывают весь тред. - -## Наблюдения - -- Кэш у Codex покрывает ~93% входа ревью-этапа: длинные треды с большим - контекстом дешевле по лимитам, чем кажется по сырому input. -- Сравнивать этапы честнее по «свежий input + output (включая reasoning)», - а не по валовому input. diff --git a/README.md b/README.md index 4dd8c00..e136d18 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Chezmoi использует отдельный source dir `chezmoi/` внутр | Пакет | Что делает | |-------|------------| | `bash` | Кастомный prompt, общие функции, подгрузка секретов | -| `xray-proxy` | Переносимый CLI и Bash-команды для явного запуска через прокси | +| `proxy-client` | Переносимый CLI и Bash-команды для явного запуска через прокси | | `tmux` | Два префикса (`Ctrl+b`, `Ctrl+Space`), vi-режим, split по `\|` / `-` | | `vim` | Минимальный .vimrc без плагинов, 2 пробела, leader = `Space` | | `zed` | Конфигурация Zed редактора | @@ -95,67 +95,16 @@ export ZAI_API_KEY="..." # https://z.ai Файл подгружается при старте bash и VS Code Server. Не трекается в git. -### Прокси (`xray-proxy`) +### Прокси (`proxy-client`) -Linux/Bash-команды `proxy_on/off`, `pclaude`, `pcodex` и `myip` находятся в -самостоятельном пакете [`xray-proxy`](xray-proxy/README.md). Он читает строгий -машинный INI, проверяет доступность явно выбранного внешнего прокси и не -запускает команду при ошибке. Без конфига сохраняется внешний Hiddify -`hproxy.local:12334`. Управление процессом Xray будет добавлено отдельно; -`type = xray` пока даёт безопасный отказ. +Linux/Bash-интерфейс `proxy-client` поддерживает внешний прокси и управляемый +пользовательский Xray. Без машинного конфига используется прежний Hiddify на +`hproxy.local:12334`; автоматического запуска Xray и переключения между режимами +нет. -Имя `hproxy.local` — прежний алиас хоста с внешним прокси. Windows и VS Code -Server продолжают использовать его напрямую. - -**Порты:** HTTP/HTTPS — `hproxy.local:12334`, SOCKS5 — `socks5://hproxy.local:12334`. - -**WSL NAT mode** (IP шлюза меняется при каждом запуске): - -```bash -# Через install.sh (интерактивно, ответить "y" на вопрос про wsl.conf) -./install.sh - -# Или вручную: -sudo tee -a /etc/wsl.conf <<'EOF' - -[boot] -command = "/path/to/dotfiles/scripts/setup-hproxy.sh" -EOF -``` - -После изменения `wsl.conf` — `wsl --shutdown`. Обновить без перезапуска: `sudo scripts/setup-hproxy.sh` или `update_hproxy`. - -**WSL mirrored mode** (`networkingMode=mirrored`) — имя резолвится из WSL через DNS/hosts хоста. Достаточно записи на стороне Windows: - -``` -# C:\Windows\System32\drivers\etc\hosts -192.168.1.100 hproxy.local -``` - -Проверка: `getent hosts hproxy.local` (запись в Linux `/etc/hosts` не обязательна). - -**Native Linux** — прокси на localhost, нужна запись в `/etc/hosts`: - -```bash -echo '127.0.0.1 hproxy.local' | sudo tee -a /etc/hosts -``` - -**VS Code Server** — `vscode/.vscode-server/server-env-setup` автоматически подхватывает `hproxy.local` и выставляет все proxy-переменные (HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, NO_PROXY и lowercase-варианты). Если `hproxy.local` не резолвится — прокси не включается, запуск не блокируется. - -**Windows pwsh** — `proxy_on` / `proxy_off` / `myip` работают аналогично bash-версиям. - -**`pclaude` / `pcodex`** — явный запуск Claude/Codex CLI через прокси (bash и pwsh). Bash-версия делегирует проверку и запуск `xray-proxy run`; Windows-версия сохраняет прежний адрес `hproxy.local:12334`. Таймаут — `PROXY_PREFLIGHT_TIMEOUT`, по умолчанию 2 c. Если прокси недоступен, клиент не запускается. Proxy-переменные видны только дочернему процессу и не остаются в сессии. Голые `claude`/`codex` не перекрываются и идут напрямую — для прокси нужен явный `p`-префикс. - -**Codex GUI на Windows** — chezmoi создает Start Menu ярлык `Codex Proxy`, который запускает Codex Windows App с proxy-переменными: - -```powershell -HTTP_PROXY=http://hproxy.local:12334 -HTTPS_PROXY=http://hproxy.local:12334 -ALL_PROXY=socks5://hproxy.local:12334 -NO_PROXY=hproxy.local,localhost,127.0.0.1,::1,[::1],.concept-software.ru -``` - -Ярлык вызывает `~/.local/bin/start-codex-proxy.ps1`. Скрипт также добавляет эти переменные в `WSLENV` с флагом `/u`: это штатный мост Windows → WSL, чтобы дочерние WSL-сессии, запущенные из Codex GUI, получили тот же proxy environment. +[Установка, выбор режима, WSL и ежедневное использование](proxy-client/README.md) +описаны рядом с пакетом. Windows PowerShell и ярлык `Codex Proxy` сохраняют +прежнюю интеграцию с `hproxy.local:12334`. ### Git baseline @@ -187,7 +136,7 @@ NO_PROXY=hproxy.local,localhost,127.0.0.1,::1,[::1],.concept-software.ru ## 📖 Справочник -### Bash-функции (`~/.config/xray-proxy/shell.bash`) +### Bash-функции (`~/.config/proxy-client/shell.bash`) | Функция | Описание | |---------|----------| diff --git a/agents/.agents/skills/.gitignore b/agents/.agents/skills/.gitignore new file mode 100644 index 0000000..e5b9030 --- /dev/null +++ b/agents/.agents/skills/.gitignore @@ -0,0 +1,23 @@ +# Matt Pocock skills +writing-great-skills/ +wayfinder/ +triage/ +to-tickets/ +to-spec/ +teach/ +tdd/ +setup-matt-pocock-skills/ +resolving-merge-conflicts/ +research/ +prototype/ +improve-codebase-architecture/ +implement/ +handoff/ +grilling/ +grill-with-docs/ +grill-me/ +domain-modeling/ +diagnosing-bugs/ +codebase-design/ +code-review/ +ask-matt/ diff --git a/agents/.agents/skills/claude-subagent-playbook/SKILL.md b/agents/.agents/skills/claude-subagent-playbook/SKILL.md index 3a40a69..f2fdbf0 100644 --- a/agents/.agents/skills/claude-subagent-playbook/SKILL.md +++ b/agents/.agents/skills/claude-subagent-playbook/SKILL.md @@ -81,6 +81,18 @@ description: >- глубины — повышай effort; не хватает качества суждения — повышай модель; после двух слабых результатов подряд — свежий более сильный агент вместо переноса неудачного контекста. +- **Effort Claude-субагентов тоже задавай явно** (решение пользователя + 2026-07-21), по цене *тихой* ошибки этапа, не по сложности: `xhigh` — + ревью постановок и планов (дефект каскадирует: отравляет реализацию, оба + ревью и триаж — казус CODE-1 в xray-proxy 04); `high` — слепое ревью + (последняя линия обороны своей линзы, пропуск тихий); `medium`/default — + узкие перепроверки и механика (за ними приёмка оркестратора, ошибка + громкая). Ревью ниже high не опускать даже при двух ревьюерах: линзы + разные, избыточность — не дублирование. Внутри одного агента effort между + резюмами не менять (у Claude смена thinking-конфига сбрасывает + messages-ярус кэша; про effort не задокументировано — не рисковать). + Ограничение любого эффорта: от уверенной выдумки внешних фактов (форматы + артефактов, версии) не страхует — факты проверять отдельно. ## Конвейер diff --git a/agents/.agents/skills/claude-subagent-playbook/references/codex-exec.md b/agents/.agents/skills/claude-subagent-playbook/references/codex-exec.md index 619ee4d..3279197 100644 --- a/agents/.agents/skills/claude-subagent-playbook/references/codex-exec.md +++ b/agents/.agents/skills/claude-subagent-playbook/references/codex-exec.md @@ -33,6 +33,22 @@ рискованная реализация, ревью кода, диагноз; `xhigh` — не дефолтить: расход удваивается, а выигрыш мал — только по явному запросу пользователя. Оверрайды проверены под `--strict-config`. + Уточнение по эксперименту xray-proxy 03/04 (решение пользователя + 2026-07-21): реализация по отревьюированному самодостаточному тикету на + `medium` допустима и для нетривиальных задач — конвейер (саморевью + два + слепых ревью + триаж) ловит просадку (03: 4 MAJOR-гонки найдены и + починены; 04: 1 «чистый» MAJOR — на уровне high-baseline 06). Но экономию + считай по всему конвейеру, не по этапу: в 04 medium вышел дороже + high-baseline (~822k+135k против ~735k+97k свежих) — сэкономленное на + реализации съел триаж находок. Критерий выбора — цена *тихой* ошибки + этапа: ревью/постановка/диагноз остаются high (там модель — последняя + линия обороны). И предел: high на ревью постановки не страхует от + уверенной выдумки внешних фактов (04: несуществующий однострочный формат + `.dgst`) — форматы артефактов и версии проверяй реальным запросом или + фикстурой из первоисточника. Попутная эмпирика: смена + `model_reasoning_effort` при `resume` кэш треда НЕ сбрасывает (доля кэша + держалась ~93–94% при high→medium в 06/03/04) — дорогое ревью на high и + дешёвая перепроверка на medium в одном треде работают как задумано. - **Запуск — собственным фоновым Bash оркестратора** (`run_in_background`): фоновый процесс детачится, живёт сквозь ходы и будит оркестратора по выходу. Потолок 10 минут — только у фронтграундного Bash; фоновому таймаут diff --git a/agents/.agents/skills/claude-subagent-playbook/references/lessons.md b/agents/.agents/skills/claude-subagent-playbook/references/lessons.md index dcc0006..1257b5b 100644 --- a/agents/.agents/skills/claude-subagent-playbook/references/lessons.md +++ b/agents/.agents/skills/claude-subagent-playbook/references/lessons.md @@ -232,6 +232,36 @@ git (`git show HEAD:path`) + повторным наложением право bulk-механика /coordinator-loop и узкие починки, если лимиты станут узкими. Проверка — один A/B на реальной узкой починке. +## Прогоны 2026-07-20/21: headless-xray-proxy, задачи 03–04 — эксперимент «реализация на medium» + +- Дизайн: реализация Codex на `medium` (03 — жизненный цикл процессов, + 04 — проверяемая установка), остальное по правилам 2026-07-19; baseline — + задача 06 на high (1 MAJOR + 1 NIT от слепых ревью). +- Качество: 03 → 4 MAJOR (все — гонки конкурентности/восстановления), + 04 → 2 MAJOR, из них один (CODE-1) — дефект ПОСТАНОВКИ, не исполнителя. + Конвейер всё выловил и починил; «чистых» MAJOR реализации в 04 — один, + на уровне baseline. +- Экономика: 03 дешевле baseline (~636k+77k против ~735k+97k свежих + Codex-токенов), 04 — ДОРОЖЕ (~822k+135k): экономию реализации съел + триаж семи находок. Урок: экономию эффорта считать по конвейеру целиком. +- Казус CODE-1: spec-review на high уверенно выдумал несуществующий + однострочный формат `SHA256=` в `.dgst` (реальный — четыре записи + `MD5=`/`SHA1=`/`SHA2-256=`/`SHA2-512=`); дефект уехал в тикет и код, + пойман только слепым CODE-ревью, стоил поправки тикета до триажа. + Урок: внешние факты (форматы артефактов, версии релизов) эффортом не + лечатся — проверять реальным скачиванием/фикстурой из первоисточника. +- Эмпирика кэша Codex: смена `model_reasoning_effort` high→medium при + `resume` кэш треда не сбрасывает (~93–94% кэша во всех трёх задачах). +- Казусы харнеса: рестарт сессии дважды убивал фоновый codex — тред + спасался по сохранённому thread_id; `codex exec resume` принимает + ТОЛЬКО `-c`-оверрайды (`-s`/`-m`/`-o` → exit 2); usage убитого хода + теряется (нет `turn.completed`) — фиксировать с оговоркой из resume-хода. +- Решения пользователя 2026-07-21 (поглощено: codex-exec.md — эффорт, + SKILL.md — эффорт-карта Claude-субагентов): реализация по + отревьюированному тикету → medium допустим; Claude-субагентам effort + задавать явно — xhigh постановки/планы, high слепое ревью, medium + перепроверки; ревью ниже high не опускать. + ## Прогон 2026-07-21: yantar-dags, предразметка — этап постановки - Issues в GitLab созданы по конвенции репозитория, но мимо критериев diff --git a/bash/.bash_custom b/bash/.bash_custom index 092c279..137e06a 100644 --- a/bash/.bash_custom +++ b/bash/.bash_custom @@ -16,7 +16,7 @@ esac # Файлы из этого репозитория (stow кладет их в $HOME). [ -f ~/.bash_functions ] && source ~/.bash_functions -[ -f ~/.config/xray-proxy/shell.bash ] && source ~/.config/xray-proxy/shell.bash +[ -f ~/.config/proxy-client/shell.bash ] && source ~/.config/proxy-client/shell.bash [ -f ~/.bash_prompt ] && source ~/.bash_prompt # Локальные секреты (не хранить в git/stow). diff --git a/docs/specs/2026-07-15-headless-xray-proxy-design.md b/docs/specs/2026-07-15-proxy-client-design.md similarity index 85% rename from docs/specs/2026-07-15-headless-xray-proxy-design.md rename to docs/specs/2026-07-15-proxy-client-design.md index f1fe7f8..0345263 100644 --- a/docs/specs/2026-07-15-headless-xray-proxy-design.md +++ b/docs/specs/2026-07-15-proxy-client-design.md @@ -1,8 +1,8 @@ -# Headless Xray-прокси для агентских CLI +# `proxy-client` для агентских CLI Дата: 2026-07-15 -Статус: дизайн утверждён, реализация не начата +Статус: реализация готова к ручной приёмке; публичное имя уточнено 2026-07-21 ## Проблема @@ -93,9 +93,12 @@ Bash нужен только там, где необходимо изменит ### Границы компонентов -`xray-proxy` — один исполняемый файл на Python 3.8+ без внешних библиотек. Он +`proxy-client` — один исполняемый файл на Python 3.8+ без внешних библиотек. Он отвечает за установку Xray, запуск и остановку процесса, диагностику, преобразование VLESS и запуск произвольной дочерней команды через прокси. +Нейтральное публичное имя описывает клиентскую прокси-подсистему целиком: +внешний прокси и управляемый Xray. Название `xray` остаётся у конкретного +движка и значения `type = xray`, где оно действительно важно. Переносимый `shell.bash` — единственный владелец `proxy_on`, `proxy_off`, `pclaude`, `pcodex`, `myip` и внутренних proxy-помощников. При реализации они @@ -105,18 +108,18 @@ Bash нужен только там, где необходимо изменит машинный конфиг самостоятельно: - `pclaude` и `pcodex` делегируют запуск форме - `xray-proxy run -- COMMAND ARG...`, где `--` отделяет параметры клиента; -- `proxy_on` получает от `xray-proxy env` проверенный набор proxy-переменных и + `proxy-client run -- COMMAND ARG...`, где `--` отделяет параметры клиента; +- `proxy_on` получает от `proxy-client env` проверенный набор proxy-переменных и применяет его к текущей Bash-сессии; - `proxy_off` очищает верхний и нижний регистр proxy-переменных. -Общий `bash/.bash_functions` и модуль Xray не зависят друг от друга. В составе +Общий `bash/.bash_functions` и модуль клиента не зависят друг от друга. В составе dotfiles `.bash_custom` подключает их двумя независимыми строками. Без -остальных dotfiles пользователь добавляет только один `source` модуля Xray в +остальных dotfiles пользователь добавляет только один `source` модуля клиента в `.bashrc`. Для неинтерактивных сценариев и других оболочек остаётся прямой -`xray-proxy run`. +`proxy-client run`. -`xray-proxy env` сначала выполняет ту же проверку доступности, что и `run`, а +`proxy-client env` сначала выполняет ту же проверку доступности, что и `run`, а затем печатает восемь строк `KEY=value`: HTTP, HTTPS, ALL и NO_PROXY в верхнем и нижнем регистре. `shell.bash` сначала целиком получает успешный ответ, затем разбирает строки по первому `=`, принимает только известные ключи и лишь после @@ -131,17 +134,17 @@ dotfiles `.bash_custom` подключает их двумя независим набора переменных, fail-fast перед `pclaude`/`pcodex` и изоляции окружения дочернего процесса. Автоматический запрос внешнего IP из `proxy_on` и `proxy_off` удаляется: он замедляет переключение и смешивает изменение -окружения с диагностикой. Для проверки остаются `myip` и `xray-proxy status`. +окружения с диагностикой. Для проверки остаются `myip` и `proxy-client status`. ### Два независимых файла настройки -Машинный файл `~/.config/proxy/config` хранит несекретное описание активного -прокси: явный тип `xray` или `external`, отображаемое имя, адрес, порт и -добавку к `NO_PROXY`. Формат — INI из стандартной библиотеки Python. Если файла -нет, прокси-подсистема сохраняет прежний внешний адрес +Машинный файл `~/.config/proxy-client/config` хранит несекретное описание +активного прокси: явный тип `xray` или `external`, отображаемое имя, адрес, порт +и добавку к `NO_PROXY`. Формат — INI из стандартной библиотеки Python. Если +файла нет, прокси-подсистема сохраняет прежний внешний адрес `hproxy.local:12334` ради обратной совместимости. -Секретный `~/.config/xray-proxy/default.url` содержит одну VLESS-ссылку. +Секретный `~/.config/proxy-client/default.url` содержит одну VLESS-ссылку. Команда запуска принимает другой файл через `-f`. Профили можно хранить в нескольких файлах, но активным у пользователя бывает только один. CLI следует за симлинком, но проверяет права конечного файла: доступ группы или @@ -156,8 +159,8 @@ CLI следует за симлинком, но проверяет права ### Явная семантика прокси Тип `xray` означает, что `pclaude` и `pcodex` требуют запущенный процесс, -которым управляет `xray-proxy`. Если его нет, агент не запускается, а сообщение -предлагает выполнить `xray-proxy start`. +которым управляет `proxy-client`. Если его нет, агент не запускается, а сообщение +предлагает выполнить `proxy-client start`. Тип `external` означает внешний процесс, например Hiddify. Проверяется только доступность явно заданного адреса и порта. Ни один тип не переходит на другой @@ -264,22 +267,32 @@ Hiddify Manager может добавлять `allowInsecure=true` и `insecure= ### Установка Xray -`xray-proxy install` — отдельная явная операция. Она устанавливает Xray в +`proxy-client install` — отдельная явная операция. Она устанавливает Xray в `~/.local/bin`, скачивает официальный архив и файл контрольной суммы, затем -проверяет SHA-256. Версия по умолчанию зафиксирована в коде клиента на текущем -на момент реализации стабильном релизе 26.3.27. Пользователь может явно указать -другую версию через `--version`; произвольная версия не становится заявленной -как поддерживаемая только из-за успешной установки. Официальные релизы -публикуют необходимые файлы `.dgst`: +проверяет SHA-256. Поддерживается только зафиксированный релиз 26.3.27; +параметра версии нет. Официальные релизы публикуют необходимые файлы `.dgst`: [релиз 26.3.27](https://github.com/XTLS/Xray-core/releases/tag/v26.3.27). -Первая версия установщика поддерживает Linux x86_64 и aarch64. Архив -распаковывается во временный пользовательский каталог, проверенный бинарник -сначала запускается с `version`, а затем атомарно переносится в -`~/.local/bin/xray`. Существующий файл другой версии не перезаписывается без -явного `--replace`. После установки CLI проверяет, что `command -v xray` -указывает именно на установленный файл; иначе завершается с подсказкой исправить -`PATH`. `start` всегда показывает фактический путь и версию выбранного бинарника. +Для Linux `x86_64`/`amd64` используется `Xray-linux-64.zip`, для +`aarch64`/`arm64` — `Xray-linux-arm64-v8a.zip`; остальные архитектуры +отклоняются. Официальный `.dgst` содержит записи `MD5`, `SHA1`, `SHA2-256` и +`SHA2-512`; принимается ровно один полный набор со строчными hex-значениями, +для проверки архива используется `SHA2-256`. Дубликаты, uppercase, отсутствие +записи и мусор отклоняются. Архив и извлечённый бинарник ограничены 64 MiB, +`.dgst` — 4 KiB; фактический размер считается с limit+1 и должен совпадать с +объявленным `Content-Length`. Архив распаковывается в закрытый staging-каталог +внутри `~/.local/bin`, проверенный бинарник запускается с `version`, после чего +одним `os.replace` атомарно переносится в `~/.local/bin/xray`. + +Корректная существующая версия 26.3.27 даёт no-op без сети. Другой файл или +симлинк не заменяется без `--replace`, а каталог не заменяется никогда. +Start-lock берётся до проверки назначения и удерживается до завершения +`os.replace`; живой, сиротский или неподтверждённый managed-state запрещает +установку с подсказкой сначала выполнить `stop`. +После установки CLI проверяет, что первый `xray` из `PATH` разрешается в +установленный файл; иначе завершается с подсказкой поставить `~/.local/bin` в +начало `PATH`, не откатывая готовую установку. `start` всегда показывает +фактический путь и версию выбранного бинарника. Перед созданием JSON CLI разрешает первый `xray` из `PATH` до одного пути и запускает этим файлом `xray version`. Первая строка должна начинаться с точного @@ -291,7 +304,7 @@ Hiddify Manager может добавлять `allowInsecure=true` и `insecure= из содержимого VLESS-ссылки. `start` никогда не скачивает бинарник. Если `xray` отсутствует в `PATH`, команда -завершается с подсказкой выполнить `xray-proxy install`. +завершается с подсказкой выполнить `proxy-client install`. ### Жизненный цикл @@ -307,7 +320,7 @@ CLI предоставляет `install`, `start`, `stop`, `status`, `env` и `r достаточно истории tmux. Супервизор на всё время работы удерживает неблокирующую эксклюзивную блокировку -`fcntl.flock` в служебном каталоге `~/.local/state/xray-proxy/`. Второй `start` +`fcntl.flock` в служебном каталоге `~/.local/state/proxy-client/`. Второй `start` отказывает до запуска процессов и изменения state или сгенерированного конфига. `state.json` нужен для восстановления после сбоя, но не служит блокировкой. @@ -354,12 +367,14 @@ UUID, ключи, short ID и другие уникальные секретны ### Доставка -Компоненты образуют отдельный stow-совместимый каталог `xray-proxy/`. Он -доставляет CLI, Bash-модуль и два шаблона: машинного INI и файла VLESS-ссылки. +Компоненты образуют отдельный stow-совместимый каталог `proxy-client/`. Он +доставляет CLI, Bash-модуль и три шаблона: машинного INI для внешнего прокси, +машинного INI для Xray и файла VLESS-ссылки. Реальные пользовательские файлы не управляются stow и не входят в Git. -CLI раскладывается как `~/.local/bin/xray-proxy`, а модуль — как -`~/.config/xray-proxy/shell.bash`. Шаблоны лежат рядом с модулем в подкаталоге -`examples/` и не подменяют настоящие пользовательские файлы. +CLI раскладывается как `~/.local/bin/proxy-client`. Машинный конфиг, профиль, +модуль `shell.bash` и каталог `examples/` находятся вместе в +`~/.config/proxy-client/`. Шаблоны не подменяют настоящие пользовательские +файлы. В корне этого каталога находится самостоятельный `README.md`. Он является основной инструкцией клиента и исключается из раскладки stow. README остаётся @@ -381,6 +396,12 @@ README репозитория только кратко описывает по Python 3.11, что исключило бы штатный Python 3.10 в Ubuntu 22.04. - Bash оставлен отдельным тонким модулем, потому что дочерний Python-процесс не может изменить окружение родительской shell-сессии. +- Выбрано публичное имя `proxy-client`, потому что подсистема обслуживает и + внешний прокси, и управляемый Xray. `xray-proxy` отклонено как имя, + выпячивающее одну реализацию, а `proxyctl` — как менее понятное пользователю. +- Все пользовательские файлы собраны в `~/.config/proxy-client/`. Разделение + между общим машинным конфигом и Xray-профилем остаётся логическим, но не + требует помнить два каталога. - Машинный прокси и VLESS-ссылка разделены: первый файл описывает локальную политику shell, второй содержит секрет и меняется независимо. - Выбран один порт, потому что SOCKS-вход Xray совместим с HTTP и не требует @@ -449,7 +470,7 @@ README репозитория только кратко описывает по ## Влияние на документацию -Основная инструкция создаётся как `xray-proxy/README.md` одновременно с +Основная инструкция находится в `proxy-client/README.md` рядом с реализацией. Она должна описывать: - различие внешнего Hiddify и управляемого Xray; diff --git a/install.sh b/install.sh index 10701f1..770e2b7 100755 --- a/install.sh +++ b/install.sh @@ -169,6 +169,77 @@ migrate_claude_stow_symlink() { fi } +# Миграция: раскрыть старую stow-ссылку ~/.config -> zed/.config. +# В пакете zed остаётся только управляемый каталог zed; остальные настройки +# возвращаются в домашний каталог и больше не попадают в репозиторий. +migrate_zed_config_stow_symlink() { + local config_path="$HOME/.config" + local zed_config="$DOTFILES_DIR/zed/.config" + local config_real="" + local zed_config_real="" + local replacement="" + local items_file="" + local item="" + local base="" + local moved_items=() + + [ -L "$config_path" ] || return 0 + + config_real="$(readlink -f "$config_path" 2>/dev/null || true)" + zed_config_real="$(readlink -f "$zed_config" 2>/dev/null || true)" + if [ -z "$zed_config_real" ] || [ "$config_real" != "$zed_config_real" ]; then + return 0 + fi + + info "Миграция: ~/.config больше не должен принадлежать пакету zed..." + replacement="$(mktemp -d "$HOME/.config.migrate.XXXXXX")" + chmod --reference="$zed_config_real" "$replacement" + + items_file="$(mktemp "$HOME/.config.migrate-items.XXXXXX")" + if ! find "$zed_config_real" -mindepth 1 -maxdepth 1 ! -name zed -print0 > "$items_file"; then + error "Не удалось получить полный список старого ~/.config" + rm -f -- "$items_file" + rmdir "$replacement" 2>/dev/null || true + return 1 + fi + + while IFS= read -r -d '' item; do + base="${item##*/}" + if ! mv -- "$item" "$replacement/$base"; then + error "Не удалось перенести ~/.config/$base" + for base in "${moved_items[@]}"; do + mv -- "$replacement/$base" "$zed_config_real/$base" 2>/dev/null || true + done + rm -f -- "$items_file" + rmdir "$replacement" 2>/dev/null || true + return 1 + fi + moved_items+=("$base") + done < "$items_file" + rm -f -- "$items_file" + + if ! rm -- "$config_path"; then + error "Не удалось удалить старую ссылку ~/.config" + for base in "${moved_items[@]}"; do + mv -- "$replacement/$base" "$zed_config_real/$base" 2>/dev/null || true + done + rmdir "$replacement" 2>/dev/null || true + return 1 + fi + + if ! mv -- "$replacement" "$config_path"; then + error "Не удалось создать новый каталог ~/.config" + ln -s "$zed_config_real" "$config_path" 2>/dev/null || true + for base in "${moved_items[@]}"; do + mv -- "$replacement/$base" "$zed_config_real/$base" 2>/dev/null || true + done + rmdir "$replacement" 2>/dev/null || true + return 1 + fi + + info "Настройки перенесены из пакета zed в ~/.config" +} + # Настроить boot command для hproxy.local в /etc/wsl.conf. setup_wsl_hproxy() { [ -n "${WSL_DISTRO_NAME:-}" ] || return 0 @@ -231,7 +302,7 @@ if ! command -v "$STOW_BIN" &>/dev/null; then exit 1 fi -PACKAGES=(bash tmux vim zed vscode agents codex gemini xray-proxy) +PACKAGES=(bash tmux vim zed vscode agents codex gemini proxy-client) AVAILABLE_PACKAGES=() info "Применяю stow пакеты..." @@ -244,6 +315,24 @@ for pkg in "${PACKAGES[@]}"; do fi done +migrate_zed_config_stow_symlink + +if [ ! -L "$HOME/.local" ]; then + mkdir -p "$HOME/.local" +fi +if [ -d "$HOME/.local" ] && [ ! -L "$HOME/.local" ] \ + && [ ! -L "$HOME/.local/bin" ]; then + mkdir -p "$HOME/.local/bin" +fi + +if [ ! -L "$HOME/.config" ]; then + mkdir -p "$HOME/.config" +fi +if [ -d "$HOME/.config" ] && [ ! -L "$HOME/.config" ] \ + && [ ! -L "$HOME/.config/proxy-client" ]; then + mkdir -p "$HOME/.config/proxy-client" +fi + if [ "${#AVAILABLE_PACKAGES[@]}" -gt 0 ]; then stow_package "${AVAILABLE_PACKAGES[@]}" fi diff --git a/xray-proxy/.config/xray-proxy/examples/config b/proxy-client/.config/proxy-client/examples/config.external similarity index 100% rename from xray-proxy/.config/xray-proxy/examples/config rename to proxy-client/.config/proxy-client/examples/config.external diff --git a/proxy-client/.config/proxy-client/examples/config.xray b/proxy-client/.config/proxy-client/examples/config.xray new file mode 100644 index 0000000..55e9cc9 --- /dev/null +++ b/proxy-client/.config/proxy-client/examples/config.xray @@ -0,0 +1,6 @@ +[proxy] +type = xray +name = Local Xray +address = 127.0.0.1 +port = 12334 +no_proxy_extra = .example.internal,git.example.internal diff --git a/xray-proxy/.config/xray-proxy/examples/default.url b/proxy-client/.config/proxy-client/examples/default.url similarity index 100% rename from xray-proxy/.config/xray-proxy/examples/default.url rename to proxy-client/.config/proxy-client/examples/default.url diff --git a/xray-proxy/.config/xray-proxy/shell.bash b/proxy-client/.config/proxy-client/shell.bash similarity index 80% rename from xray-proxy/.config/xray-proxy/shell.bash rename to proxy-client/.config/proxy-client/shell.bash index a88a78d..63a6fcd 100644 --- a/xray-proxy/.config/xray-proxy/shell.bash +++ b/proxy-client/.config/proxy-client/shell.bash @@ -1,8 +1,8 @@ -# Переносимый Bash-интерфейс xray-proxy. Файл можно подключать отдельно от -# остальных dotfiles: source ~/.config/xray-proxy/shell.bash +# Переносимый Bash-интерфейс proxy-client. Файл можно подключать отдельно от +# остальных dotfiles: source ~/.config/proxy-client/shell.bash _proxy_cli() { - "${XRAY_PROXY_BIN:-xray-proxy}" "$@" + "${PROXY_CLIENT_BIN:-proxy-client}" "$@" } _proxy_apply_env_file() { @@ -15,7 +15,7 @@ _proxy_apply_env_file() { while IFS= read -r line || [ -n "$line" ]; do if [[ "$line" != *=* ]]; then - echo "Ошибка: xray-proxy env вернул строку без '='" >&2 + echo "Ошибка: proxy-client env вернул строку без '='" >&2 return 1 fi key="${line%%=*}" @@ -23,7 +23,7 @@ _proxy_apply_env_file() { case "$key" in HTTP_PROXY|HTTPS_PROXY|ALL_PROXY|NO_PROXY|http_proxy|https_proxy|all_proxy|no_proxy) ;; *) - echo "Ошибка: xray-proxy env вернул неизвестный ключ: $key" >&2 + echo "Ошибка: proxy-client env вернул неизвестный ключ: $key" >&2 return 1 ;; esac @@ -42,7 +42,7 @@ proxy_on() { local output_file old_umask old_umask="$(umask)" umask 077 - if ! output_file="$(mktemp "${TMPDIR:-/tmp}/xray-proxy-env.XXXXXX")"; then + if ! output_file="$(mktemp "${TMPDIR:-/tmp}/proxy-client-env.XXXXXX")"; then umask "$old_umask" echo "Ошибка: не удалось создать временный файл для proxy-env" >&2 return 1 diff --git a/xray-proxy/.local/bin/xray-proxy b/proxy-client/.local/bin/proxy-client similarity index 74% rename from xray-proxy/.local/bin/xray-proxy rename to proxy-client/.local/bin/proxy-client index 35d9c44..2103ac2 100755 --- a/xray-proxy/.local/bin/xray-proxy +++ b/proxy-client/.local/bin/proxy-client @@ -3,7 +3,7 @@ import sys if sys.version_info < (3, 8): sys.stderr.write( - "Ошибка: xray-proxy требует Python 3.8 или новее; обнаружен Python {}.{}.\n".format( + "Ошибка: proxy-client требует Python 3.8 или новее; обнаружен Python {}.{}.\n".format( sys.version_info[0], sys.version_info[1] ) ) @@ -12,18 +12,24 @@ if sys.version_info < (3, 8): import argparse import configparser import fcntl +import hashlib import http.client import ipaddress import json import os +import platform import re import shutil import signal import socket import stat import subprocess +import tempfile import time +import urllib.error +import urllib.request import uuid +import zipfile from pathlib import Path from urllib.parse import parse_qsl, unquote, urlsplit @@ -33,7 +39,17 @@ DEFAULT_NAME = "Hiddify" DEFAULT_ADDRESS = "hproxy.local" DEFAULT_PORT = 12334 DEFAULT_NO_PROXY_EXTRA = (".concept-software.ru",) -VERSION_LINE = re.compile(r"^Xray (26\.3\.27)(?: .*)?$") +XRAY_VERSION = "26.3.27" +VERSION_LINE = re.compile( + r"^Xray ({0})(?: .*)?$".format(re.escape(XRAY_VERSION)) +) +XRAY_RELEASE_BASE_URL = ( + "https://github.com/XTLS/Xray-core/releases/download/v{}/".format(XRAY_VERSION) +) +MAX_ARCHIVE_BYTES = 64 * 1024 * 1024 +MAX_DIGEST_BYTES = 4 * 1024 +MAX_XRAY_BYTES = 64 * 1024 * 1024 +STREAM_CHUNK_BYTES = 64 * 1024 QUERY_KEYS = { "type", "security", @@ -75,6 +91,10 @@ class ProxyError(Exception): pass +class StartLockBusyError(ProxyError): + pass + + class ProxyConfig: def __init__(self, proxy_type, name, address, port, no_proxy_extra): self.proxy_type = proxy_type @@ -105,22 +125,22 @@ class VlessProfile: def config_path(): config_home = os.environ.get("XDG_CONFIG_HOME") if config_home: - return Path(config_home).expanduser() / "proxy/config" - return Path.home() / ".config/proxy/config" + return Path(config_home).expanduser() / "proxy-client/config" + return Path.home() / ".config/proxy-client/config" -def xray_config_home(): +def proxy_client_config_home(): config_home = os.environ.get("XDG_CONFIG_HOME") if config_home: - return Path(config_home).expanduser() / "xray-proxy" - return Path.home() / ".config/xray-proxy" + return Path(config_home).expanduser() / "proxy-client" + return Path.home() / ".config/proxy-client" def state_home(): state_root = os.environ.get("XDG_STATE_HOME") if state_root: - return Path(state_root).expanduser() / "xray-proxy" - return Path.home() / ".local/state/xray-proxy" + return Path(state_root).expanduser() / "proxy-client" + return Path.home() / ".local/state/proxy-client" def state_path(): @@ -554,7 +574,7 @@ def build_xray_config(profile, local_address, local_port): def resolve_xray(): found = shutil.which("xray") if found is None: - raise ProxyError("xray не найден в PATH; сначала выполните xray-proxy install") + raise ProxyError("xray не найден в PATH; сначала выполните proxy-client install") try: return str(Path(found).resolve(strict=True)) except OSError as error: @@ -580,6 +600,293 @@ def xray_version(path): return match.group(1) +def xray_asset_for_architecture(machine): + normalized = machine.lower() + if normalized in ("x86_64", "amd64"): + return "Xray-linux-64.zip" + if normalized in ("aarch64", "arm64"): + return "Xray-linux-arm64-v8a.zip" + raise ProxyError("архитектура {} не поддерживается установщиком Xray".format(machine)) + + +def download_file(url, destination, max_bytes): + try: + with urllib.request.urlopen(url, timeout=30) as response: + raw_length = response.headers.get("Content-Length") + if raw_length is None: + declared_length = None + elif re.fullmatch(r"[0-9]+", raw_length) is None: + raise ProxyError("сервер сообщил неверный Content-Length") + else: + declared_length = int(raw_length) + if declared_length > max_bytes: + raise ProxyError("скачиваемый файл превышает допустимый размер") + with destination.open("wb") as stream: + actual_length = 0 + while True: + read_size = min( + STREAM_CHUNK_BYTES, max_bytes - actual_length + 1 + ) + chunk = response.read(read_size) + if not chunk: + break + actual_length += len(chunk) + if actual_length > max_bytes: + raise ProxyError("скачиваемый файл превышает допустимый размер") + if declared_length is not None and actual_length > declared_length: + raise ProxyError( + "фактический размер не совпадает с Content-Length" + ) + stream.write(chunk) + if declared_length is not None and actual_length != declared_length: + raise ProxyError("фактический размер не совпадает с Content-Length") + except ProxyError: + raise + except (OSError, urllib.error.URLError, http.client.HTTPException) as error: + raise ProxyError("не удалось скачать официальный Xray: {}".format(error)) + + +def expected_archive_sha256(digest_path): + try: + with digest_path.open("rb") as stream: + digest_bytes = stream.read(MAX_DIGEST_BYTES + 1) + except (OSError, UnicodeError) as error: + raise ProxyError("не удалось прочитать файл SHA-256: {}".format(error)) + if len(digest_bytes) > MAX_DIGEST_BYTES: + raise ProxyError("файл SHA-256 превышает допустимый размер") + try: + digest_text = digest_bytes.decode("ascii") + except UnicodeError as error: + raise ProxyError("не удалось прочитать файл SHA-256: {}".format(error)) + lines = digest_text.split("\n") + if lines and lines[-1] == "": + lines.pop() + expected_lengths = { + "MD5": 32, + "SHA1": 40, + "SHA2-256": 64, + "SHA2-512": 128, + } + records = {} + for line in lines: + match = re.fullmatch( + r"(MD5|SHA1|SHA2-256|SHA2-512)= ([0-9a-f]+)", line + ) + if match is None: + raise ProxyError("файл SHA-256 имеет неожиданный формат") + name, value = match.groups() + if name in records or len(value) != expected_lengths[name]: + raise ProxyError("файл SHA-256 имеет неожиданный формат") + records[name] = value + if set(records) != set(expected_lengths): + raise ProxyError("файл SHA-256 имеет неожиданный формат") + return records["SHA2-256"] + + +def archive_sha256(archive_path): + digest = hashlib.sha256() + total = 0 + try: + with archive_path.open("rb") as stream: + while True: + chunk = stream.read( + min(STREAM_CHUNK_BYTES, MAX_ARCHIVE_BYTES - total + 1) + ) + if not chunk: + break + total += len(chunk) + if total > MAX_ARCHIVE_BYTES: + raise ProxyError("архив Xray превышает допустимый размер") + digest.update(chunk) + except ProxyError: + raise + except OSError as error: + raise ProxyError("не удалось прочитать архив Xray: {}".format(error)) + return digest.hexdigest() + + +def extract_xray_candidate(archive_path, candidate_path): + try: + with zipfile.ZipFile(str(archive_path)) as archive: + members = [item for item in archive.infolist() if item.filename == "xray"] + if len(members) != 1 or members[0].is_dir(): + raise ProxyError("официальный архив не содержит ровно один файл xray") + if members[0].file_size > MAX_XRAY_BYTES: + raise ProxyError("извлекаемый xray превышает допустимый размер") + with archive.open(members[0]) as source: + descriptor = os.open( + str(candidate_path), + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + ) + with os.fdopen(descriptor, "wb") as destination: + extracted = 0 + while True: + chunk = source.read( + min(STREAM_CHUNK_BYTES, MAX_XRAY_BYTES - extracted + 1) + ) + if not chunk: + break + extracted += len(chunk) + if extracted > MAX_XRAY_BYTES: + raise ProxyError( + "извлекаемый xray превышает допустимый размер" + ) + destination.write(chunk) + if extracted != members[0].file_size: + raise ProxyError("размер извлечённого xray не совпадает с ZIP") + candidate_path.chmod(0o755) + except ProxyError: + raise + except (OSError, KeyError, zipfile.BadZipFile) as error: + raise ProxyError("не удалось извлечь xray из проверенного архива: {}".format(error)) + + +def install_destination(): + return Path.home() / ".local/bin/xray" + + +def require_install_parent_within_home(parent): + try: + home = Path.home().resolve(strict=True) + resolved_parent = parent.resolve(strict=False) + resolved_parent.relative_to(home) + except (OSError, RuntimeError, ValueError): + raise ProxyError( + "каталог установки должен находиться внутри домашнего каталога пользователя" + ) + + +def require_install_safe_managed_state(): + path = state_path() + try: + path.lstat() + except FileNotFoundError: + return + except OSError: + raise ProxyError( + "состояние managed-режима не удалось проверить; сначала выполните proxy-client stop" + ) + try: + state = read_managed_state() + outcome = managed_state_outcome(state) + except ProxyError: + raise ProxyError( + "состояние managed-режима не подтверждено; сначала выполните proxy-client stop" + ) + if outcome != "stale": + raise ProxyError( + "managed Xray работает или не подтверждён; сначала выполните proxy-client stop" + ) + + +def acquire_install_lock(): + try: + return acquire_start_lock() + except StartLockBusyError: + raise ProxyError( + "другая операция удерживает блокировку; сначала выполните proxy-client stop" + ) + + +def existing_installation_outcome(destination, replace): + try: + info = destination.lstat() + except FileNotFoundError: + return "install" + except OSError as error: + raise ProxyError("не удалось проверить назначение {}: {}".format(destination, error)) + if stat.S_ISDIR(info.st_mode): + raise ProxyError("назначение {} является каталогом".format(destination)) + if replace: + return "install" + if stat.S_ISREG(info.st_mode) and os.access(str(destination), os.X_OK): + try: + xray_version(str(destination)) + except ProxyError: + pass + else: + return "already-installed" + raise ProxyError( + "назначение {} уже существует; для проверенной замены используйте --replace".format( + destination + ) + ) + + +def verify_installed_xray_on_path(destination): + found = shutil.which("xray") + try: + matches = found is not None and Path(found).resolve(strict=True) == destination.resolve( + strict=True + ) + except (OSError, RuntimeError): + matches = False + if not matches: + raise ProxyError( + "Xray установлен в {}, но PATH выбирает другой файл; добавьте ~/.local/bin в начало PATH".format( + destination + ) + ) + + +def command_install(replace=False, downloader=download_file): + destination = install_destination() + lock = acquire_install_lock() + try: + require_install_safe_managed_state() + require_install_parent_within_home(destination.parent) + outcome = existing_installation_outcome(destination, replace) + if outcome == "already-installed": + print("Xray {} уже установлен: {}".format(XRAY_VERSION, destination)) + else: + asset = xray_asset_for_architecture(platform.machine()) + try: + destination.parent.mkdir(parents=True, exist_ok=True, mode=0o755) + except OSError as error: + raise ProxyError( + "не удалось создать каталог установки {}: {}".format( + destination.parent, error + ) + ) + require_install_parent_within_home(destination.parent) + if not destination.parent.is_dir(): + raise ProxyError( + "каталог установки {} не является каталогом".format(destination.parent) + ) + staging = Path( + tempfile.mkdtemp(prefix=".xray-install-", dir=str(destination.parent)) + ) + try: + staging.chmod(0o700) + archive_path = staging / asset + digest_path = staging / (asset + ".dgst") + candidate_path = staging / "xray" + downloader( + XRAY_RELEASE_BASE_URL + asset, + archive_path, + MAX_ARCHIVE_BYTES, + ) + downloader( + XRAY_RELEASE_BASE_URL + asset + ".dgst", + digest_path, + MAX_DIGEST_BYTES, + ) + expected = expected_archive_sha256(digest_path) + if archive_sha256(archive_path) != expected: + raise ProxyError("SHA-256 архива Xray не совпадает") + extract_xray_candidate(archive_path, candidate_path) + xray_version(str(candidate_path)) + os.replace(str(candidate_path), str(destination)) + finally: + shutil.rmtree(str(staging), ignore_errors=True) + print("Xray {} установлен: {}".format(XRAY_VERSION, destination)) + finally: + lock.close() + verify_installed_xray_on_path(destination) + return 0 + + def run_xray_test(path, config_path): try: completed = subprocess.run( @@ -684,7 +991,7 @@ def read_managed_state(): raise ProxyError("состояние управляемого Xray не подтверждено") value = json.loads(path.read_text(encoding="utf-8")) except FileNotFoundError: - raise ProxyError("управляемый Xray не запущен; выполните xray-proxy start") + raise ProxyError("управляемый Xray не запущен; выполните proxy-client start") except ProxyError: raise except (OSError, ValueError, TypeError): @@ -816,7 +1123,7 @@ def verify_managed_state(config=None, require_port=True): finally: lock.close() if outcome == "orphan": - raise ProxyError("обнаружен Xray-сирота; выполните xray-proxy stop") + raise ProxyError("обнаружен Xray-сирота; выполните proxy-client stop") if outcome != "healthy": raise unverifiable_state_error() if config is not None and ( @@ -840,7 +1147,7 @@ def acquire_start_lock(): except BlockingIOError: if "lock" in locals(): lock.close() - raise ProxyError("другой управляемый запуск уже удерживает блокировку") + raise StartLockBusyError("другой управляемый запуск уже удерживает блокировку") except OSError as error: if "lock" in locals(): lock.close() @@ -893,7 +1200,7 @@ def diagnose_ip(config): connection.request( "GET", "http://ifconfig.me/ip", - headers={"Host": "ifconfig.me", "User-Agent": "xray-proxy/1"}, + headers={"Host": "ifconfig.me", "User-Agent": "proxy-client/1"}, ) response = connection.getresponse() address = response.read(256).decode("ascii", "replace").strip() @@ -952,7 +1259,7 @@ def command_env(config): def command_run(config, remainder): if not remainder or remainder[0] != "--" or len(remainder) == 1: - raise ProxyError("использование: xray-proxy run -- COMMAND ARG...") + raise ProxyError("использование: proxy-client run -- COMMAND ARG...") command = remainder[1:] if shutil.which(command[0]) is None: sys.stderr.write("Ошибка: команда не найдена в PATH: {}\n".format(command[0])) @@ -1012,7 +1319,7 @@ def command_start_locked(config, profile_file, address): if outcome == "stale": cleanup_stale_files() elif outcome == "orphan": - raise ProxyError("обнаружен Xray-сирота; сначала выполните xray-proxy stop") + raise ProxyError("обнаружен Xray-сирота; сначала выполните proxy-client stop") elif outcome == "healthy": raise ProxyError("управляемый Xray уже запущен") else: @@ -1020,7 +1327,11 @@ def command_start_locked(config, profile_file, address): check_port_available(address, config.port) - profile_path = Path(profile_file) if profile_file else xray_config_home() / "default.url" + profile_path = ( + Path(profile_file) + if profile_file + else proxy_client_config_home() / "default.url" + ) raw_profile = read_profile(profile_path) executable = resolve_xray() version = xray_version(executable) @@ -1125,13 +1436,13 @@ def command_start_locked(config, profile_file, address): def stop_timeout(): - raw = os.environ.get("XRAY_PROXY_STOP_TIMEOUT", "5") + raw = os.environ.get("PROXY_CLIENT_STOP_TIMEOUT", "5") try: value = float(raw) if not value > 0: raise ValueError except ValueError: - raise ProxyError("XRAY_PROXY_STOP_TIMEOUT должен быть положительным числом секунд") + raise ProxyError("PROXY_CLIENT_STOP_TIMEOUT должен быть положительным числом секунд") return value @@ -1191,10 +1502,14 @@ def command_stop(): def build_parser(): parser = argparse.ArgumentParser( - prog="xray-proxy", + prog="proxy-client", description="Безопасный запуск команд через явно настроенный прокси.", ) subparsers = parser.add_subparsers(dest="subcommand") + install_parser = subparsers.add_parser("install", help="установить проверенный Xray") + install_parser.add_argument( + "--replace", action="store_true", help="проверенно заменить существующее назначение" + ) subparsers.add_parser("status", help="проверить доступность активного прокси") subparsers.add_parser("env", help="вывести восемь proxy-переменных") start_parser = subparsers.add_parser("start", help="запустить управляемый Xray") @@ -1210,10 +1525,12 @@ def main(): arguments = parser.parse_args() if arguments.subcommand is None: parser.print_usage(sys.stderr) - sys.stderr.write("xray-proxy: ошибка: укажите явную подкоманду\n") + sys.stderr.write("proxy-client: ошибка: укажите явную подкоманду\n") return 2 try: + if arguments.subcommand == "install": + return command_install(arguments.replace) if arguments.subcommand == "stop": return command_stop() config = load_config() diff --git a/xray-proxy/.stow-local-ignore b/proxy-client/.stow-local-ignore similarity index 100% rename from xray-proxy/.stow-local-ignore rename to proxy-client/.stow-local-ignore diff --git a/proxy-client/README.md b/proxy-client/README.md new file mode 100644 index 0000000..6c26514 --- /dev/null +++ b/proxy-client/README.md @@ -0,0 +1,325 @@ +# proxy-client + +`proxy-client` — переносимый Linux/Bash-интерфейс для запуска команд через +внешний прокси или через управляемый пользовательский Xray. Он добавляет команды +`proxy_on`, `proxy_off`, `pclaude` и `pcodex`, но не подменяет обычные `claude` +и `codex`. + +CLI требует Python 3.8+ и использует только стандартную библиотеку. Он не +запускает Xray и не переключает режим прокси без явной команды пользователя. + +## Установка + +### В составе dotfiles + +Полная установка подключает пакет и автоматически загружает Bash-функции в +новых интерактивных сессиях: + +```bash +cd ~/dotfiles +./install.sh +``` + +Установщик заранее создаёт `~/.local/bin` и `~/.config/proxy-client`, чтобы Stow +размещал ссылки внутри них, а не превращал сами каталоги в ссылки на +репозиторий. + +После установки откройте новый Bash или выполните: + +```bash +source ~/.bash_custom +``` + +### Только пакет `proxy-client` + +```bash +cd ~/dotfiles +mkdir -p ~/.local/bin ~/.config/proxy-client +stow proxy-client +source ~/.config/proxy-client/shell.bash +``` + +Если Bash-пакет dotfiles не установлен, добавьте подключение модуля в +`~/.bashrc`: + +```bash +printf '%s\n' 'source ~/.config/proxy-client/shell.bash' >> ~/.bashrc +``` + +## Как выбирается прокси + +Режим определяется только машинным файлом +`~/.config/proxy-client/config`: + +| Машинный конфиг | Используется | Что запускать | +|-----------------|-------------|---------------| +| Файла нет | Hiddify на `hproxy.local:12334` | Ничего | +| `type = external` | Указанный внешний прокси | Ничего | +| `type = xray` | Управляемый локальный Xray | `proxy-client start` | + +Доступность процесса не меняет выбранный режим. Если в конфиге указан Xray, но +он не запущен, `status`, `env`, `run`, `proxy_on`, `pclaude` и `pcodex` +завершатся с ошибкой. Автоматического перехода на Hiddify или другой прокси нет. + +Текущий выбор и доступность проверяет команда: + +```bash +proxy-client status +``` + +## Настройка режима + +### Hiddify в Windows, команды в WSL + +Это режим по умолчанию. Если Hiddify доступен из WSL как +`hproxy.local:12334`, создавать `config` не нужно: + +```bash +getent hosts hproxy.local +proxy-client status +pclaude --version +``` + +В WSL NAT mode IP Windows-шлюза меняется при перезапуске. Интерактивный +`./install.sh` предлагает добавить обновление `hproxy.local` в `/etc/wsl.conf`. +То же можно настроить вручную: + +```ini +[boot] +command = "/path/to/dotfiles/scripts/setup-hproxy.sh" +``` + +После изменения выполните `wsl --shutdown` из Windows. Обновить адрес без +перезапуска WSL можно так: + +```bash +sudo ~/dotfiles/scripts/setup-hproxy.sh +# или после загрузки Bash-функций: +update_hproxy +``` + +В NAT mode Hiddify должен слушать адрес, доступный через Windows-шлюз, а не +только `127.0.0.1`. + +В [WSL mirrored mode](https://learn.microsoft.com/windows/wsl/networking#mirrored-mode-networking) +(`networkingMode=mirrored`) Windows-сервисы доступны из WSL через `127.0.0.1`. +Чтобы сохранить адрес по умолчанию, добавьте алиас в Windows hosts: + +```text +# C:\Windows\System32\drivers\etc\hosts +127.0.0.1 hproxy.local +``` + +Альтернатива — создать `config.external` с `address = 127.0.0.1`. В обоих +случаях проверьте результат через `getent hosts hproxy.local` или +`proxy-client status`. + +### Другой внешний прокси + +Скопируйте пример и измените адрес, порт и локальные исключения: + +```bash +cp ~/.config/proxy-client/examples/config.external \ + ~/.config/proxy-client/config +``` + +```ini +[proxy] +type = external +name = Office proxy +address = proxy.example.internal +port = 12334 +no_proxy_extra = .example.internal,git.example.internal +``` + +Для внешнего прокси на той же Linux-машине можно указать `127.0.0.1` напрямую. + +### Локальный Xray + +Создайте машинный конфиг: + +```bash +cp ~/.config/proxy-client/examples/config.xray \ + ~/.config/proxy-client/config +``` + +Закрытый профиль должен содержать ровно одну VLESS-ссылку: + +```bash +cp ~/.config/proxy-client/examples/default.url \ + ~/.config/proxy-client/default.url +$EDITOR ~/.config/proxy-client/default.url +chmod 600 ~/.config/proxy-client/default.url +``` + +Установите проверенную версию Xray без root: + +```bash +proxy-client install +``` + +Запустите Xray в отдельной панели tmux: + +```bash +proxy-client start +``` + +Другой закрытый профиль можно выбрать без замены `default.url`: + +```bash +proxy-client start -f ~/.config/proxy-client/work.url +``` + +Для переключения профиля сначала выполните `proxy-client stop`. Все файлы с +настоящими VLESS-ссылками должны иметь права `600` и не должны попадать в Git. + +## Ежедневное использование + +| Задача | Команда | Область действия | +|--------|---------|------------------| +| Запустить Claude через прокси | `pclaude [ARG...]` | Только дочерний процесс | +| Запустить Codex через прокси | `pcodex [ARG...]` | Только дочерний процесс | +| Запустить другую команду | `proxy-client run -- COMMAND ARG...` | Только дочерний процесс | +| Включить прокси | `proxy_on` | Текущая Bash-сессия | +| Выключить прокси | `proxy_off` | Текущая Bash-сессия | +| Проверить прокси | `proxy-client status` | Без изменения окружения | +| Получить proxy-переменные | `proxy-client env` | Машинный вывод в stdout | +| Показать внешний IP | `myip` | С учётом текущего окружения | + +`run`, `pclaude` и `pcodex` передают дочернему процессу восемь вариантов +`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` и `NO_PROXY`, включая имена в нижнем +регистре. После завершения команды переменные не остаются в оболочке. + +`proxy_on` применяет те же переменные к текущей Bash-сессии только после +успешной проверки прокси. `proxy_off` удаляет их. Обычные `claude` и `codex` не +обёрнуты и не выполняют предварительную проверку, но после `proxy_on` наследуют +прокси-переменные, как и любой другой дочерний процесс. + +### Жизненный цикл Xray + +```bash +proxy-client install [--replace] +proxy-client start [-f PROFILE] +proxy-client status +proxy-client stop +``` + +`start` сначала проверяет версию и конфигурацию Xray, затем остаётся на переднем +плане. Systemd, фоновый автозапуск и скрытый запуск из `run` не используются. +Один loopback-порт принимает HTTP CONNECT и SOCKS5. + +`stop` завершает управляемые процессы и удаляет созданные служебные файлы. Он +работает и после изменения машинного типа на `external`. + +## Диагностика + +Начните с проверки выбранного режима: + +```bash +proxy-client status +``` + +Для внешнего Hiddify в WSL проверьте имя и порт: + +```bash +getent hosts hproxy.local +curl --proxy http://hproxy.local:12334 https://example.com +curl --proxy socks5h://hproxy.local:12334 https://example.com +``` + +Для запущенного Xray можно дополнительно проверить созданный JSON: + +```bash +xray run -test -c \ + "${XDG_STATE_HOME:-$HOME/.local/state}/proxy-client/client.json" +``` + +Если выбран `type = xray`, но процесс не запущен, запустите его отдельно — +`proxy_on` и `run` этого не делают. Профиль XHTTP с ALPN `h3` требует доступного +UDP-маршрута до сервера; автоматического перехода на H2 нет. + +Таймаут предварительной проверки по умолчанию равен двум секундам. Его можно +изменить положительным числом: + +```bash +export PROXY_PREFLIGHT_TIMEOUT=5 +``` + +## Справочник + +### Ручное подключение без Stow + +Можно создать ссылки на CLI, Bash-модуль и примеры либо скопировать те же файлы: + +```bash +mkdir -p ~/.local/bin ~/.config/proxy-client +ln -s /path/to/proxy-client/.local/bin/proxy-client ~/.local/bin/proxy-client +ln -s /path/to/proxy-client/.config/proxy-client/shell.bash \ + ~/.config/proxy-client/shell.bash +ln -s /path/to/proxy-client/.config/proxy-client/examples \ + ~/.config/proxy-client/examples +``` + +Исполняемому файлу нужен режим `755`, а `~/.local/bin` должен входить в `PATH`. + +### Машинный конфиг + +Формат строгий: одна секция `[proxy]` и пять обязательных ключей без повторов и +неизвестных полей: + +- `type` — `external` или `xray`; +- `name` — имя для диагностики; +- `address` — IPv4, IPv6 без квадратных скобок или ASCII DNS-имя без схемы и + порта; +- `port` — TCP-порт локального входа; +- `no_proxy_extra` — дополнительные IP и домены через запятую, значение может + быть пустым. + +Поддержка CIDR в `NO_PROXY` различается между программами, поэтому надёжнее +указывать точные IP и домены. Без машинного файла внешний режим дополнительно +включает `.concept-software.ru` в `NO_PROXY`. Для `type = xray` адрес обязан +быть адресом обратной петли, например `127.0.0.1` или `::1`. + +### Поддерживаемые профили + +Поддерживаются VLESS TCP/RAW с REALITY и VLESS XHTTP с TLS. Неизвестные, +повторные и несовместимые query-параметры отклоняются. XHTTP поддерживает ALPN +`http/1.1`, `h2` и `h3`; `extra` должен быть JSON-объектом без повторных ключей. + +Xray 26.3.27 использует `pcs` и `vcn`, а устаревшие `allowInsecure` и +`insecure` не переносит. Истинное legacy-значение даёт предупреждение, но +обычная проверка сертификата остаётся включённой. + +Последняя версия Xray, где `allowInsecure` ещё применяется, — +[25.12.8](https://github.com/XTLS/Xray-core/releases/tag/v25.12.8); опция +удалена в [26.2.6](https://github.com/XTLS/Xray-core/releases/tag/v26.2.6). +`proxy-client` эти версии не поддерживает и не устанавливает. + +### Проверяемая установка Xray + +`proxy-client install` устанавливает Xray 26.3.27 в `~/.local/bin/xray`. Для +`x86_64`/`amd64` используется `Xray-linux-64.zip`, для `aarch64`/`arm64` — +`Xray-linux-arm64-v8a.zip`; остальные архитектуры отклоняются. + +CLI скачивает официальный архив и файл `.dgst`, проверяет SHA-256, ограничения +размера и фактическую версию бинарника, а затем атомарно заменяет назначение. +Корректная уже установленная версия даёт no-op без сети. Другой файл или симлинк +не заменяется без `--replace`; каталог не заменяется никогда. + +`start` использует первый `xray` из `PATH` и принимает только версию 26.3.27. +Если `PATH` выбирает другой бинарник, команда сообщает об ошибке. Во время +живого или неподтверждённого управляемого процесса установка запрещена. + +### Файлы и безопасность + +| Путь | Назначение | +|------|------------| +| `~/.config/proxy-client/config` | Несекретный машинный выбор режима | +| `~/.config/proxy-client/default.url` | Закрытая VLESS-ссылка, права `600` | +| `~/.config/proxy-client/examples/` | Синтетические примеры без секретов | +| `$XDG_STATE_HOME/proxy-client/` или `~/.local/state/proxy-client/` | Состояние и сгенерированный JSON | + +Сводка скрывает UUID и ключи, а для XHTTP показывает только длину `path`. +Служебный каталог закрыт для других пользователей и очищается после штатной +остановки Xray. Сбой определения внешнего IP показывается как предупреждение и +не останавливает уже готовый процесс. diff --git a/tests/test_install.sh b/tests/test_install.sh index 8d6c2da..c412699 100755 --- a/tests/test_install.sh +++ b/tests/test_install.sh @@ -3,6 +3,11 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# Один корневой временный каталог на прогон: тестовые деревья копируют заметную +# часть репозитория и без уборки переполняют /tmp за несколько прогонов. +TEST_TMP_ROOT="$(mktemp -d /tmp/dotfiles-install-test.XXXXXX)" +trap 'rm -rf "$TEST_TMP_ROOT"' EXIT + fail() { echo "FAIL: $*" >&2 exit 1 @@ -21,26 +26,21 @@ assert_not_exists() { setup_fixture() { local fixture_root repo_root bin_dir - fixture_root="$(mktemp -d /tmp/dotfiles-install-test.XXXXXX)" + fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" repo_root="$fixture_root/repo" bin_dir="$fixture_root/bin" mkdir -p "$repo_root" "$bin_dir" - cp -a \ - "$REPO_ROOT/install.sh" \ - "$REPO_ROOT/bash" \ - "$REPO_ROOT/tmux" \ - "$REPO_ROOT/vim" \ - "$REPO_ROOT/zed" \ - "$REPO_ROOT/vscode" \ - "$REPO_ROOT/agents" \ - "$REPO_ROOT/opencode" \ - "$REPO_ROOT/codex" \ - "$REPO_ROOT/gemini" \ - "$REPO_ROOT/claude" \ - "$REPO_ROOT/xray-proxy" \ - "$REPO_ROOT/scripts" \ - "$repo_root/" + # Список файлов соответствует свежему клону: только отслеживаемые Git-файлы + # (новые файлы видны после git add). Содержимое берётся из текущего рабочего + # дерева. Локальные данные инструментов внутри пакетов (vscode/.vscode-server, + # zed/.config/opencode и т.п.) весят гигабайты, переполняют /tmp и ломают + # stow конфликтами, поэтому в тестовое дерево не попадает. + git -C "$REPO_ROOT" ls-files -z --cached -- \ + install.sh bash tmux vim zed vscode agents opencode codex gemini \ + claude proxy-client scripts \ + | tar -C "$REPO_ROOT" --null -T - -cf - \ + | tar -xf - -C "$repo_root" cat > "$bin_dir/fake-git" <<'EOF' #!/usr/bin/env bash @@ -94,17 +94,159 @@ test_fresh_install_uses_home_target() { home_dir="$fixture/home" mkdir -p "$home_dir" - run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out + run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out" [ -L "$home_dir/.bash_custom" ] || fail "Expected .bash_custom symlink in HOME" - [ -x "$home_dir/.local/bin/xray-proxy" ] || fail "Expected executable xray-proxy CLI in HOME" - [ -f "$home_dir/.config/xray-proxy/shell.bash" ] || fail "Expected xray-proxy shell module" + [ -d "$home_dir/.local" ] || fail "Expected ~/.local directory" + [ ! -L "$home_dir/.local" ] || fail "Expected ~/.local to stay a real directory" + [ -d "$home_dir/.local/bin" ] || fail "Expected ~/.local/bin directory" + [ ! -L "$home_dir/.local/bin" ] || fail "Expected ~/.local/bin to stay a real directory" + [ -x "$home_dir/.local/bin/proxy-client" ] || fail "Expected executable proxy-client CLI in HOME" + [ -d "$home_dir/.config/proxy-client" ] || fail "Expected proxy-client config directory" + [ ! -L "$home_dir/.config/proxy-client" ] || fail "Expected proxy-client config to stay outside the repository" + [ -f "$home_dir/.config/proxy-client/shell.bash" ] || fail "Expected proxy-client shell module" + [ -f "$home_dir/.config/proxy-client/examples/config.external" ] || fail "Expected external proxy config example" + [ -f "$home_dir/.config/proxy-client/examples/config.xray" ] || fail "Expected managed Xray config example" + assert_not_exists "$home_dir/.config/proxy-client/examples/config" [ ! -e "$home_dir/README.md" ] || fail "Package README must not be stowed into HOME" [ -L "$home_dir/.claude/skills" ] || fail "Expected ~/.claude/skills symlink" assert_file_contains "$home_dir/.bashrc" "[ -f ~/.bash_custom ] && source ~/.bash_custom" assert_not_exists "$fixture/.bash_custom" } +test_fresh_install_does_not_fold_config_into_zed() { + local fixture repo_root home_dir target expected + fixture="$(setup_fixture)" + repo_root="$fixture/repo" + home_dir="$fixture/home" + mkdir -p "$home_dir" + + # Неполный клон: zed остаётся единственным stow-пакетом с каталогом .config. + mv "$repo_root/proxy-client" "$fixture/proxy-client-disabled" + + run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out" + + [ -d "$home_dir/.config" ] || fail "Expected ~/.config directory" + [ ! -L "$home_dir/.config" ] || fail "Expected ~/.config to stay a real directory" + [ -L "$home_dir/.config/zed" ] || fail "Expected ~/.config/zed symlink" + target="$(readlink -f "$home_dir/.config/zed")" + expected="$(readlink -f "$repo_root/zed/.config/zed")" + [ "$target" = "$expected" ] || fail "~/.config/zed points to $target, expected $expected" +} + +test_migrates_legacy_zed_config_symlink() { + local fixture repo_root home_dir legacy_config output mode + fixture="$(setup_fixture)" + repo_root="$fixture/repo" + home_dir="$fixture/home" + legacy_config="$repo_root/zed/.config" + mkdir -p "$home_dir" "$legacy_config/btop" "$legacy_config/proxy-client" + printf '%s\n' 'btop settings' > "$legacy_config/btop/btop.conf" + printf '%s\n' 'VLESS_PROFILE_SENTINEL' > "$legacy_config/proxy-client/default.url" + chmod 600 "$legacy_config/proxy-client/default.url" + ln -s "$legacy_config" "$home_dir/.config" + + if ! output="$( + run_install "$repo_root" "$home_dir" \ + env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never + )"; then + fail "Expected legacy ~/.config migration to complete" + fi + + [ -d "$home_dir/.config" ] || fail "Expected migrated ~/.config directory" + [ ! -L "$home_dir/.config" ] || fail "Expected migrated ~/.config to stop being a symlink" + [ -L "$home_dir/.config/zed" ] || fail "Expected managed ~/.config/zed symlink" + [ -d "$home_dir/.config/btop" ] || fail "Expected btop config to be migrated" + [ ! -L "$home_dir/.config/btop" ] || fail "Expected btop config outside the repository" + [ -f "$home_dir/.config/proxy-client/default.url" ] || fail "Expected profile to be migrated" + [ ! -L "$home_dir/.config/proxy-client/default.url" ] || fail "Expected profile outside the repository" + mode="$(stat -c '%a' "$home_dir/.config/proxy-client/default.url")" + [ "$mode" = "600" ] || fail "Expected profile mode 600, got $mode" + assert_file_contains "$home_dir/.config/proxy-client/default.url" "VLESS_PROFILE_SENTINEL" + assert_not_exists "$legacy_config/btop" + assert_not_exists "$legacy_config/proxy-client" + if printf '%s' "$output" | grep -Fq 'VLESS_PROFILE_SENTINEL'; then + fail "Secret profile leaked into install output" + fi + + run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out" + assert_file_contains "$home_dir/.config/proxy-client/default.url" "VLESS_PROFILE_SENTINEL" +} + +test_stops_legacy_migration_when_listing_fails() { + local fixture repo_root home_dir legacy_config target expected + fixture="$(setup_fixture)" + repo_root="$fixture/repo" + home_dir="$fixture/home" + legacy_config="$repo_root/zed/.config" + mkdir -p "$home_dir" "$legacy_config/proxy-client" + printf '%s\n' 'VLESS_PROFILE_SENTINEL' > "$legacy_config/proxy-client/default.url" + ln -s "$legacy_config" "$home_dir/.config" + + cat > "$fixture/bin/find" <<'EOF' +#!/usr/bin/env bash +exit 7 +EOF + chmod +x "$fixture/bin/find" + + if run_install "$repo_root" "$home_dir" \ + env PATH="$fixture/bin:$PATH" WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never \ + >"$fixture/install.out" 2>&1; then + fail "Expected migration to stop when legacy config listing fails" + fi + + [ -L "$home_dir/.config" ] || fail "Expected legacy ~/.config symlink after listing failure" + target="$(readlink -f "$home_dir/.config")" + expected="$(readlink -f "$legacy_config")" + [ "$target" = "$expected" ] || fail "Legacy ~/.config target changed after listing failure" + assert_file_contains "$legacy_config/proxy-client/default.url" "VLESS_PROFILE_SENTINEL" +} + +test_does_not_migrate_unrelated_config_symlink() { + local fixture repo_root home_dir unrelated_config target expected + fixture="$(setup_fixture)" + repo_root="$fixture/repo" + home_dir="$fixture/home" + unrelated_config="$fixture/unrelated-config" + mkdir -p "$home_dir" "$unrelated_config" + printf '%s\n' 'keep me' > "$unrelated_config/local.conf" + ln -s "$unrelated_config" "$home_dir/.config" + + # Stow вправе сообщить о конфликте, но установщик не должен раскрывать + # произвольную ссылку и переносить чужие данные. + run_install "$repo_root" "$home_dir" \ + env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out" 2>&1 || true + + [ -L "$home_dir/.config" ] || fail "Expected unrelated ~/.config symlink to be preserved" + target="$(readlink -f "$home_dir/.config")" + expected="$(readlink -f "$unrelated_config")" + [ "$target" = "$expected" ] || fail "Unrelated ~/.config symlink target changed" + assert_file_contains "$unrelated_config/local.conf" "keep me" + assert_not_exists "$unrelated_config/proxy-client" +} + +test_does_not_follow_unrelated_local_symlink() { + local fixture repo_root home_dir unrelated_local target expected + fixture="$(setup_fixture)" + repo_root="$fixture/repo" + home_dir="$fixture/home" + unrelated_local="$fixture/unrelated-local" + mkdir -p "$home_dir" "$unrelated_local" + printf '%s\n' 'keep me' > "$unrelated_local/local.conf" + ln -s "$unrelated_local" "$home_dir/.local" + + # Установщик не должен создавать bin через произвольную ссылку ~/.local. + run_install "$repo_root" "$home_dir" \ + env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out" 2>&1 || true + + [ -L "$home_dir/.local" ] || fail "Expected unrelated ~/.local symlink to be preserved" + target="$(readlink -f "$home_dir/.local")" + expected="$(readlink -f "$unrelated_local")" + [ "$target" = "$expected" ] || fail "Unrelated ~/.local target changed" + assert_file_contains "$unrelated_local/local.conf" "keep me" + assert_not_exists "$unrelated_local/bin" +} + test_settings_local_is_symlinked_to_repo() { local fixture repo_root home_dir fixture="$(setup_fixture)" @@ -385,6 +527,11 @@ main() { command -v stow >/dev/null 2>&1 || fail "stow is required for tests" test_fresh_install_uses_home_target + test_fresh_install_does_not_fold_config_into_zed + test_migrates_legacy_zed_config_symlink + test_stops_legacy_migration_when_listing_fails + test_does_not_migrate_unrelated_config_symlink + test_does_not_follow_unrelated_local_symlink test_settings_local_is_symlinked_to_repo test_settings_local_backup_on_existing_file test_settings_local_dir_is_skipped diff --git a/tests/test_proxy_client.py b/tests/test_proxy_client.py new file mode 100644 index 0000000..cd0939d --- /dev/null +++ b/tests/test_proxy_client.py @@ -0,0 +1,1235 @@ +#!/usr/bin/env python3 +import fcntl +import http.client +import json +import hashlib +import os +import runpy +import subprocess +import sys +import tempfile +import unittest +import urllib.error +import zipfile +from unittest import mock +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +CLI = REPO_ROOT / "proxy-client/.local/bin/proxy-client" + + +class FakeUrlResponse: + def __init__(self, data=b"", content_length=None, read_error=None): + self.data = data + self.position = 0 + self.read_error = read_error + self.headers = {} + if content_length is not None: + self.headers["Content-Length"] = str(content_length) + + def __enter__(self): + return self + + def __exit__(self, _type, _value, _traceback): + return False + + def read(self, size=-1): + if self.read_error is not None: + raise self.read_error + if size < 0: + size = len(self.data) - self.position + chunk = self.data[self.position : self.position + size] + self.position += len(chunk) + return chunk + + +def checking_socket_setup(outcome="success"): + if outcome == "success": + outcome_line = " return Connection()\n" + elif outcome == "refused": + outcome_line = " raise OSError('connection refused')\n" + else: + raise ValueError("unknown socket outcome: {}".format(outcome)) + return ( + "import json, os, socket\n" + "expected_connection = json.loads(os.environ['TEST_EXPECTED_CONNECTION'])\n" + "class Connection:\n" + " def close(self):\n" + " pass\n" + "def checked_create_connection(endpoint, timeout):\n" + " actual_connection = [endpoint[0], endpoint[1], timeout]\n" + " if actual_connection != expected_connection:\n" + " raise AssertionError('socket call mismatch: expected %r, got %r' " + "% (expected_connection, actual_connection))\n" + + outcome_line + + "socket.create_connection = checked_create_connection" + ) + + +SUCCESSFUL_SOCKET_SETUP = checking_socket_setup() +REFUSED_SOCKET_SETUP = checking_socket_setup("refused") + + +class ProxyClientCliTest(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.config_home = Path(self.temp_dir.name) + self.environment = os.environ.copy() + self.environment["XDG_CONFIG_HOME"] = str(self.config_home) + self.environment["PYTHONDONTWRITEBYTECODE"] = "1" + + def tearDown(self): + self.temp_dir.cleanup() + + def write_config( + self, + port, + proxy_type="external", + name="Test proxy", + address="127.0.0.1", + no_proxy_extra=".example.test", + ): + config_dir = self.config_home / "proxy-client" + config_dir.mkdir(exist_ok=True) + (config_dir / "config").write_text( + "[proxy]\n" + "type = {}\n" + "name = {}\n" + "address = {}\n" + "port = {}\n" + "no_proxy_extra = {}\n".format( + proxy_type, name, address, port, no_proxy_extra + ), + encoding="utf-8", + ) + + def write_raw_config(self, content): + config_dir = self.config_home / "proxy-client" + config_dir.mkdir(exist_ok=True) + path = config_dir / "config" + if isinstance(content, bytes): + path.write_bytes(content) + else: + path.write_text(content, encoding="utf-8") + + def run_cli(self, *arguments, environment=None): + command_environment = self.environment.copy() + if environment: + command_environment.update(environment) + return subprocess.run( + [str(CLI)] + list(arguments), + text=True, + capture_output=True, + env=command_environment, + check=False, + ) + + def run_cli_with_socket_setup( + self, arguments, setup, environment=None, expected_connection=None + ): + command_environment = self.environment.copy() + if environment: + command_environment.update(environment) + command_environment["TEST_CLI_ARGUMENTS"] = json.dumps(list(arguments)) + if expected_connection is not None: + command_environment["TEST_EXPECTED_CONNECTION"] = json.dumps( + list(expected_connection) + ) + code = ( + "import json, os, runpy, sys\n" + + setup + + "\nsys.argv = [sys.argv[1]] + " + "json.loads(os.environ['TEST_CLI_ARGUMENTS'])\n" + "runpy.run_path(sys.argv[0], run_name='__main__')" + ) + return subprocess.run( + [sys.executable, "-c", code, str(CLI)], + text=True, + capture_output=True, + env=command_environment, + check=False, + ) + + def test_socket_fixture_rejects_wrong_endpoint_and_timeout(self): + self.write_config(23456) + result = self.run_cli_with_socket_setup( + ("status",), + SUCCESSFUL_SOCKET_SETUP, + expected_connection=("wrong.example", 1, 0.5), + ) + + self.assertNotEqual(0, result.returncode) + self.assertIn("AssertionError: socket call mismatch", result.stderr) + + def test_no_subcommand_has_no_side_effect_and_reports_usage(self): + result = self.run_cli() + self.assertEqual(2, result.returncode) + self.assertEqual("", result.stdout) + self.assertIn("укажите явную подкоманду", result.stderr) + self.assertFalse((self.config_home / "proxy-client").exists()) + + def test_old_python_version_gets_clear_error_before_main_program(self): + code = ( + "import runpy, sys; " + "sys.version_info = (3, 7, 9); " + "runpy.run_path(sys.argv[1], run_name='__main__')" + ) + result = subprocess.run( + [sys.executable, "-c", code, str(CLI)], + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(1, result.returncode) + self.assertEqual("", result.stdout) + self.assertIn("требует Python 3.8", result.stderr) + self.assertIn("Python 3.7", result.stderr) + + def test_proc_stat_parser_handles_spaces_and_closing_parentheses_in_comm(self): + module = runpy.run_path(str(CLI)) + parse_starttime = module["parse_proc_stat_starttime"] + tail = ["S"] + [str(value) for value in range(4, 22)] + ["987654"] + + for comm in ("worker", "worker name", "worker name ) with parens"): + with self.subTest(comm=comm): + self.assertEqual( + 987654, + parse_starttime("321 ({}) ".format(comm) + " ".join(tail)), + ) + for malformed in ("321 worker S 1 2", "321 (worker) S 1 2"): + with self.subTest(malformed=malformed): + with self.assertRaises(ValueError): + parse_starttime(malformed) + + def test_process_permission_error_is_not_treated_as_dead(self): + module = runpy.run_path(str(CLI)) + with mock.patch.object(module["Path"], "stat", side_effect=PermissionError()): + self.assertTrue(module["process_alive"](12345)) + + def test_same_pid_cannot_be_a_healthy_managed_pair(self): + module = runpy.run_path(str(CLI)) + function_globals = module["managed_state_outcome"].__globals__ + state = {"supervisor_pid": 12345, "xray_pid": 12345, "boot_id": "boot"} + replacements = { + "read_boot_id": mock.Mock(return_value="boot"), + "process_alive": mock.Mock(return_value=True), + "supervisor_identity_matches": mock.Mock(return_value=True), + "xray_identity_matches": mock.Mock(return_value=True), + } + with mock.patch.dict(function_globals, replacements): + self.assertEqual("unverifiable", module["managed_state_outcome"](state)) + + def test_identity_reads_starttime_after_process_paths(self): + module = runpy.run_path(str(CLI)) + function_globals = module["xray_identity_matches"].__globals__ + state = { + "supervisor_pid": 101, + "supervisor_starttime": 201, + "supervisor_path": "/cli", + "xray_pid": 102, + "xray_starttime": 202, + "xray_path": "/xray", + } + + xray_events = [] + xray_replacements = { + "process_executable": lambda _pid: xray_events.append("exe") or "/xray", + "process_starttime": lambda _pid: xray_events.append("stat") or 202, + } + with mock.patch.dict(function_globals, xray_replacements): + self.assertTrue(module["xray_identity_matches"](state)) + self.assertEqual(["exe", "stat"], xray_events) + + supervisor_events = [] + supervisor_replacements = { + "process_cmdline": lambda _pid: supervisor_events.append("cmdline") + or ["python", "/cli"], + "process_argument_resolves_to": lambda *_args: supervisor_events.append( + "script" + ) + or True, + "process_starttime": lambda _pid: supervisor_events.append("stat") or 201, + } + with mock.patch.dict(function_globals, supervisor_replacements): + self.assertTrue(module["supervisor_identity_matches"](state)) + self.assertEqual(["cmdline", "script", "stat"], supervisor_events) + + def test_initial_child_starttime_retries_while_child_is_alive(self): + module = runpy.run_path(str(CLI)) + wait_for_starttime = module["wait_for_child_starttime"] + function_globals = wait_for_starttime.__globals__ + child = mock.Mock(pid=12345) + child.poll.return_value = None + replacements = { + "process_starttime": mock.Mock(side_effect=[None, 7654321]), + "time": mock.Mock(), + } + with mock.patch.dict(function_globals, replacements): + self.assertEqual(7654321, wait_for_starttime(child)) + + def test_broken_config_symlink_is_not_treated_as_missing_config(self): + config_dir = self.config_home / "proxy-client" + config_dir.mkdir() + (config_dir / "config").symlink_to(self.config_home / "missing-config") + result = self.run_cli("start") + self.assertNotEqual(0, result.returncode) + self.assertIn("отсутствующий файл", result.stderr) + self.assertNotIn("только для type=xray", result.stderr) + + def test_missing_config_keeps_legacy_external_hiddify_endpoint(self): + result = self.run_cli("status", environment={"PROXY_PREFLIGHT_TIMEOUT": "0.05"}) + output = result.stdout + result.stderr + self.assertIn("Hiddify", output) + self.assertIn("hproxy.local:12334", output) + + def test_status_reports_reachable_external_proxy(self): + self.write_config(23456) + result = self.run_cli_with_socket_setup( + ("status",), + SUCCESSFUL_SOCKET_SETUP, + expected_connection=("127.0.0.1", 23456, 2.0), + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("Test proxy (external) доступен", result.stdout) + self.assertEqual("", result.stderr) + + def test_env_prints_complete_proxy_environment_for_external_proxy(self): + self.write_config( + 23456, no_proxy_extra=" .example.test , git.example.test " + ) + result = self.run_cli_with_socket_setup( + ("env",), + SUCCESSFUL_SOCKET_SETUP, + expected_connection=("127.0.0.1", 23456, 2.0), + ) + + proxy_url = "http://127.0.0.1:23456" + socks_url = "socks5://127.0.0.1:23456" + no_proxy = ( + "127.0.0.1,localhost,::1,[::1],.example.test,git.example.test" + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("", result.stderr) + self.assertEqual( + [ + "HTTP_PROXY=" + proxy_url, + "HTTPS_PROXY=" + proxy_url, + "ALL_PROXY=" + socks_url, + "NO_PROXY=" + no_proxy, + "http_proxy=" + proxy_url, + "https_proxy=" + proxy_url, + "all_proxy=" + socks_url, + "no_proxy=" + no_proxy, + ], + result.stdout.splitlines(), + ) + + def test_env_brackets_ipv6_without_requiring_host_ipv6(self): + self.write_config(23456, address="2001:db8::1", no_proxy_extra="") + result = self.run_cli_with_socket_setup( + ("env",), + SUCCESSFUL_SOCKET_SETUP, + expected_connection=("2001:db8::1", 23456, 2.0), + ) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("HTTP_PROXY=http://[2001:db8::1]:23456\n", result.stdout) + self.assertIn("ALL_PROXY=socks5://[2001:db8::1]:23456\n", result.stdout) + + def test_scoped_ipv6_is_rejected_when_ipaddress_lacks_scope_support(self): + self.write_config(23456, address="fe80::1%eth0", no_proxy_extra="") + setup = ( + "import ipaddress\n" + "real_ip_address = ipaddress.ip_address\n" + "def python38_ip_address(value):\n" + " if '%' in value:\n" + " raise ValueError('scope IDs unsupported')\n" + " return real_ip_address(value)\n" + "ipaddress.ip_address = python38_ip_address\n" + + SUCCESSFUL_SOCKET_SETUP + ) + result = self.run_cli_with_socket_setup( + ("env",), + setup, + expected_connection=("fe80::1%eth0", 23456, 2.0), + ) + + self.assertNotEqual(0, result.returncode) + self.assertEqual("", result.stdout) + self.assertIn("address", result.stderr) + + def test_unreachable_env_prints_no_partial_output(self): + self.write_config(23456) + result = self.run_cli_with_socket_setup( + ("env",), + REFUSED_SOCKET_SETUP, + {"PROXY_PREFLIGHT_TIMEOUT": "0.1"}, + expected_connection=("127.0.0.1", 23456, 0.1), + ) + self.assertNotEqual(0, result.returncode) + self.assertEqual("", result.stdout) + self.assertIn("недоступен", result.stderr) + + def test_unreachable_external_proxy_never_runs_child(self): + self.write_config(23456) + marker = self.config_home / "external-child-ran" + helper = self.config_home / "external-child" + helper.write_text( + "#!/usr/bin/env bash\nprintf ran > \"$1\"\n", encoding="ascii" + ) + helper.chmod(0o755) + + result = self.run_cli_with_socket_setup( + ("run", "--", str(helper), str(marker)), + REFUSED_SOCKET_SETUP, + {"PROXY_PREFLIGHT_TIMEOUT": "0.1"}, + expected_connection=("127.0.0.1", 23456, 0.1), + ) + + self.assertNotEqual(0, result.returncode) + self.assertIn("недоступен", result.stderr) + self.assertFalse(marker.exists()) + + def test_xray_type_fails_closed_for_all_ticket_commands(self): + self.write_config(12334, proxy_type="xray") + marker = self.config_home / "child-ran" + helper = self.config_home / "child" + helper.write_text( + "#!/usr/bin/env bash\nprintf ran > \"$1\"\n", encoding="ascii" + ) + helper.chmod(0o755) + commands = [ + ("status",), + ("env",), + ("run", "--", str(helper), str(marker)), + ("start",), + ] + for command in commands: + with self.subTest(command=command): + result = self.run_cli(*command) + self.assertNotEqual(0, result.returncode) + self.assertEqual("", result.stdout) + self.assertIn("Ошибка:", result.stderr) + self.assertFalse(marker.exists()) + + def test_external_start_fails_without_starting_anything(self): + self.write_config(12334) + result = self.run_cli("start") + self.assertNotEqual(0, result.returncode) + self.assertEqual("", result.stdout) + self.assertIn("только для type=xray", result.stderr) + + def test_run_preserves_arguments_exit_code_and_child_only_environment(self): + self.write_config(23456) + code = ( + "import json, os, sys; " + "print(json.dumps({'args': sys.argv[1:], " + "'http': os.environ['HTTP_PROXY'], " + "'lower': os.environ['http_proxy']})); sys.exit(42)" + ) + original_http = os.environ.get("HTTP_PROXY") + result = self.run_cli_with_socket_setup( + ("run", "--", sys.executable, "-c", code, "a b", "--", "--flag"), + SUCCESSFUL_SOCKET_SETUP, + expected_connection=("127.0.0.1", 23456, 2.0), + ) + + self.assertEqual(42, result.returncode, result.stderr) + child = json.loads(result.stdout) + self.assertEqual(["a b", "--", "--flag"], child["args"]) + expected_url = "http://127.0.0.1:23456" + self.assertEqual(expected_url, child["http"]) + self.assertEqual(expected_url, child["lower"]) + self.assertEqual(original_http, os.environ.get("HTTP_PROXY")) + + def test_missing_run_command_returns_127(self): + self.write_config(12334) + result = self.run_cli("run", "--", "definitely-no-such-command-xyz") + self.assertEqual(127, result.returncode) + self.assertEqual("", result.stdout) + self.assertIn("не найдена в PATH", result.stderr) + + def test_run_maps_child_signal_to_shell_exit_code(self): + self.write_config(23456) + result = self.run_cli_with_socket_setup( + ( + "run", + "--", + sys.executable, + "-c", + "import os, signal; os.kill(os.getpid(), signal.SIGTERM)", + ), + SUCCESSFUL_SOCKET_SETUP, + expected_connection=("127.0.0.1", 23456, 2.0), + ) + self.assertEqual(143, result.returncode, result.stderr) + + def test_run_requires_explicit_separator_and_command(self): + self.write_config(12334) + for command in (("run",), ("run", "true"), ("run", "--")): + with self.subTest(command=command): + result = self.run_cli(*command) + self.assertNotEqual(0, result.returncode) + self.assertIn("run -- COMMAND", result.stderr) + + def test_invalid_preflight_timeout_is_rejected_clearly(self): + self.write_config(12334) + result = self.run_cli( + "status", environment={"PROXY_PREFLIGHT_TIMEOUT": "not-a-number"} + ) + self.assertNotEqual(0, result.returncode) + self.assertIn("положительным числом", result.stderr) + + def test_ini_rejects_structure_key_and_value_grammar_errors(self): + valid = ( + "[proxy]\n" + "type = external\n" + "name = Test\n" + "address = 127.0.0.1\n" + "port = 12334\n" + "no_proxy_extra =\n" + ) + invalid_configs = { + "unknown section": valid + "[other]\nkey = value\n", + "unknown key": valid + "extra = value\n", + "duplicate key": valid + "port = 12335\n", + "missing key": valid.replace("name = Test\n", ""), + "empty name": valid.replace("name = Test", "name = "), + "empty address": valid.replace("address = 127.0.0.1", "address = "), + "port zero": valid.replace("port = 12334", "port = 0"), + "port high": valid.replace("port = 12334", "port = 65536"), + "port syntax": valid.replace("port = 12334", "port = +80"), + "empty no-proxy item": valid.replace( + "no_proxy_extra =", "no_proxy_extra = one.example,,two.example" + ), + } + for label, content in invalid_configs.items(): + with self.subTest(label=label): + self.write_raw_config(content) + result = self.run_cli("start") + self.assertNotEqual(0, result.returncode) + self.assertEqual("", result.stdout) + self.assertIn("Ошибка:", result.stderr) + + def test_address_accepts_ip_literals_and_simple_ascii_names(self): + valid_addresses = ( + "127.0.0.1", + "2001:db8::1", + "proxy-1.example", + ".leading.example", + "trailing.example.", + "bad..example", + "-bad.example", + "bad-.example", + ) + for address in valid_addresses: + with self.subTest(address=address): + self.write_config(12334, address=address) + result = self.run_cli("start") + self.assertIn("только для type=xray", result.stderr) + + invalid_addresses = ( + "http://proxy.example", + "[::1]", + "has space", + "proxy/path", + "user@proxy", + "прокси.example", + "fe80::1%eth0", + ) + for address in invalid_addresses: + with self.subTest(address=address): + self.write_config(12334, address=address) + result = self.run_cli("start") + self.assertNotEqual(0, result.returncode) + self.assertNotIn("только для type=xray", result.stderr) + self.assertIn("address", result.stderr) + + +class XrayInstallTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.home = self.root / "home" + self.state_home = self.root / "state" + self.home.mkdir() + self.environment = { + "HOME": str(self.home), + "XDG_STATE_HOME": str(self.state_home), + "PATH": str(self.home / ".local/bin"), + } + self.module = runpy.run_path(str(CLI)) + self.destination = self.home / ".local/bin/xray" + self.archive = self.root / "fixture.zip" + self.write_archive("installed") + + def tearDown(self): + self.temporary.cleanup() + + @staticmethod + def fake_xray(label="fixture", version="26.3.27"): + return ( + "#!/bin/sh\n" + "if [ \"$1\" = version ]; then\n" + " printf '%s\\n' 'Xray {} ({})'\n" + " exit 0\n" + "fi\n" + "exit 64\n".format(version, label) + ).encode("ascii") + + def write_archive(self, label="fixture", version="26.3.27", member="xray"): + with zipfile.ZipFile(str(self.archive), "w") as archive: + archive.writestr(member, self.fake_xray(label, version)) + + @staticmethod + def official_digest(archive_hash): + return ( + "MD5= {}\n" + "SHA1= {}\n" + "SHA2-256= {}\n" + "SHA2-512= {}\n".format( + "1" * 32, + "2" * 40, + archive_hash, + "3" * 128, + ) + ) + + def downloader(self, bad_hash=False, digest_text=None, fail_after_archive=False): + calls = [] + + def download(url, destination, _max_bytes): + calls.append(url) + if url.endswith(".dgst"): + if fail_after_archive: + raise self.module["ProxyError"]("инъецированная ошибка загрузки") + archive_hash = hashlib.sha256(self.archive.read_bytes()).hexdigest() + if bad_hash: + archive_hash = "0" * 64 + text = digest_text + if text is None: + text = self.official_digest(archive_hash) + destination.write_text(text, encoding="ascii") + else: + destination.write_bytes(self.archive.read_bytes()) + + return download, calls + + def install(self, replace=False, downloader=None, machine="x86_64", path=None): + if downloader is None: + downloader, _calls = self.downloader() + environment = dict(self.environment) + if path is not None: + environment["PATH"] = path + globals_ = self.module["command_install"].__globals__ + with mock.patch.dict(os.environ, environment, clear=False): + with mock.patch.object(globals_["platform"], "machine", return_value=machine): + return self.module["command_install"](replace, downloader) + + def write_destination(self, content=None, executable=True): + self.destination.parent.mkdir(parents=True, exist_ok=True) + self.destination.write_bytes(content or self.fake_xray("existing")) + self.destination.chmod(0o755 if executable else 0o644) + + def tree_snapshot(self): + snapshot = {} + for path in self.root.rglob("*"): + relative = path.relative_to(self.root).as_posix() + info = path.lstat() + mode = info.st_mode & 0o777 + if path.is_symlink(): + snapshot[relative] = ("symlink", mode, os.readlink(str(path))) + elif path.is_dir(): + snapshot[relative] = ("directory", mode) + elif path.is_file(): + digest = hashlib.sha256(path.read_bytes()).hexdigest() + snapshot[relative] = ("file", mode, digest) + else: + snapshot[relative] = ("other", mode) + return snapshot + + def tree_snapshot_without_install_lock(self): + snapshot = self.tree_snapshot() + for path in ("state/proxy-client/lock", "state/proxy-client", "state"): + snapshot.pop(path, None) + return snapshot + + def assert_no_staging(self): + if self.destination.parent.exists(): + self.assertEqual( + [], list(self.destination.parent.glob(".xray-install-*")) + ) + + def assert_install_lock_released(self): + lock_path = self.state_home / "proxy-client/lock" + with lock_path.open("r+", encoding="ascii") as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + + def test_architecture_mapping_is_pure_and_rejects_unknown(self): + asset_for = self.module["xray_asset_for_architecture"] + for machine in ("x86_64", "amd64"): + self.assertEqual("Xray-linux-64.zip", asset_for(machine)) + for machine in ("aarch64", "arm64"): + self.assertEqual("Xray-linux-arm64-v8a.zip", asset_for(machine)) + with self.assertRaisesRegex(self.module["ProxyError"], "не поддерживается"): + asset_for("riscv64") + + def test_valid_digest_installs_verified_executable_and_only_expected_files(self): + downloader, calls = self.downloader() + self.assertEqual(0, self.install(downloader=downloader)) + + self.assertEqual(0o755, self.destination.stat().st_mode & 0o777) + self.assertIn(b"installed", self.destination.read_bytes()) + self.assertEqual(2, len(calls)) + self.assertTrue(calls[0].endswith("/Xray-linux-64.zip")) + self.assertEqual(calls[0] + ".dgst", calls[1]) + self.assertEqual( + { + "fixture.zip", + "home", + "home/.local", + "home/.local/bin", + "home/.local/bin/xray", + "state", + "state/proxy-client", + "state/proxy-client/lock", + }, + set(self.tree_snapshot()), + ) + self.assert_no_staging() + + def test_platform_machine_selects_arm64_asset(self): + downloader, calls = self.downloader() + self.assertEqual( + 0, self.install(downloader=downloader, machine="aarch64") + ) + self.assertTrue(calls[0].endswith("/Xray-linux-arm64-v8a.zip")) + self.assertEqual(calls[0] + ".dgst", calls[1]) + + def test_candidate_is_private_while_zip_bytes_are_written(self): + os_module = self.module["extract_xray_candidate"].__globals__["os"] + real_fdopen = os_module.fdopen + observed_modes = [] + + def checked_fdopen(descriptor, *arguments, **keywords): + descriptor_path = os.readlink("/proc/self/fd/{}".format(descriptor)) + if Path(descriptor_path).name == "xray": + observed_modes.append(os.fstat(descriptor).st_mode & 0o777) + return real_fdopen(descriptor, *arguments, **keywords) + + with mock.patch.object( + os_module, "fdopen", side_effect=checked_fdopen + ): + self.assertEqual(0, self.install()) + + self.assertEqual([0o600], observed_modes) + self.assertEqual(0o755, self.destination.stat().st_mode & 0o777) + + def test_bad_hash_and_digest_format_preserve_destination_before_extraction(self): + valid_hash = "a" * 64 + cases = ( + self.downloader(bad_hash=True)[0], + self.downloader(digest_text="SHA256= " + valid_hash + "\n")[0], + self.downloader( + digest_text=self.official_digest(valid_hash).replace( + "SHA2-256= " + valid_hash + "\n", "" + ) + )[0], + self.downloader( + digest_text=self.official_digest(valid_hash) + + "SHA2-256= " + + valid_hash + + "\n" + )[0], + self.downloader( + digest_text=self.official_digest(valid_hash) + "garbage\n" + )[0], + self.downloader( + digest_text=self.official_digest(valid_hash).replace( + valid_hash, "a" * 63 + ) + )[0], + self.downloader( + digest_text=self.official_digest(valid_hash).replace( + valid_hash, "a" * 65 + ) + )[0], + ) + for downloader in cases: + with self.subTest(downloader=downloader): + sentinel = b"destination sentinel" + self.write_destination(sentinel) + before = self.tree_snapshot_without_install_lock() + with self.assertRaises(self.module["ProxyError"]): + self.install(replace=True, downloader=downloader) + self.assertEqual(sentinel, self.destination.read_bytes()) + self.assert_no_staging() + self.assertEqual(before, self.tree_snapshot_without_install_lock()) + + def test_bad_or_truncated_archive_is_rejected_before_zip_open(self): + full_archive = self.archive.read_bytes() + + def truncated_downloader(url, destination, _max_bytes): + if url.endswith(".dgst"): + digest = hashlib.sha256(full_archive).hexdigest() + destination.write_text(self.official_digest(digest), encoding="ascii") + else: + destination.write_bytes(full_archive[: len(full_archive) // 2]) + + zip_file = self.module["extract_xray_candidate"].__globals__["zipfile"] + with mock.patch.object( + zip_file, "ZipFile", side_effect=AssertionError("ZIP открыт до SHA-256") + ) as opened: + with self.assertRaisesRegex(self.module["ProxyError"], "SHA-256"): + self.install(downloader=truncated_downloader) + opened.assert_not_called() + self.assertFalse(self.destination.exists()) + self.assert_no_staging() + + def test_real_downloader_installs_complete_declared_responses(self): + archive = self.archive.read_bytes() + digest = self.official_digest(hashlib.sha256(archive).hexdigest()).encode( + "ascii" + ) + responses = [ + FakeUrlResponse(archive, content_length=len(archive)), + FakeUrlResponse(digest, content_length=len(digest)), + ] + urllib_request = self.module["download_file"].__globals__["urllib"].request + + with mock.patch.object(urllib_request, "urlopen", side_effect=responses): + self.assertEqual( + 0, self.install(downloader=self.module["download_file"]) + ) + + def test_real_downloader_refuses_length_errors_and_releases_install(self): + archive = self.archive.read_bytes() + cases = ( + ( + "early EOF", + FakeUrlResponse(archive, content_length=len(archive) + 1), + ), + ( + "extra bytes", + FakeUrlResponse(archive, content_length=len(archive) - 1), + ), + ( + "declared oversized", + FakeUrlResponse( + b"", + content_length=self.module["MAX_ARCHIVE_BYTES"] + 1, + ), + ), + ("url error", urllib.error.URLError("offline")), + ( + "http error", + FakeUrlResponse(read_error=http.client.HTTPException("broken HTTP")), + ), + ( + "incomplete read", + FakeUrlResponse( + read_error=http.client.IncompleteRead(b"partial", 10) + ), + ), + ) + urllib_request = self.module["download_file"].__globals__["urllib"].request + + for label, outcome in cases: + with self.subTest(label=label): + sentinel = b"destination sentinel" + self.write_destination(sentinel) + before = self.tree_snapshot_without_install_lock() + with mock.patch.object(urllib_request, "urlopen", side_effect=[outcome]): + with self.assertRaises(self.module["ProxyError"]): + self.install( + replace=True, downloader=self.module["download_file"] + ) + self.assertEqual(sentinel, self.destination.read_bytes()) + self.assert_no_staging() + self.assert_install_lock_released() + self.assertEqual(before, self.tree_snapshot_without_install_lock()) + + def test_real_downloader_counts_limit_plus_one_without_content_length(self): + urllib_request = self.module["download_file"].__globals__["urllib"].request + globals_ = self.module["command_install"].__globals__ + sentinel = b"destination sentinel" + self.write_destination(sentinel) + + with mock.patch.dict(globals_, {"MAX_ARCHIVE_BYTES": 8}): + response = FakeUrlResponse(b"123456789") + with mock.patch.object(urllib_request, "urlopen", return_value=response): + with self.assertRaisesRegex(self.module["ProxyError"], "размер"): + self.install( + replace=True, downloader=self.module["download_file"] + ) + + self.assertEqual(sentinel, self.destination.read_bytes()) + self.assert_no_staging() + self.assert_install_lock_released() + + def test_uppercase_digest_is_rejected_before_zip_open(self): + digest = hashlib.sha256(self.archive.read_bytes()).hexdigest().upper() + downloader, _calls = self.downloader( + digest_text=self.official_digest(digest) + ) + zip_file = self.module["extract_xray_candidate"].__globals__["zipfile"] + with mock.patch.object( + zip_file, "ZipFile", side_effect=AssertionError("ZIP открыт до digest") + ) as opened: + with self.assertRaisesRegex(self.module["ProxyError"], "SHA-256"): + self.install(downloader=downloader) + opened.assert_not_called() + self.assertFalse(self.destination.exists()) + self.assert_no_staging() + + def test_existing_verified_install_is_noop_without_downloader(self): + self.write_destination() + downloader = mock.Mock(side_effect=AssertionError("network used")) + self.assertEqual(0, self.install(downloader=downloader)) + downloader.assert_not_called() + + def test_existing_version_gate_keeps_timeout_and_avoids_downloader(self): + self.write_destination() + downloader = mock.Mock(side_effect=AssertionError("network used")) + subprocess_module = self.module["xray_version"].__globals__["subprocess"] + timeout = subprocess.TimeoutExpired([str(self.destination), "version"], 5) + + with mock.patch.object(subprocess_module, "run", side_effect=timeout) as run: + with self.assertRaisesRegex(self.module["ProxyError"], "--replace"): + self.install(downloader=downloader) + + downloader.assert_not_called() + self.assertEqual(5, run.call_args.kwargs["timeout"]) + self.assertEqual(self.fake_xray("existing"), self.destination.read_bytes()) + + def test_replace_reinstalls_verified_existing_file(self): + self.write_destination(self.fake_xray("old")) + downloader, calls = self.downloader() + self.assertEqual(0, self.install(replace=True, downloader=downloader)) + self.assertIn(b"installed", self.destination.read_bytes()) + self.assertEqual(2, len(calls)) + + def test_destination_table_for_symlink_directory_and_bad_files(self): + self.destination.parent.mkdir(parents=True) + target = self.root / "target" + target.write_bytes(b"target sentinel") + self.destination.symlink_to(target) + with self.assertRaisesRegex(self.module["ProxyError"], "--replace"): + self.install() + self.assertTrue(self.destination.is_symlink()) + self.assertEqual(0, self.install(replace=True)) + self.assertFalse(self.destination.is_symlink()) + self.assertEqual(b"target sentinel", target.read_bytes()) + + self.destination.unlink() + self.destination.mkdir() + for replace in (False, True): + with self.subTest(directory=True, replace=replace): + with self.assertRaisesRegex(self.module["ProxyError"], "каталог"): + self.install(replace=replace) + self.destination.rmdir() + + for content, executable in ((b"broken", True), (self.fake_xray(), False)): + with self.subTest(content=content, executable=executable): + self.write_destination(content, executable) + with self.assertRaisesRegex(self.module["ProxyError"], "--replace"): + self.install() + self.assertEqual(0, self.install(replace=True)) + self.assertIn(b"installed", self.destination.read_bytes()) + self.destination.unlink() + + def test_parent_symlink_cannot_redirect_install_outside_home(self): + outside = self.root / "outside" + outside.mkdir() + (self.home / ".local").symlink_to(outside, target_is_directory=True) + downloader = mock.Mock() + + with self.assertRaisesRegex(self.module["ProxyError"], "домашнего каталога"): + self.install(downloader=downloader) + + downloader.assert_not_called() + self.assertEqual([], list(outside.iterdir())) + + def test_managed_state_guard_only_allows_absent_or_stale(self): + guard = self.module["require_install_safe_managed_state"] + globals_ = guard.__globals__ + present = mock.Mock() + present.lstat.return_value = mock.Mock() + with mock.patch.dict( + globals_, + { + "state_path": mock.Mock(return_value=present), + "read_managed_state": mock.Mock(return_value={"state": "fixture"}), + }, + ): + for outcome in ("healthy", "orphan", "unverifiable"): + with self.subTest(outcome=outcome): + globals_["managed_state_outcome"] = mock.Mock(return_value=outcome) + with self.assertRaisesRegex(self.module["ProxyError"], "stop"): + guard() + globals_["managed_state_outcome"] = mock.Mock(return_value="stale") + guard() + + absent = mock.Mock() + absent.lstat.side_effect = FileNotFoundError() + with mock.patch.dict( + globals_, {"state_path": mock.Mock(return_value=absent)} + ): + guard() + + def test_command_classifies_managed_state_before_even_noop_destination(self): + self.write_destination() + downloader = mock.Mock(side_effect=AssertionError("network used")) + guard = mock.Mock( + side_effect=self.module["ProxyError"]( + "managed Xray работает; сначала выполните proxy-client stop" + ) + ) + globals_ = self.module["command_install"].__globals__ + + with mock.patch.dict( + globals_, {"require_install_safe_managed_state": guard} + ): + with self.assertRaisesRegex(self.module["ProxyError"], "stop"): + self.install(downloader=downloader) + + guard.assert_called_once_with() + downloader.assert_not_called() + self.assertEqual(self.fake_xray("existing"), self.destination.read_bytes()) + + def test_download_extraction_and_version_errors_are_atomic_and_clean_staging(self): + failures = [] + failures.append(self.downloader(fail_after_archive=True)[0]) + missing_member = self.root / "missing-member.zip" + with zipfile.ZipFile(str(missing_member), "w") as archive: + archive.writestr("not-xray", b"data") + + def missing_member_downloader(url, destination, _max_bytes): + source = missing_member if not url.endswith(".dgst") else None + if source is not None: + destination.write_bytes(source.read_bytes()) + else: + digest = hashlib.sha256(missing_member.read_bytes()).hexdigest() + destination.write_text(self.official_digest(digest), encoding="ascii") + + failures.append(missing_member_downloader) + self.write_archive("wrong", version="26.3.26") + failures.append(self.downloader()[0]) + + for downloader in failures: + with self.subTest(downloader=downloader): + sentinel = b"destination sentinel" + self.write_destination(sentinel) + before = self.tree_snapshot_without_install_lock() + with self.assertRaises(self.module["ProxyError"]): + self.install(replace=True, downloader=downloader) + self.assertEqual(sentinel, self.destination.read_bytes()) + self.assert_no_staging() + self.assertEqual(before, self.tree_snapshot_without_install_lock()) + + def test_corrupt_zip_with_matching_digest_is_atomic_and_cleaned(self): + corrupt = b"not a ZIP archive" + + def corrupt_downloader(url, destination, _max_bytes): + if url.endswith(".dgst"): + digest = hashlib.sha256(corrupt).hexdigest() + destination.write_text(self.official_digest(digest), encoding="ascii") + else: + destination.write_bytes(corrupt) + + sentinel = b"destination sentinel" + self.write_destination(sentinel) + before = self.tree_snapshot_without_install_lock() + with self.assertRaisesRegex(self.module["ProxyError"], "извлечь"): + self.install(replace=True, downloader=corrupt_downloader) + self.assertEqual(sentinel, self.destination.read_bytes()) + self.assert_no_staging() + self.assertEqual(before, self.tree_snapshot_without_install_lock()) + + def test_injected_oversized_archive_and_digest_are_bounded(self): + globals_ = self.module["command_install"].__globals__ + cases = ( + ("MAX_ARCHIVE_BYTES", len(self.archive.read_bytes()) - 1, self.downloader()[0]), + ( + "MAX_DIGEST_BYTES", + 8, + self.downloader(digest_text=self.official_digest("a" * 64))[0], + ), + ) + for constant, limit, downloader in cases: + with self.subTest(constant=constant): + sentinel = b"destination sentinel" + self.write_destination(sentinel) + with mock.patch.dict(globals_, {constant: limit}): + with self.assertRaisesRegex(self.module["ProxyError"], "размер"): + self.install(replace=True, downloader=downloader) + self.assertEqual(sentinel, self.destination.read_bytes()) + self.assert_no_staging() + + def test_zip_member_declared_over_limit_is_rejected_before_extraction(self): + globals_ = self.module["command_install"].__globals__ + binary_size = len(self.fake_xray()) + sentinel = b"destination sentinel" + self.write_destination(sentinel) + + with mock.patch.dict(globals_, {"MAX_XRAY_BYTES": binary_size - 1}): + with self.assertRaisesRegex(self.module["ProxyError"], "размер"): + self.install(replace=True) + + self.assertEqual(sentinel, self.destination.read_bytes()) + self.assert_no_staging() + + def test_streamed_zip_output_cannot_exceed_limit_even_if_metadata_lies(self): + class FakeInfo: + filename = "xray" + file_size = 8 + + @staticmethod + def is_dir(): + return False + + class FakeArchive: + def __enter__(self): + return self + + def __exit__(self, _type, _value, _traceback): + return False + + @staticmethod + def infolist(): + return [FakeInfo()] + + @staticmethod + def open(_member): + return FakeUrlResponse(b"123456789") + + globals_ = self.module["command_install"].__globals__ + zip_file = globals_["zipfile"] + sentinel = b"destination sentinel" + self.write_destination(sentinel) + with mock.patch.dict(globals_, {"MAX_XRAY_BYTES": 8}): + with mock.patch.object(zip_file, "ZipFile", return_value=FakeArchive()): + with self.assertRaisesRegex(self.module["ProxyError"], "размер"): + self.install(replace=True) + self.assertEqual(sentinel, self.destination.read_bytes()) + self.assert_no_staging() + + def test_interruption_preserves_destination_cleans_staging_and_releases_lock(self): + sentinel = b"destination sentinel" + self.write_destination(sentinel) + before = self.tree_snapshot_without_install_lock() + + def interrupted_downloader(_url, destination, _max_bytes): + destination.write_bytes(b"partial") + raise KeyboardInterrupt() + + with self.assertRaises(KeyboardInterrupt): + self.install(replace=True, downloader=interrupted_downloader) + + self.assertEqual(sentinel, self.destination.read_bytes()) + self.assert_no_staging() + lock_path = self.state_home / "proxy-client/lock" + with lock_path.open("r+", encoding="ascii") as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + self.assertEqual(before, self.tree_snapshot_without_install_lock()) + + def test_start_lock_is_held_during_download_and_through_replace(self): + downloader, _calls = self.downloader() + lock_path = self.state_home / "proxy-client/lock" + + def assert_lock_held(): + with lock_path.open("r+", encoding="ascii") as competing: + with self.assertRaises(BlockingIOError): + fcntl.flock(competing, fcntl.LOCK_EX | fcntl.LOCK_NB) + + def checked_downloader(url, destination, max_bytes): + assert_lock_held() + downloader(url, destination, max_bytes) + + os_module = self.module["command_install"].__globals__["os"] + real_replace = os_module.replace + + def checked_replace(source, destination): + real_replace(source, destination) + assert_lock_held() + + with mock.patch.object(os_module, "replace", side_effect=checked_replace): + self.assertEqual(0, self.install(downloader=checked_downloader)) + + with lock_path.open("r+", encoding="ascii") as released: + fcntl.flock(released, fcntl.LOCK_EX | fcntl.LOCK_NB) + + def test_unsupported_architecture_refuses_before_downloading(self): + downloader = mock.Mock() + with self.assertRaisesRegex(self.module["ProxyError"], "не поддерживается"): + self.install(downloader=downloader, machine="riscv64") + downloader.assert_not_called() + self.assertFalse(self.destination.exists()) + + def test_path_missing_or_competing_returns_error_without_rollback(self): + cases = (("empty", False), ("competitor", True)) + for directory_name, competing in cases: + with self.subTest(directory_name=directory_name): + path_dir = self.root / directory_name + path_dir.mkdir() + if competing: + competitor = path_dir / "xray" + competitor.write_bytes(self.fake_xray("competitor")) + competitor.chmod(0o755) + path = str(path_dir) + os.pathsep + str(self.destination.parent) + else: + path = str(path_dir) + with self.assertRaisesRegex(self.module["ProxyError"], "PATH"): + self.install(path=path) + self.assertTrue(self.destination.is_file()) + self.destination.unlink() + + def test_busy_install_hint_uses_exception_type_not_message_text(self): + globals_ = self.module["acquire_install_lock"].__globals__ + busy_error = self.module["StartLockBusyError"]("completely changed text") + with mock.patch.dict( + globals_, {"acquire_start_lock": mock.Mock(side_effect=busy_error)} + ): + with self.assertRaisesRegex(self.module["ProxyError"], "stop"): + self.module["acquire_install_lock"]() + + def test_path_comparison_handles_relative_entry_and_internal_bin_symlink(self): + local = self.home / ".local" + actual_bin = self.home / "private-bin" + local.mkdir() + actual_bin.mkdir() + (local / "bin").symlink_to(actual_bin, target_is_directory=True) + + self.assertEqual(0, self.install()) + self.assertTrue((actual_bin / "xray").is_file()) + + previous_cwd = Path.cwd() + try: + os.chdir(str(actual_bin)) + self.assertEqual(0, self.install(path=".")) + finally: + os.chdir(str(previous_cwd)) + + def test_install_dispatches_before_machine_config_loading(self): + self.write_destination() + config = self.root / "config/proxy-client" + config.mkdir(parents=True) + (config / "config").write_text("not an ini\n", encoding="ascii") + environment = os.environ.copy() + environment.update(self.environment) + environment["PATH"] = self.environment["PATH"] + os.pathsep + os.environ["PATH"] + environment["XDG_CONFIG_HOME"] = str(self.root / "config") + result = subprocess.run( + [str(CLI), "install"], + text=True, + capture_output=True, + env=environment, + check=False, + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("уже установлен", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_proxy_client_full.sh b/tests/test_proxy_client_full.sh new file mode 100755 index 0000000..4f2ca36 --- /dev/null +++ b/tests/test_proxy_client_full.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +if ! command -v uv >/dev/null 2>&1; then + echo "Ошибка: для полной проверки требуется uv" >&2 + exit 1 +fi + +export PYTHONDONTWRITEBYTECODE=1 + +python3 -m unittest discover -s tests +bash tests/test_proxy_launchers.sh +bash tests/test_install.sh +uv run --python 3.8 --no-project python -m unittest discover -s tests diff --git a/tests/test_xray_proxy_managed.py b/tests/test_proxy_client_managed.py similarity index 93% rename from tests/test_xray_proxy_managed.py rename to tests/test_proxy_client_managed.py index 456c310..fae2ce8 100644 --- a/tests/test_xray_proxy_managed.py +++ b/tests/test_proxy_client_managed.py @@ -15,7 +15,7 @@ from urllib.parse import quote REPO_ROOT = Path(__file__).resolve().parents[1] -CLI = REPO_ROOT / "xray-proxy/.local/bin/xray-proxy" +CLI = REPO_ROOT / "proxy-client/.local/bin/proxy-client" UUID = "11111111-2222-4333-8444-555555555555" PBK = "PUBLIC_KEY_SENTINEL_7f8a" SID = "SHORT_ID_SENTINEL_9b3c" @@ -166,6 +166,10 @@ class ManagedXrayCliTest(unittest.TestCase): self.bin_dir.mkdir() self.log = self.root / "xray.log" self.xray = self.bin_dir / "xray" + python_prefix = Path(sys.executable).resolve().parent.parent + python_lib = python_prefix / "lib" + if python_lib.is_dir(): + (self.root / "lib").symlink_to(python_lib, target_is_directory=True) shutil.copy2(sys.executable, self.xray) self.xray.chmod(0o755) for operand in ("version", "run"): @@ -181,7 +185,7 @@ class ManagedXrayCliTest(unittest.TestCase): "FAKE_XRAY_LOG": str(self.log), "FAKE_XRAY_VERSION": "Xray 26.3.27 (fake)", "PROXY_PREFLIGHT_TIMEOUT": "0.5", - "XRAY_PROXY_STOP_TIMEOUT": "2", + "PROXY_CLIENT_STOP_TIMEOUT": "2", } ) self.port = self.free_port() @@ -197,7 +201,7 @@ class ManagedXrayCliTest(unittest.TestCase): return listener.getsockname()[1] def write_machine_config(self, proxy_type): - directory = self.config_home / "proxy" + directory = self.config_home / "proxy-client" directory.mkdir(parents=True, exist_ok=True) (directory / "config").write_text( "[proxy]\n" @@ -213,7 +217,7 @@ class ManagedXrayCliTest(unittest.TestCase): if explicit: path = self.root / "chosen.url" else: - path = self.config_home / "xray-proxy/default.url" + path = self.config_home / "proxy-client/default.url" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(url + "\n", encoding="utf-8") path.chmod(0o600) @@ -253,7 +257,7 @@ class ManagedXrayCliTest(unittest.TestCase): ) def wait_for_state(self, process): - state = self.state_home / "xray-proxy/state.json" + state = self.state_home / "proxy-client/state.json" deadline = time.monotonic() + 5 while time.monotonic() < deadline: if state.exists(): @@ -272,7 +276,7 @@ class ManagedXrayCliTest(unittest.TestCase): return [json.loads(line) for line in self.log.read_text().splitlines()] def managed_state_path(self): - return self.state_home / "xray-proxy/state.json" + return self.state_home / "proxy-client/state.json" def read_state(self): return json.loads(self.managed_state_path().read_text(encoding="utf-8")) @@ -354,8 +358,8 @@ class ManagedXrayCliTest(unittest.TestCase): self.assertIn("ip-probe", kinds) for entry in entries: self.assertEqual(str(self.xray.resolve()), entry["executable"]) - self.assertFalse((self.state_home / "xray-proxy/state.json").exists()) - self.assertFalse((self.state_home / "xray-proxy/client.json").exists()) + self.assertFalse((self.state_home / "proxy-client/state.json").exists()) + self.assertFalse((self.state_home / "proxy-client/client.json").exists()) self.assert_no_secrets(stdout + stderr + status.stdout + env.stdout, profile_text) self.log.unlink() self.port = self.free_port() @@ -391,9 +395,9 @@ class ManagedXrayCliTest(unittest.TestCase): profile_text = self.tcp_profile() self.write_profile(profile_text) process = self.start() - state_path = self.state_home / "xray-proxy/state.json" - config_path = self.state_home / "xray-proxy/client.json" - lock_path = self.state_home / "xray-proxy/lock" + state_path = self.state_home / "proxy-client/state.json" + config_path = self.state_home / "proxy-client/client.json" + lock_path = self.state_home / "proxy-client/lock" state = json.loads(state_path.read_text(encoding="utf-8")) config = json.loads(config_path.read_text(encoding="utf-8")) self.assertEqual(0o600, state_path.stat().st_mode & 0o777) @@ -452,7 +456,7 @@ class ManagedXrayCliTest(unittest.TestCase): def test_held_lock_rejects_start_without_changing_managed_files(self): profile_text = self.tcp_profile() self.write_profile(profile_text) - state_dir = self.state_home / "xray-proxy" + state_dir = self.state_home / "proxy-client" state_dir.mkdir(parents=True, mode=0o700) state_path = state_dir / "state.json" config_path = state_dir / "client.json" @@ -473,8 +477,26 @@ class ManagedXrayCliTest(unittest.TestCase): profile_text, ) + def test_live_managed_instance_rejects_install_replace_without_changes(self): + profile_text = self.tcp_profile() + self.write_profile(profile_text) + process = self.start() + state_before = self.managed_state_path().read_bytes() + xray_before = self.xray.read_bytes() + + result = self.run_cli("install", "--replace") + + self.assertNotEqual(0, result.returncode) + self.assertIn("сначала", result.stderr) + self.assertIn("stop", result.stderr) + self.assertEqual(state_before, self.managed_state_path().read_bytes()) + self.assertEqual(xray_before, self.xray.read_bytes()) + self.assertFalse((self.home / ".local/bin/xray").exists()) + self.assert_no_secrets(result.stdout + result.stderr, profile_text) + self.stop_and_collect(process) + def test_stale_cleanup_waits_for_start_lock_and_preserves_files(self): - state_dir = self.state_home / "xray-proxy" + state_dir = self.state_home / "proxy-client" state_dir.mkdir(parents=True, mode=0o700) state = { "supervisor_pid": 999999998, @@ -564,7 +586,7 @@ class ManagedXrayCliTest(unittest.TestCase): self.assertNotEqual(0, status.returncode) self.assertIn("устаревшее", status.stderr) self.assertFalse(self.managed_state_path().exists()) - self.assertFalse((self.state_home / "xray-proxy/client.json").exists()) + self.assertFalse((self.state_home / "proxy-client/client.json").exists()) self.assertTrue(Path("/proc/{}".format(state["xray_pid"])).exists()) os.kill(state["xray_pid"], signal.SIGKILL) @@ -572,7 +594,7 @@ class ManagedXrayCliTest(unittest.TestCase): self.assert_no_secrets(stdout + stderr, profile_text) def test_dead_pids_allow_stale_state_and_config_cleanup(self): - state_dir = self.state_home / "xray-proxy" + state_dir = self.state_home / "proxy-client" state_dir.mkdir(parents=True, mode=0o700) state = { "supervisor_pid": 999999998, @@ -616,7 +638,7 @@ class ManagedXrayCliTest(unittest.TestCase): len([entry for entry in self.log_entries() if entry["kind"] == "run"]), ) - lock_path = self.state_home / "xray-proxy/lock" + lock_path = self.state_home / "proxy-client/lock" with lock_path.open("r+", encoding="ascii") as lock: with self.assertRaises(OSError): fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) @@ -640,7 +662,7 @@ class ManagedXrayCliTest(unittest.TestCase): self.assertEqual(0, stopped.returncode, stopped.stderr) self.assertIn("сирот", stopped.stdout) self.assertFalse(self.managed_state_path().exists()) - self.assertFalse((self.state_home / "xray-proxy/client.json").exists()) + self.assertFalse((self.state_home / "proxy-client/client.json").exists()) stdout, stderr = process.communicate(timeout=2) self.assert_no_secrets(stdout + stderr + stopped.stdout, profile_text) @@ -655,7 +677,7 @@ class ManagedXrayCliTest(unittest.TestCase): def test_unverifiable_live_child_preserves_state_and_config(self): profile_text = self.tcp_profile() self.write_profile(profile_text) - self.environment["XRAY_PROXY_STOP_TIMEOUT"] = "0.2" + self.environment["PROXY_CLIENT_STOP_TIMEOUT"] = "0.2" process = self.start() state = self.read_state() self.xray.unlink() @@ -663,7 +685,7 @@ class ManagedXrayCliTest(unittest.TestCase): os.kill(state["supervisor_pid"], signal.SIGTERM) process.wait(timeout=3) state_exists = self.managed_state_path().exists() - config_path = self.state_home / "xray-proxy/client.json" + config_path = self.state_home / "proxy-client/client.json" config_exists = config_path.exists() child_alive = Path("/proc/{}".format(state["xray_pid"])).exists() if child_alive: @@ -693,11 +715,11 @@ class ManagedXrayCliTest(unittest.TestCase): process.wait(timeout=2) stopped = self.run_cli( - "stop", environment={"XRAY_PROXY_STOP_TIMEOUT": "0.2"} + "stop", environment={"PROXY_CLIENT_STOP_TIMEOUT": "0.2"} ) self.assertEqual(0, stopped.returncode, stopped.stderr) self.assertFalse(self.managed_state_path().exists()) - self.assertFalse((self.state_home / "xray-proxy/client.json").exists()) + self.assertFalse((self.state_home / "proxy-client/client.json").exists()) signals = [ entry["signum"] for entry in self.log_entries() @@ -723,7 +745,7 @@ class ManagedXrayCliTest(unittest.TestCase): self.assertIn("занят", result.stderr) self.assertEqual([], self.log_entries()) self.assertFalse(self.managed_state_path().exists()) - self.assertFalse((self.state_home / "xray-proxy/client.json").exists()) + self.assertFalse((self.state_home / "proxy-client/client.json").exists()) self.assert_no_secrets(result.stdout + result.stderr, profile_text) def test_versions_and_profile_failures_stop_before_test_or_run_without_leaks(self): @@ -908,8 +930,8 @@ class ManagedXrayCliTest(unittest.TestCase): self.write_profile(profile_text) process = self.start() stdout, stderr = self.stop_and_collect(process) - self.assertFalse((self.state_home / "xray-proxy/state.json").exists()) - self.assertFalse((self.state_home / "xray-proxy/client.json").exists()) + self.assertFalse((self.state_home / "proxy-client/state.json").exists()) + self.assertFalse((self.state_home / "proxy-client/client.json").exists()) self.assert_no_secrets(stdout + stderr, profile_text) self.log.unlink() self.port = self.free_port() @@ -947,12 +969,12 @@ class ManagedXrayCliTest(unittest.TestCase): self.assertNotEqual(0, result.returncode) self.assertEqual(["version", "test"], [entry["kind"] for entry in self.log_entries()]) self.assert_no_secrets(result.stdout + result.stderr, profile_text) - self.assertFalse((self.state_home / "xray-proxy/client.json").exists()) + self.assertFalse((self.state_home / "proxy-client/client.json").exists()) def test_xray_start_requires_loopback_before_selecting_binary(self): profile_text = self.tcp_profile() self.write_profile(profile_text) - config_path = self.config_home / "proxy/config" + config_path = self.config_home / "proxy-client/config" config_path.write_text( config_path.read_text(encoding="utf-8").replace( "address = 127.0.0.1", "address = 192.0.2.10" @@ -969,7 +991,7 @@ class ManagedXrayCliTest(unittest.TestCase): listener = socket.socket() listener.bind(("127.0.0.1", self.port)) listener.listen(1) - state_dir = self.state_home / "xray-proxy" + state_dir = self.state_home / "proxy-client" state_dir.mkdir(parents=True) state = { "supervisor_pid": os.getpid(), @@ -999,7 +1021,7 @@ class ManagedXrayCliTest(unittest.TestCase): listener = socket.socket() listener.bind(("127.0.0.1", foreign_port)) listener.listen(1) - config_path = self.config_home / "proxy/config" + config_path = self.config_home / "proxy-client/config" config_path.write_text( config_path.read_text(encoding="utf-8").replace( "port = {}".format(managed_port), "port = {}".format(foreign_port) @@ -1034,7 +1056,7 @@ class ManagedXrayCliTest(unittest.TestCase): self.write_profile(profile_text) process = self.start() state = json.loads( - (self.state_home / "xray-proxy/state.json").read_text( + (self.state_home / "proxy-client/state.json").read_text( encoding="utf-8" ) ) @@ -1055,12 +1077,12 @@ class ManagedXrayCliTest(unittest.TestCase): def test_absolute_relative_and_path_symlink_cli_invocations_are_manageable(self): proxy_bin = self.root / "proxy-bin" proxy_bin.mkdir() - (proxy_bin / "xray-proxy").symlink_to(CLI) + (proxy_bin / "proxy-client").symlink_to(CLI) invocations = ( ([str(CLI)], self.root, self.environment), ([os.path.relpath(str(CLI), str(self.root))], self.root, self.environment), ( - ["xray-proxy"], + ["proxy-client"], self.root, dict( self.environment, @@ -1084,7 +1106,7 @@ class ManagedXrayCliTest(unittest.TestCase): stderr=subprocess.PIPE, env=environment, ) - state_path = self.state_home / "xray-proxy/state.json" + state_path = self.state_home / "proxy-client/state.json" deadline = time.monotonic() + 3 while time.monotonic() < deadline and not state_path.exists(): if process.poll() is not None: diff --git a/tests/test_proxy_launchers.sh b/tests/test_proxy_launchers.sh index 97b1e2d..abb3171 100755 --- a/tests/test_proxy_launchers.sh +++ b/tests/test_proxy_launchers.sh @@ -4,7 +4,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" FUNCTIONS_FILE="$REPO_ROOT/bash/.bash_functions" CUSTOM_FILE="$REPO_ROOT/bash/.bash_custom" -SHELL_MODULE="$REPO_ROOT/xray-proxy/.config/xray-proxy/shell.bash" +SHELL_MODULE="$REPO_ROOT/proxy-client/.config/proxy-client/shell.bash" pass_count=0 @@ -52,10 +52,10 @@ assert_five_proxy_commands() { # Обычная .bash_custom независимо подключает общие функции и proxy-модуль. ( fixture="$(mktemp -d /tmp/proxy-bash-custom-test.XXXXXX)" - mkdir -p "$fixture/.config/xray-proxy" + mkdir -p "$fixture/.config/proxy-client" ln -s "$CUSTOM_FILE" "$fixture/.bash_custom" ln -s "$FUNCTIONS_FILE" "$fixture/.bash_functions" - ln -s "$SHELL_MODULE" "$fixture/.config/xray-proxy/shell.bash" + ln -s "$SHELL_MODULE" "$fixture/.config/proxy-client/shell.bash" output="$( HOME="$fixture" bash --noprofile --norc -ic ' source "$HOME/.bash_custom" diff --git a/tests/test_xray_proxy.py b/tests/test_xray_proxy.py deleted file mode 100644 index 63e017a..0000000 --- a/tests/test_xray_proxy.py +++ /dev/null @@ -1,521 +0,0 @@ -#!/usr/bin/env python3 -import json -import os -import runpy -import subprocess -import sys -import tempfile -import unittest -from unittest import mock -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[1] -CLI = REPO_ROOT / "xray-proxy/.local/bin/xray-proxy" - - -def checking_socket_setup(outcome="success"): - if outcome == "success": - outcome_line = " return Connection()\n" - elif outcome == "refused": - outcome_line = " raise OSError('connection refused')\n" - else: - raise ValueError("unknown socket outcome: {}".format(outcome)) - return ( - "import json, os, socket\n" - "expected_connection = json.loads(os.environ['TEST_EXPECTED_CONNECTION'])\n" - "class Connection:\n" - " def close(self):\n" - " pass\n" - "def checked_create_connection(endpoint, timeout):\n" - " actual_connection = [endpoint[0], endpoint[1], timeout]\n" - " if actual_connection != expected_connection:\n" - " raise AssertionError('socket call mismatch: expected %r, got %r' " - "% (expected_connection, actual_connection))\n" - + outcome_line - + "socket.create_connection = checked_create_connection" - ) - - -SUCCESSFUL_SOCKET_SETUP = checking_socket_setup() -REFUSED_SOCKET_SETUP = checking_socket_setup("refused") - - -class XrayProxyCliTest(unittest.TestCase): - def setUp(self): - self.temp_dir = tempfile.TemporaryDirectory() - self.config_home = Path(self.temp_dir.name) - self.environment = os.environ.copy() - self.environment["XDG_CONFIG_HOME"] = str(self.config_home) - self.environment["PYTHONDONTWRITEBYTECODE"] = "1" - - def tearDown(self): - self.temp_dir.cleanup() - - def write_config( - self, - port, - proxy_type="external", - name="Test proxy", - address="127.0.0.1", - no_proxy_extra=".example.test", - ): - config_dir = self.config_home / "proxy" - config_dir.mkdir(exist_ok=True) - (config_dir / "config").write_text( - "[proxy]\n" - "type = {}\n" - "name = {}\n" - "address = {}\n" - "port = {}\n" - "no_proxy_extra = {}\n".format( - proxy_type, name, address, port, no_proxy_extra - ), - encoding="utf-8", - ) - - def write_raw_config(self, content): - config_dir = self.config_home / "proxy" - config_dir.mkdir(exist_ok=True) - path = config_dir / "config" - if isinstance(content, bytes): - path.write_bytes(content) - else: - path.write_text(content, encoding="utf-8") - - def run_cli(self, *arguments, environment=None): - command_environment = self.environment.copy() - if environment: - command_environment.update(environment) - return subprocess.run( - [str(CLI)] + list(arguments), - text=True, - capture_output=True, - env=command_environment, - check=False, - ) - - def run_cli_with_socket_setup( - self, arguments, setup, environment=None, expected_connection=None - ): - command_environment = self.environment.copy() - if environment: - command_environment.update(environment) - command_environment["TEST_CLI_ARGUMENTS"] = json.dumps(list(arguments)) - if expected_connection is not None: - command_environment["TEST_EXPECTED_CONNECTION"] = json.dumps( - list(expected_connection) - ) - code = ( - "import json, os, runpy, sys\n" - + setup - + "\nsys.argv = [sys.argv[1]] + " - "json.loads(os.environ['TEST_CLI_ARGUMENTS'])\n" - "runpy.run_path(sys.argv[0], run_name='__main__')" - ) - return subprocess.run( - [sys.executable, "-c", code, str(CLI)], - text=True, - capture_output=True, - env=command_environment, - check=False, - ) - - def test_socket_fixture_rejects_wrong_endpoint_and_timeout(self): - self.write_config(23456) - result = self.run_cli_with_socket_setup( - ("status",), - SUCCESSFUL_SOCKET_SETUP, - expected_connection=("wrong.example", 1, 0.5), - ) - - self.assertNotEqual(0, result.returncode) - self.assertIn("AssertionError: socket call mismatch", result.stderr) - - def test_no_subcommand_has_no_side_effect_and_reports_usage(self): - result = self.run_cli() - self.assertEqual(2, result.returncode) - self.assertEqual("", result.stdout) - self.assertIn("укажите явную подкоманду", result.stderr) - self.assertFalse((self.config_home / "proxy").exists()) - - def test_old_python_version_gets_clear_error_before_main_program(self): - code = ( - "import runpy, sys; " - "sys.version_info = (3, 7, 9); " - "runpy.run_path(sys.argv[1], run_name='__main__')" - ) - result = subprocess.run( - [sys.executable, "-c", code, str(CLI)], - text=True, - capture_output=True, - check=False, - ) - self.assertEqual(1, result.returncode) - self.assertEqual("", result.stdout) - self.assertIn("требует Python 3.8", result.stderr) - self.assertIn("Python 3.7", result.stderr) - - def test_proc_stat_parser_handles_spaces_and_closing_parentheses_in_comm(self): - module = runpy.run_path(str(CLI)) - parse_starttime = module["parse_proc_stat_starttime"] - tail = ["S"] + [str(value) for value in range(4, 22)] + ["987654"] - - for comm in ("worker", "worker name", "worker name ) with parens"): - with self.subTest(comm=comm): - self.assertEqual( - 987654, - parse_starttime("321 ({}) ".format(comm) + " ".join(tail)), - ) - for malformed in ("321 worker S 1 2", "321 (worker) S 1 2"): - with self.subTest(malformed=malformed): - with self.assertRaises(ValueError): - parse_starttime(malformed) - - def test_process_permission_error_is_not_treated_as_dead(self): - module = runpy.run_path(str(CLI)) - with mock.patch.object(module["Path"], "stat", side_effect=PermissionError()): - self.assertTrue(module["process_alive"](12345)) - - def test_same_pid_cannot_be_a_healthy_managed_pair(self): - module = runpy.run_path(str(CLI)) - function_globals = module["managed_state_outcome"].__globals__ - state = {"supervisor_pid": 12345, "xray_pid": 12345, "boot_id": "boot"} - replacements = { - "read_boot_id": mock.Mock(return_value="boot"), - "process_alive": mock.Mock(return_value=True), - "supervisor_identity_matches": mock.Mock(return_value=True), - "xray_identity_matches": mock.Mock(return_value=True), - } - with mock.patch.dict(function_globals, replacements): - self.assertEqual("unverifiable", module["managed_state_outcome"](state)) - - def test_identity_reads_starttime_after_process_paths(self): - module = runpy.run_path(str(CLI)) - function_globals = module["xray_identity_matches"].__globals__ - state = { - "supervisor_pid": 101, - "supervisor_starttime": 201, - "supervisor_path": "/cli", - "xray_pid": 102, - "xray_starttime": 202, - "xray_path": "/xray", - } - - xray_events = [] - xray_replacements = { - "process_executable": lambda _pid: xray_events.append("exe") or "/xray", - "process_starttime": lambda _pid: xray_events.append("stat") or 202, - } - with mock.patch.dict(function_globals, xray_replacements): - self.assertTrue(module["xray_identity_matches"](state)) - self.assertEqual(["exe", "stat"], xray_events) - - supervisor_events = [] - supervisor_replacements = { - "process_cmdline": lambda _pid: supervisor_events.append("cmdline") - or ["python", "/cli"], - "process_argument_resolves_to": lambda *_args: supervisor_events.append( - "script" - ) - or True, - "process_starttime": lambda _pid: supervisor_events.append("stat") or 201, - } - with mock.patch.dict(function_globals, supervisor_replacements): - self.assertTrue(module["supervisor_identity_matches"](state)) - self.assertEqual(["cmdline", "script", "stat"], supervisor_events) - - def test_initial_child_starttime_retries_while_child_is_alive(self): - module = runpy.run_path(str(CLI)) - wait_for_starttime = module["wait_for_child_starttime"] - function_globals = wait_for_starttime.__globals__ - child = mock.Mock(pid=12345) - child.poll.return_value = None - replacements = { - "process_starttime": mock.Mock(side_effect=[None, 7654321]), - "time": mock.Mock(), - } - with mock.patch.dict(function_globals, replacements): - self.assertEqual(7654321, wait_for_starttime(child)) - - def test_broken_config_symlink_is_not_treated_as_missing_config(self): - config_dir = self.config_home / "proxy" - config_dir.mkdir() - (config_dir / "config").symlink_to(self.config_home / "missing-config") - result = self.run_cli("start") - self.assertNotEqual(0, result.returncode) - self.assertIn("отсутствующий файл", result.stderr) - self.assertNotIn("только для type=xray", result.stderr) - - def test_missing_config_keeps_legacy_external_hiddify_endpoint(self): - result = self.run_cli("status", environment={"PROXY_PREFLIGHT_TIMEOUT": "0.05"}) - output = result.stdout + result.stderr - self.assertIn("Hiddify", output) - self.assertIn("hproxy.local:12334", output) - - def test_status_reports_reachable_external_proxy(self): - self.write_config(23456) - result = self.run_cli_with_socket_setup( - ("status",), - SUCCESSFUL_SOCKET_SETUP, - expected_connection=("127.0.0.1", 23456, 2.0), - ) - self.assertEqual(0, result.returncode, result.stderr) - self.assertIn("Test proxy (external) доступен", result.stdout) - self.assertEqual("", result.stderr) - - def test_env_prints_complete_proxy_environment_for_external_proxy(self): - self.write_config( - 23456, no_proxy_extra=" .example.test , git.example.test " - ) - result = self.run_cli_with_socket_setup( - ("env",), - SUCCESSFUL_SOCKET_SETUP, - expected_connection=("127.0.0.1", 23456, 2.0), - ) - - proxy_url = "http://127.0.0.1:23456" - socks_url = "socks5://127.0.0.1:23456" - no_proxy = ( - "127.0.0.1,localhost,::1,[::1],.example.test,git.example.test" - ) - self.assertEqual(0, result.returncode, result.stderr) - self.assertEqual("", result.stderr) - self.assertEqual( - [ - "HTTP_PROXY=" + proxy_url, - "HTTPS_PROXY=" + proxy_url, - "ALL_PROXY=" + socks_url, - "NO_PROXY=" + no_proxy, - "http_proxy=" + proxy_url, - "https_proxy=" + proxy_url, - "all_proxy=" + socks_url, - "no_proxy=" + no_proxy, - ], - result.stdout.splitlines(), - ) - - def test_env_brackets_ipv6_without_requiring_host_ipv6(self): - self.write_config(23456, address="2001:db8::1", no_proxy_extra="") - result = self.run_cli_with_socket_setup( - ("env",), - SUCCESSFUL_SOCKET_SETUP, - expected_connection=("2001:db8::1", 23456, 2.0), - ) - - self.assertEqual(0, result.returncode, result.stderr) - self.assertIn("HTTP_PROXY=http://[2001:db8::1]:23456\n", result.stdout) - self.assertIn("ALL_PROXY=socks5://[2001:db8::1]:23456\n", result.stdout) - - def test_scoped_ipv6_is_rejected_when_ipaddress_lacks_scope_support(self): - self.write_config(23456, address="fe80::1%eth0", no_proxy_extra="") - setup = ( - "import ipaddress\n" - "real_ip_address = ipaddress.ip_address\n" - "def python38_ip_address(value):\n" - " if '%' in value:\n" - " raise ValueError('scope IDs unsupported')\n" - " return real_ip_address(value)\n" - "ipaddress.ip_address = python38_ip_address\n" - + SUCCESSFUL_SOCKET_SETUP - ) - result = self.run_cli_with_socket_setup( - ("env",), - setup, - expected_connection=("fe80::1%eth0", 23456, 2.0), - ) - - self.assertNotEqual(0, result.returncode) - self.assertEqual("", result.stdout) - self.assertIn("address", result.stderr) - - def test_unreachable_env_prints_no_partial_output(self): - self.write_config(23456) - result = self.run_cli_with_socket_setup( - ("env",), - REFUSED_SOCKET_SETUP, - {"PROXY_PREFLIGHT_TIMEOUT": "0.1"}, - expected_connection=("127.0.0.1", 23456, 0.1), - ) - self.assertNotEqual(0, result.returncode) - self.assertEqual("", result.stdout) - self.assertIn("недоступен", result.stderr) - - def test_unreachable_external_proxy_never_runs_child(self): - self.write_config(23456) - marker = self.config_home / "external-child-ran" - helper = self.config_home / "external-child" - helper.write_text( - "#!/usr/bin/env bash\nprintf ran > \"$1\"\n", encoding="ascii" - ) - helper.chmod(0o755) - - result = self.run_cli_with_socket_setup( - ("run", "--", str(helper), str(marker)), - REFUSED_SOCKET_SETUP, - {"PROXY_PREFLIGHT_TIMEOUT": "0.1"}, - expected_connection=("127.0.0.1", 23456, 0.1), - ) - - self.assertNotEqual(0, result.returncode) - self.assertIn("недоступен", result.stderr) - self.assertFalse(marker.exists()) - - def test_xray_type_fails_closed_for_all_ticket_commands(self): - self.write_config(12334, proxy_type="xray") - marker = self.config_home / "child-ran" - helper = self.config_home / "child" - helper.write_text( - "#!/usr/bin/env bash\nprintf ran > \"$1\"\n", encoding="ascii" - ) - helper.chmod(0o755) - commands = [ - ("status",), - ("env",), - ("run", "--", str(helper), str(marker)), - ("start",), - ] - for command in commands: - with self.subTest(command=command): - result = self.run_cli(*command) - self.assertNotEqual(0, result.returncode) - self.assertEqual("", result.stdout) - self.assertIn("Ошибка:", result.stderr) - self.assertFalse(marker.exists()) - - def test_external_start_fails_without_starting_anything(self): - self.write_config(12334) - result = self.run_cli("start") - self.assertNotEqual(0, result.returncode) - self.assertEqual("", result.stdout) - self.assertIn("только для type=xray", result.stderr) - - def test_run_preserves_arguments_exit_code_and_child_only_environment(self): - self.write_config(23456) - code = ( - "import json, os, sys; " - "print(json.dumps({'args': sys.argv[1:], " - "'http': os.environ['HTTP_PROXY'], " - "'lower': os.environ['http_proxy']})); sys.exit(42)" - ) - original_http = os.environ.get("HTTP_PROXY") - result = self.run_cli_with_socket_setup( - ("run", "--", sys.executable, "-c", code, "a b", "--", "--flag"), - SUCCESSFUL_SOCKET_SETUP, - expected_connection=("127.0.0.1", 23456, 2.0), - ) - - self.assertEqual(42, result.returncode, result.stderr) - child = json.loads(result.stdout) - self.assertEqual(["a b", "--", "--flag"], child["args"]) - expected_url = "http://127.0.0.1:23456" - self.assertEqual(expected_url, child["http"]) - self.assertEqual(expected_url, child["lower"]) - self.assertEqual(original_http, os.environ.get("HTTP_PROXY")) - - def test_missing_run_command_returns_127(self): - self.write_config(12334) - result = self.run_cli("run", "--", "definitely-no-such-command-xyz") - self.assertEqual(127, result.returncode) - self.assertEqual("", result.stdout) - self.assertIn("не найдена в PATH", result.stderr) - - def test_run_maps_child_signal_to_shell_exit_code(self): - self.write_config(23456) - result = self.run_cli_with_socket_setup( - ( - "run", - "--", - sys.executable, - "-c", - "import os, signal; os.kill(os.getpid(), signal.SIGTERM)", - ), - SUCCESSFUL_SOCKET_SETUP, - expected_connection=("127.0.0.1", 23456, 2.0), - ) - self.assertEqual(143, result.returncode, result.stderr) - - def test_run_requires_explicit_separator_and_command(self): - self.write_config(12334) - for command in (("run",), ("run", "true"), ("run", "--")): - with self.subTest(command=command): - result = self.run_cli(*command) - self.assertNotEqual(0, result.returncode) - self.assertIn("run -- COMMAND", result.stderr) - - def test_invalid_preflight_timeout_is_rejected_clearly(self): - self.write_config(12334) - result = self.run_cli( - "status", environment={"PROXY_PREFLIGHT_TIMEOUT": "not-a-number"} - ) - self.assertNotEqual(0, result.returncode) - self.assertIn("положительным числом", result.stderr) - - def test_ini_rejects_structure_key_and_value_grammar_errors(self): - valid = ( - "[proxy]\n" - "type = external\n" - "name = Test\n" - "address = 127.0.0.1\n" - "port = 12334\n" - "no_proxy_extra =\n" - ) - invalid_configs = { - "unknown section": valid + "[other]\nkey = value\n", - "unknown key": valid + "extra = value\n", - "duplicate key": valid + "port = 12335\n", - "missing key": valid.replace("name = Test\n", ""), - "empty name": valid.replace("name = Test", "name = "), - "empty address": valid.replace("address = 127.0.0.1", "address = "), - "port zero": valid.replace("port = 12334", "port = 0"), - "port high": valid.replace("port = 12334", "port = 65536"), - "port syntax": valid.replace("port = 12334", "port = +80"), - "empty no-proxy item": valid.replace( - "no_proxy_extra =", "no_proxy_extra = one.example,,two.example" - ), - } - for label, content in invalid_configs.items(): - with self.subTest(label=label): - self.write_raw_config(content) - result = self.run_cli("start") - self.assertNotEqual(0, result.returncode) - self.assertEqual("", result.stdout) - self.assertIn("Ошибка:", result.stderr) - - def test_address_accepts_ip_literals_and_simple_ascii_names(self): - valid_addresses = ( - "127.0.0.1", - "2001:db8::1", - "proxy-1.example", - ".leading.example", - "trailing.example.", - "bad..example", - "-bad.example", - "bad-.example", - ) - for address in valid_addresses: - with self.subTest(address=address): - self.write_config(12334, address=address) - result = self.run_cli("start") - self.assertIn("только для type=xray", result.stderr) - - invalid_addresses = ( - "http://proxy.example", - "[::1]", - "has space", - "proxy/path", - "user@proxy", - "прокси.example", - "fe80::1%eth0", - ) - for address in invalid_addresses: - with self.subTest(address=address): - self.write_config(12334, address=address) - result = self.run_cli("start") - self.assertNotEqual(0, result.returncode) - self.assertNotIn("только для type=xray", result.stderr) - self.assertIn("address", result.stderr) - - -if __name__ == "__main__": - unittest.main() diff --git a/xray-proxy/README.md b/xray-proxy/README.md deleted file mode 100644 index 927e0fd..0000000 --- a/xray-proxy/README.md +++ /dev/null @@ -1,147 +0,0 @@ -# xray-proxy - -Переносимый Linux/Bash-интерфейс для безопасного запуска команд через явно -настроенный внешний прокси или через управляемый пользовательский Xray. - -CLI требует Python 3.8+ и использует только стандартную библиотеку. Он ничего не -устанавливает и не запускает без явной подкоманды. `start` использует первый -`xray` из `PATH`; скачивания и установки при запуске нет. - -## Подключение - -В составе dotfiles: - -```bash -cd ~/dotfiles -stow xray-proxy -source ~/.config/xray-proxy/shell.bash -``` - -Полный `./install.sh` выполняет тот же `stow` вместе с остальными пакетами, а -`.bash_custom` подключает модуль автоматически. - -Без остальных dotfiles можно создать две ссылки: - -```bash -mkdir -p ~/.local/bin ~/.config/xray-proxy -ln -s /path/to/xray-proxy/.local/bin/xray-proxy ~/.local/bin/xray-proxy -ln -s /path/to/xray-proxy/.config/xray-proxy/shell.bash \ - ~/.config/xray-proxy/shell.bash -printf '%s\n' 'source ~/.config/xray-proxy/shell.bash' >> ~/.bashrc -``` - -Вместо ссылок файлы можно скопировать в те же пути. Исполняемому файлу нужен -режим `chmod 755`. Каталог `~/.local/bin` должен входить в `PATH`. - -## Машинный конфиг - -Скопируйте пример и отредактируйте его: - -```bash -mkdir -p ~/.config/proxy -cp ~/.config/xray-proxy/examples/config ~/.config/proxy/config -``` - -Формат строгий: одна секция `[proxy]`, пять обязательных ключей без повторов и -неизвестных полей. - -```ini -[proxy] -type = external -name = Hiddify -address = hproxy.local -port = 12334 -no_proxy_extra = .example.internal,git.example.internal -``` - -`address` — IPv4, IPv6 без квадратных скобок или ASCII DNS-имя без схемы и -порта. `no_proxy_extra` — список через запятую; значение может быть пустым. -Поддержка CIDR в `NO_PROXY` различается между программами, поэтому надёжнее -указывать точные IP и домены. - -Если файла нет, используется прежний внешний Hiddify на -`hproxy.local:12334`, включая `.concept-software.ru` в `NO_PROXY`. Это не -переключает тип прокси автоматически. - -Для управляемого Xray задайте явный тип, loopback IP и нужный локальный порт: - -```ini -[proxy] -type = xray -name = Local Xray -address = 127.0.0.1 -port = 12334 -no_proxy_extra = .example.internal,git.example.internal -``` - -Секретный профиль хранится отдельно. Скопируйте синтетический пример, замените -его одной настоящей VLESS-ссылкой и закройте права конечного файла: - -```bash -cp ~/.config/xray-proxy/examples/default.url ~/.config/xray-proxy/default.url -chmod 600 ~/.config/xray-proxy/default.url -``` - -Файл должен содержать ровно одну непустую строку. Можно передать другой файл -через `xray-proxy start -f /path/to/profile.url`. Если это симлинк, проверяются -права конечного файла. - -## Команды - -```bash -xray-proxy status -xray-proxy start [-f PROFILE] -xray-proxy stop -xray-proxy env -xray-proxy run -- COMMAND ARG... -proxy_on -proxy_off -pclaude [ARG...] -pcodex [ARG...] -myip -``` - -Для `external` команды `status`, `env` и `run` проверяют настроенный адрес и -порт. Для `xray` они дополнительно требуют закрытое состояние именно того -супервизора и дочернего Xray, который запустил CLI. Один открытый порт или чужой -PID не принимается. `run` никогда не запускает Xray скрыто. - -`start` поддерживает Xray `25.12.8` и `26.3.27`, сначала выполняет -`xray version` и `xray run -test`, затем остаётся на переднем плане. Используйте -отдельную панель tmux. После готовности один loopback-порт принимает HTTP и -SOCKS. `stop` ограниченно ждёт завершения супервизора и Xray, очищает служебные -файлы и возвращает исходный `start` в терминал. Команда `stop` работает и после -явного переключения машинного типа на `external`. - -`run` передаёт восемь proxy-переменных только дочернему процессу и сохраняет -аргументы и код возврата. `proxy_on` атомарно применяет те же восемь переменных -к текущей Bash-сессии; `proxy_off` удаляет их. - -Таймаут проверки задаётся положительным числом секунд: - -```bash -export PROXY_PREFLIGHT_TIMEOUT=2 -``` - -Значение по умолчанию — 2 секунды. `env` при успехе печатает только восемь строк -`KEY=value`; для обычной диагностики используйте `status` или `myip`. - -## Поддерживаемые профили - -Поддерживаются только VLESS TCP/RAW с REALITY и VLESS XHTTP с TLS. Неизвестные, -повторные и несовместимые query-параметры отклоняются. XHTTP поддерживает ALPN -`http/1.1`, `h2` и `h3`; `extra` должен быть JSON-объектом без повторных ключей. - -Xray 26.3.27 использует `pcs` и `vcn`, а устаревшие `allowInsecure` и -`insecure` не переносит. Истинное legacy-значение даёт предупреждение, но -обычная проверка сертификата остаётся включённой. Xray 25.12.8 предназначен -только для явной старой совместимости: согласованное `true` переносится как -`allowInsecure=true` с предупреждением об отключённой проверке сертификата; -`pcs` и `vcn` для этой версии запрещены. Автоматического перехода между -версиями или типами прокси нет. - -Сводка скрывает UUID и ключи. Содержимое XHTTP `path` не показывается — только -его длина. Сгенерированный JSON и состояние лежат в закрытом каталоге -`$XDG_STATE_HOME/xray-proxy` либо `~/.local/state/xray-proxy` и удаляются после -штатной остановки. Сбой диагностики внешнего IP через локальный HTTP-прокси -показывается как предупреждение и не останавливает уже готовый Xray.