Compare commits

...
10 Commits
Author SHA1 Message Date
ddadminandClaude Opus 5.5 205e995f26 fix(proxy-client): порт свободен сразу после остановки по Ctrl+C
Проверка порта перед запуском открывала сокет без SO_REUSEADDR и
отказывала, пока на порту доживали закрытые соединения Xray (TIME_WAIT),
хотя сам Xray запустился бы. Живой слушатель флаг на Linux не обходит,
так что занятый порт по-прежнему обнаруживается.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 08:51:34 -04:00
ddadminandClaude Opus 5.5 b492f8f4eb fix(proxy-client): поддержка новых VLESS-ссылок Hiddify
Новые ссылки Hiddify пишут «/» перед параметрами, используют
VLESS-шифрование mlkem768x25519plus и flow=xtls-rprx-vision поверх XHTTP.
Разбор принимает эти ссылки; прежние форматы работают как раньше.
Vision поверх XHTTP разрешён только вместе с шифрованием.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 08:40:36 -04:00
Dmitry DementievandClaude Opus 5 a074568cd9 chore(gitignore): исключён кэш скиллов, синхронизируемых с claude.ai
- Зачем: Claude Code с v2.1.273 при старте сессии скачивает скиллы аккаунта
  claude.ai в ~/.claude/skills/synced/; ~/.claude/skills — симлинк на
  agents/.agents/skills, поэтому кэш попадал в репозиторий как
  неотслеживаемый каталог.
- Что: agents/.agents/skills/synced/ добавлен в .gitignore.
- Проверка: git status после правки чистый, кроме самого .gitignore.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:13:08 +03:00
Dmitry DementievandClaude Opus 5 e0048bb5b2 feat(skills): добавлен скилл visual-pr для описания PR
- Зачем: не было скилла про содержание описания PR — только gh-команды
  в github. Формат humanlayer/visual-pr совпадает с принятой структурой
  коммитов «Зачем / Что / Проверка» и опирается на уже установленный show-me.
- Что: адаптированная копия humanlayer/skills visual-pr. Убраны привязки
  к платформе HumanLayer (.humanlayer/tasks, permalink из хука), дубль
  show-me заменён ссылкой на установленный скилл, финальный отчёт свёрнут
  в одну строку. Скилл хостинг-независимый (glab, tea, gh), ручной вызов
  через disable-model-invocation, язык описания — по истории коммитов.
  Шаблон references/pr_description_template.md — без изменений.
  В .skill-lock.json не добавлен, чтобы npx skills update не перезатёр
  правки.
- Проверка: SKILL.md вычитан по правилам writing-for-agents (кэш --help
  и дубли шаблона убраны); ссылка ../show-me/SKILL.md резолвится через
  ~/.claude/skills. Прогон на реальном PR ещё не выполнен.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:06:54 +03:00
Dmitry DementievandClaude Sonnet 5 3b9f422acb docs(skills): урок про накладные расходы конвейера vs «сделать самому»
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 17:46:21 +03:00
Dmitry DementievandClaude Sonnet 5 53761f0a87 chore(opencode): удалён кастомный провайдер zai
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 17:45:27 +03:00
ddadmin 5f23fdda3c chore(opencode): удалён плагин superpowers
- Зачем: плагин больше не используется, а в OpenCode 2 он не грузится (V1-формат) и даёт ошибку на старте.
- Что: из opencode.json удалена опция plugin с репозиторием obra/superpowers.
- Проверка: opencode plugin list больше не показывает superpowers; JSON валиден; V1 и V2 читают конфиг как раньше.
2026-09-17 10:22:38 +03:00
Dmitry Dementiev 8008fb914e docs(skills): обновлён навык передачи контекста сессии
- Зачем: новому агенту нужна точка продолжения работы без пересказа существующих артефактов.
- Что: навык переименован в session-handoff; уточнены содержание записки и выбор пути, сохранён вызов из других навыков.
- Проверка: YAML и git diff --check пройдены; quick_validate.py отклоняет сохранённые поля argument-hint и disable-model-invocation.
2026-09-11 12:37:03 +03:00
Dmitry Dementiev fa90b2e817 docs(skills): упрощены правила сообщений коммитов
- Зачем: сохранён общий стиль сообщений без избыточных ограничений для модели.
- Что: тело стало гибким, уточнены условия вызова и требования к amend; убраны повторения и пошаговые инструкции.
- Проверка: quick_validate.py и git diff --check пройдены.
2026-09-11 12:24:52 +03:00
Dmitry Dementiev 0a8e7bb499 chore(skills): скилл retro исключён из версионирования
- Зачем:
  - retro входит в набор Matt Pocock и не должен храниться в этом репозитории.
