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:
commit
78aa5f6473
31 files changed
+2391
-1507
No files matched your search
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.
|
||||
@@ -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`)
|
||||
|
||||
| Функция | Описание |
|
||||
|---------|----------|
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
+59
-38
@@ -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
@@ -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
|
||||
|
||||
File renamed without changes.
@@ -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
|
||||
File renamed without changes.
+6
-6
@@ -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.
@@ -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
@@ -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
Executable
+17
@@ -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:
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user