Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e7777527f | ||
|
|
09f57aa1e7 |
No files matched your search
@@ -1,64 +0,0 @@
|
|||||||
# 01. Упростить CLI proxy-client и его Python-тесты
|
|
||||||
|
|
||||||
Status: ready-for-agent
|
|
||||||
|
|
||||||
Спецификация: `../spec.md` (читать целиком — там список того, что сохраняем).
|
|
||||||
|
|
||||||
## Файлы
|
|
||||||
|
|
||||||
- `proxy-client/.local/bin/proxy-client`
|
|
||||||
- `tests/test_proxy_client.py`, `tests/test_proxy_client_managed.py`
|
|
||||||
(можно слить, переименовать или разнести по-новому — как станет проще)
|
|
||||||
- `proxy-client/README.md` — все разделы, **кроме** «Установка» и «Ручное
|
|
||||||
подключение без установщика» (их правит задача 02; при конфликте правки
|
|
||||||
перечитай файл и повтори).
|
|
||||||
- `proxy-client/.config/proxy-client/examples/` — если нужно.
|
|
||||||
|
|
||||||
Не трогать: `proxy-client/install.sh`, `shell.bash`, shell-тесты.
|
|
||||||
|
|
||||||
## Что сделать
|
|
||||||
|
|
||||||
Пункты 1–4 и 6 спецификации для CLI. Затем привести тесты к пяти местам
|
|
||||||
проверок из спецификации:
|
|
||||||
|
|
||||||
- Все проверки разбора ссылки (ALPN, legacy insecure, некорректные ссылки,
|
|
||||||
соответствие полей REALITY/XHTTP, pcs/vcn, extra, шифрование) — табличные
|
|
||||||
тесты `parse_vless`/`build_xray_config`, без запуска процессов.
|
|
||||||
- CLI-тесты управляемого Xray: один полный жизненный цикл (start → status/env/
|
|
||||||
run → stop → файлы удалены, права 600/700, секретов нет в выводе), Ctrl+C,
|
|
||||||
SIGKILL супервизора → `stop` добивает Xray, устаревшие файлы после смерти
|
|
||||||
процессов, занятый порт, не-loopback адрес, права профиля (с новой
|
|
||||||
подсказкой), сбой `xray run -test` не оставляет файлов, сбой определения
|
|
||||||
внешнего IP — лишь предупреждение, второй `start` при работающем — отказ.
|
|
||||||
Каждый сценарий — один запуск. Готовность ждать по строке вывода, а не
|
|
||||||
опросом `status` в цикле, если это упрощает.
|
|
||||||
- Удалить тесты-пустышки: проверку тестовой подделки сокета, проверку, что
|
|
||||||
поддельный xray отвергает не-JSON, тесты порядка внутренних вызовов, всё,
|
|
||||||
что подменяет функции модуля через `__globals__`. Подделку сокета
|
|
||||||
(`checking_socket_setup`/`run_cli_with_socket_setup` через runpy) заменить
|
|
||||||
настоящим слушающим сокетом на 127.0.0.1 (и закрытым портом для «недоступен»).
|
|
||||||
- Тесты удалённых возможностей (ограничения размера, строгий `.dgst`,
|
|
||||||
запрет заглавных букв в хеше, «каталог установки внутри HOME», boot_id,
|
|
||||||
повторный PID, подмена xray_path, способы вызова супервизора и т. п.) уходят
|
|
||||||
вместе с возможностями.
|
|
||||||
- Новая подсказка о правах — тест первым (красный → зелёный).
|
|
||||||
|
|
||||||
Упрощай и сам код: если функция после правок становится проходной — встрой.
|
|
||||||
Сообщения об ошибках — на русском, коротко, с путём к файлу там, где речь о
|
|
||||||
файле. Сохраняй совместимость с Python 3.8 (без `match`, `str.removeprefix`,
|
|
||||||
`|` в типах и т. п.).
|
|
||||||
|
|
||||||
## Готово, когда
|
|
||||||
|
|
||||||
- `python3 -m unittest discover -s tests` зелёный; прогон Python-тестов
|
|
||||||
proxy-client заметно быстрее нынешних ~75 с.
|
|
||||||
- Если есть `uv`: `uv run --python 3.8 python -m unittest discover -s tests`
|
|
||||||
тоже зелёный.
|
|
||||||
- Реальная проверка: сгенерированный конфиг для синтетических ссылок REALITY
|
|
||||||
и XHTTP с шифрованием проходит `xray run -test` (xray 26.3.27 есть в
|
|
||||||
`~/.local/bin/xray`).
|
|
||||||
- README описывает текущее поведение, без упоминаний удалённого.
|
|
||||||
- Не коммитить: коммиты делает оркестратор.
|
|
||||||
|
|
||||||
Отчёт: что удалено из кода и тестов (строки до/после, время прогона до/после),
|
|
||||||
какие решения принял сам, что осталось спорным.
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# 02. Упростить установщик пакета, shell-тесты и запускалку
|
|
||||||
|
|
||||||
Status: ready-for-agent
|
|
||||||
|
|
||||||
Спецификация: `../spec.md` (читать целиком).
|
|
||||||
|
|
||||||
## Файлы
|
|
||||||
|
|
||||||
- `proxy-client/install.sh`
|
|
||||||
- `proxy-client/.config/proxy-client/shell.bash` — только если упрощение
|
|
||||||
очевидно; поведение `proxy_on`/`proxy_off`/`pclaude`/`pcodex` не меняется.
|
|
||||||
- `tests/test_proxy_client_install.sh`, `tests/test_proxy_launchers.sh`,
|
|
||||||
`tests/test_proxy_client_full.sh`
|
|
||||||
- `proxy-client/README.md` — только разделы «Установка» и «Ручное подключение
|
|
||||||
без установщика» (остальное правит задача 01; при конфликте правки
|
|
||||||
перечитай файл и повтори).
|
|
||||||
|
|
||||||
Не трогать: `proxy-client/.local/bin/proxy-client`, Python-тесты, корневой
|
|
||||||
`install.sh` и `tests/test_install.sh` (кроме случая, если они ломаются от
|
|
||||||
твоих правок — тогда минимально поправь и сообщи).
|
|
||||||
|
|
||||||
## Что сделать
|
|
||||||
|
|
||||||
1. Пункт 5 спецификации: `install.sh` ставит три симлинка напрямую (`ln -s`),
|
|
||||||
без Stow и `STOW_IGNORE_PATTERNS`. Повторный запуск — ничего не меняет.
|
|
||||||
Чужие файлы не перезаписывает и перечисляет все конфликты сразу (битая
|
|
||||||
ссылка — тоже конфликт). `~/.local` и `~/.config`, которые сами ссылки,
|
|
||||||
допустимы. Блок в `~/.bashrc` — дописать, если нет маркера;
|
|
||||||
`--no-shell-setup` сохраняется. Машинные `config` и `default.url` никогда
|
|
||||||
не ссылаются из пакета.
|
|
||||||
2. Тесты установщика: свежая установка (три ссылки, права, `bash -ic` после
|
|
||||||
установки находит `proxy-client` и `proxy_on`), повторный запуск ничего не
|
|
||||||
меняет, конфликты (включая битую ссылку) без частичной установки, машинные
|
|
||||||
файлы сохраняются, `--no-shell-setup`, родительские каталоги-ссылки
|
|
||||||
работают. Тесты удалённого поведения — удалить.
|
|
||||||
3. Тесты функций Bash: вместо переопределения внутренней `_proxy_cli` —
|
|
||||||
поддельный исполняемый файл через `PROXY_CLIENT_BIN` (посмотри, как это
|
|
||||||
сделано в `tests/test_chatgpt_proxy_launcher.sh`). Удалить тест голого
|
|
||||||
`declare -F`, тест «владение перенесено» (охрана давнего рефакторинга) и
|
|
||||||
ловушки `curl`. Сохранить: `proxy_on` применяет переменные, неуспешный
|
|
||||||
`env` не меняет окружение, `proxy_off` чистит восемь переменных,
|
|
||||||
`pclaude`/`pcodex` передают аргументы и код выхода, `.bash_custom`
|
|
||||||
подключает модули.
|
|
||||||
4. `tests/test_proxy_client_full.sh` запускает только тесты proxy-client
|
|
||||||
(Python-тесты proxy-client, env_sync, shell-тесты proxy-client). Прогон
|
|
||||||
под Python 3.8 через `uv` — если `uv` есть, иначе предупреждение и пропуск.
|
|
||||||
В README (раздел «Установка» или новый короткий раздел в конце —
|
|
||||||
согласуй с задачей 01 через простоту: одна строка команды) — как запускать.
|
|
||||||
|
|
||||||
## Готово, когда
|
|
||||||
|
|
||||||
- Все изменённые shell-тесты зелёные; `tests/test_install.sh` тоже зелёный.
|
|
||||||
- Тесты не требуют установленного `stow`.
|
|
||||||
- `bash -n` и (если есть) `shellcheck` для изменённых скриптов чистые.
|
|
||||||
- Не коммитить: коммиты делает оркестратор.
|
|
||||||
|
|
||||||
Отчёт: строки до/после, время до/после, принятые самостоятельно решения,
|
|
||||||
что осталось спорным.
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# Упрощение proxy-client
|
|
||||||
|
|
||||||
Status: ready-for-agent
|
|
||||||
|
|
||||||
## Зачем
|
|
||||||
|
|
||||||
`proxy-client` — личный клиент для владельца и нескольких друзей, а не
|
|
||||||
промышленное ПО. Большая часть кода и тестов — защита от сценариев, которые на
|
|
||||||
личной машине не случаются (повторное использование PID, подложный state.json,
|
|
||||||
zip-бомба в проверенном по SHA-256 официальном архиве, гонка `status` с
|
|
||||||
параллельным `start`). Эта защита стоит сотни строк кода и тестов и около
|
|
||||||
минуты прогона. Цель — оставить существенную сложность и убрать случайную.
|
|
||||||
|
|
||||||
Принцип: **простой код, который честно делает своё дело и понятно сообщает об
|
|
||||||
ошибке**. Не защищаемся от самого пользователя и от его собственных файлов.
|
|
||||||
|
|
||||||
## Что сохраняем (обязательное поведение)
|
|
||||||
|
|
||||||
- Команды и их договор: `install [--replace]`, `status`, `env`, `run -- CMD`,
|
|
||||||
`start [-f FILE]`, `stop`; коды выхода (2 без подкоманды, 127 — команда не
|
|
||||||
найдена, 128+N — дочерний процесс убит сигналом N); восемь proxy-переменных.
|
|
||||||
- Отказ без запасного пути: выбран Xray, но он не запущен — ошибка, а не
|
|
||||||
переход на Hiddify.
|
|
||||||
- Машинный INI: строгий формат как сейчас (он маленький и понятный).
|
|
||||||
- Разбор VLESS: поддерживаемые TCP/REALITY и XHTTP/TLS, VLESS-шифрование
|
|
||||||
`mlkem768x25519plus`, неизвестные query-параметры отклоняются.
|
|
||||||
- Точная версия Xray 26.3.27 для `install` и `start` (сознательное решение).
|
|
||||||
- Установка Xray: официальный архив, проверка SHA-256 по `.dgst`, атомарная
|
|
||||||
замена через временный каталог рядом с назначением; `--replace` не пишет
|
|
||||||
через симлинк; неудачная установка не портит старый бинарник.
|
|
||||||
- `start` только на loopback-адресе; занятый порт — понятная ошибка.
|
|
||||||
- Не выводим UUID, ключи REALITY и `path` XHTTP в stdout/stderr.
|
|
||||||
- `client.json` и `state.json` — права 600 в каталоге 700 (в них секреты).
|
|
||||||
- Один управляемый Xray за раз: блокировка `flock` на время `start`.
|
|
||||||
- Остановка: Ctrl+C или `stop` гасят Xray и удаляют служебные файлы; если
|
|
||||||
супервизор убит (SIGKILL), `stop` добивает оставшийся Xray и чистит файлы.
|
|
||||||
- Python 3.8+ (у друзей старые дистрибутивы), только стандартная библиотека.
|
|
||||||
|
|
||||||
## Что упрощаем
|
|
||||||
|
|
||||||
1. **Проверка «тот ли процесс».** Вместо boot_id + starttime + `/proc/PID/exe`
|
|
||||||
+ разрешения argv супервизора относительно его cwd: Xray — PID жив и
|
|
||||||
`/proc/PID/exe` совпадает с `xray_path`; супервизор — PID жив (и, если
|
|
||||||
дёшево, его cmdline упоминает `proxy-client`). Непохожий процесс считаем
|
|
||||||
мёртвым: сигнал не шлём, файлы считаем устаревшими и чистим. Состояние
|
|
||||||
«не удалось подтвердить, удалите файлы вручную» исчезает. Двойная проверка
|
|
||||||
под блокировкой при чистке устаревших файлов не нужна. state.json читаем
|
|
||||||
без строгой схемы: битый файл — понятная ошибка с путём.
|
|
||||||
2. **Скачивание Xray.** Убрать три уровня ограничений размера, сверку
|
|
||||||
Content-Length, строгую грамматику `.dgst`. Достаточно: скачать, найти в
|
|
||||||
`.dgst` строку `SHA2-256=`, сравнить без учёта регистра. Убрать
|
|
||||||
`O_EXCL`/0600 для кандидата внутри закрытого временного каталога и
|
|
||||||
требование «каталог установки внутри $HOME».
|
|
||||||
3. **`install` и работающий Xray.** Замена бинарника под работающим процессом
|
|
||||||
на Linux безопасна: не блокировать `install`, а напомнить перезапустить
|
|
||||||
`start`. Блокировку start на время install не брать.
|
|
||||||
4. **Разбор ссылки.** `extra` — обычный `json.loads` + проверка, что это
|
|
||||||
объект (без своих проверок повторных ключей и NaN: `xray run -test`
|
|
||||||
проверит). `allowInsecure`/`insecure` — принимаются с любым значением и
|
|
||||||
игнорируются; при истинном значении (`1`, `true`) — предупреждение.
|
|
||||||
5. **Установщик пакета `proxy-client/install.sh`.** Один способ — прямые
|
|
||||||
симлинки (без Stow). Не перезаписывать чужие файлы и сообщить обо всех
|
|
||||||
конфликтах сразу. Разрешить `~/.local` и `~/.config`, которые сами являются
|
|
||||||
ссылками. Блок в `~/.bashrc` — дописать, если нет маркера; без разбора
|
|
||||||
«повреждённых» блоков, поиска `.bash_custom` и точного текста `export PATH`.
|
|
||||||
6. **Подсказки об ошибках с файлами** называют файл полным путём и дают
|
|
||||||
готовую команду. Пример: «файл профиля /home/u/.config/proxy-client/default.url
|
|
||||||
доступен другим пользователям; выполните: chmod 600 /home/u/...». Если
|
|
||||||
профиль — симлинк, называть конечный файл (права ставятся на него).
|
|
||||||
|
|
||||||
## Где живут тесты (согласовано с владельцем)
|
|
||||||
|
|
||||||
1. Команда `proxy-client`: argv → код выхода, вывод, созданные файлы,
|
|
||||||
запущенные и остановленные процессы.
|
|
||||||
2. Разбор ссылки: `parse_vless` + `build_xray_config` (ссылка → JSON Xray) —
|
|
||||||
быстрые табличные тесты без запуска процессов.
|
|
||||||
3. Установка Xray: `command_install` с подставляемым скачивателем.
|
|
||||||
4. Функции Bash: через поддельный исполняемый файл в `PROXY_CLIENT_BIN`.
|
|
||||||
5. Установщик пакета: через временный HOME.
|
|
||||||
|
|
||||||
Больше нигде. Запрещено: подмена внутренних функций через `__globals__` /
|
|
||||||
`mock.patch.dict`, проверки порядка внутренних вызовов, тесты тестовых
|
|
||||||
подделок, по нескольку полных циклов start/stop ради одного утверждения.
|
|
||||||
|
|
||||||
## Задачи
|
|
||||||
|
|
||||||
- `issues/01-cli.md` — CLI и Python-тесты.
|
|
||||||
- `issues/02-installer-shell.md` — установщик пакета, shell-тесты, запускалка.
|
|
||||||
- После 01 и 02 (делает оркестратор): привести разделы
|
|
||||||
`docs/specs/2026-07-15-proxy-client-design.md` про установку, жизненный
|
|
||||||
цикл, доставку, риски и проверку в соответствие с кодом; добавить в
|
|
||||||
`proxy-client/README.md` ссылку на спецификацию для тех, кто меняет код.
|
|
||||||
@@ -215,7 +215,7 @@ def check_reachable(config):
|
|||||||
|
|
||||||
def check_proxy(config):
|
def check_proxy(config):
|
||||||
if config.proxy_type == "xray":
|
if config.proxy_type == "xray":
|
||||||
require_supervised_xray()
|
require_supervised_xray(config)
|
||||||
check_reachable(config)
|
check_reachable(config)
|
||||||
|
|
||||||
|
|
||||||
@@ -262,7 +262,7 @@ def write_private_json(path, value):
|
|||||||
path.chmod(0o600)
|
path.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
def read_profile(path):
|
def load_profile(path):
|
||||||
# Права проверяем у конечного файла: у симлинка они ничего не значат.
|
# Права проверяем у конечного файла: у симлинка они ничего не значат.
|
||||||
resolved = path.expanduser().resolve(strict=True)
|
resolved = path.expanduser().resolve(strict=True)
|
||||||
if resolved.stat().st_mode & 0o077:
|
if resolved.stat().st_mode & 0o077:
|
||||||
@@ -277,7 +277,10 @@ def read_profile(path):
|
|||||||
raise ProxyError(
|
raise ProxyError(
|
||||||
"файл профиля {} должен содержать ровно одну непустую VLESS-ссылку".format(resolved)
|
"файл профиля {} должен содержать ровно одну непустую VLESS-ссылку".format(resolved)
|
||||||
)
|
)
|
||||||
return lines[0]
|
try:
|
||||||
|
return parse_vless(lines[0])
|
||||||
|
except ProxyError as error:
|
||||||
|
raise ProxyError("файл профиля {}: {}".format(resolved, error))
|
||||||
|
|
||||||
|
|
||||||
def parse_query(raw_query):
|
def parse_query(raw_query):
|
||||||
@@ -581,7 +584,8 @@ def read_state():
|
|||||||
path = state_path()
|
path = state_path()
|
||||||
try:
|
try:
|
||||||
state = json.loads(path.read_text(encoding="utf-8"))
|
state = json.loads(path.read_text(encoding="utf-8"))
|
||||||
return {key: state[key] for key in ("supervisor_pid", "xray_pid", "xray_path")}
|
keys = ("supervisor_pid", "xray_pid", "xray_path", "address", "port")
|
||||||
|
return {key: state[key] for key in keys}
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise ProxyError("управляемый Xray не запущен; выполните proxy-client start")
|
raise ProxyError("управляемый Xray не запущен; выполните proxy-client start")
|
||||||
except (ValueError, TypeError, KeyError):
|
except (ValueError, TypeError, KeyError):
|
||||||
@@ -634,12 +638,21 @@ def managed_xray_running():
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def require_supervised_xray():
|
def require_supervised_xray(config):
|
||||||
if not supervisor_running(managed_state()):
|
state = managed_state()
|
||||||
|
if not supervisor_running(state):
|
||||||
raise ProxyError(
|
raise ProxyError(
|
||||||
"Xray работает без proxy-client start (его процесс завершён); "
|
"Xray работает без proxy-client start (его процесс завершён); "
|
||||||
"выполните proxy-client stop"
|
"выполните proxy-client stop"
|
||||||
)
|
)
|
||||||
|
if (config.address, config.port) != (state["address"], state["port"]):
|
||||||
|
raise ProxyError(
|
||||||
|
"Xray запущен на {}:{}, а {} указывает {}:{}; чтобы применить конфиг, "
|
||||||
|
"выполните proxy-client stop, затем proxy-client start".format(
|
||||||
|
state["address"], state["port"], config_home() / "config",
|
||||||
|
config.address, config.port,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def wait_until(condition, timeout):
|
def wait_until(condition, timeout):
|
||||||
@@ -809,7 +822,7 @@ def run_managed_xray(config, profile_file):
|
|||||||
remove_state_files()
|
remove_state_files()
|
||||||
check_port_available(ipaddress.ip_address(config.address), config.port)
|
check_port_available(ipaddress.ip_address(config.address), config.port)
|
||||||
profile_path = Path(profile_file) if profile_file else config_home() / "default.url"
|
profile_path = Path(profile_file) if profile_file else config_home() / "default.url"
|
||||||
profile = parse_vless(read_profile(profile_path))
|
profile = load_profile(profile_path)
|
||||||
executable = resolve_xray()
|
executable = resolve_xray()
|
||||||
check_xray_version(executable)
|
check_xray_version(executable)
|
||||||
write_private_json(
|
write_private_json(
|
||||||
@@ -826,7 +839,13 @@ def run_managed_xray(config, profile_file):
|
|||||||
child = subprocess.Popen([executable, "run", "-c", str(generated_config_path())])
|
child = subprocess.Popen([executable, "run", "-c", str(generated_config_path())])
|
||||||
write_private_json(
|
write_private_json(
|
||||||
state_path(),
|
state_path(),
|
||||||
{"supervisor_pid": os.getpid(), "xray_pid": child.pid, "xray_path": executable},
|
{
|
||||||
|
"supervisor_pid": os.getpid(),
|
||||||
|
"xray_pid": child.pid,
|
||||||
|
"xray_path": executable,
|
||||||
|
"address": config.address,
|
||||||
|
"port": config.port,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if not wait_until(
|
if not wait_until(
|
||||||
lambda: port_open(config.address, config.port), max(1.0, preflight_timeout())
|
lambda: port_open(config.address, config.port), max(1.0, preflight_timeout())
|
||||||
|
|||||||
+40
-34
@@ -152,8 +152,12 @@ class ExternalProxyCliTest(unittest.TestCase):
|
|||||||
self.assertIn("только для type=xray", result.stderr)
|
self.assertIn("только для type=xray", result.stderr)
|
||||||
|
|
||||||
def test_missing_config_uses_hiddify_default(self):
|
def test_missing_config_uses_hiddify_default(self):
|
||||||
|
# Доступен ли Hiddify на этой машине, неважно: и отчёт о доступности,
|
||||||
|
# и ошибка называют адрес по умолчанию.
|
||||||
result = self.run_cli("status", PROXY_PREFLIGHT_TIMEOUT="0.05")
|
result = self.run_cli("status", PROXY_PREFLIGHT_TIMEOUT="0.05")
|
||||||
self.assertIn("Hiddify (hproxy.local:12334)", result.stderr)
|
output = result.stdout + result.stderr
|
||||||
|
self.assertIn("Hiddify", output)
|
||||||
|
self.assertIn("hproxy.local:12334", output)
|
||||||
|
|
||||||
def test_broken_config_symlink_is_an_error(self):
|
def test_broken_config_symlink_is_an_error(self):
|
||||||
self.config_dir.mkdir()
|
self.config_dir.mkdir()
|
||||||
@@ -178,24 +182,37 @@ class ExternalProxyCliTest(unittest.TestCase):
|
|||||||
self.assertIn("положительным числом", result.stderr)
|
self.assertIn("положительным числом", result.stderr)
|
||||||
|
|
||||||
|
|
||||||
class MachineConfigTest(unittest.TestCase):
|
class MachineConfigCliTest(unittest.TestCase):
|
||||||
|
"""Машинный конфиг проверяем через команду: код выхода и путь в ошибке."""
|
||||||
|
|
||||||
VALID = (
|
VALID = (
|
||||||
"[proxy]\ntype = external\nname = Test\naddress = 127.0.0.1\n"
|
"[proxy]\ntype = external\nname = Test\naddress = 127.0.0.1\n"
|
||||||
"port = 12334\nno_proxy_extra =\n"
|
"port = 12334\nno_proxy_extra =\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
def parse(self, text):
|
def setUp(self):
|
||||||
return MODULE["parse_config"](text)
|
temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(temporary.cleanup)
|
||||||
|
self.root = Path(temporary.name)
|
||||||
|
self.path = self.root / "proxy-client/config"
|
||||||
|
self.path.parent.mkdir()
|
||||||
|
|
||||||
def test_valid_config(self):
|
def start_with(self, text):
|
||||||
config = self.parse(self.VALID.replace("no_proxy_extra =", "no_proxy_extra = a, b"))
|
# start с type=external отказывает сразу после чтения конфига:
|
||||||
self.assertEqual(
|
# сеть и Xray не нужны.
|
||||||
("external", "Test", "127.0.0.1", 12334, ("a", "b")),
|
self.path.write_text(text, encoding="utf-8")
|
||||||
(config.proxy_type, config.name, config.address, config.port, config.no_proxy_extra),
|
return subprocess.run(
|
||||||
|
[str(CLI), "start"],
|
||||||
|
env=dict(os.environ, XDG_CONFIG_HOME=str(self.root), PYTHONDONTWRITEBYTECODE="1"),
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=15,
|
||||||
|
check=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_invalid_structure_and_values(self):
|
def test_invalid_configs_are_rejected_with_path(self):
|
||||||
cases = {
|
cases = {
|
||||||
|
"not ini": "not an ini\n",
|
||||||
"unknown section": self.VALID + "[other]\nkey = value\n",
|
"unknown section": self.VALID + "[other]\nkey = value\n",
|
||||||
"unknown key": self.VALID + "extra = value\n",
|
"unknown key": self.VALID + "extra = value\n",
|
||||||
"duplicate key": self.VALID + "port = 12335\n",
|
"duplicate key": self.VALID + "port = 12335\n",
|
||||||
@@ -210,33 +227,20 @@ class MachineConfigTest(unittest.TestCase):
|
|||||||
"no_proxy_extra =", "no_proxy_extra = one.example,,two.example"
|
"no_proxy_extra =", "no_proxy_extra = one.example,,two.example"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
for label, text in cases.items():
|
|
||||||
with self.subTest(label):
|
|
||||||
# Ошибки синтаксиса INI приходят от configparser; load_config
|
|
||||||
# добавляет к ним путь к файлу.
|
|
||||||
with self.assertRaises((ProxyError, MODULE["configparser"].Error)):
|
|
||||||
self.parse(text)
|
|
||||||
|
|
||||||
def test_addresses(self):
|
|
||||||
for address in ("127.0.0.1", "2001:db8::1", "proxy-1.example", "trailing.example."):
|
|
||||||
with self.subTest(address):
|
|
||||||
self.assertEqual(
|
|
||||||
address, self.parse(self.VALID.replace("127.0.0.1", address)).address
|
|
||||||
)
|
|
||||||
for address in ("http://proxy", "[::1]", "has space", "proxy/path", "user@proxy",
|
for address in ("http://proxy", "[::1]", "has space", "proxy/path", "user@proxy",
|
||||||
"прокси.example", "fe80::1%eth0"):
|
"прокси.example", "fe80::1%eth0"):
|
||||||
with self.subTest(address):
|
cases["address " + address] = self.VALID.replace("127.0.0.1", address)
|
||||||
with self.assertRaisesRegex(ProxyError, "address"):
|
for label, text in cases.items():
|
||||||
self.parse(self.VALID.replace("127.0.0.1", address))
|
with self.subTest(label):
|
||||||
|
result = self.start_with(text)
|
||||||
|
self.assertEqual(1, result.returncode)
|
||||||
|
self.assertIn("Ошибка: машинный конфиг {}:".format(self.path), result.stderr)
|
||||||
|
|
||||||
def test_load_error_names_config_file(self):
|
def test_valid_addresses_are_accepted(self):
|
||||||
with tempfile.TemporaryDirectory() as root:
|
for address in ("127.0.0.1", "2001:db8::1", "proxy-1.example", "trailing.example."):
|
||||||
path = Path(root) / "proxy-client/config"
|
with self.subTest(address):
|
||||||
path.parent.mkdir()
|
result = self.start_with(self.VALID.replace("127.0.0.1", address))
|
||||||
path.write_text("not an ini\n", encoding="utf-8")
|
self.assertIn("только для type=xray", result.stderr)
|
||||||
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": root}):
|
|
||||||
with self.assertRaisesRegex(ProxyError, str(path)):
|
|
||||||
MODULE["load_config"]()
|
|
||||||
|
|
||||||
|
|
||||||
UUID = "11111111-2222-4333-8444-555555555555"
|
UUID = "11111111-2222-4333-8444-555555555555"
|
||||||
@@ -538,6 +542,8 @@ class XrayInstallTest(unittest.TestCase):
|
|||||||
"supervisor_pid": os.getpid(),
|
"supervisor_pid": os.getpid(),
|
||||||
"xray_pid": os.getpid(),
|
"xray_pid": os.getpid(),
|
||||||
"xray_path": os.path.realpath("/proc/self/exe"),
|
"xray_path": os.path.realpath("/proc/self/exe"),
|
||||||
|
"address": "127.0.0.1",
|
||||||
|
"port": 1,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"""Команда proxy-client с управляемым Xray: argv → код выхода, вывод, файлы, процессы."""
|
"""Команда proxy-client с управляемым Xray: argv → код выхода, вывод, файлы, процессы."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -21,6 +22,12 @@ PROFILE = (
|
|||||||
"vless://{}@cdn.example:8443?type=xhttp&security=tls&encryption=none"
|
"vless://{}@cdn.example:8443?type=xhttp&security=tls&encryption=none"
|
||||||
"&sni=tls.example&fp=firefox&alpn=h2&host=host.example&path={}#Synthetic"
|
"&sni=tls.example&fp=firefox&alpn=h2&host=host.example&path={}#Synthetic"
|
||||||
).format(UUID, quote(PATH_SECRET, safe=""))
|
).format(UUID, quote(PATH_SECRET, safe=""))
|
||||||
|
PBK = "PUBLIC_KEY_SENTINEL_7f8a"
|
||||||
|
SID = "SHORT_ID_SENTINEL_9b3c"
|
||||||
|
REALITY_PROFILE = (
|
||||||
|
"vless://{}@edge.example:443?type=tcp&security=reality&encryption=none"
|
||||||
|
"&flow=xtls-rprx-vision&sni=front.example&fp=chrome&pbk={}&sid={}#Synthetic"
|
||||||
|
).format(UUID, PBK, SID)
|
||||||
READY = "остановка: Ctrl+C или proxy-client stop"
|
READY = "остановка: Ctrl+C или proxy-client stop"
|
||||||
|
|
||||||
# Поддельный Xray — симлинк на интерпретатор Python: тогда /proc/PID/exe
|
# Поддельный Xray — симлинк на интерпретатор Python: тогда /proc/PID/exe
|
||||||
@@ -73,7 +80,8 @@ class ManagedXrayTest(unittest.TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
temporary = tempfile.TemporaryDirectory()
|
temporary = tempfile.TemporaryDirectory()
|
||||||
self.addCleanup(temporary.cleanup)
|
self.addCleanup(temporary.cleanup)
|
||||||
self.root = Path(temporary.name)
|
self.root = Path(os.path.realpath(temporary.name))
|
||||||
|
self.addCleanup(self.kill_strays)
|
||||||
bin_dir = self.root / "bin"
|
bin_dir = self.root / "bin"
|
||||||
bin_dir.mkdir()
|
bin_dir.mkdir()
|
||||||
self.xray = bin_dir / "xray"
|
self.xray = bin_dir / "xray"
|
||||||
@@ -102,8 +110,8 @@ class ManagedXrayTest(unittest.TestCase):
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
def write_profile(self, path, mode=0o600):
|
def write_profile(self, path, mode=0o600, text=PROFILE):
|
||||||
path.write_text(PROFILE + "\n", encoding="utf-8")
|
path.write_text(text + "\n", encoding="utf-8")
|
||||||
path.chmod(mode)
|
path.chmod(mode)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
@@ -118,10 +126,10 @@ class ManagedXrayTest(unittest.TestCase):
|
|||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
def start(self, **environment):
|
def start(self, *arguments, **environment):
|
||||||
"""Запускает start и ждёт строку готовности; возвращает процесс."""
|
"""Запускает start и ждёт строку готовности; возвращает процесс."""
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
[str(CLI), "start"],
|
[str(CLI), "start"] + list(arguments),
|
||||||
cwd=str(self.root),
|
cwd=str(self.root),
|
||||||
env=dict(self.environment, **environment),
|
env=dict(self.environment, **environment),
|
||||||
text=True,
|
text=True,
|
||||||
@@ -141,14 +149,17 @@ class ManagedXrayTest(unittest.TestCase):
|
|||||||
watchdog.cancel()
|
watchdog.cancel()
|
||||||
self.fail("start не дошёл до готовности: " + process.communicate()[1])
|
self.fail("start не дошёл до готовности: " + process.communicate()[1])
|
||||||
|
|
||||||
|
def kill_strays(self):
|
||||||
|
# Все процессы теста (CLI и поддельный Xray) работают в self.root.
|
||||||
|
for entry in Path("/proc").iterdir():
|
||||||
|
try:
|
||||||
|
if os.readlink(str(entry / "cwd")) == str(self.root):
|
||||||
|
os.kill(int(entry.name), signal.SIGKILL)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
def kill_leftovers(self, process):
|
def kill_leftovers(self, process):
|
||||||
state = self.state_dir / "state.json"
|
self.kill_strays()
|
||||||
if state.exists():
|
|
||||||
pid = json.loads(state.read_text(encoding="utf-8"))["xray_pid"]
|
|
||||||
if pid_alive(pid):
|
|
||||||
os.kill(pid, signal.SIGKILL)
|
|
||||||
if process.poll() is None:
|
|
||||||
process.kill()
|
|
||||||
process.communicate()
|
process.communicate()
|
||||||
|
|
||||||
def finish(self, process):
|
def finish(self, process):
|
||||||
@@ -165,7 +176,7 @@ class ManagedXrayTest(unittest.TestCase):
|
|||||||
self.assertFalse((self.state_dir / "client.json").exists())
|
self.assertFalse((self.state_dir / "client.json").exists())
|
||||||
|
|
||||||
def assert_no_secrets(self, text):
|
def assert_no_secrets(self, text):
|
||||||
for secret in (UUID, PATH_SECRET, quote(PATH_SECRET, safe="")):
|
for secret in (UUID, PATH_SECRET, quote(PATH_SECRET, safe=""), PBK, SID):
|
||||||
self.assertNotIn(secret, text)
|
self.assertNotIn(secret, text)
|
||||||
|
|
||||||
def test_full_lifecycle(self):
|
def test_full_lifecycle(self):
|
||||||
@@ -230,7 +241,13 @@ class ManagedXrayTest(unittest.TestCase):
|
|||||||
self.state_dir.mkdir(parents=True)
|
self.state_dir.mkdir(parents=True)
|
||||||
(self.state_dir / "state.json").write_text(
|
(self.state_dir / "state.json").write_text(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{"supervisor_pid": 999999998, "xray_pid": 999999999, "xray_path": "/x"}
|
{
|
||||||
|
"supervisor_pid": 999999998,
|
||||||
|
"xray_pid": 999999999,
|
||||||
|
"xray_path": "/x",
|
||||||
|
"address": "127.0.0.1",
|
||||||
|
"port": self.port,
|
||||||
|
}
|
||||||
),
|
),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
@@ -292,6 +309,76 @@ class ManagedXrayTest(unittest.TestCase):
|
|||||||
self.assertEqual(0, process.returncode)
|
self.assertEqual(0, process.returncode)
|
||||||
self.assertIn("Предупреждение: внешний IP", stderr)
|
self.assertIn("Предупреждение: внешний IP", stderr)
|
||||||
|
|
||||||
|
def test_reality_secrets_stay_out_of_start_output(self):
|
||||||
|
profile = self.write_profile(self.root / "reality.url", text=REALITY_PROFILE)
|
||||||
|
process = self.start("-f", str(profile))
|
||||||
|
process.send_signal(signal.SIGINT)
|
||||||
|
stdout, stderr = self.finish(process)
|
||||||
|
self.assertIn("Секреты REALITY: скрыты", stdout)
|
||||||
|
self.assert_no_secrets(stdout + stderr)
|
||||||
|
|
||||||
|
def test_wrong_xray_version_is_refused_before_launch(self):
|
||||||
|
(self.root / "version").write_text("print('Xray 26.3.26 (fake)')\n", encoding="utf-8")
|
||||||
|
result = self.run_cli("start")
|
||||||
|
self.assertEqual(1, result.returncode)
|
||||||
|
self.assertIn("не сообщил версию Xray 26.3.27", result.stderr)
|
||||||
|
self.assert_no_state_files()
|
||||||
|
|
||||||
|
def test_replaced_xray_file_is_still_recognised_and_stopped(self):
|
||||||
|
# Нужен настоящий файл, а не симлинк: только тогда замена файла видна
|
||||||
|
# в /proc/PID/exe как «(deleted)». Копия интерпретатора находит свою
|
||||||
|
# стандартную библиотеку через ../lib.
|
||||||
|
interpreter = Path(os.path.realpath(sys.executable))
|
||||||
|
self.xray.unlink()
|
||||||
|
shutil.copy2(str(interpreter), str(self.xray))
|
||||||
|
(self.root / "lib").symlink_to(interpreter.parent.parent / "lib")
|
||||||
|
process = self.start()
|
||||||
|
xray_pid = self.xray_pid()
|
||||||
|
|
||||||
|
replacement = self.xray.with_name("xray.new")
|
||||||
|
shutil.copy2(str(self.xray), str(replacement))
|
||||||
|
os.replace(str(replacement), str(self.xray))
|
||||||
|
self.assertTrue(os.readlink("/proc/{}/exe".format(xray_pid)).endswith(" (deleted)"))
|
||||||
|
|
||||||
|
status = self.run_cli("status")
|
||||||
|
self.assertEqual(0, status.returncode, status.stderr)
|
||||||
|
stopped = self.run_cli("stop")
|
||||||
|
self.assertEqual(0, stopped.returncode, stopped.stderr)
|
||||||
|
self.finish(process)
|
||||||
|
self.assertFalse(pid_alive(xray_pid))
|
||||||
|
self.assert_no_state_files()
|
||||||
|
|
||||||
|
def test_changed_machine_port_is_refused_while_xray_runs(self):
|
||||||
|
process = self.start()
|
||||||
|
managed_port = self.port
|
||||||
|
with socket.socket() as other:
|
||||||
|
other.bind(("127.0.0.1", 0))
|
||||||
|
other.listen()
|
||||||
|
self.port = other.getsockname()[1]
|
||||||
|
self.write_machine_config()
|
||||||
|
marker = self.root / "child-ran"
|
||||||
|
for command in (("status",), ("env",), ("run", "--", "touch", str(marker))):
|
||||||
|
with self.subTest(command=command):
|
||||||
|
result = self.run_cli(*command)
|
||||||
|
self.assertEqual(1, result.returncode)
|
||||||
|
self.assertEqual("", result.stdout)
|
||||||
|
self.assertIn(str(self.config_dir / "config"), result.stderr)
|
||||||
|
self.assertIn(":{}".format(managed_port), result.stderr)
|
||||||
|
self.assertIn("proxy-client stop", result.stderr)
|
||||||
|
self.assertFalse(marker.exists())
|
||||||
|
process.send_signal(signal.SIGINT)
|
||||||
|
self.finish(process)
|
||||||
|
|
||||||
|
def test_bad_link_error_names_profile_file(self):
|
||||||
|
target = self.root / "real profile.url"
|
||||||
|
target.write_text("vless://not-a-link\n", encoding="utf-8")
|
||||||
|
target.chmod(0o600)
|
||||||
|
link = self.root / "link.url"
|
||||||
|
link.symlink_to(target)
|
||||||
|
result = self.run_cli("start", "-f", str(link))
|
||||||
|
self.assertEqual(1, result.returncode)
|
||||||
|
self.assertIn("Ошибка: файл профиля {}: ".format(target), result.stderr)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
Reference in new issue
Block a user