- Что:
  - в agents/.agents/skills/.gitignore добавлен retro/.
- Проверка:
  - git status не показывает agents/.agents/skills/retro/.
2026-09-11 12:11:29 +03:00
12 changed files with 304 additions and 120 deletions

No files matched your search

+3
View File
@@ -31,3 +31,6 @@ __pycache__/
!/.claude/agents/ !/.claude/agents/
.skill-lock.json .skill-lock.json
# Кэш скиллов, синхронизируемых Claude Code с claude.ai (~/.claude/skills -> agents)
agents/.agents/skills/synced/
+1
View File
@@ -38,6 +38,7 @@ to-questionnaire/
wait-what/ wait-what/
wizard/ wizard/
writing-for-agents/ writing-for-agents/
retro/
# Skills, подключаемые в agents/ как внешние плагины (не версионируются здесь) # Skills, подключаемые в agents/ как внешние плагины (не версионируются здесь)
claude-team-review claude-team-review
@@ -464,3 +464,30 @@ codex-cli 0.146.0 и была неверна. Развели две провер
строк, вердикт ревью постановки) дали больше всего. Разведку просить строк, вердикт ревью постановки) дали больше всего. Разведку просить
индексом до 80 строк с путями; вердикты читать только строками индексом до 80 строк с путями; вердикты читать только строками
находок через grep. находок через grep.
## 2026-09-11: накладные расходы конвейера против «сделать самому» (t2-ml-forecast, #36)
Тикет #36: условие с инерцией, семь файлов, все развилки закрыты
ответами бизнеса и оркестратором до старта. Конвейер развёрнут по
плейбуку: файл-задача, craftsman исполнителем, Astra ревьюером.
- **Замер контекста оркестратора (тысячи токенов, оценка).** Конвейер:
чтение плейбука и справочников 22, сбор фактуры 8, файл-задача и
промпты 5, приёмка ~10, итого ~45. Своими руками: чтение кода 15,
правки и тесты 10, приёмка 5, итого ~30. Разница целиком в
фиксированной плате за плейбук: вторая задача в той же сессии стоит
конвейером ~20.
- **Слова владельца.** У Astra как оркестратора накладные расходы на
постановку, оркестрацию и приёмку во многих случаях превышают
экономию от делегирования; её оркестрация тяжелее, чем у Fable, хотя и
мощнее. Экономика субагентов полезна для ревью, где нужен чистый
контекст, и редко для разработки.
- **Следствие для вводных скилла (переписать позже):** одиночная задача
с закрытыми развилками до десятка файлов делается оркестратором
самостоятельно, конвейер сводится к слепому ревью. Исполнитель-субагент
оправдан при нескольких задачах в сессии или при открытых решениях,
когда контекст оркестратора нужен для них, а не для кода.
- **Лишняя плата этого прогона:** часть фактуры ушла на ловушки Codex
(snap `uv`, зависание `TestClient` в песочнице), хотя исполнителем в
итоге выбран Claude. Исполнителя выбирать до чтения справочника по
механике вызова.
@@ -1,96 +1,37 @@
--- ---
name: conventional-commits name: conventional-commits
description: "ОБЯЗАТЕЛЬНО вызывай этот навык каждый раз, когда пользователь просит сделать коммит, написать сообщение для git, зафиксировать изменения или проанализировать git diff. Навык содержит критически важные правила формата Conventional Commits" description: "Формат сообщений Conventional Commits. Применяй при создании коммита, amend или подготовке сообщения коммита."
--- ---
# Твоя роль # Формат сообщения
Ты — педантичный Release Manager и Senior Engineer. Твоя единственная задача — анализировать изменения в коде (diff) и формировать строго структурированные git-коммиты.
# Правила заголовка ## Заголовок
* Формат: `<type>(<scope>): <описание>`
* Длина: не более 72 символов.
* Типы (type): feat, fix, refactor, docs, test, chore, ci, perf.
* Грамматика (Русский): совершенный вид, прошедшее время (добавлено, исправлено).
* Грамматика (Английский): повелительное наклонение (add, fix, update). Без точки в конце.
* Технические термины (SQL, DWH, PostgreSQL, Airflow) не переводи.
# Шаблон тела коммита (Markdown-список) - Формат: `<type>(<scope>): <описание>`. Указывай scope, когда он помогает обозначить область изменения.
- Зачем: - Типы: feat, fix, refactor, docs, test, chore, ci, perf, build, revert.
- [Бизнес-причина или техническая необходимость] - До 72 символов, без точки в конце.
- Что: - По умолчанию пиши по-русски, в совершенном виде и прошедшем времени: «добавлено», «исправлено». Если нужен английский — используй повелительное наклонение: add, fix, update.
- [Ключевое изменение 1]
- [Ключевое изменение 2, если есть]
- Проверка:
- [Как проверить: команда, тест или скрипт]
Требования к телу: ## Тело
* Всегда используй именно список с маркерами `-`, как в шаблоне выше.
* Обязательные секции: `Зачем`, `Что`, `Проверка`.
* Внутри каждой секции минимум 1 подпункт.
* В секции `Что` обычно 1–3 подпункта с фактами по diff.
# Правило переносов строк Для простой правки достаточно заголовка. Когда нужны подробности, ориентируйся на короткий список «Зачем / Что / Проверка»:
В тексте коммита должны быть реальные переносы строк. Не пиши буквальные `\n`, `\r`, `\t` как часть текста сообщения.
# Специфические правила для Amend - Зачем: причина изменения из контекста задачи.
Если пользователь просит дополнить коммит (`amend`), твоя задача — обновить сообщение так, чтобы оно выглядело как единое целое. - Что: существенные изменения, которые дополняют заголовок.
- Проверка: что проверено и с каким результатом; предложенную проверку явно обозначай как ещё не выполненную.
1. **Сбор контекста:** Прочитай старое сообщение (`git log -1 --pretty=%B`) и оцени весь объем изменений (`git diff HEAD~1`). Сохраняй этот порядок и названия там, где они уместны. Включай только содержательные пункты; объём пояснений соразмеряй изменению. Если причина неизвестна, опирайся на подтверждённые факты.
2. **Секция "Зачем":** НИКОГДА не меняй эту секцию из старого коммита.
3. **Секция "Что":** Сгенерируй этот список ЗАНОВО для всего итогового кода.
4. **Секция "Проверка":** Оставь старую, либо обнови, если тесты изменились.
# Примеры Пример:
Пример 1 (Русский, новая фича, PowerShell): ```text
@'
feat(sql): добавлен скрипт валидации данных для DWH слоя
- Зачем:
- необходима проверка целостности и уникальности перед загрузкой в хранилище.
- Что:
- создан файл 04_validation.sql.
- добавлены проверки на NULL.
- Проверка:
- psql -f dwh-modeling/sql/04_validation.sql.
'@ | git commit -F-
Пример 2 (Русский, багфикс):
git commit -F- <<'EOF'
fix(airflow): увеличен таймаут подключения ClickHouse fix(airflow): увеличен таймаут подключения ClickHouse
- Зачем: - Зачем: ночная загрузка прерывалась по таймауту подключения.
- DAG падал с ошибкой таймаута при ночной пакетной загрузке. - Что: conn_timeout увеличен с 30 до 120 секунд.
- Что: - Проверка: ещё не выполнена; нужен тестовый запуск DAG.
- параметр conn_timeout изменен с 30 до 120 секунд. ```
- Проверка:
- тестовый запуск таски в Airflow UI.
EOF
Пример 3 (Английский, lang:en): ## Amend
git commit -F- <<'EOF'
refactor(docker): optimize postgres-bookings image size
- Зачем: Сообщение описывает весь итоговый состав коммита. Перепиши его как единое целое; сохраняй исходную причину, пока она соответствует изменениям. Сведения о проверках должны относиться к итоговой версии.
- reduce deployment time and registry storage costs.
- Что:
- switched base image to alpine.
- removed intermediate build artifacts.
- Проверка:
- docker build and check image size.
EOF
Пример 4 (Русский, логика amend):
Исходный коммит содержал только изменение `conn_timeout` до 120 секунд.
Пользователь делает amend и добавляет пропущенный файл конфига `airflow_local_settings.py`.
Сообщение (в `.git_commit_msg.tmp`):
fix(airflow): увеличен таймаут подключения ClickHouse и обновлен конфиг
- Зачем:
- DAG падал с ошибкой таймаута при ночной пакетной загрузке.
- Что:
- параметр conn_timeout изменен с 30 до 120 секунд.
- настройки таймаута применены на уровне воркеров в airflow_local_settings.py.
- Проверка:
- тестовый запуск таски в Airflow UI.
@@ -0,0 +1,37 @@
---
name: session-handoff
description: Write a handoff for a fresh session when requested by the user or another skill.
argument-hint: "What should the next session focus on?"
disable-model-invocation: false
---
# Session handoff
Напиши записку, по которой новый агент сможет продолжить работу.
Если передан аргумент, используй его как фокус следующей сессии.
Сохрани записку по правилам проекта; если путь не задан —
во временный каталог ОС. Верни путь к записке.
## Содержание
Обеспечь следующему агенту понимание:
- цели пользователя и действующих ограничений;
- выполненной и оставшейся работы, препятствий;
- принятых решений и открытых вопросов;
- следующего действия.
Не дублируй содержание существующих артефактов: спецификаций,
планов, ADR, задач, коммитов и diff. Ссылайся на них по пути
или URL. В самой записке сохраняй то, что иначе останется
только в разговоре: уточнения пользователя, причины решений,
результаты проверок и незавершённые рассуждения.
Объём и структуру выбирай по задаче.
Если для продолжения нужны навыки, укажи их имена
и когда их применять.
Исключи секретные значения и ненужные для продолжения персональные данные.
При необходимости оставь имена переменных и пути к настройкам.
@@ -1,16 +0,0 @@
---
name: session-state-handoff
description: Keep current session state and possibility of resume after compaction or in fresh session.
argument-hint: "How to keep current work after interruption?"
disable-model-invocation: false
---
Write a handoff document summarising the current conversation so a fresh agent can continue the work. Save to the temporary directory of the user's OS or where is prescribed in AGENTS.md or other project config.
Include a "suggested skills" section in the document, naming which skills the next agent should call the Skill tool for.
Do not duplicate content already captured in other artifacts (specs, plans, ADRs, issues, commits, diffs). Reference them by path or URL instead.
Redact any sensitive information, such as API keys, passwords, or personally identifiable information.
If the user passed arguments, treat them as a description of what the next session will focus on and tailor the doc accordingly.
+31
View File
@@ -0,0 +1,31 @@
---
name: visual-pr
description: Describe the current branch's pull request for a reviewer — one-sentence why, reviewer notes, and a show-me-style change outline.
disable-model-invocation: true
---
# Describe a Pull Request
Create or update the pull request (merge request on GitLab) for the current branch so a reviewer sees why the change exists and the shape of the implementation. Work through the hosting's CLI (`glab`, `tea`, `gh`); without one, hand the finished description to the user to paste.
Adapted from [humanlayer/skills visual-pr](https://github.com/humanlayer/skills/tree/main/plugins/visual-pr/skills/visual-pr).
## Workflow
1. Identify or create the pull request:
- Check the current branch for a PR.
- If none exists, review `git status --short --branch` and the branch's commits; commit task-related changes when needed, push the branch with an upstream, and create the PR.
- Ask the user to select a PR only when the current branch has no relevant work and there is no safe current-branch PR to create.
2. Gather only the context needed to explain the change:
- Read the ticket and any relevant task artifacts.
- Read the complete PR diff and enough surrounding code to understand behavior and ownership.
- Read `../show-me/SKILL.md` for the visual vocabulary used in the change outline.
3. Write the description by filling `references/pr_description_template.md`; the PR body is the filled template. Write in the language of the repository's commit history.
4. Publish it to the PR and confirm the update succeeded.
5. Report the PR URL, a 2-3 sentence summary of the change, and the changed files.
Write as one human talking to another: simple, coherent, concise language.
@@ -0,0 +1,53 @@
[{RELEVANT LINK}]({RELEVANT LINK}) | [{RELEVANT LINK 2}]({RELEVANT LINK 2}) | ...
## Why the change
{Exactly one sentence explaining the problem this PR solves and what becomes possible after it ships.}
## Special things to note
- {List 1-3 reviewer-relevant warnings, migrations, constraints, deliberate omissions, or surprising decisions. Use "None." when there are no special considerations.}
## Change outline
{Use the smallest combination of the following `/show-me`-style views that explains the implementation. Prefer `diff` blocks for changes to existing shapes and complete blocks for mostly new shapes. Do not include headings for views that are not relevant.}
{...short-description...}
```diff|sql|json|etc
{Show changed SQL tables, important columns and relationships, and endpoint request/response contracts.}
```
{...short-description...}
```typescript|python|etc
{show new data structures that are key to the implementation}
```
{...short-description...}
```diff
{Show concise pseudocode for the changed behavior.}
```
{...short-description...}
```diff
{Show a shallow file tree with changed responsibilities.}
```
{...short-description...}
```diff
{Show changed React component trees, important hooks or state, and package boundaries.}
```
{...short-description...}
```diff
{Show changed call trees, call stacks, control flow, or data flow.}
```
{Tell the story in the order that makes it easiest to understand. It may make sense to show files first, or it may make sense to establish a data structure, SQL table, or API contract first. All views and subheadings are optional. Use only the views that help explain the pr, and name or order them based on the change rather than a fixed template. It should be written as one human would write to another. Use `diff` for a focused change to an existing shape. Show the complete target shape in a language-specific or `text` block when it is new, high-level, or clearer without diff notation.}
-20
View File
@@ -1,6 +1,5 @@
{ {
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"plugin": ["superpowers@git+https://github.com/obra/superpowers.git"],
"default_agent": "safe-plan", "default_agent": "safe-plan",
"permission": { "permission": {
"write": "ask", "write": "ask",
@@ -42,24 +41,5 @@
"enabled": true "enabled": true
} }
}, },
"provider": {
"zai": {
"npm": "@ai-sdk/openai-compatible",
"name": "GLM Coding Plan (custom)",
"options": {
"baseURL": "https://api.z.ai/api/coding/paas/v4",
"apiKey": "{env:ZAI_API_KEY}"
},
"models": {
"glm-5.1": {
"name": "GLM-5.1",
"limit": {
"context": 200000,
"output": 65536
}
}
}
}
},
"share": "manual" "share": "manual"
} }
+15 -4
View File
@@ -442,11 +442,12 @@ def parse_vless(url):
parsed = urlsplit(url) parsed = urlsplit(url)
except ValueError: except ValueError:
raise ProxyError("неверная VLESS-ссылка") raise ProxyError("неверная VLESS-ссылка")
# Новые ссылки Hiddify пишут «/» перед «?»: ...@SERVER:PORT/?type=...
if ( if (
parsed.scheme != "vless" parsed.scheme != "vless"
or not parsed.netloc or not parsed.netloc
or parsed.password is not None or parsed.password is not None
or parsed.path or parsed.path not in ("", "/")
): ):
raise ProxyError("ожидалась одна ссылка vless://UUID@SERVER:PORT") raise ProxyError("ожидалась одна ссылка vless://UUID@SERVER:PORT")
try: try:
@@ -464,8 +465,11 @@ def parse_vless(url):
validate_address(server) validate_address(server)
query = parse_query(parsed.query) query = parse_query(parsed.query)
required(query, "type", "security", "encryption", "sni", "fp") required(query, "type", "security", "encryption", "sni", "fp")
if query["encryption"] != "none": # Кроме none, Xray принимает VLESS-шифрование вида
raise ProxyError("поддерживается только encryption=none") # mlkem768x25519plus.РЕЖИМ.RTT.КЛЮЧ...; подробности проверит xray run -test.
encrypted = query["encryption"] != "none"
if encrypted and not query["encryption"].startswith("mlkem768x25519plus."):
raise ProxyError("поддерживается только encryption=none или mlkem768x25519plus")
if query.get("flow") not in (None, "xtls-rprx-vision"): if query.get("flow") not in (None, "xtls-rprx-vision"):
raise ProxyError("поддерживается только flow=xtls-rprx-vision") raise ProxyError("поддерживается только flow=xtls-rprx-vision")
if query.get("headerType", "none") != "none": if query.get("headerType", "none") != "none":
@@ -485,7 +489,8 @@ def parse_vless(url):
raise ProxyError("параметры профиля несовместимы с TCP/REALITY") raise ProxyError("параметры профиля несовместимы с TCP/REALITY")
elif query["type"] == "xhttp" and query["security"] == "tls": elif query["type"] == "xhttp" and query["security"] == "tls":
required(query, "host", "path") required(query, "host", "path")
if "flow" in query or "pbk" in query or "sid" in query: # Vision поверх XHTTP работает только внутри VLESS-шифрования.
if ("flow" in query and not encrypted) or "pbk" in query or "sid" in query:
raise ProxyError("параметры профиля несовместимы с XHTTP/TLS") raise ProxyError("параметры профиля несовместимы с XHTTP/TLS")
if "pcs" in query: if "pcs" in query:
hashes = list_value(query["pcs"]) hashes = list_value(query["pcs"])
@@ -1188,6 +1193,8 @@ def safe_summary(profile, local_port):
), ),
flush=True, flush=True,
) )
if profile.query["encryption"] != "none":
print("VLESS-шифрование: mlkem768x25519plus", flush=True)
print("UUID и ключи: скрыты", flush=True) print("UUID и ключи: скрыты", flush=True)
@@ -1310,6 +1317,10 @@ def check_port_available(address, port):
family = socket.AF_INET6 if address.version == 6 else socket.AF_INET family = socket.AF_INET6 if address.version == 6 else socket.AF_INET
try: try:
with socket.socket(family, socket.SOCK_STREAM) as listener: with socket.socket(family, socket.SOCK_STREAM) as listener:
# Без флага bind отказывает, пока после остановки Xray на порту
# доживают закрытые соединения (TIME_WAIT); живой слушатель
# на Linux флаг не обходит, и занятый порт по-прежнему виден.
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind((str(address), port)) listener.bind((str(address), port))
except OSError: except OSError:
raise ProxyError("локальный порт {} занят или недоступен".format(port)) raise ProxyError("локальный порт {} занят или недоступен".format(port))
+6
View File
@@ -345,6 +345,12 @@ source ~/.config/proxy-client/shell.bash
повторные и несовместимые query-параметры отклоняются. XHTTP поддерживает ALPN повторные и несовместимые query-параметры отклоняются. XHTTP поддерживает ALPN
`http/1.1`, `h2` и `h3`; `extra` должен быть JSON-объектом без повторных ключей. `http/1.1`, `h2` и `h3`; `extra` должен быть JSON-объектом без повторных ключей.
Кроме `encryption=none`, принимается VLESS-шифрование
`encryption=mlkem768x25519plus.…` из новых ссылок Hiddify; остальную часть
значения проверяет сам Xray. Только с таким шифрованием XHTTP допускает
`flow=xtls-rprx-vision`. Косая черта перед параметрами (`…:443/?type=…`)
допустима, другой путь в ссылке — нет.
Xray 26.3.27 использует `pcs` и `vcn`, а устаревшие `allowInsecure` и Xray 26.3.27 использует `pcs` и `vcn`, а устаревшие `allowInsecure` и
`insecure` не переносит. Истинное legacy-значение даёт предупреждение, но `insecure` не переносит. Истинное legacy-значение даёт предупреждение, но
обычная проверка сертификата остаётся включённой. обычная проверка сертификата остаётся включённой.
+110
View File
@@ -5,6 +5,7 @@ import json
import hashlib import hashlib
import os import os
import runpy import runpy
import socket
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -1231,5 +1232,114 @@ class XrayInstallTest(unittest.TestCase):
self.assertIn("уже установлен", result.stdout) self.assertIn("уже установлен", result.stdout)
class VlessProfileTest(unittest.TestCase):
UUID = "11111111-2222-4333-8444-555555555555"
ENCRYPTION = "mlkem768x25519plus.native.0rtt.SYNTHETIC_CLIENT_KEY"
EXTRA = (
"%7B%22downloadSettings%22%3A%7B%22address%22%3A%22cdn.example.invalid%22"
"%2C%22port%22%3A443%2C%22network%22%3A%22xhttp%22%7D%7D"
)
OLD_REALITY = (
"vless://{}@example.invalid:443?type=tcp&security=reality&encryption=none"
"&flow=xtls-rprx-vision&sni=front.example.invalid&fp=chrome"
"&pbk=SYNTHETIC_PUBLIC_KEY&sid=SYNTHETIC_SHORT_ID&headerType=none#old"
).format(UUID)
OLD_XHTTP = (
"vless://{}@example.invalid:443?type=xhttp&security=tls&encryption=none"
"&sni=cdn.example.invalid&fp=chrome&host=cdn.example.invalid&path=%2Fp"
"&alpn=h2&mode=auto"
).format(UUID)
# Формат Hiddify с VLESS-шифрованием: «/» перед «?» и flow поверх XHTTP.
NEW_XHTTP = (
"vless://{}@192.0.2.1:443/?hiddify=1&fp=chrome&sni=cdn.example.invalid"
"&security=tls&alpn=h3&type=xhttp&mode=auto&path=%2Fp"
"&host=cdn.example.invalid&extra={}&flow=xtls-rprx-vision"
"&encryption={}#xhttp%20vless%20QUIC"
).format(UUID, EXTRA, ENCRYPTION)
def setUp(self):
self.module = runpy.run_path(str(CLI))
def outbound(self, url):
profile = self.module["parse_vless"](url)
config = self.module["build_xray_config"](profile, "127.0.0.1", 12334)
return config["outbounds"][0]
def test_old_formats_still_parse(self):
reality = self.outbound(self.OLD_REALITY)
self.assertEqual("none", reality["settings"]["encryption"])
self.assertEqual("reality", reality["streamSettings"]["security"])
xhttp = self.outbound(self.OLD_XHTTP)
self.assertEqual("none", xhttp["settings"]["encryption"])
self.assertNotIn("flow", xhttp["settings"])
self.assertEqual(["h2"], xhttp["streamSettings"]["tlsSettings"]["alpn"])
def test_new_hiddify_xhttp_with_vless_encryption_parses(self):
outbound = self.outbound(self.NEW_XHTTP)
settings = outbound["settings"]
stream = outbound["streamSettings"]
self.assertEqual(self.ENCRYPTION, settings["encryption"])
self.assertEqual("xtls-rprx-vision", settings["flow"])
self.assertEqual("192.0.2.1", settings["address"])
self.assertEqual(["h3"], stream["tlsSettings"]["alpn"])
self.assertEqual(
"cdn.example.invalid",
stream["xhttpSettings"]["extra"]["downloadSettings"]["address"],
)
def test_incompatible_profiles_are_still_rejected(self):
cases = {
"flow over XHTTP without encryption": self.OLD_XHTTP
+ "&flow=xtls-rprx-vision",
"unknown encryption": self.NEW_XHTTP.replace(
"mlkem768x25519plus.", "aes-128-gcm."
),
"non-root path": self.NEW_XHTTP.replace(":443/?", ":443/sub?"),
}
for label, url in cases.items():
with self.subTest(label=label):
with self.assertRaises(self.module["ProxyError"]):
self.module["parse_vless"](url)
class PortCheckTest(unittest.TestCase):
def setUp(self):
self.module = runpy.run_path(str(CLI))
self.check = self.module["check_port_available"]
self.address = self.module["ipaddress"].ip_address("127.0.0.1")
@staticmethod
def listener():
# Как у Xray: Go ставит SO_REUSEADDR на слушающий сокет.
sock = socket.socket()
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", 0))
sock.listen()
return sock
def test_port_is_free_right_after_xray_closed_its_connections(self):
listener = self.listener()
port = listener.getsockname()[1]
client = socket.create_connection(("127.0.0.1", port))
accepted, _peer = listener.accept()
# После Ctrl+C Xray первым закрывает соединения, и ядро держит их
# «хвосты» на локальном порту ещё около минуты.
accepted.close()
listener.close()
try:
self.check(self.address, port)
finally:
client.close()
def test_live_listener_is_still_reported_as_busy(self):
listener = self.listener()
try:
with self.assertRaisesRegex(self.module["ProxyError"], "занят"):
self.check(self.address, listener.getsockname()[1])
finally:
listener.close()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()