feat(proxy-client): добавлен управляемый прокси-клиент

- Зачем:
  - консольным инструментам нужен единый способ работать через внешний или локальный управляемый прокси.
- Что:
  - добавлены режимы external и xray, команды управления и оболочечные функции.
  - обновлены установка, документация и автоматические проверки.
  - рабочие материалы .scratch исключены из итогового состояния master.
- Проверка:
  - bash tests/test_install.sh.
  - bash tests/test_proxy_launchers.sh.
  - bash -n install.sh tests/test_install.sh tests/test_proxy_launchers.sh.
  - git diff --check origin/master...feature/proxy-client.
This commit is contained in:
ddadmin committed 2026-07-21 11:23:36 -04:00
commit 78aa5f6473
31 files changed
+2391 -1507

No files matched your search

View File
Whitespace-only changes.
@@ -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 <id>` без потерь.
- **Синтаксис resume отличается**: `codex exec resume <id>` принимает
ТОЛЬКО `-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 с ручной приёмкой пользователя на целевых системах.
@@ -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`.
@@ -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-запрос.
Подмена порта или состояния чужим процессом даёт безопасный отказ.
@@ -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. Принятые по ходу решения и сомнения, если есть.
@@ -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`, атомарность и отсутствие
записи вне изолированного пользовательского каталога.
@@ -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 в РЕД ОС проверена до заявления поддержки, а недоступная среда
явно записана как неподтверждённая вместо неподкреплённого обещания.
- [ ] После приёмки статус исходной спецификации и документация соответствуют
фактически подтверждённому объёму, включая все оставшиеся ограничения.
@@ -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. Сомнения, если есть.
@@ -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.
+10 -61
View File
@@ -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`)
| Функция | Описание |
|---------|----------|
+23
View File
@@ -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/
@@ -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 не задокументировано — не рисковать).
Ограничение любого эффорта: от уверенной выдумки внешних фактов (форматы
артефактов, версии) не страхует — факты проверять отдельно.
## Конвейер
@@ -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; фоновому таймаут
@@ -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 созданы по конвенции репозитория, но мимо критериев
+1 -1
View File
@@ -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).
@@ -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;
+90 -1
View File
@@ -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
@@ -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
@@ -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
@@ -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()
File renamed without changes.
+325
View File
@@ -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 показывается как предупреждение и
не останавливает уже готовый процесс.
+166 -19
View File
@@ -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
File diff suppressed because it is too large. Load diff
+17
View File
@@ -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
@@ -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:
+3 -3
View File
@@ -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"
-521
View File
@@ -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()
-147
View File
@@ -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.