fix(admin): закрыть обещания панели, которые продукт не выполнял
Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.
Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».
Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.
Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.
Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.
Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.
Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.
docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+39
-15
@@ -47,25 +47,49 @@
|
||||
persistent path. У мутирующей фазы ровно один владелец — оркестратор:
|
||||
`install.sh` проверяет и передаёт управление, не изменяя ничего сам.
|
||||
Очистка предыдущей установки — отдельная явная операция оператора,
|
||||
см. [14-legacy-cleanup.md](14-legacy-cleanup.md).
|
||||
см. [operations/14-legacy-cleanup.md](operations/14-legacy-cleanup.md).
|
||||
8. Выдача доступа пользователям, Telegram-бот, billing, backend профилей и похожие контуры **не входят** в этот baseline.
|
||||
|
||||
## Состав документов
|
||||
|
||||
1. [01-architecture-baseline.md](01-architecture-baseline.md)
|
||||
2. [02-build-layer-and-package.md](02-build-layer-and-package.md)
|
||||
3. [03-server-hysteria2.md](03-server-hysteria2.md)
|
||||
4. [04-admin-panel.md](04-admin-panel.md)
|
||||
5. [05-client-and-access-scope.md](05-client-and-access-scope.md)
|
||||
6. [06-speed-limits-and-congestion.md](06-speed-limits-and-congestion.md)
|
||||
7. [07-systemd-and-firewall.md](07-systemd-and-firewall.md)
|
||||
8. [08-orchestrator-spec.md](08-orchestrator-spec.md)
|
||||
9. [09-post-install-env.md](09-post-install-env.md)
|
||||
10. [10-access-layer-out-of-scope.md](10-access-layer-out-of-scope.md)
|
||||
11. [11-testing-and-acceptance.md](11-testing-and-acceptance.md)
|
||||
12. [12-operations-and-troubleshooting.md](12-operations-and-troubleshooting.md)
|
||||
13. [13-production-runbook.md](13-production-runbook.md)
|
||||
14. [14-legacy-cleanup.md](14-legacy-cleanup.md)
|
||||
Документы разложены по слою, к которому относятся. Двузначный префикс в имени —
|
||||
стабильный идентификатор документа: под ним на него ссылаются CHANGELOG,
|
||||
релизные гейты и сообщения оркестратора, поэтому при переносе в каталоги он
|
||||
сохранён.
|
||||
|
||||
### Архитектура и рамки
|
||||
|
||||
- [architecture/01-architecture-baseline.md](architecture/01-architecture-baseline.md) — baseline-модель двух слоёв
|
||||
- [architecture/03-server-hysteria2.md](architecture/03-server-hysteria2.md) — серверный транспорт
|
||||
- [architecture/05-client-and-access-scope.md](architecture/05-client-and-access-scope.md) — граница клиента
|
||||
- [architecture/06-speed-limits-and-congestion.md](architecture/06-speed-limits-and-congestion.md) — ограничения скорости
|
||||
- [architecture/10-access-layer-out-of-scope.md](architecture/10-access-layer-out-of-scope.md) — что вне baseline
|
||||
|
||||
### Сборка
|
||||
|
||||
- [build/02-build-layer-and-package.md](build/02-build-layer-and-package.md) — builder layer, состав пакета, требования к сборочной машине
|
||||
|
||||
### Runtime на target
|
||||
|
||||
- [runtime/08-orchestrator-spec.md](runtime/08-orchestrator-spec.md) — спецификация оркестратора
|
||||
- [runtime/07-systemd-and-firewall.md](runtime/07-systemd-and-firewall.md) — systemd и nftables
|
||||
- [runtime/09-post-install-env.md](runtime/09-post-install-env.md) — post-install состояние
|
||||
|
||||
### Панель
|
||||
|
||||
- [admin/04-admin-panel.md](admin/04-admin-panel.md) — HY2XS admin
|
||||
- [admin/15-ui-contracts.md](admin/15-ui-contracts.md) — контракты панели: иконки, структурированные ошибки, необязательные поля, атрибуция
|
||||
|
||||
### Эксплуатация
|
||||
|
||||
- [operations/13-production-runbook.md](operations/13-production-runbook.md) — production runbook
|
||||
- [operations/12-operations-and-troubleshooting.md](operations/12-operations-and-troubleshooting.md) — операции и разбор отказов
|
||||
- [operations/14-legacy-cleanup.md](operations/14-legacy-cleanup.md) — очистка установки предыдущего поколения
|
||||
|
||||
### Проверки
|
||||
|
||||
- [testing/](testing/README.md) — набор проверок по слоям (бывший `11-testing-and-acceptance.md`)
|
||||
- [acceptance/](acceptance/README.md) — отчёты о фактических прогонах приёмки
|
||||
|
||||
История изменений проекта — в [CHANGELOG.md](../CHANGELOG.md).
|
||||
|
||||
|
||||
@@ -0,0 +1,674 @@
|
||||
# HY2XS 1.0.0-rc1 — отчёт build/host acceptance
|
||||
|
||||
**Дата прогона:** 2026-09-01
|
||||
**Вердикт:** `RC ACCEPTED WITH RELEASE-REQUIRED UX FIXES`
|
||||
|
||||
Прогон выполнялся не как статический аудит исходного кода, а как фактическая
|
||||
release/host acceptance RC-сборки: сборка артефакта, установка на
|
||||
переустановленный Debian 13 и проверка работающего сервера.
|
||||
|
||||
Release candidate:
|
||||
|
||||
```text
|
||||
Version: 1.0.0-rc1
|
||||
Source commit: a1f0db22c2f6c0b0436789b64e82f53bfa314327
|
||||
Artifact: hy2xs-install-1.0.0.tar.gz
|
||||
SHA-256: 7fd18a34f56ebb7e9cf62f579e857d6ed3f6a9711075a17da22818a4b079683c
|
||||
Target: Debian 13 / amd64
|
||||
Hysteria: v2.12.2
|
||||
Default obfs: gecko
|
||||
Fallback obfs: salamander
|
||||
```
|
||||
|
||||
> Публичный IPv4 тестового хоста в отчёте заменён на `198.51.100.10`
|
||||
> (RFC 5737, документационный диапазон). Доменное имя и номер SSH-порта
|
||||
> оставлены: без них шаги прогона невоспроизводимы.
|
||||
|
||||
---
|
||||
|
||||
## 1. Что проверялось
|
||||
|
||||
* воспроизводимость release build;
|
||||
* соответствие version contract;
|
||||
* тесты оркестратора;
|
||||
* typecheck и сборка frontend;
|
||||
* тесты Go;
|
||||
* гейты уязвимостей зависимостей;
|
||||
* разрешение и фиксация актуального stable Hysteria;
|
||||
* upstream SHA-256 Hysteria;
|
||||
* совместимость сгенерированной production-конфигурации с Gecko и Salamander;
|
||||
* содержимое готового release archive;
|
||||
* внутренние контрольные суммы пакета;
|
||||
* clean-host boundary;
|
||||
* read-only PHASE 0;
|
||||
* отказ установки поверх HY2XS 0.x;
|
||||
* фактическая чистая установка на переустановленный Debian 13;
|
||||
* systemd;
|
||||
* nftables takeover;
|
||||
* firewall rollback guard;
|
||||
* ACME;
|
||||
* runtime Hysteria;
|
||||
* runtime админки;
|
||||
* install-state;
|
||||
* `status`;
|
||||
* read-only `doctor`;
|
||||
* доступ к admin UI исключительно через SSH local forwarding;
|
||||
* базовые функциональные операции admin UI.
|
||||
|
||||
Полный внешний Hysteria/Gecko dataplane через пользовательский desktop-клиент
|
||||
сознательно отложен до готовности собственного C#/sing-box клиента HY2XS. Это
|
||||
не подменяется server-side self-test — см. раздел 12.
|
||||
|
||||
---
|
||||
|
||||
## 2. Build acceptance
|
||||
|
||||
Финальная release-сборка выполнена из `a1f0db22c2f6c0b0436789b64e82f53bfa314327`.
|
||||
Рабочее дерево перед сборкой было чистым.
|
||||
|
||||
Orchestrator:
|
||||
|
||||
```text
|
||||
399 pass
|
||||
0 fail
|
||||
932 expect() calls
|
||||
18 test files
|
||||
```
|
||||
|
||||
Hysteria:
|
||||
|
||||
```text
|
||||
Resolved stable: v2.12.2
|
||||
Tag: app/v2.12.2
|
||||
Published: 2026-08-23
|
||||
```
|
||||
|
||||
Upstream SHA-256:
|
||||
|
||||
```text
|
||||
6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94
|
||||
```
|
||||
|
||||
Скачанный бинарь совпал с upstream `hashes.txt`.
|
||||
|
||||
Compatibility gate:
|
||||
|
||||
```text
|
||||
Gecko PASS
|
||||
Salamander PASS
|
||||
```
|
||||
|
||||
Сгенерированная production-конфигурация HY2XS принята Hysteria `v2.12.2`.
|
||||
|
||||
Frontend:
|
||||
|
||||
```text
|
||||
vue-tsc --noEmit PASS
|
||||
vite production PASS
|
||||
```
|
||||
|
||||
Go:
|
||||
|
||||
```text
|
||||
go test PASS
|
||||
```
|
||||
|
||||
Security:
|
||||
|
||||
```text
|
||||
govulncheck v1.7.0 PASS
|
||||
reachable vulns 0
|
||||
|
||||
pnpm audit high+ PASS
|
||||
high/critical vulns 0
|
||||
```
|
||||
|
||||
Полный release acceptance дошёл до:
|
||||
|
||||
```text
|
||||
[hy2xs-build] Built dist/hy2xs-install-1.0.0.tar.gz
|
||||
```
|
||||
|
||||
Проверки release pipeline включают в том числе read-only installer boundary,
|
||||
семантику отката, firewall guard, сериализацию операций, транзакционный импорт
|
||||
пиров, редактирование секретов, гигиену зависимостей и обязательность
|
||||
test/security-гейтов.
|
||||
|
||||
### 2.1. Требование к памяти build-хоста
|
||||
|
||||
Первый `govulncheck` был убит Linux OOM killer на машине с:
|
||||
|
||||
```text
|
||||
RAM: ~1.9 GiB
|
||||
Swap: 0
|
||||
```
|
||||
|
||||
После подключения временного swap 4 GiB полный security gate прошёл.
|
||||
|
||||
Это не runtime-дефект HY2XS, но требование к сборочной машине: около 2 GiB RAM
|
||||
без swap может быть недостаточно для `govulncheck`. 4 GiB swap здесь — не
|
||||
формально доказанный минимум, а подтверждённая рабочая конфигурация данного
|
||||
прогона. См. [docs/build/02-build-layer-and-package.md](../build/02-build-layer-and-package.md).
|
||||
|
||||
---
|
||||
|
||||
## 3. Исправления release verifier, сделанные во время приёмки
|
||||
|
||||
Приёмка выявила несколько ошибок не продукта, а самого release verifier. Они
|
||||
были исправлены до формирования принятого RC.
|
||||
|
||||
### 3.1. Ранний выход matcher'а и `pipefail`
|
||||
|
||||
Обнаружен антипаттерн вида `printf … | grep -q …` при `set -o pipefail`. На
|
||||
достаточно большом выводе продюсера раннее завершение `grep -q` способно
|
||||
привести продюсера к `SIGPIPE`, и статус всей конструкции становится 141 —
|
||||
ненулевым именно тогда, когда совпадение НАЙДЕНО.
|
||||
|
||||
56 проверок переведены на форму без опасного pipeline.
|
||||
|
||||
Кроме того, исправлена более существенная проблема: прежнее
|
||||
`2>/dev/null || true` превращало ошибку или опечатку в пути файла в пустой
|
||||
ввод, а отрицательная проверка после этого получала ложный PASS. Теперь
|
||||
отсутствие ожидаемого исходного файла — ошибка приёмки.
|
||||
|
||||
Примечание: единичное первоначальное падение на LICENSE нельзя доказанно
|
||||
объяснить этим механизмом — размер LICENSE был ниже воспроизведённого порога
|
||||
буфера канала. После исправлений содержимое LICENSE, его копия в архиве и
|
||||
контрольные суммы подтверждены отдельно.
|
||||
|
||||
### 3.2. Проверки кода против комментариев
|
||||
|
||||
Выявлены три ложных совпадения: `virtual:svg-icons-register`, прежние имена
|
||||
раннеров, `cancelFirewallRollback`. Все они находились в комментариях и прозе,
|
||||
а приёмка трактовала присутствие строки как возвращение исполняемого кода.
|
||||
|
||||
Семантика гейтов исправлена: SVG проверяется по реальному runtime/build
|
||||
contract; определение раннера учитывает форму идентификатора;
|
||||
`cancelFirewallRollback` проверяется как declaration/call form, а не как любое
|
||||
упоминание строки.
|
||||
|
||||
Наивный общий разбор `/* … */` намеренно не добавлен: неполный лексер может
|
||||
удалить настоящее содержимое внутри строкового или регулярного литерала и
|
||||
создать уже опасный ложный PASS.
|
||||
|
||||
### 3.3. Устаревший gate reconfigure
|
||||
|
||||
Приёмка ожидала прежний вызов `classifyReconfigureFailure(ownership)` после
|
||||
того, как фактический контракт стал `classifyReconfigureFailure(ownership, error)`.
|
||||
|
||||
Новая архитектура:
|
||||
|
||||
```text
|
||||
обычные ошибки -> классификация по ownership
|
||||
FirewallGuardFired -> типизированное исключение
|
||||
текст error.message -> не участвует
|
||||
```
|
||||
|
||||
Gate приведён к фактическому контракту.
|
||||
|
||||
---
|
||||
|
||||
## 4. Artifact acceptance
|
||||
|
||||
Release archive `hy2xs-install-1.0.0.tar.gz`, SHA-256:
|
||||
|
||||
```text
|
||||
7fd18a34f56ebb7e9cf62f579e857d6ed3f6a9711075a17da22818a4b079683c
|
||||
```
|
||||
|
||||
Хеш независимо пересчитан после копирования архива на Windows и совпал с
|
||||
серверным.
|
||||
|
||||
Metadata пакета:
|
||||
|
||||
```text
|
||||
name=HY2XS
|
||||
license=AGPL-3.0-only
|
||||
version=1.0.0
|
||||
release_line=1
|
||||
config_schema_version=2
|
||||
|
||||
source_git_commit=a1f0db22c2f6
|
||||
dirty_tree=false
|
||||
build_profile=production
|
||||
|
||||
dependency_security_gate=true
|
||||
tests_gate=true
|
||||
|
||||
hysteria_source=official-upstream
|
||||
hysteria_version=v2.12.2
|
||||
hysteria_sha_source=upstream-hashes
|
||||
hysteria_channel=stable
|
||||
hysteria_resolution=latest-stable
|
||||
hysteria_compat_gate=true
|
||||
```
|
||||
|
||||
Полный `sha256sum -c metadata/checksums.txt` для распакованного пакета
|
||||
завершился без ошибок.
|
||||
|
||||
RC опубликован отдельным tag/release `v1.0.0-rc1`.
|
||||
|
||||
---
|
||||
|
||||
## 5. D0 — установка поверх legacy HY2XS
|
||||
|
||||
До переустановки ОС RC был запущен на действующем сервере HY2XS 0.x.
|
||||
|
||||
Ожидаемое поведение:
|
||||
|
||||
```text
|
||||
PHASE 0
|
||||
→ обнаружить legacy markers
|
||||
→ завершиться до первой persistent mutation
|
||||
```
|
||||
|
||||
Фактический результат: `RC=1`.
|
||||
|
||||
Installer обнаружил старые:
|
||||
|
||||
```text
|
||||
/etc/hy2xs
|
||||
/etc/hysteria
|
||||
/var/lib/hy2xs
|
||||
/var/lib/hy2xs-admin
|
||||
/var/lib/hysteria
|
||||
/usr/local/lib/hy2xs
|
||||
/usr/local/bin/hysteria
|
||||
/usr/local/bin/hy2xs-orchestrator
|
||||
/etc/nftables.d/hy2xs.nft
|
||||
systemd units
|
||||
admin installation
|
||||
```
|
||||
|
||||
и сообщил:
|
||||
|
||||
```text
|
||||
HY2XS v1 не поддерживает установку поверх и не мигрирует состояние 0.x.
|
||||
Ни один файл на сервере не изменён.
|
||||
```
|
||||
|
||||
Контрольный before/after diff показал только изменение активной базы SQLite
|
||||
legacy-админки. Отдельный idle-тест без installer подтвердил, что `h_ui.db`
|
||||
сама меняет hash и mtime примерно каждые 20 секунд при работающем legacy
|
||||
`hy2xs-admin`.
|
||||
|
||||
Следовательно:
|
||||
|
||||
```text
|
||||
D0 legacy detection PASS
|
||||
D0 fail-before-apply PASS
|
||||
D0 zero product mutation PASS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Baseline чистого хоста
|
||||
|
||||
Для дальнейшей проверки ОС была переустановлена.
|
||||
|
||||
```text
|
||||
Debian GNU/Linux 13
|
||||
amd64
|
||||
kernel 6.12.85+deb13-amd64
|
||||
|
||||
RAM ~1.9 GiB
|
||||
Swap 0
|
||||
```
|
||||
|
||||
Сеть:
|
||||
|
||||
```text
|
||||
198.51.100.10/24
|
||||
fi.api.withen.pro -> 198.51.100.10
|
||||
```
|
||||
|
||||
До установки:
|
||||
|
||||
```text
|
||||
HY2XS paths absent
|
||||
Hysteria paths absent
|
||||
HY2XS units absent
|
||||
Hysteria unit absent
|
||||
nft ruleset empty
|
||||
UDP 443 free
|
||||
TCP 80 free
|
||||
TCP 443 free
|
||||
```
|
||||
|
||||
Единственный ожидаемый внешний listener — SSH :2323.
|
||||
|
||||
Clean-host contract подтверждён фактическим составом хоста.
|
||||
|
||||
---
|
||||
|
||||
## 7. Чистая установка
|
||||
|
||||
Установка выполнялась непосредственно из ранее созданного и проверенного RC
|
||||
artifact. Пакет не пересобирался на target-сервере.
|
||||
|
||||
Production profile:
|
||||
|
||||
```text
|
||||
schema 2
|
||||
domain fi.api.withen.pro
|
||||
public host fi.api.withen.pro
|
||||
public port 443
|
||||
SSH 2323
|
||||
firewall mode takeover
|
||||
staged firewall true
|
||||
|
||||
admin bind 127.0.0.1
|
||||
admin port 8080
|
||||
admin public access false
|
||||
|
||||
TLS ACME
|
||||
ACME challenge HTTP
|
||||
ACME email admin@withen.pro
|
||||
|
||||
Hysteria port 443/udp
|
||||
obfs gecko
|
||||
|
||||
IPv6 disabled
|
||||
DNS AAAA policy strict
|
||||
public endpoint policy strict
|
||||
```
|
||||
|
||||
Результат: `INSTALL_RC=0`.
|
||||
|
||||
Фактически прошли:
|
||||
|
||||
```text
|
||||
PHASE 0
|
||||
package checksums
|
||||
clean-host preflight
|
||||
operation lock
|
||||
orchestrator bootstrap
|
||||
system dependencies
|
||||
capability preflight
|
||||
filesystem
|
||||
runtime env
|
||||
bundled admin
|
||||
Hysteria download
|
||||
Hysteria SHA verification
|
||||
config generation
|
||||
systemd installation
|
||||
firewall staged apply
|
||||
rollback guard
|
||||
post-install env
|
||||
bootstrap admin secret
|
||||
smoke
|
||||
firewall guard disarm
|
||||
durable install commit
|
||||
rollback cleanup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Firewall
|
||||
|
||||
До применения firewall создан rollback snapshot. Candidate-конфигурация
|
||||
проверена до активации:
|
||||
|
||||
```text
|
||||
nft -c -f hy2xs.nft.candidate
|
||||
nft -c -f nftables.conf.candidate
|
||||
```
|
||||
|
||||
После этого создан transient rollback timer:
|
||||
|
||||
```text
|
||||
deadline: 45 s
|
||||
AccuracySec: 1 s
|
||||
RemainAfterElapse=no
|
||||
```
|
||||
|
||||
Firewall применён только после успешного arm guard.
|
||||
|
||||
После smoke:
|
||||
|
||||
```text
|
||||
guard disarmed
|
||||
installed state durably committed
|
||||
rollback files removed
|
||||
```
|
||||
|
||||
Post-install:
|
||||
|
||||
```text
|
||||
rollback_guard_active=false
|
||||
rollback_guard_state=quiescent
|
||||
```
|
||||
|
||||
Transient guard units отсутствуют. Candidate-файлы отсутствуют. В
|
||||
`/run/hy2xs/rollback` остался только пустой родительский каталог.
|
||||
|
||||
Действующие правила:
|
||||
|
||||
```text
|
||||
table inet hy2xs
|
||||
|
||||
input policy drop
|
||||
|
||||
allow loopback
|
||||
allow established/related
|
||||
allow TCP/2323 IPv4
|
||||
allow TCP/80 IPv4
|
||||
allow UDP/443 IPv4
|
||||
allow ICMP echo-request
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Состояние runtime
|
||||
|
||||
Hysteria:
|
||||
|
||||
```text
|
||||
v2.12.2
|
||||
active
|
||||
enabled
|
||||
UDP 0.0.0.0:443
|
||||
trafficStats 127.0.0.1:36712
|
||||
```
|
||||
|
||||
Админка:
|
||||
|
||||
```text
|
||||
active
|
||||
enabled
|
||||
TCP 127.0.0.1:8080
|
||||
```
|
||||
|
||||
nftables: `active`, `enabled`.
|
||||
|
||||
Let's Encrypt ACME:
|
||||
|
||||
```text
|
||||
authorization valid
|
||||
certificate obtained successfully
|
||||
```
|
||||
|
||||
Install state:
|
||||
|
||||
```text
|
||||
product=hy2xs
|
||||
release_line=1
|
||||
config_schema_version=2
|
||||
product_version=1.0.0
|
||||
|
||||
installed=true
|
||||
phase=installed
|
||||
last_error=""
|
||||
```
|
||||
|
||||
Секретные runtime-файлы имеют ограниченные permissions.
|
||||
`/usr/local/bin/hy2xs-orchestrator` является symlink, конечный исполняемый файл
|
||||
имеет `0755 root:root`.
|
||||
|
||||
---
|
||||
|
||||
## 10. `status` и `doctor`
|
||||
|
||||
С корректным runtime `--package-dir`:
|
||||
|
||||
```text
|
||||
STATUS_RC=0
|
||||
DOCTOR_RC=0
|
||||
```
|
||||
|
||||
`status` подтвердил:
|
||||
|
||||
```text
|
||||
services active
|
||||
firewall valid
|
||||
firewall entrypoint hy2xs-managed
|
||||
|
||||
install generation current
|
||||
operation none
|
||||
|
||||
rollback guard quiescent
|
||||
runtime state running
|
||||
install state installed
|
||||
```
|
||||
|
||||
`doctor` прошёл preflight и smoke.
|
||||
|
||||
Отдельно проверен read-only contract `doctor` — по фактическим PID процессов,
|
||||
а не только статическим тестом:
|
||||
|
||||
```text
|
||||
до doctor: hysteria PID = 5041, admin PID = 5042
|
||||
после doctor: hysteria PID = 5041, admin PID = 5042
|
||||
|
||||
doctor Hysteria restart NO
|
||||
doctor admin restart NO
|
||||
```
|
||||
|
||||
Post-install состояние systemd/firewall/install-state соответствует ожидаемому
|
||||
контракту.
|
||||
|
||||
---
|
||||
|
||||
## 11. Admin UI — ручная функциональная проверка
|
||||
|
||||
Доступ:
|
||||
|
||||
```text
|
||||
SSH local forwarding
|
||||
127.0.0.1:8080
|
||||
```
|
||||
|
||||
Публичный admin listener отсутствует.
|
||||
|
||||
Проверены: вход, дашборд, список пиров, создание пира, генерация share URI.
|
||||
|
||||
Дашборд получает данные CPU/RAM/disk/runtime.
|
||||
|
||||
Создание пира фактически работает **при ручном указании секрета** — см. UX-02 в
|
||||
[перечне дефектов](2026-09-01-v1.0.0-rc1-ux-findings.md).
|
||||
|
||||
Share URI формируется в ожидаемом production-формате:
|
||||
|
||||
```text
|
||||
hysteria2://<peer-secret>@<host>:443/
|
||||
?insecure=0
|
||||
&obfs=gecko
|
||||
&obfs-password=<server-obfs-secret>
|
||||
&sni=<host>
|
||||
#<peer-name>
|
||||
```
|
||||
|
||||
Реальный секрет из тестовой URI в документацию не переносится.
|
||||
|
||||
**Незакрытое действие среды:** одна тестовая URI была выведена за пределы admin
|
||||
UI, поэтому соответствующий тестовый пир перед дальнейшим использованием среды
|
||||
следует удалить или пересоздать с новым секретом.
|
||||
|
||||
---
|
||||
|
||||
## 12. Отложенная проверка Gecko E2E
|
||||
|
||||
Полноценный внешний client E2E на этом прогоне не выполнялся.
|
||||
|
||||
Причина не в обнаруженном server-side дефекте. Целевой пользовательский клиент
|
||||
HY2XS ещё разрабатывается:
|
||||
|
||||
```text
|
||||
C#
|
||||
sing-box core
|
||||
HY2XS desktop shell
|
||||
```
|
||||
|
||||
Практически пригодных сторонних клиентов с необходимой Gecko-поддержкой
|
||||
недостаточно для того, чтобы считать их корректной reference implementation.
|
||||
|
||||
При этом уже подтверждено:
|
||||
|
||||
```text
|
||||
Hysteria v2.12.2 Gecko config compatibility PASS
|
||||
Hysteria v2.12.2 Salamander compatibility PASS
|
||||
server startup PASS
|
||||
ACME PASS
|
||||
UDP/443 listener PASS
|
||||
peer auth/control-plane smoke PASS
|
||||
share URI generator PASS
|
||||
```
|
||||
|
||||
Статус внешнего Gecko E2E:
|
||||
|
||||
```text
|
||||
DEFERRED — WAITING FOR HY2XS DESKTOP CLIENT
|
||||
```
|
||||
|
||||
Он обязателен для окончательной ecosystem acceptance «server + client», но
|
||||
отсутствие стороннего Gecko-клиента не следует трактовать как отказ текущего
|
||||
server/orchestrator/admin RC.
|
||||
|
||||
---
|
||||
|
||||
## 13. Итоговый статус прогона
|
||||
|
||||
```text
|
||||
SOURCE AUDIT PASS
|
||||
BUILD PASS
|
||||
TEST GATE PASS
|
||||
SECURITY GATE PASS
|
||||
ARTIFACT INTEGRITY PASS
|
||||
|
||||
LEGACY D0 PASS
|
||||
CLEAN HOST PASS
|
||||
FRESH INSTALL PASS
|
||||
ACME PASS
|
||||
SYSTEMD PASS
|
||||
NFTABLES PASS
|
||||
FIREWALL ROLLBACK GUARD PASS
|
||||
INSTALL STATE PASS
|
||||
STATUS PASS
|
||||
DOCTOR PASS
|
||||
DOCTOR READ-ONLY PASS
|
||||
|
||||
ADMIN LOGIN PASS
|
||||
ADMIN DASHBOARD PASS
|
||||
PEER CREATE PARTIAL / UX DEFECT
|
||||
SHARE URI GENERATION PASS
|
||||
|
||||
EXTERNAL GECKO CLIENT E2E DEFERRED
|
||||
EXTERNAL SALAMANDER E2E DEFERRED
|
||||
|
||||
FINAL v1.0.0 NOT YET ACCEPTED
|
||||
```
|
||||
|
||||
`v1.0.0-rc1` сохраняется как успешно прошедший server/install RC.
|
||||
|
||||
Что требуется до финального `v1.0.0` — см.
|
||||
[перечень дефектов и план закрытия](2026-09-01-v1.0.0-rc1-ux-findings.md).
|
||||
|
||||
---
|
||||
|
||||
## 14. Замечания по шуму в логах прогона
|
||||
|
||||
Две записи в журнале прогона к HY2XS отношения не имеют:
|
||||
|
||||
* `ystemctl` — опечатка в shell;
|
||||
* пустой `journalctl -u hysteria-server --since '-2 min'` — ожидаемо, поскольку
|
||||
реального внешнего клиента в этот момент не подключали.
|
||||
@@ -0,0 +1,285 @@
|
||||
# 1.0.0-rc1 — дефекты приёмки и их закрытие
|
||||
|
||||
Относится к прогону
|
||||
[2026-09-01, `v1.0.0-rc1`](2026-09-01-v1.0.0-rc1-host-acceptance.md).
|
||||
|
||||
Ни один из перечисленных дефектов не является P0 safety blocker и не
|
||||
дискредитирует пройденную server acceptance. Все они заметно ухудшают работу
|
||||
оператора и закрыты до финального `v1.0.0`.
|
||||
|
||||
Раздел «Найдено сверх отчёта» описывает дефекты того же класса, обнаруженные
|
||||
при разборе корневых причин: искали причину одного отказа — нашли механизм,
|
||||
порождавший несколько.
|
||||
|
||||
## Сводка
|
||||
|
||||
| ID | Дефект | Приоритет | Статус |
|
||||
| --- | --- | --- | --- |
|
||||
| UX-01 | Некорректный цвет SVG-иконок | P1 | закрыт |
|
||||
| UX-02 | Необязательный секрет пира фактически обязателен | P1 | закрыт |
|
||||
| UX-03 | Сообщение `Invalid` неинформативно | P1 | закрыт |
|
||||
| UX-04 | Плейсхолдеры слишком персонализированы | P2 | закрыт |
|
||||
| UX-05 | Нет атрибуции Flamy в боковом меню | P1 | закрыт |
|
||||
| EX-01 | Фильтр списка пиров ломается после очистки | P1 | закрыт |
|
||||
| EX-02 | Правила имени пира противоречили друг другу | P1 | закрыт |
|
||||
| EX-03 | Набор символов имени пира допускал `/ : ; . ,` | P1 | закрыт |
|
||||
| EX-04 | Истечение сессии не обрабатывалось | P1 | закрыт |
|
||||
| EX-05 | `id` требовался и в пути, и в теле запроса | P2 | закрыт |
|
||||
| EX-06 | Обработчик транспортных ошибок падал сам | P2 | закрыт |
|
||||
|
||||
---
|
||||
|
||||
## UX-01 — некорректный цвет SVG-иконок
|
||||
|
||||
**Наблюдалось:** иконки логина и бокового меню отображались почти чёрными и не
|
||||
соответствовали теме.
|
||||
|
||||
**Корневая причина.** Контракт `currentColor` в панели УЖЕ существовал —
|
||||
`fill: currentcolor` объявлен и в `SvgIcon/index.vue`, и в `styles/sidebar.scss`.
|
||||
Он не действовал, потому что восемь из семнадцати ассетов несли литеральный
|
||||
атрибут `fill="#000000"` прямо на `<path>`, а атрибут представления перебивает
|
||||
унаследованное CSS-свойство. Под это попали ВСЕ семь иконок бокового меню
|
||||
(`report`, `users`, `hysteria`, `setting`, `error`, `log-system`,
|
||||
`log-hysteria`) на фоне `--menuBg: #181818`, а также `user` на форме входа.
|
||||
Соседняя `password` литерального цвета не несёт и рисовалась белой — отсюда и
|
||||
ощущение, что иконки не соответствуют друг другу.
|
||||
|
||||
Ни одна существующая проверка этого не видела: гейт приёмки проверял у ассетов
|
||||
только наличие системы координат.
|
||||
|
||||
**Как закрыто.**
|
||||
|
||||
1. Литеральный цвет убран из монохромных ассетов: они несут `fill="currentColor"`.
|
||||
2. Многоцветные ассеты (`download`, `upload`) объявлены явным списком
|
||||
`MULTICOLOR_ICONS` и под проверку цвета не попадают — их палитра является
|
||||
частью ассета.
|
||||
3. Преобразование файла в `<symbol>` и контракт ассета вынесены в чистый модуль
|
||||
`SvgIcon/symbol.ts`: без Vite и DOM, поэтому проверяются тестом и гейтом, а
|
||||
не только глазами на живой странице.
|
||||
4. У `SvgIcon` убран проп `color` и атрибут `fill` на `<use>` — он приглашал
|
||||
чинить цвет точечно в обход общего контракта.
|
||||
5. Цвета в рантайме НЕ переписываются: источник истины — файл. Молчаливая
|
||||
нормализация скрывала бы ровно тот дефект, который контракт обязан делать
|
||||
видимым.
|
||||
|
||||
**Чем закреплено:** `tools/test/frontend-sprite.test.ts` (контракт всех
|
||||
ассетов, обе ветки нормализации, наличие обеих половин контракта — ассета и
|
||||
CSS, запрет CSS-фильтров и селекторов по имени иконки) и соответствующие гейты
|
||||
приёмки в `tools/build/lib/acceptance.sh`.
|
||||
|
||||
**Что проверяется вручную** (машина этого не докажет): фактический цвет на
|
||||
светлой и тёмной теме, в состояниях hover и active, в свёрнутом меню.
|
||||
|
||||
---
|
||||
|
||||
## UX-02 — необязательный секрет фактически обязателен
|
||||
|
||||
**Наблюдалось:** подпись под полем обещает «оставьте пустым — сгенерируем
|
||||
автоматически», пустое поле блокирует создание пира и выдаёт `Invalid`.
|
||||
|
||||
**Корневая причина.** Не отсутствие автогенерации: `service.CreatePeer` умел
|
||||
генерировать секрет и делал это. Запрос до неё не доходил.
|
||||
|
||||
В `go-playground/validator` тег `omitempty` НЕ пропускает правило, если поле
|
||||
объявлено указателем и указатель не nil. Помощник `hasValue` (`baked_in.go`):
|
||||
|
||||
```go
|
||||
if fl.(*validate).fldIsPointer && getValue(field) != nil {
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
Для `*string`, указывающего на пустую строку, это возвращает «значение есть».
|
||||
Панель отправляет `secret: ""`, правило `min=6` применяется к пустой строке и
|
||||
отказывает.
|
||||
|
||||
**Как закрыто.** Не тегом на одном поле, а механизмом: между разбором тела и
|
||||
проверкой правил добавлен шаг нормализации DTO (`dto.Normalizable`). Он
|
||||
приводит «поле отсутствует», «пустая строка» и «одни пробелы» к одному
|
||||
состоянию для тех полей, где отсутствие значения законно.
|
||||
|
||||
Граница проходит по каждому полю ОТДЕЛЬНО и это существенно: у `remark` пустая
|
||||
строка означает «убрать пометку», у `disabled` ноль означает «включён», у
|
||||
`quotaBytes` ноль — нулевую квоту. Общее правило «пусто → не задано» молча
|
||||
сломало бы все три.
|
||||
|
||||
Генерация названа явным шагом сервисного слоя — `service.GeneratePeerSecret` на
|
||||
базе `util.RandomString` (`crypto/rand` с отбрасыванием смещённых байтов). Тот
|
||||
же вызов используется импортом: пир, созданный формой, и пир, импортированный
|
||||
без секрета, теперь неотличимы.
|
||||
|
||||
**Чем закреплено:** матрица «отсутствует / пусто / пробелы / перевод строки →
|
||||
генерируется», границы `5 → отказ, 6 → приём, 128 → приём, 129 → отказ`,
|
||||
неповторяемость сгенерированных секретов и — главное — проверка того, что
|
||||
сгенерированный секрет НЕМЕДЛЕННО аутентифицирует пира через
|
||||
`service.Hysteria2Auth`. То, что секрет записан, ничего не значит, пока по нему
|
||||
не проходит доступ.
|
||||
|
||||
---
|
||||
|
||||
## UX-03 — сообщение `Invalid` неинформативно
|
||||
|
||||
**Корневая причина.** `validateField` схлопывал любую ошибку любого поля в
|
||||
`constant.InvalidError = "invalid"`, а `vo.Fail` определял HTTP-семантику
|
||||
СРАВНЕНИЕМ текста сообщения с тремя известными литералами — тот же антипаттерн,
|
||||
который запрещён панели, только на сервере.
|
||||
|
||||
**Как закрыто.**
|
||||
|
||||
* Ответ об ошибке несёт `errors: [{code, field, message, params}]`.
|
||||
* Отказ разбора тела (`body_invalid`) отделён от нарушения правила.
|
||||
* Коды правил различают границы числа и границы длины строки
|
||||
(`min` / `min_length`): оператору это разные фразы.
|
||||
* Доменные отказы получили коды: `peer_name_taken`, `peer_name_reserved`,
|
||||
`peer_bootstrap_identity_locked`, `invalid_credentials`.
|
||||
* `vo` больше не выводит код из текста — код передаётся аргументом.
|
||||
* Панель выбирает локализованную фразу ПО КОДУ и подставляет причины под
|
||||
соответствующие поля формы; текст сервера остаётся ответом для клиента без UI
|
||||
и запасным вариантом для неизвестного кода.
|
||||
* Числа правил приходят в `params`, поэтому второй копии границ в панели нет.
|
||||
|
||||
Отдельно: отказ входа кодируется как `invalid_credentials`, но НЕ уточняется —
|
||||
«такого администратора нет» и «пароль не тот» остаются неразличимы снаружи,
|
||||
иначе форма входа становится способом проверять существование имён. Отказ базы
|
||||
при этом остаётся системной ошибкой: выдавать «неверный логин или пароль» при
|
||||
недоступной SQLite значит отправить оператора искать несуществующую опечатку.
|
||||
|
||||
---
|
||||
|
||||
## UX-04 — плейсхолдеры слишком персонализированы
|
||||
|
||||
Заменено на нейтральный компактный baseline:
|
||||
|
||||
| Поле | Было | Стало |
|
||||
| --- | --- | --- |
|
||||
| Имя | `например, ivan-laptop` | `client-01` |
|
||||
| Комментарий | `например, Ноутбук Ивана, отдел продаж` | `ноутбук` |
|
||||
|
||||
Префикс «например,» убран: плейсхолдер и так является примером. Подсказки под
|
||||
полями остались подробными; подсказка имени теперь называет фактические границы
|
||||
(6-32 символа).
|
||||
|
||||
---
|
||||
|
||||
## UX-05 — атрибуция Flamy в боковом меню
|
||||
|
||||
Внизу бокового меню добавлена подпись «Разработано во **Flamy**», где `Flamy` —
|
||||
ссылка на `https://flamy.studio` фирменным цветом, с
|
||||
`target="_blank"` и `rel="noopener noreferrer"`.
|
||||
|
||||
Адрес объявлен ОДИН раз в `apps/frontend/src/constants/branding.ts` и
|
||||
принадлежит приложению: он не читается ни из `hy2xs.env`, ни из config API, ни
|
||||
из таблицы `config`, ни из настроек панели. Оператор HY2XS не должен иметь
|
||||
возможности переназначить, куда ведёт подпись разработчика.
|
||||
|
||||
Вёрстка: высота области прокрутки меню вычитает `$sidebarFooterHeight`, поэтому
|
||||
пункты меню не могут наехать на подпись даже при длинном списке — им физически
|
||||
некуда. В свёрнутом меню (54 px) остаётся только имя-ссылка; на узком экране
|
||||
меню уходит в off-canvas на полную ширину.
|
||||
|
||||
**Чем закреплено:** `tools/test/frontend-contract.test.ts` — единственность
|
||||
адреса в исходниках панели, отсутствие его в операторских поверхностях, наличие
|
||||
футера в меню, учёт его высоты, атрибуты безопасности внешней ссылки. Плюс
|
||||
гейты приёмки.
|
||||
|
||||
---
|
||||
|
||||
# Найдено сверх отчёта
|
||||
|
||||
## EX-01 — фильтр списка пиров ломался после очистки
|
||||
|
||||
`el-input` с крестиком очистки ставит пустую строку, axios сериализует её как
|
||||
`?name=`, и та же ловушка `omitempty` на указателе (см. UX-02) отказывала
|
||||
поиску пиров с `invalid`. Список пиров ломался в один клик по крестику.
|
||||
|
||||
Закрыто тем же механизмом нормализации; закреплено регрессионным тестом.
|
||||
|
||||
## EX-02 — правила имени пира противоречили друг другу
|
||||
|
||||
На поле стояли `min=1,max=32` И `validateStr`, требовавший 6-32 символа. Имя из
|
||||
трёх символов проходило одно правило и отказывалось на другом, а оператор видел
|
||||
`invalid` рядом с подсказкой «короткий идентификатор пира».
|
||||
|
||||
Длина перенесена внутрь одного правила `peerName`. Действующая граница — 6-32,
|
||||
то есть та, которая уже была задокументирована и закреплена тестами импорта.
|
||||
|
||||
## EX-03 — набор символов имени пира допускал `/ : ; . ,`
|
||||
|
||||
Слой контроллеров нёс собственную копию правила:
|
||||
|
||||
```text
|
||||
^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$
|
||||
```
|
||||
|
||||
с комментарием «тот же набор символов, что и у импорта». Набор был другим:
|
||||
дефис внутри класса не экранирован, поэтому `+-=` образует ДИАПАЗОН и впускает
|
||||
`, - . / 0-9 : ; < =`. Через панель проходило имя `peer/name`, которое импорт
|
||||
того же пира отклонял, — при том что имя пира уезжает во fragment клиентской
|
||||
ссылки и в автогенерируемый секрет.
|
||||
|
||||
Правило объявлено один раз (`service.IsValidPeerName`) и используется обеими
|
||||
дверями в таблицу пиров.
|
||||
|
||||
Набор символов ЛОГИНА администратора при этом сознательно НЕ сужен: он
|
||||
записан явно, но повторяет прежнее фактическое множество. Имя администратора
|
||||
приходит из `HY2XS_ADMIN_USER`, оркестратор его не ограничивает, и сужение
|
||||
правила означало бы, что установка с логином вроде `admin.ops` перестаёт
|
||||
пускать оператора в панель. Это закреплено отдельным тестом, чтобы попытка
|
||||
«навести порядок» роняла сборку, а не вход на живом сервере.
|
||||
|
||||
## EX-04 — истечение сессии не обрабатывалось
|
||||
|
||||
Ветка «сессия истекла, войдите заново» была недостижима в двух местах сразу.
|
||||
Сервер отвечает HTTP 200 на любой отказ, поэтому обработчик ошибок axios для
|
||||
отказов API не вызывался вовсе — а ветка сессии жила именно там. Условие в ней
|
||||
проверяло `code === "A0230"` и поле `msg`, которых в этом API никогда не было.
|
||||
Ключ локализации `common.sessionExpired` существовал и был мёртвым.
|
||||
|
||||
Вдобавок истёкший токен уезжал с кодом системной ошибки: `vo` не узнавал
|
||||
`token expired` среди трёх известных строк.
|
||||
|
||||
Закрыто: `ParseToken` возвращает объявленные значения ошибок вместо свежих
|
||||
строк, middleware различает истечение и недействительность через `errors.Is`,
|
||||
ответ несёт код `session_expired`, панель показывает диалог и возвращает на
|
||||
форму входа. Диалог показывается один раз, даже когда истёкший токен уронил
|
||||
несколько параллельных запросов страницы.
|
||||
|
||||
Побочно: сброс сессии больше не зовёт `localStorage.clear()`, который заодно
|
||||
стирал выбранный оператором язык панели.
|
||||
|
||||
## EX-05 — `id` требовался и в пути, и в теле
|
||||
|
||||
`PeerUpdateDto` встраивал `IdDto` с правилом `required`, поэтому тело запроса
|
||||
обязано было повторять идентификатор из адреса. Панель его повторяла, поэтому
|
||||
расхождение не проявлялось; любой другой клиент, сделавший `PATCH /peers/7` без
|
||||
`"id": 7` в теле, получал отказ — при том что значение из тела всё равно
|
||||
затирается значением из пути.
|
||||
|
||||
Заодно убрана недостижимая запасная ветка `resolveID`, читавшая идентификатор
|
||||
из тела: она вызывала разбор тела, которое обработчик читает следом второй раз,
|
||||
а gin его не буферизует. То есть запасной путь не сработал бы ровно тогда,
|
||||
когда понадобился бы.
|
||||
|
||||
## EX-06 — обработчик транспортных ошибок падал сам
|
||||
|
||||
Обработчик читал `error.response.data`, не проверив `error.response`. При
|
||||
обрыве соединения или таймауте он падал с `TypeError` и подменял настоящую
|
||||
причину отказом внутри себя. Теперь сетевой отказ отличается от отказа сервера
|
||||
и сообщается отдельной фразой.
|
||||
|
||||
---
|
||||
|
||||
## Что осталось сделать до финального v1.0.0
|
||||
|
||||
1. пересобрать `rc2` и повторить build/security acceptance;
|
||||
2. установить `rc2` на тестовый хост;
|
||||
3. проверить визуально: цвет иконок в обеих темах, hover/active, свёрнутое меню,
|
||||
подпись Flamy на узком экране;
|
||||
4. проверить создание пира с пустым секретом и работоспособность его share URI
|
||||
на живом сервере;
|
||||
5. выполнить reconfigure/fault matrix D1-D1h на `rc2`;
|
||||
6. выполнить reboot acceptance;
|
||||
7. отдельно проверить Salamander fallback;
|
||||
8. после готовности HY2XS Desktop — внешний Gecko E2E;
|
||||
9. удалить или пересоздать тестовый пир, чья URI была выведена за пределы
|
||||
admin UI во время прогона `rc1`.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Реестр прогонов приёмки
|
||||
|
||||
Здесь лежат отчёты о ФАКТИЧЕСКИХ прогонах приёмки: какая сборка, на каком
|
||||
хосте, что прошло и что нет. Описание самих проверок — в
|
||||
[docs/testing/](../testing/README.md).
|
||||
|
||||
Разделение намеренное. Документ проверок переживает релизы и правится по мере
|
||||
развития продукта; отчёт о прогоне относится к одному артефакту и одному хосту
|
||||
и после публикации релиза не редактируется — иначе он перестаёт быть
|
||||
свидетельством.
|
||||
|
||||
## Правила ведения
|
||||
|
||||
1. Один прогон — один файл `ГГГГ-ММ-ДД-<версия>-<вид>.md`.
|
||||
2. Отчёт фиксирует только то, что действительно выполнялось. Отложенная
|
||||
проверка отмечается как `DEFERRED` с причиной, а не опускается.
|
||||
3. Реальные секреты, ключи и клиентские ссылки в отчёт не переносятся.
|
||||
Публичные IP-адреса тестовых хостов обезличиваются; доменное имя и номер
|
||||
порта SSH остаются, потому что без них шаги прогона невоспроизводимы.
|
||||
4. Найденные дефекты живут в отдельном файле рядом с отчётом и закрываются
|
||||
ссылками на коммиты, а не правкой самого отчёта.
|
||||
|
||||
## Прогоны
|
||||
|
||||
| Дата | Версия | Коммит источника | Вид | Вердикт |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 2026-09-01 | `1.0.0-rc1` | `a1f0db22` | build + host acceptance, Debian 13 | [RC ACCEPTED WITH RELEASE-REQUIRED UX FIXES](2026-09-01-v1.0.0-rc1-host-acceptance.md) |
|
||||
|
||||
## Открытые дефекты приёмки
|
||||
|
||||
| Прогон | Дефекты |
|
||||
| --- | --- |
|
||||
| 2026-09-01, `1.0.0-rc1` | [UX-01…UX-05 и найденное сверх отчёта](2026-09-01-v1.0.0-rc1-ux-findings.md) |
|
||||
@@ -1,5 +1,10 @@
|
||||
# Admin panel: HY2XS admin
|
||||
|
||||
> Контракты панели, закреплённые тестами и релизными гейтами — отрисовка
|
||||
> иконок, структурированные ошибки, необязательные поля и генерация секретов,
|
||||
> атрибуция — вынесены в отдельный документ:
|
||||
> [15-ui-contracts.md](15-ui-contracts.md).
|
||||
|
||||
## Цель документа
|
||||
|
||||
Зафиксировать модель работы с admin-панелью: HY2XS admin является **штатным компонентом HY2XS**, а не внешней зависимостью, которую target server где-то добывает во время установки.
|
||||
@@ -79,7 +84,7 @@ ingress/reverse-proxy, а не возвращаться к модели «пан
|
||||
|
||||
Имена ключей предыдущего поколения намеренно не приводятся: в обычных v1-доках
|
||||
их словаря нет. Всё, что нужно для распознавания и удаления старой установки, —
|
||||
в [14-legacy-cleanup.md](14-legacy-cleanup.md).
|
||||
в [14-legacy-cleanup.md](../operations/14-legacy-cleanup.md).
|
||||
|
||||
## Пространства имён HTTP API
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# Контракты панели
|
||||
|
||||
Три свойства HY2XS admin, которые не проверяются ни типами, ни сборкой bundle и
|
||||
потому ломались молча. Каждое из них закреплено тестом
|
||||
(`tools/test/frontend-*.test.ts`) и гейтом приёмки.
|
||||
|
||||
Общее описание панели — [04-admin-panel.md](04-admin-panel.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Отрисовка иконок
|
||||
|
||||
**Правило.** Монохромная UI-иконка получает цвет ровно одним способом —
|
||||
наследованием `currentColor` от компонента и темы. Ассет не содержит
|
||||
литеральных цветов; цвет объявляется в CSS один раз, на `.svg-icon`.
|
||||
|
||||
**Запрещено:**
|
||||
|
||||
* литеральный `fill` / `stroke` / `stop-color` в монохромном ассете;
|
||||
* цвет в инлайновом `style` внутри ассета;
|
||||
* непустой `<style>` внутри ассета — его селекторы глобальны и красят чужие
|
||||
иконки;
|
||||
* растровое `<image>` — оно не подчиняется `currentColor` никогда;
|
||||
* CSS-фильтр на `.svg-icon`;
|
||||
* селектор по имени конкретной иконки (`[icon-class="…"]`);
|
||||
* передача цвета параметром компонента.
|
||||
|
||||
**Многоцветные ассеты** объявляются явным списком `MULTICOLOR_ICONS` в
|
||||
`SvgIcon/symbol.ts`. Их палитра — часть ассета, и проверка цвета к ним не
|
||||
применяется. «Многоцветность» обязана быть решением, а не следствием того, что
|
||||
иконку скачали с готовыми значениями `fill`.
|
||||
|
||||
**Система координат.** У каждого ассета обязан быть `viewBox` либо пара
|
||||
`width`/`height`, из которой он синтезируется. Без неё `<use>` рисует иконку в
|
||||
натуральную величину и обрезает её по размеру родительского `<svg>`.
|
||||
|
||||
**Почему цвета не переписываются в рантайме.** Источник истины — файл. Молчаливая
|
||||
нормализация при сборке спрайта скрывала бы ровно тот дефект, который контракт
|
||||
обязан делать видимым: добавленная с чёрным `fill` иконка выглядела бы
|
||||
правильно и оставалась бы сломанной в исходниках.
|
||||
|
||||
**Что машина не докажет.** Фактический цвет на экране. Визуальная проверка
|
||||
светлой и тёмной темы, состояний hover/active и свёрнутого меню остаётся ручной
|
||||
и фиксируется в отчёте приёмки.
|
||||
|
||||
---
|
||||
|
||||
## 2. Структурированные ошибки
|
||||
|
||||
**Правило.** Панель не разбирает текст ответа. Отказ несёт код, а отказ по полю
|
||||
— ещё и имя поля.
|
||||
|
||||
Форма ответа:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 50001,
|
||||
"type": "no",
|
||||
"message": "проверка данных не пройдена",
|
||||
"errors": [
|
||||
{ "code": "min_length", "field": "secret", "message": "…", "params": { "min": "6" } }
|
||||
],
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
* `code` — числовой код ответа (`model/constant/code.go`);
|
||||
* `errors[].code` — причина (`model/constant/error.go`, `ErrCode*`);
|
||||
* `errors[].field` — имя поля из JSON-тега; пусто для отказов уровня операции;
|
||||
* `errors[].params` — числа правила, чтобы панель не заводила их вторую копию;
|
||||
* `message` — человекочитаемый ответ для клиента без UI и запасной вариант для
|
||||
кода, которого панель ещё не знает.
|
||||
|
||||
Границы числа и границы длины строки различаются кодом (`min` против
|
||||
`min_length`), хотя тег валидатора у них один: оператору это разные фразы.
|
||||
|
||||
**Запрещено:**
|
||||
|
||||
* выводить код ответа сравнением текста сообщения;
|
||||
* отдавать обобщённое `invalid` вместо описания полей;
|
||||
* разбирать сообщение сервера на стороне панели.
|
||||
|
||||
**Локализация** строится по ключу `error.code.<код>` с параметрами правила.
|
||||
Наборы ключей `ru` и `en` обязаны совпадать: забытый ключ не ломает ни типы, ни
|
||||
сборку — vue-i18n молча отдаёт сам ключ, и оператор видит `error.code.min_length`
|
||||
вместо фразы.
|
||||
|
||||
**Состояние сессии** сообщается кодами `unauthorized`, `session_expired`,
|
||||
`token_invalid`, `account_disabled` при `code = 50401`. Панель по ним
|
||||
показывает диалог и возвращает на форму входа.
|
||||
|
||||
**Вход администратора.** Неверные учётные данные всегда дают один код
|
||||
`invalid_credentials`: «такого администратора нет» и «пароль не тот» обязаны
|
||||
быть неразличимы снаружи. Отказ хранилища при этом остаётся системной ошибкой —
|
||||
выдавать «неверный логин или пароль» при недоступной базе значит отправить
|
||||
оператора искать несуществующую опечатку.
|
||||
|
||||
---
|
||||
|
||||
## 3. Необязательные поля и генерация секретов
|
||||
|
||||
**Правило.** Поле, объявленное необязательным, обязано принимать три
|
||||
неразличимых состояния: отсутствует, пустая строка, одни пробелы.
|
||||
|
||||
Это НЕ следует автоматически из тега `omitempty`. В `go-playground/validator`
|
||||
он не пропускает правило, если поле объявлено указателем и указатель не nil —
|
||||
`hasValue` считает указатель на пустую строку «значением». Поэтому DTO,
|
||||
у которых есть необязательные строковые поля, реализуют `dto.Normalizable`, и
|
||||
слой контроллеров вызывает `Normalize()` между разбором тела и проверкой
|
||||
правил.
|
||||
|
||||
**Граница проходит по каждому полю отдельно.** Общее правило «пусто → не
|
||||
задано» молча ломает смысл:
|
||||
|
||||
| Поле | Пустое значение означает |
|
||||
| --- | --- |
|
||||
| `secret` при создании | сгенерировать |
|
||||
| `secret` при изменении | не менять |
|
||||
| `name` при изменении | не менять |
|
||||
| `remark` | убрать пометку |
|
||||
| `disabled = 0` | включён |
|
||||
| `quotaBytes = 0` | нулевая квота |
|
||||
|
||||
**Генерация секрета принадлежит серверу.** Панель, подставляющая значение в
|
||||
пустое поле, выполняла бы обещание «сгенерируем автоматически» ровно для одной
|
||||
двери из четырёх: остаются прямой вызов API, импорт и будущие клиенты.
|
||||
Единственная реализация — `service.GeneratePeerSecret`, на базе
|
||||
`util.RandomString` (`crypto/rand` с отбрасыванием смещённых байтов). Тот же
|
||||
вызов используется импортом.
|
||||
|
||||
**Имя пира** проверяется одним правилом `service.IsValidPeerName` на весь
|
||||
продукт: в таблицу пиров ведут две двери, и они не имеют права требовать
|
||||
разного.
|
||||
|
||||
---
|
||||
|
||||
## 4. Атрибуция
|
||||
|
||||
Адрес атрибуции объявлен один раз в `apps/frontend/src/constants/branding.ts` и
|
||||
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
|
||||
ни config API, ни таблица `config`, ни настройки панели его не содержат и не
|
||||
могут переопределить.
|
||||
+20
-1
@@ -28,6 +28,25 @@ Production builder работает на отдельном build host:
|
||||
|
||||
Builder не является частью target install flow: на target server приезжает уже готовый install package, без JS/TS/Go build step.
|
||||
|
||||
### Память build-хоста
|
||||
|
||||
Самый требовательный шаг сборки — не компиляция, а `govulncheck`: он строит
|
||||
граф достижимости по всему модулю вместе со stdlib.
|
||||
|
||||
На прогоне `v1.0.0-rc1` машина с ~1.9 GiB RAM и **нулевым swap** получила
|
||||
`govulncheck`, убитый Linux OOM killer. После подключения временного swap 4 GiB
|
||||
полный security gate прошёл.
|
||||
|
||||
4 GiB swap — не формально доказанный минимум, а подтверждённая рабочая
|
||||
конфигурация того прогона; см.
|
||||
[отчёт приёмки](../acceptance/2026-09-01-v1.0.0-rc1-host-acceptance.md).
|
||||
Практическое следствие: сборочная машина примерно с 2 GiB RAM без swap может
|
||||
оказаться недостаточной, и отказ выглядит как убитый процесс, а не как
|
||||
внятная ошибка инструмента.
|
||||
|
||||
Это требование к сборочной машине, а не к target-серверу: `govulncheck` в
|
||||
runtime-артефакт не попадает.
|
||||
|
||||
## Что хранится в репозитории проекта
|
||||
|
||||
Минимум:
|
||||
@@ -138,7 +157,7 @@ bundle из кода, который не проходит проверку ти
|
||||
|
||||
Закрыто это не подавлением, а границей: `api/config/hysteriaViewModel.ts`
|
||||
превращает ответ сервера в модель, где присутствие каждой секции — свойство
|
||||
типа. Подробности — в [docs/04](04-admin-panel.md).
|
||||
типа. Подробности — в [docs/04](../admin/04-admin-panel.md).
|
||||
|
||||
Контракт теперь читается так:
|
||||
|
||||
@@ -41,7 +41,7 @@ HY2XS v1 не поддерживает установку поверх и не
|
||||
- /etc/hysteria/post-install.env (post-install.env предыдущей установки HY2XS)
|
||||
- hy2xs-admin.service (systemd-юнит админки HY2XS)
|
||||
|
||||
Очистите сервер и установите HY2XS заново: см. docs/14-legacy-cleanup.md
|
||||
Очистите сервер и установите HY2XS заново: см. docs/operations/14-legacy-cleanup.md
|
||||
```
|
||||
|
||||
Если вы видите этот текст — сервер в том же состоянии, в котором был до запуска.
|
||||
@@ -120,7 +120,7 @@ clean-host. Ко второму вызову на диске лежал собс
|
||||
пути, которые раньше приходилось исключать, теперь создаются после проверки.
|
||||
|
||||
Полный список маркеров чужой установки и порядок очистки —
|
||||
[14-legacy-cleanup.md](14-legacy-cleanup.md).
|
||||
[14-legacy-cleanup.md](../operations/14-legacy-cleanup.md).
|
||||
|
||||
### Раннеры подпроцессов: два набора, а не один
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Как запускать тесты
|
||||
|
||||
Часть набора проверок HY2XS. Карта всех частей — [docs/testing/README.md](README.md).
|
||||
|
||||
## Цель набора
|
||||
|
||||
Зафиксировать checklist для двухслойной схемы «builder layer + runtime/target layer».
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
# Юнит-тесты и типы оркестратора
|
||||
cd orchestrator && bun install --frozen-lockfile && bun run check && bun test
|
||||
|
||||
# Тесты и статический анализ HY2XS admin
|
||||
cd apps && go vet ./... && go test ./...
|
||||
|
||||
# Проверка типов и сборка frontend
|
||||
cd apps/frontend && pnpm install --frozen-lockfile && pnpm run verify
|
||||
|
||||
# Контракты панели: спрайт иконок, словари локализации, коды ошибок, атрибуция
|
||||
bun test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts
|
||||
|
||||
# Сверка среды разработки с versions.env (ничего не меняет)
|
||||
./tools/dev/doctor.sh
|
||||
|
||||
# Полный E2E с реальным клиентом Hysteria (Debian 13 amd64; нужен Go)
|
||||
HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
|
||||
|
||||
# Production-сборка: прогоняет тесты, резолвер и compatibility gate
|
||||
./tools/build/build.sh
|
||||
```
|
||||
|
||||
`build.sh` останавливается, если падают тесты оркестратора, контрактные тесты
|
||||
панели, тесты админки или compatibility gate.
|
||||
|
||||
## Почему контракты панели проверяет Bun, а не vitest
|
||||
|
||||
Проверяемые модули (`SvgIcon/symbol.ts`, `constants/branding.ts`, словари
|
||||
локализации) намеренно чистые: ни Vite, ни DOM в них нет, поэтому их можно
|
||||
выполнить вне браузера уже закреплённым в `versions.env` Bun.
|
||||
|
||||
Vitest с jsdom не вычисляет `currentColor` и визуальной корректности всё равно
|
||||
не доказал бы, зато привёл бы в граф `pnpm audit` — а его порог считается по
|
||||
ВСЕМУ lock-файлу frontend — сотню транзитивных зависимостей ради нулевой
|
||||
дополнительной гарантии.
|
||||
|
||||
Визуальная часть остаётся ручной и фиксируется в отчёте приёмки, см.
|
||||
[docs/acceptance/README.md](../acceptance/README.md).
|
||||
@@ -0,0 +1,714 @@
|
||||
# A. Тесты слоя сборки
|
||||
|
||||
Часть набора проверок HY2XS. Карта всех частей — [docs/testing/README.md](README.md).
|
||||
|
||||
## A. Builder layer tests
|
||||
|
||||
### Проверяем
|
||||
1. builder запускается на Debian 13 amd64 build host
|
||||
2. итоговый пакет собирается без target-side шагов
|
||||
3. bundled HY2XS admin реально входит в пакет
|
||||
4. package metadata / build id присутствуют
|
||||
5. compiled Bun/TypeScript orchestrator artifact присутствует
|
||||
6. в пакет не попадает build-мусор
|
||||
7. builder сам доставляет отсутствующие build-зависимости
|
||||
8. builder проверяет версии Go/Bun/Node.js/pnpm
|
||||
9. builder пишет версии toolchain в metadata
|
||||
10. builder прогоняет `bun test` и `go test` до упаковки
|
||||
|
||||
## A1. Latest-stable resolver
|
||||
|
||||
Фикстуры и ожидаемое поведение (`orchestrator/test/hysteria-release.test.ts`):
|
||||
|
||||
| Сценарий | Ожидание |
|
||||
| --- | --- |
|
||||
| stable `app/v2.12.2` | выбирается |
|
||||
| prerelease `app/v2.13.0` | игнорируется |
|
||||
| draft `app/v2.14.0` | игнорируется |
|
||||
| чужое семейство тегов (`core/`, `docs/`) | игнорируется |
|
||||
| тег без префикса `app/` | игнорируется |
|
||||
| `app/v2.9.10` против `app/v2.9.2` | выбирается `2.9.10` (числовое сравнение, не строковое) |
|
||||
| отсутствует `hysteria-linux-amd64` | ошибка |
|
||||
| дублирующийся `hysteria-linux-amd64` | ошибка, а не случайный выбор |
|
||||
| non-https URL артефакта | ошибка |
|
||||
| невалидный semver в теге | игнорируется |
|
||||
| пустой список релизов | понятная ошибка |
|
||||
| несовпадение SHA-256 | сборка падает |
|
||||
| сетевая ошибка / rate limit | понятная ошибка с подсказкой про `GITHUB_TOKEN` и `HYSTERIA_CHANNEL=pinned` |
|
||||
|
||||
Отдельно проверяется, что **`latest stable` — это именно stable, а не максимальная строка или самый свежий тег**.
|
||||
|
||||
## A2. Release rollover
|
||||
|
||||
Ключевой acceptance-критерий модели «latest на сборке»:
|
||||
|
||||
```text
|
||||
Сегодня: latest = 2.12.2 → пакет A закрепляет 2.12.2
|
||||
Завтра: latest = 2.12.3 → пакет B закрепляет 2.12.3
|
||||
|
||||
Повторная установка пакета A всё равно ставит 2.12.2
|
||||
```
|
||||
|
||||
Проверяется на двух уровнях:
|
||||
- резолвер даёт разный результат на разных снимках upstream (`orchestrator/test/release-rollover.test.ts`);
|
||||
- install-time код не импортирует резолвер, не обращается к `api.github.com` и не использует moving `latest` — это утверждение проверяется тестом и acceptance-шагом сборки.
|
||||
|
||||
## A3. Compatibility gate
|
||||
|
||||
1. скачанный артефакт проходит проверку SHA-256;
|
||||
2. `hysteria version` совпадает с разрешённой версией;
|
||||
3. реальный бинарник принимает канонический конфиг HY2XS для Gecko;
|
||||
4. то же для Salamander;
|
||||
5. при несовместимости падает **сборка** с сообщением `BUILD FAILED: unsupported Hysteria stable vX.Y.Z`, а не установка у пользователя.
|
||||
|
||||
## A4. Конфигурационный контракт (unit)
|
||||
|
||||
Таблица `orchestrator/test/env.test.ts`:
|
||||
|
||||
| Вход | Ожидание |
|
||||
| --- | --- |
|
||||
| значение не задано | `gecko` |
|
||||
| `gecko` | принято |
|
||||
| `salamander` | принято |
|
||||
| неизвестный тип | отклонено |
|
||||
| `Gecko` (регистр) | отклонено |
|
||||
| gecko `max < min` | отклонено |
|
||||
| gecko `max > 2048` | отклонено |
|
||||
| gecko `max == 2048` | принято |
|
||||
| неположительный/нецелый `min` | отклонено |
|
||||
| пустой obfs-пароль | автогенерация, а не пустое значение в конфиге |
|
||||
| `HY2XS_CONFIG_SCHEMA_VERSION=1` | отклонено с указанием на чистую установку |
|
||||
| `HY2XS_CONFIG_SCHEMA_VERSION` отсутствует | отклонено как legacy-конфигурация |
|
||||
| `HY2XS_CONFIG_SCHEMA_VERSION=` (пусто) | отклонено как legacy-конфигурация |
|
||||
| `HY2XS_CONFIG_SCHEMA_VERSION=2` | принято |
|
||||
|
||||
Отсутствие маркера схемы отклоняется намеренно: до v1 этого поля не
|
||||
существовало, поэтому именно пустое значение — самый вероятный признак
|
||||
конфигурации 0.x. Любой fallback здесь молча превращал бы legacy-конфиг в
|
||||
якобы валидный.
|
||||
|
||||
Отдельно — round-trip `parse(render(config)) == config`. Этот тест ловит класс ошибок «в рендер runtime-конфига попал литерал вместо значения из конфигурации».
|
||||
|
||||
Рендер конфига (`orchestrator/test/render-config.test.ts`):
|
||||
|
||||
- Gecko рендерит **только** gecko-подблок;
|
||||
- Salamander рендерит **только** salamander-подблок;
|
||||
- в конфиге никогда нет двух подтипов obfs одновременно;
|
||||
- шаблон не содержит захардкоженного типа обфускации;
|
||||
- пароль с пробелами и спецсимволами экранируется;
|
||||
- YAML-инъекция через пароль отклоняется даже в обход env-валидации.
|
||||
|
||||
## A5. Граница установки и поколение (unit)
|
||||
|
||||
`orchestrator/test/clean-host.test.ts`:
|
||||
|
||||
- чистый хост проходит;
|
||||
- **каждый** маркер по отдельности останавливает установку;
|
||||
- список покрывает состояние, юниты, бинарник Hysteria и наследие 0.x;
|
||||
- пути из конфигурации (`HY2XS_INSTALL_DIR`, `HY2XS_DATA_DIR`, `HY2XS_LOG_DIR`)
|
||||
попадают в список, а не только значения по умолчанию;
|
||||
- всё, что удаляет `purge-v0.sh`, покрыто маркерами clean-host: два списка
|
||||
описывают одну границу и не имеют права разъезжаться;
|
||||
- bootstrap-пути (`/usr/local/lib/hy2xs`, `/usr/local/lib/hy2xs/package`,
|
||||
`/usr/local/bin/hy2xs-orchestrator`) остаются маркерами **без исключений**:
|
||||
их создаёт оркестратор уже после проверки чистоты хоста, поэтому «мягкой»
|
||||
версии списка для PHASE 1 больше не существует;
|
||||
- эти пути берутся из `config/profile.ts`, а не из копий строк: шаг, который
|
||||
их создаёт, и контракт, который на них отказывает, обязаны читать одно
|
||||
значение;
|
||||
- сообщение перечисляет найденные маркеры и говорит, что хост не изменён.
|
||||
|
||||
`orchestrator/test/install-boundary.test.ts`:
|
||||
|
||||
- под read-only guard недоступны `writeText`, `writeTextAtomic`, `ensureDir` и
|
||||
все `runMutating*`-раннеры;
|
||||
- read-only раннеры под guard'ом продолжают работать: разделение API — это не
|
||||
запрет наблюдения, а запрет мутации;
|
||||
- классификация отказа зависит от ownership-флагов и фазы, а **не** от текста
|
||||
ошибки;
|
||||
- `fatal_pre_apply` недостижим ни при одном взведённом флаге, включая
|
||||
`stateTouched`: записанный `install-state.json` уже делает хост изменённым;
|
||||
- частично выполненная запись маркера (отказ на `chown` после успешного `write`)
|
||||
тоже даёт post-apply: флаг взводится **до** записи, а не после неё;
|
||||
- начатая (не обязательно завершённая) установка пакетов уже даёт
|
||||
`fatal_post_apply` — регрессия на сценарий «PHASE 0 прошла, apt-get упал,
|
||||
установщик заявил, что ничего не тронул»;
|
||||
- начатый bootstrap (`bootstrapTouched`) тоже даёт `fatal_post_apply`: раскладку
|
||||
выполняет оркестратор, и она учитывается наравне с остальными шагами.
|
||||
|
||||
`orchestrator/test/install-sequence.test.ts` — порядок фаз, который иначе
|
||||
проверяется только на живом сервере:
|
||||
|
||||
- `preflight()` в режиме install **отказывается работать без явного
|
||||
`checkCleanHost`**, и отказ наступает до любой работы с системой;
|
||||
- clean-host запрашивается ровно один раз за операцию и **до** первой записи
|
||||
install-state — регрессия на сценарий, где повторный preflight после
|
||||
`installDeps` опознавал собственный `install-state.json` как маркер чужой
|
||||
установки и валил каждую чистую установку;
|
||||
- проход capabilities явно отказывается от clean-host;
|
||||
- `bootstrapTouched` взводится **перед** `bootstrapRuntime`, а сам bootstrap
|
||||
идёт до `installDeps`;
|
||||
- дальнейшая установка работает от установленного runtime-пакета;
|
||||
- `diagnosticsCollect` обёрнута в `try/catch`, и `catch` стоит **до** отката:
|
||||
диагностика — best effort, откат — обязателен;
|
||||
- в `package/install.sh` не осталось ни одной мутирующей команды, и он
|
||||
передаёт управление оркестратору через `exec`;
|
||||
- классификация отказа `reconfigure`/`repair` идёт по ownership-флагам, а не по
|
||||
регулярному выражению над текстом ошибки.
|
||||
|
||||
`orchestrator/test/install-state.test.ts`:
|
||||
|
||||
- маркер текущего поколения принимается;
|
||||
- маркер без полей поколения отклоняется, **несмотря на `installed: true`**;
|
||||
- чужой `product`, `release_line` или `config_schema_version` отклоняются;
|
||||
- записываемый маркер всегда несёт идентификацию поколения;
|
||||
- незавершённая установка подсказывает `repair --allow-partial-state`.
|
||||
|
||||
## A5a. Обязательный откат (unit)
|
||||
|
||||
`orchestrator/test/rollback-mandatory.test.ts` — поведение механизма проверяется
|
||||
настоящим внедрением отказа в стадию, проводка команд к нему — разбором
|
||||
исходника (поднять systemd и nftables в этой среде нельзя):
|
||||
|
||||
- при отказе первой стадии отката выполняются **все** последующие;
|
||||
- отказавшие стадии перечисляются по именам и в порядке объявления;
|
||||
- откат не бросает даже при отказе всех стадий: наружу обязана уйти исходная
|
||||
ошибка операции, а не проблема внутри восстановления;
|
||||
- не-`Error` причина (брошенная строка) не роняет откат;
|
||||
- `persistFailureState` не пробрасывает отказ записи наружу — это и был P0:
|
||||
падение записи маркера отменяло откат целиком;
|
||||
- в обработчике ошибки `install` и `reconfigure` не осталось незащищённой записи
|
||||
состояния (`advanceInstallState` / `markPhase` голым `await`);
|
||||
- откат в обеих командах идёт через `runRollbackStages`, а не цепочкой `await`;
|
||||
- внутри `rollbackCurrentState` ни одна команда не обрывает следующие: отказ
|
||||
`systemctl daemon-reload` отменял перезапуск сервисов строкой ниже, то есть
|
||||
восстановленные unit-файлы так и не применялись.
|
||||
|
||||
## A5c. Целостность резервных копий (unit)
|
||||
|
||||
`orchestrator/test/backup-integrity.test.ts`:
|
||||
|
||||
- копия каждой операции адресуется своим каталогом, разные `op-id` не
|
||||
пересекаются;
|
||||
- разные пути дают разные имена файлов копии, и имя не выходит за пределы
|
||||
каталога;
|
||||
- отсутствовавший файл записан **явно** (`present: false`), а не выведен из
|
||||
неудачи `cp`;
|
||||
- манифест переживает сериализацию без потерь;
|
||||
- разбор строгий: манифест чужой операции, неизвестная версия, битый JSON,
|
||||
запись без пути, без признака существования или без имени копии —
|
||||
отклоняются. «Поле не разобралось, будем считать, что файла не было» означало
|
||||
бы удаление существующего файла при откате;
|
||||
- копирование в `reconfigure` и в `firewall` не глушит ошибки, факт создания
|
||||
копии проверяется, а копия снимается **до** первой мутации;
|
||||
- маркер готовности firewall (`prepared`) ставится после проверенных копий;
|
||||
- восстановление firewall не глушит ошибки `cp`/`nft`, а резервные копии
|
||||
удаляются только после подтверждённого успеха — иначе сохраняются вместе с
|
||||
сообщением `manual recovery data preserved at …`.
|
||||
|
||||
## A5d. Порядок фиксации успеха (unit)
|
||||
|
||||
`orchestrator/test/commit-ordering.test.ts`:
|
||||
|
||||
- снятие таймера автоотката и удаление резервных копий — **разные** операции
|
||||
(`disarmFirewallRollback` / `cleanupFirewallRollback`), объединённая
|
||||
`cancelFirewallRollback` не вернулась ни в один вызов;
|
||||
- `disarm` не удаляет копии;
|
||||
- порядок в `install` и `reconfigure` одинаков: `disarm` → долговечная запись
|
||||
`installed` → `cleanup`;
|
||||
- успешный smoke фиксируется отдельной фазой до снятия таймера;
|
||||
- уборка после точки фиксации выполняется best-effort.
|
||||
|
||||
## A5e. Транзакционность rollback guard (unit)
|
||||
|
||||
`orchestrator/test/firewall-guard.test.ts`:
|
||||
|
||||
- маркер `auto-rollback-fired` создаётся rollback-скриптом **первым действием** —
|
||||
до проверки `prepared` и до первой попытки восстановления, в том числе когда
|
||||
восстанавливать нечего. Без этого «guard сработал» недоказуемо: транзиентные
|
||||
юниты systemd после выполнения исчезают, и `systemctl stop` для них неотличим
|
||||
от успешного снятия взведённого таймера;
|
||||
- скрипт не маскирует ошибки (`|| true`, `2>/dev/null`), не использует `set -e`
|
||||
и возвращает накопленный `rc`: каждый сообщённый отказ поднимает код возврата,
|
||||
поэтому частичное восстановление уходит в `failed`, а не в молчаливый `0`;
|
||||
- `rc` объявляется **до** создания маркера, а ранний выход возвращает его, а не
|
||||
жёсткий `0`. Инвариант фиксации верен только при условии «guard способен
|
||||
записать маркер»: пока `rc=0` стояло после, отказ записи (заполненный tmpfs
|
||||
`/run`, read-only ФС) не влиял ни на что — скрипт восстанавливал прежний
|
||||
firewall, завершался нулём, и операция фиксировала успех после реально
|
||||
сработавшего отката. Теперь у факта два канала: маркер и отказ юнита;
|
||||
- маркер создаётся `touch`, а не `: >file`: двоеточие — special builtin POSIX,
|
||||
и ошибка перенаправления на нём обязана завершить неинтерактивный shell
|
||||
целиком, то есть в dash скрипт умер бы **до** восстановления firewall;
|
||||
- поведенчески проверяется ранний путь скрипта — он заканчивается до первой
|
||||
команды восстановления и потому безопасен для запуска: при доступном каталоге
|
||||
маркер создаётся и выход нулевой, при недоступном — выход ненулевой;
|
||||
- скрипт не трогает `nftables.service`: у него `ExecStop=nft flush ruleset`, и
|
||||
остановка сервиса стёрла бы только что восстановленные правила;
|
||||
- скрипт разбирается **настоящим** shell-парсером. Парсер принимается только
|
||||
после двусторонней проверки — он обязан принять заведомо корректный скрипт и
|
||||
отвергнуть заведомо сломанный, иначе тест ничего не проверяет;
|
||||
- небезопасный ключ операции отвергается: он служит именем каталога, именем
|
||||
systemd-юнита и подставляется в текст скрипта;
|
||||
- снятие guard проверяет маркер **с обеих сторон** остановки, подтверждается
|
||||
`ActiveState` обоих юнитов, и на пути фиксации успеха допускает единственное
|
||||
состояние — `inactive`; на пути восстановления `failed` тоже допустим;
|
||||
- сработавший guard опознаётся по **типу** ошибки: ошибка с тем же текстом, но
|
||||
другого типа классифицируется по владению, как и прежде;
|
||||
- эффективный firewall сверяется семантически (фрагмент, entrypoint,
|
||||
загруженная таблица), и проверка выполняется read-only раннерами — та же
|
||||
проверка идёт в `doctor`;
|
||||
- состояние `nftables.service` снимается до первой мутации и восстанавливается
|
||||
стадиями, идущими **до** применения ruleset;
|
||||
- candidate-файлы убираются после успеха и best-effort при откате;
|
||||
- ключ операции считается одной функцией: install писал в маркер сырой
|
||||
ISO-timestamp, и путь `/run/hy2xs/rollback/<op_id>` из runbook не существовал;
|
||||
- команда взведения guard проверяется как **значение**, а не грепом по
|
||||
исходнику: `buildArmGuardArgv` возвращает готовый argv, и тест сверяет его
|
||||
целиком — имя юнита с явным суффиксом, `--on-active=45s`,
|
||||
`--timer-property=AccuracySec=1s`, `--timer-property=RemainAfterElapse=no`.
|
||||
Небезопасный ключ операции отвергается до запуска: shell в этой команде не
|
||||
участвует, поэтому единственная защита — отказ;
|
||||
- барьер покоя проверяется **поведенчески**, с подставляемым наблюдателем
|
||||
systemd (`SystemdUnitProbe`), без systemd и без Linux:
|
||||
- взведённый таймер (`active/waiting`) и идущий прямо сейчас откат
|
||||
(`activating`) запрещают операцию с `PendingRecoveryError`;
|
||||
- отказ `list-units` **и** отказ `show` на любом отдельном юните дают
|
||||
`GuardStateUnknownError`: отсутствие ответа systemd — отсутствие
|
||||
наблюдения, а не наблюдение покоя. Прежний тест закреплял обратное
|
||||
(`return [];` в тексте функции) и потому пережил инверсию смысла: строка
|
||||
была на месте, а решение стало неверным;
|
||||
- оба отказа имеют общего предка `OperationBarrierError`;
|
||||
- покой — ровно `inactive` и `failed`; `maintenance`, `refreshing` и любое
|
||||
незнакомое состояние блокируют операцию, потому что политика перечисляет
|
||||
безопасные состояния, а не опасные;
|
||||
- `*.timer` в `SubState=elapsed` считается покоем: `TIMER_ELAPSED` в systemd
|
||||
отображается в `UNIT_ACTIVE`, и без этой ветки отработавший таймер
|
||||
блокировал бы `repair` навсегда. У сервиса тот же `SubState` ничего не
|
||||
значит;
|
||||
- разбор вывода `list-units` находит имя юнита и когда первой колонкой идёт
|
||||
маркер `●` (состояние `failed`) — иначе терялся бы именно аварийно
|
||||
сработавший guard.
|
||||
|
||||
## A5f. Взаимное исключение операций (unit)
|
||||
|
||||
`orchestrator/test/operation-lock.test.ts`:
|
||||
|
||||
- второй захват при живом держателе отказывает, и отказ называет держателя —
|
||||
команду, PID и время начала;
|
||||
- замок снимается в `finally` и после отказа операции: иначе первая же неудачная
|
||||
установка заблокировала бы сервер до перезагрузки;
|
||||
- замок мёртвого держателя переиспользуется, временный файл переиспользования не
|
||||
остаётся на диске;
|
||||
- непонятое содержимое замка **не** снимается автоматически: оно не доказывает
|
||||
отсутствие операции, и сомнение трактуется в пользу отказа;
|
||||
- `readLockHolder` отличает «замка нет» от «замок нечитаем»;
|
||||
- захват под read-only guard отказывает, наблюдение — разрешено. Замок берётся
|
||||
до включения guard, и проверка существует, чтобы перенос захвата внутрь
|
||||
читающей фазы отказал громко, а не записал файл молча;
|
||||
- барьер покоя вызывается **дважды** — до захвата и уже под замком, — а отказ
|
||||
второй проверки снимает замок за собой. Замок сам по себе гарантии не даёт:
|
||||
он защищает production paths, пока жив держатель, а rollback guard переживает
|
||||
свой процесс;
|
||||
- политика CLI закреплена структурно: `install`/`reconfigure`/`repair`/`doctor`
|
||||
вызываются только под замком, `status`/`diagnostics` его не берут, но сообщают
|
||||
об идущей операции, а `preflight-install` отказывает до собственных проверок.
|
||||
|
||||
## A5b. Долговечная запись маркера (unit)
|
||||
|
||||
`orchestrator/test/atomic-write.test.ts`:
|
||||
|
||||
- содержимое заменяется целиком, а не дописывается поверх прежнего;
|
||||
- при отказе записи по целевому пути остаётся **прежний полный** документ;
|
||||
- временный файл не выживает ни при успехе, ни при отказе подстановки;
|
||||
- права выставляются точно, независимо от umask (`0600`, `0644`);
|
||||
- `ensureDir` приводит права **существующего** каталога к объявленным: `mkdir`
|
||||
этого не делает, поэтому «создать» и «права такие, как объявлено» — два
|
||||
разных действия;
|
||||
- и запись, и создание каталога проходят через read-only guard;
|
||||
- `persistInstallState` под guard'ом отказывает: единственный писатель маркера
|
||||
обязан идти через guarded-примитивы, иначе PHASE 0 смогла бы создать
|
||||
`/var/lib/hy2xs`, и «read-only» перестало бы быть правдой ровно для того
|
||||
файла, по которому clean-host принимает решение.
|
||||
|
||||
- `ensureDir` сообщает, был ли каталог **фактически создан**: родитель
|
||||
синхронизируется только при создании, иначе долговечность записи `hy2xs` в
|
||||
`/var/lib` осталась бы необеспеченной, и после потери питания мог исчезнуть
|
||||
весь каталог вместе с маркером.
|
||||
|
||||
Наличие самих `fsync` проверяется приёмкой сборки по исходнику: из
|
||||
пользовательского процесса их не наблюдать, а без них `rename()` даёт
|
||||
атомарность видимости без долговечности.
|
||||
|
||||
## A6. Редактирование секретов (unit)
|
||||
|
||||
`orchestrator/test/redaction.test.ts`:
|
||||
|
||||
- machine token не переживает редакцию серверного конфига — регрессия на
|
||||
построчное правило `auth:`, оставлявшее нетронутым `auth.http.url`;
|
||||
- obfs-пароль не переживает редакцию;
|
||||
- результат остаётся валидным YAML;
|
||||
- несекретные поля сохраняются: диагностика должна оставаться полезной;
|
||||
- неизвестное поле с секретоподобным именем вырезается;
|
||||
- `acme.dns.config` вырезается целиком;
|
||||
- невалидный YAML не роняет редакцию и всё равно чистится;
|
||||
- секрет внутри URL-значения в env вырезается, даже если имя ключа несекретное
|
||||
(`HY2_AUTH_URL`);
|
||||
- URL под **произвольным** именем ключа теряет встроенные учётные данные и
|
||||
секретные query-параметры, но сохраняет адрес; то же для URL внутри списка;
|
||||
- `redactLogText` вырезает machine token из строки journald, сохраняя host,
|
||||
port и path; ловит секрет и вне URL; не трогает обычные строки; сохраняет
|
||||
хвостовую пунктуацию; идемпотентен — регрессия на diagnostics-бандл, где
|
||||
редактировались env и YAML, а `journal-admin.log` копировался как есть.
|
||||
|
||||
## A7. Machine token в журналах (unit)
|
||||
|
||||
`apps/middleware/log_test.go` — запрос
|
||||
`/internal/hysteria/auth?access_token=SUPER_SECRET_SENTINEL`:
|
||||
|
||||
- sentinel **не появляется** в журнале ни в каком виде;
|
||||
- в журнале есть `reqPath`, поля `reqUri` нет;
|
||||
- имя query-параметра сохраняется (`reqQueryKeys`), значение — нет;
|
||||
- пустой список параметров в журнал не пишется;
|
||||
- то же правило действует на операторских маршрутах, а не только на машинном.
|
||||
|
||||
`apps/service/log_sanitize_test.go` — тот же санитайз на стороне админки: журнал
|
||||
Hysteria покидает сервер через `ExportLog`, а `HY2_AUTH_URL` несёт
|
||||
`access_token`, который upstream волен упомянуть в сообщении об ошибке.
|
||||
|
||||
## A8. Инвариант публичного endpoint (unit)
|
||||
|
||||
`orchestrator/test/network-endpoint.test.ts` — проба подменяет и DNS, и список
|
||||
локальных адресов, поэтому тест не зависит ни от сети, ни от интерфейсов машины
|
||||
разработчика.
|
||||
|
||||
| Сценарий | Результат |
|
||||
| --- | --- |
|
||||
| A-запись == текущий публичный IPv4 | PASS |
|
||||
| A-запись == старый IPv4 | FAIL, в тексте оба адреса |
|
||||
| A-запись отсутствует | FAIL |
|
||||
| A == текущий + чужой | FAIL |
|
||||
| у сервера 2 публичных IP, DNS использует один | PASS |
|
||||
| `PUBLIC_HOST` — правильный IPv4-литерал | PASS |
|
||||
| `PUBLIC_HOST` — устаревший IPv4-литерал | FAIL |
|
||||
| `DOMAIN` совпадает, отдельный `PUBLIC_HOST` устарел | FAIL |
|
||||
| `PUBLIC_HOST` совпадает, отдельный TLS-домен устарел | FAIL |
|
||||
| нет ни одного локального публичного IPv4 | FAIL |
|
||||
| `HY2XS_PUBLIC_ENDPOINT_POLICY` = strict / warn / off | fail / warn / skip |
|
||||
| отсутствие A-записи при любой политике | FAIL |
|
||||
| отказ резолвера (SERVFAIL/таймаут/отказ) при любой политике | FAIL, отдельный текст |
|
||||
|
||||
Отдельно проверяется классификация IPv4. Список исключений приведён к IANA
|
||||
Special-Purpose Address Registry: приватные, CGNAT, link-local, multicast,
|
||||
reserved, benchmarking (`198.18/15`), 6to4-anycast и **документационные**
|
||||
диапазоны (`192.0.2/24`, `198.51.100/24`, `203.0.113/24`) не считаются
|
||||
публичным адресом сервера. Регрессия: `203.0.113.5` из RFC-примеров раньше
|
||||
проходил проверку как обычный публичный адрес. Границы проверяются с обеих
|
||||
сторон — `172.32.0.0`, `192.0.1.1`, `198.20.0.1` и `203.0.112.255` считаются
|
||||
публичными.
|
||||
|
||||
Отказ резолвера отделён от отсутствия записи: `ENODATA`/`ENOTFOUND`/`NXDOMAIN`
|
||||
— это «нет A-записи» и чинится в DNS-панели, всё остальное — «резолвер не
|
||||
ответил» и чинится в `/etc/resolv.conf`. Раньше оба случая печатались как
|
||||
«has no A-record», и при сломанном резолвере оператор шёл править запись,
|
||||
которая была на месте. Фатальны оба: без ответа резолвера проверка не выполнена,
|
||||
а не «выполнена с замечанием».
|
||||
|
||||
## A9. Регистрация маршрутов (unit)
|
||||
|
||||
`apps/router/router_test.go` — единственное место, где ошибка проявляется
|
||||
**паникой при старте сервиса**, а не ответом с кодом. Конфликт с
|
||||
wildcard-маршрутом фронтенда или дублирующая регистрация обнаружились бы иначе
|
||||
только на живом сервере.
|
||||
|
||||
- контур маршрутов собирается без паники;
|
||||
- machine-auth зарегистрирован ровно на `constant.HysteriaMachineAuthPath`;
|
||||
- операторский и auth API — под `constant.AdminAPIBase`;
|
||||
- ни один маршрут не начинается со старого пространства имён;
|
||||
- удалённые маршруты (включая `exportConfig`/`importConfig` и `getConfig`) не
|
||||
вернулись;
|
||||
- пространство `/api/config` закрыто: в нём ровно четыре маршрута, и любой
|
||||
новый обязан быть добавлен в тест осознанно;
|
||||
- `/healthz` на месте.
|
||||
|
||||
## A9a. Доступ к таблице `config` (unit)
|
||||
|
||||
`apps/model/constant/config_test.go` — allowlist как структура, а не как
|
||||
соглашение:
|
||||
|
||||
- ни один внутренний ключ не читается и не записывается через API;
|
||||
- `JWT_SECRET`, `PEER_SECRET_KEY`, `PEER_SECRET_ENCRYPTION_KEY` и
|
||||
`HYSTERIA2_TRAFFIC_STATS_SECRET` поимённо объявлены внутренними;
|
||||
- пользовательские настройки остаются доступными;
|
||||
- множество записываемых ключей — подмножество читаемых;
|
||||
- неизвестный ключ закрыт **по умолчанию**: забытый при denylist ключ был бы
|
||||
сразу публичным.
|
||||
|
||||
`apps/controller/config_test.go` — то же на уровне HTTP:
|
||||
|
||||
- чтение и запись каждого секрета отклоняются;
|
||||
- секрет, спрятанный среди разрешённых ключей, отклоняет весь запрос;
|
||||
- отказ наступает **до** обращения к базе (тест работает без SQLite — сам факт,
|
||||
что обработчик не падает, это и доказывает);
|
||||
- ключи оркестратора отклоняются с указанием владельца, а не общим «нет такого
|
||||
ключа»: оператор должен быть отправлен к `hy2xs-orchestrator reconfigure`;
|
||||
- удалённые ключи (`HYSTERIA2_ENABLE`, `HYSTERIA2_CONFIG`,
|
||||
`HYSTERIA2_TRAFFIC_TIME`, `HYSTERIA2_CONFIG_REMARK`) отклоняются как
|
||||
неизвестные — проверка идёт по строковым литералам, потому что соответствующих
|
||||
констант в коде уже нет и появиться они не должны.
|
||||
|
||||
Атомарность партии проверяется на **настоящей** SQLite: без базы утверждение
|
||||
«партия не применилась частично» бессмысленно, поскольку предметом утверждения
|
||||
является именно состояние базы.
|
||||
|
||||
- разрешённый ключ первым, запрещённый вторым → запрос отклонён, значение
|
||||
первого ключа в базе **не изменилось**;
|
||||
- невалидное cron-выражение → отказ, значение в базе не изменилось;
|
||||
- один ключ дважды в партии → отказ (какое из двух значений считать намерением
|
||||
оператора, определить нельзя);
|
||||
- корректная партия → значение сохранено, расписание применено к планировщику,
|
||||
число его записей не выросло.
|
||||
|
||||
Порядок в первом тесте принципиален. Предыдущая версия ставила запрещённый ключ
|
||||
**первым** и до второго элемента не доходила, поэтому проходила и на реализации,
|
||||
которая проверяла и записывала настройки в одном цикле.
|
||||
|
||||
## A9b. Планировщик (unit)
|
||||
|
||||
`apps/service/cron_scheduler_test.go` — планировщик как собственность процесса:
|
||||
|
||||
- четыре последовательные смены расписания **не увеличивают** число записей
|
||||
планировщика (главная регрессия: раньше каждая смена добавляла целый
|
||||
дублирующий набор джоб, а старое расписание продолжало работать);
|
||||
- пустое выражение снимает джобу сброса, непустое возвращает её — без
|
||||
перезапуска процесса;
|
||||
- невалидное выражение не меняет планировщик и не снимает действующую джобу;
|
||||
- набор валидных и невалидных выражений проверяется тем же парсером, что и
|
||||
runtime: то, что `cron` умеет, обязано приниматься, остальное — отклоняться;
|
||||
- невалидное значение в базе **не роняет старт**: на панели висит
|
||||
`/internal/hysteria/auth`, и отказ старта из-за строки расписания положил бы
|
||||
подключения пользователей. Фиксированные джобы поднимаются, сброс отключён,
|
||||
в журнале ERROR, и настройка чинится через API без перезапуска;
|
||||
- второй `InitCron` поверх работающего отклоняется;
|
||||
- `StopCron` идемпотентен и оставляет планировщик пустым.
|
||||
|
||||
## A9c. Токены и пароли (unit)
|
||||
|
||||
`apps/service/jwt_test.go`:
|
||||
|
||||
- round-trip: claims, включая `token_version`, переживают выписку и разбор;
|
||||
- токен, подписанный **другим** HMAC-алгоритмом тем же ключом, отклоняется
|
||||
(прежний `keyfunc` не смотрел на `token.Method` вовсе);
|
||||
- токен без `exp` отклоняется, истёкший отклоняется отдельным сообщением;
|
||||
- токен с чужим `issuer` отклоняется даже при совпадении ключа;
|
||||
- пустой `JWT_SECRET` — отказ и на выписку, и на разбор, а не подпись ключом
|
||||
нулевой длины.
|
||||
|
||||
`apps/util/encrypt_test.go`:
|
||||
|
||||
- `HashPassword` выдаёт bcrypt и солит: два хеша одного пароля различаются;
|
||||
- вход по несолёному SHA-224 (формат предыдущего поколения) **невозможен**;
|
||||
- любая не-bcrypt строка в поле хеша отклоняется.
|
||||
|
||||
`apps/util/rand_test.go` — отсутствие modulo bias: на выборке 200 000 символов
|
||||
частоты первых восьми символов алфавита не отличаются от остальных более чем на
|
||||
5%. Прежняя реализация давала здесь отношение 1.25.
|
||||
|
||||
## A9d. Пир установщика (unit)
|
||||
|
||||
`apps/service/peer_bootstrap_guard_test.go` — защита действует во всех путях
|
||||
записи, а не только в импорте:
|
||||
|
||||
- смена секрета и переименование `bootstrap-admin-peer` отклоняются, состояние
|
||||
в базе не меняется;
|
||||
- переименование обычного пира в зарезервированное имя отклоняется;
|
||||
- создание пира с зарезервированным именем отклоняется;
|
||||
- отключение и изменение квоты **разрешены**;
|
||||
- удаление **разрешено**: это осознанное действие оператора, и расхождения
|
||||
между базой и `bootstrap-admin.secret` оно не создаёт.
|
||||
|
||||
## A9e. Жизненный цикл пира установщика (unit, настоящая SQLite)
|
||||
|
||||
`apps/dao/bootstrap_peer_test.go` — проверяется не функция, а поведение сервиса
|
||||
при перезапуске: дефект, ради которого написан этот файл, проявлялся только на
|
||||
ВТОРОМ запуске, поэтому каждый тест прогоняет полную последовательность
|
||||
`InitSqlAt` дважды на одной базе.
|
||||
|
||||
- первый запуск создаёт пира и выставляет отметку `BOOTSTRAP_PEER_SEEDED`;
|
||||
- обычный перезапуск не пересоздаёт пира и не плодит дублей (`id` тот же,
|
||||
запись ровно одна);
|
||||
- **удаление переживает перезапуск**: после `DELETE` и рестарта пир не
|
||||
возвращается, хотя `HY2XS_ADMIN_CON_PASS` остаётся в окружении;
|
||||
- то же после трёх перезапусков подряд;
|
||||
- отключённый пир сохраняет `disabled = 1` и свой `secret_digest`;
|
||||
- отметка и пир пишутся одной транзакцией: при конфликте `UNIQUE(name)` внутри
|
||||
транзакции отметка не остаётся выставленной;
|
||||
- отсутствие `HY2XS_ADMIN_CON_PASS` на чистой базе — отказ старта;
|
||||
- перезапуск установленного сервиса без этой переменной проходит штатно;
|
||||
- `HYSTERIA2_TRAFFIC_STATS_SECRET`: пустой env при пустой базе — отказ старта,
|
||||
сгенерированного токена в базе не появляется; токен, уже согласованный
|
||||
ранее, принимается без переменной.
|
||||
|
||||
## A9f. Резервная копия пиров (unit)
|
||||
|
||||
`apps/service/peer_export_backup_test.go`:
|
||||
|
||||
- `includeSecrets=true` на исправных данных отдаёт секрет каждого пира;
|
||||
- нерасшифровываемый секрет хотя бы одного пира отклоняет **весь** запрос,
|
||||
сообщение называет пира, частичное содержимое не возвращается;
|
||||
- пир вовсе без шифртекста — тот же отказ;
|
||||
- `includeSecrets=false` повреждённых данных не замечает и пустой `secret`
|
||||
отдаёт штатно: это и есть смысл безопасного режима.
|
||||
|
||||
## A9g. Слой данных: «нет записи» против «база не ответила» (unit)
|
||||
|
||||
`apps/dao/config_test.go`:
|
||||
|
||||
- `UpdateConfig` по отсутствующей строке — **отказ**, а не тихий успех: UPDATE
|
||||
без совпавших строк не является ошибкой SQL, и раньше оператор получал
|
||||
подтверждение изменения, которого не произошло, а планировщик тут же получал
|
||||
новое расписание;
|
||||
- `UpdateConfig` не создаёт строк: это работа `UpsertConfigValue`;
|
||||
- транзакционная партия откатывается целиком, если одна из строк отсутствует;
|
||||
- `GetConfig`/`GetPeer` возвращают `ErrConfigNotFound`/`ErrPeerNotFound`,
|
||||
отличимые через `errors.Is` от `ErrStorage`.
|
||||
|
||||
`apps/cmd/reset_test.go` — единственный оставшийся потребитель, который склеивал
|
||||
эти два ответа:
|
||||
|
||||
- на пустой базе `reset-admin` создаёт учётную запись, bcrypt-хеш подходит к
|
||||
напечатанному паролю, `force_password_change` выставлен;
|
||||
- поверх существующей записи обновление идёт **на месте**: тот же `id`, новый
|
||||
пароль, увеличенный `token_version`, прежний пароль больше не действует;
|
||||
- при отказе чтения (`ErrStorage`) сброс **останавливается**: вторая учётная
|
||||
запись не создаётся, существующая не меняется, напечатанный пароль не
|
||||
действует. База в этом тесте полностью работоспособна — воспроизводится ровно
|
||||
транзиентный отказ («database is locked»), при котором прежний код уходил в
|
||||
ветку создания и оставлял на сервере вторую рабочую учётку с уже
|
||||
напечатанным паролем;
|
||||
- при `ErrAdminUserNotFound` создание по-прежнему выполняется: строгость к
|
||||
отказу хранилища не имеет права сломать штатный путь восстановления;
|
||||
- непригодный для bcrypt пароль останавливает сброс, а не пишет пустую строку
|
||||
в `password_hash` — раньше ошибка хеширования проглатывалась
|
||||
(`hash, _ := util.HashPassword(...)`), и команда восстановления доступа
|
||||
молча его отбирала: `VerifyPassword` отклоняет всё, что не bcrypt;
|
||||
- sentinel'ы разных таблиц несут одинаковый текст (`WrongPassword` уезжает в
|
||||
ответ Hysteria и менять его нельзя), поэтому проверяется именно
|
||||
различимость через `errors.Is`, а не по строке.
|
||||
|
||||
## A10. Импорт пиров (unit)
|
||||
|
||||
`apps/service/peer_import_test.go`:
|
||||
|
||||
- выгрузка, сделанная `ExportPeer`, принимается без правок;
|
||||
- имя проверяется теми же правилами, что и при обычном создании пира: длина,
|
||||
набор символов, отсутствие пробелов и переводов строки;
|
||||
- `bootstrap-admin-peer` не может быть импортирован ни по имени, ни по `authId`:
|
||||
его секрет продублирован в `/etc/hy2xs/bootstrap-admin.secret`;
|
||||
- диапазоны `quotaBytes`, `expiresAt`, `maxDevices`, `disabled`, `bannedUntil`,
|
||||
счётчиков трафика и длины секрета проверяются;
|
||||
- sentinel-значения (`quotaBytes = -1`, `maxDevices = 0`) остаются валидными;
|
||||
- дубликаты имени и `authId` внутри одной партии отклоняются;
|
||||
- партия сверх лимита отклоняется;
|
||||
- невалидная **последняя** запись отклоняет весь файл: импорт применяется
|
||||
целиком или не применяется вовсе.
|
||||
|
||||
`apps/service/peer_import_tx_test.go` — та же гарантия уже на уровне базы, на
|
||||
настоящей SQLite. Валидация не даёт применить испорченный файл, но она ничего
|
||||
не говорит о конфликте с тем, что УЖЕ лежит в базе:
|
||||
|
||||
- валидная партия применяется целиком;
|
||||
- **cross-conflict откатывается полностью**: пусть в базе есть
|
||||
`A(auth_id=aaa, name=alice1)` и `B(auth_id=bbb, name=bob123)`, а файл несёт
|
||||
`(auth_id=aaa, name=bob123)` — поиск найдёт A по `auth_id` и попытается
|
||||
переименовать её в `bob123`, прямо в `UNIQUE(name)`. Записи, шедшие в файле
|
||||
до конфликтной, не должны остаться применёнными. Тест дополнительно
|
||||
убеждается, что отказ пришёл **из базы**, а не из валидации;
|
||||
- дубликат `auth_id` на вставке ведёт себя так же;
|
||||
- отказ валидации не доходит до базы вовсе;
|
||||
- пир установщика защищён и внутри транзакции;
|
||||
- обновление без секрета в файле не перезаписывает существующий секрет.
|
||||
|
||||
`apps/controller/peer_test.go` — разбор загруженного файла:
|
||||
|
||||
- файл с **хвостовым** JSON-документом отклоняется: `json.Decoder` читает первый
|
||||
документ и останавливается, поэтому раньше оператор видел «импорт выполнен»,
|
||||
а вторая половина файла молча не применялась;
|
||||
- неизвестные поля и файл не с расширением `.json` отклоняются;
|
||||
- корректный одиночный документ доходит до базы и создаёт пира.
|
||||
|
||||
## A7. Контракт версий (build)
|
||||
|
||||
Шаг `verify_versions_contract` (`tools/build/lib/versions.sh`) роняет сборку до
|
||||
создания tarball при рассинхроне `versions.env` с `PACKAGE_VERSION`,
|
||||
`packageManager` обоих `package.json`, схемой в `package/config/hy2xs.env`,
|
||||
константами, скомпилированными в оркестратор, директивой `go` в `apps/go.mod`,
|
||||
metadata пакета и версией, которую сообщает собранный `hy2xs-admin`.
|
||||
|
||||
Разбор upstream `hashes.txt` покрыт `orchestrator/test/hysteria-release.test.ts`:
|
||||
реальный формат релиза, отсутствие путаницы `hysteria-linux-amd64` с
|
||||
`hysteria-linux-amd64-avx`, форма `sha256:<hex>`, верхний регистр,
|
||||
противоречивые записи, отсутствие нужной строки.
|
||||
|
||||
## A11. Проверка зависимостей на уязвимости (build)
|
||||
|
||||
`tools/build/lib/security.sh` — обязательный шаг между тестами админки и записью
|
||||
metadata. Подробности в [docs/02](../build/02-build-layer-and-package.md); здесь важно
|
||||
поведение при отказе:
|
||||
|
||||
| Код `govulncheck` | Трактовка |
|
||||
| --- | --- |
|
||||
| `0` | чисто |
|
||||
| `3` | найдены **вызываемые** уязвимости → сборка падает |
|
||||
| иное | отказ самого инструмента → сборка падает отдельным сообщением |
|
||||
|
||||
Последняя строка существенна: ненулевой код неизвестной природы нельзя
|
||||
трактовать как «уязвимостей нет». По той же причине недоступность реестра npm
|
||||
для `pnpm audit` — это отказ проверки, а не её отрицательный результат.
|
||||
|
||||
`pnpm audit` проверяет **весь** lock-граф frontend, а не production-подграф:
|
||||
build tooling исполняется на build-машине и порождает production-бандл, поэтому
|
||||
уязвимость в нём уезжает в артефакт. Приёмка сборки следит, чтобы `--prod` не
|
||||
вернулся в гейт.
|
||||
|
||||
## A11a. Обязательные тесты (build)
|
||||
|
||||
Гейт тестов устроен так же, как гейт зависимостей: аварийного выхода нет,
|
||||
результат виден по готовому артефакту.
|
||||
|
||||
| Шаг сборки | Что запускается |
|
||||
| --- | --- |
|
||||
| `run_orchestrator_tests` | `bun x tsc --noEmit`, `bun test` |
|
||||
| `bundle_ui` | `pnpm run typecheck` до сборки bundle |
|
||||
| `run_admin_tests` | `go vet ./...`, `go test ./...` |
|
||||
|
||||
Приёмка проверяет:
|
||||
|
||||
- отключающей тесты переменной нет ни в одном модуле сборки, ни в README/docs
|
||||
(место для истории — `CHANGELOG.md`);
|
||||
- `metadata/package.env` содержит `tests_gate=true`;
|
||||
- утверждение о прогоне выставляется **после** самого прогона, а не до него;
|
||||
- `write_metadata` отказывается писать метаданные, если хотя бы один из двух
|
||||
прогонов не подтверждён.
|
||||
|
||||
## A12. Приёмка проверяет код, а не упоминания
|
||||
|
||||
Два контракта приёмки на снимке до этого патча **гарантированно роняли сборку на
|
||||
корректном коде**, и оба — по одной причине: они искали подстроку там, где
|
||||
подстрока обязана присутствовать.
|
||||
|
||||
| Проверка | Что ловила на самом деле |
|
||||
| --- | --- |
|
||||
| `verify_api_namespace_contract` | `grep -rlF '/hui'` возвращал `apps/router/router_test.go` (регрессионный тест, который ПЕРЕЧИСЛЯЕТ legacy-префикс, чтобы доказать его отсутствие) и сам `versions.sh`, где эта строка стоит в тексте проверки |
|
||||
| «peer import не выходит за транзакцию» | `source.slice(start)` брал файл от начала `applyPeerImportEntry` **и до конца**, захватывая `ExistPeerName` и `UpdatePeerLastConnectionAt` — обычные операции вне импорта, которым глобальное соединение положено |
|
||||
|
||||
Первая падала на шаге versions contract — шестым из четырнадцати, до резолва
|
||||
Hysteria. Вторая не была замечена только потому, что сборка до неё не доходила.
|
||||
|
||||
Отсюда правило и помощники `code_without_comments` / `code_mentions_in` в
|
||||
`acceptance.sh`: проверка смотрит на код, а комментарий, объясняющий, почему
|
||||
чего-то больше нет, обязан называть это по имени и не должен ломать сборку.
|
||||
Отсутствие legacy-маршрута доказывает не `grep` по исходникам, а
|
||||
`TestRouterHasNoLegacyNamespace` на таблице маршрутов собранного роутера —
|
||||
и существование этого теста само проверяется контрактом.
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# B, C. Установка на target и runtime
|
||||
|
||||
Часть набора проверок HY2XS. Карта всех частей — [docs/testing/README.md](README.md).
|
||||
|
||||
## B. Target install tests
|
||||
|
||||
### На чистом Debian 13 проверяем
|
||||
1. пакет запускается без ручной сборки на сервере
|
||||
2. Hysteria2 скачивается с official upstream
|
||||
3. bundled HY2XS admin раскладывается локально из пакета
|
||||
4. создаются нужные каталоги
|
||||
5. создаются systemd unit-файлы
|
||||
6. создаются `hy2xs.env` и `post-install.env` с правами `0600 root:root`
|
||||
7. baseline firewall применяется корректно через staged mode
|
||||
8. SSH остаётся доступным
|
||||
9. `reconfigure --dry-run` выводит план изменений
|
||||
10. `reconfigure --apply` применяет изменения и проходит smoke
|
||||
|
||||
## C. Runtime tests
|
||||
|
||||
1. `hysteria-server` active
|
||||
2. `hy2xs-admin` active
|
||||
3. Hysteria слушает только IPv4 (`0.0.0.0:<udp_port>`)
|
||||
4. HY2XS admin слушает ожидаемый `HY2XS_UI_BIND_HOST:<ui_port>`
|
||||
5. тестовый совместимый клиент подключается
|
||||
6. идёт реальный трафик
|
||||
7. лимит 50/50 Mbps соблюдается при согласованной клиентской конфигурации
|
||||
8. reboot не ломает baseline
|
||||
9. Hysteria2 управляется systemd unit, а не внутренним updater'ом admin panel
|
||||
10. нет IPv6 listen (`[::]`) для Hysteria/HY2XS admin
|
||||
11. `trafficStats.secret` не равен `JWT_SECRET`
|
||||
12. bootstrap admin secret существует и имеет `0600`
|
||||
13. `trafficStats` API: корректный secret принимает запрос, неверный secret отклоняется
|
||||
14. TLS mode в `config.yaml` соответствует runtime env (`acme|file|self_signed_dev`)
|
||||
15. при `HY2XS_TLS_MODE=acme` в `config.yaml` выставлен `acme.type` из `HY2XS_ACME_TYPE`
|
||||
16. direct `hysteria2://` node URL в API/QR формируется по `HY2XS_PUBLIC_HOST` + `HY2XS_PUBLIC_PORT`; subscription delivery endpoint отключён в baseline и не входит в acceptance
|
||||
17. `nft -c -f /etc/nftables.conf` проходит после apply
|
||||
18. пароль admin и `con_pass` не перезаписываются при рестарте `hy2xs-admin`
|
||||
19. остановка/рестарт UI не останавливает `hysteria-server`
|
||||
20. traffic accounting/kick ориентируются на systemd status; ключа `HYSTERIA2_ENABLE` в базе больше нет
|
||||
21. `/etc/hysteria/config.yaml` имеет `0640 hysteria:hy2xs-admin`
|
||||
22. `hy2xs-admin` может читать `/etc/hysteria/config.yaml`, но не может писать
|
||||
23. смена расписания сброса трафика применяется **без** перезапуска `hy2xs-admin`, и число джоб планировщика не растёт
|
||||
24. невалидное cron-выражение отклоняется API, а значение в базе не меняется
|
||||
25. `systemctl restart hy2xs-admin` завершает сервис штатно: планировщик остановлен до закрытия SQLite, в журнале нет `database is closed`
|
||||
26. `govulncheck ./...` на графе релиза не находит вызываемых уязвимостей
|
||||
|
||||
## C1. Семантический smoke конфига
|
||||
|
||||
Недостаточно `grep` по YAML: он не отличит нужное поле от такой же строки в другой секции и не заметит оставшийся рядом лишний подблок.
|
||||
|
||||
Smoke разбирает `/etc/hysteria/config.yaml` и сверяет с production-профилем:
|
||||
|
||||
```text
|
||||
effective Hysteria version == версия из metadata пакета
|
||||
|
||||
obfs:
|
||||
type == HY2XS_HYSTERIA_OBFS_TYPE
|
||||
ровно один подблок, соответствующий type
|
||||
password непустой
|
||||
для gecko: minPacketSize == 512, maxPacketSize == 1200
|
||||
|
||||
bandwidth:
|
||||
up/down == runtime env
|
||||
disableLossCompensation == false
|
||||
|
||||
congestion:
|
||||
type == bbr
|
||||
bbrProfile == standard
|
||||
|
||||
quic:
|
||||
disableStatelessReset == false
|
||||
окна, maxIncomingStreams, disablePathMTUDiscovery == baseline
|
||||
maxIdleTimeout == 30s
|
||||
|
||||
trafficStats:
|
||||
listen == runtime env
|
||||
secret непустой
|
||||
|
||||
auth:
|
||||
type == http
|
||||
url == http://127.0.0.1:<UI_PORT>/internal/hysteria/auth?access_token=<machine token>
|
||||
insecure == (tlsMode == self_signed_dev)
|
||||
|
||||
TLS:
|
||||
acme-режим не содержит секции tls
|
||||
acme: type/email/ca/dir/listenHost/первый домен == профиль
|
||||
file-режим не содержит секции acme
|
||||
|
||||
верхний уровень:
|
||||
нет секций вне production-профиля
|
||||
```
|
||||
|
||||
`maxIdleTimeout` присутствовал в профиле, но не проверялся: конфиг с уехавшим
|
||||
idle timeout проходил семантическую проверку. Точно так же `auth.http.url`
|
||||
раньше сверялся только на наличие подстроки `access_token=`, из-за чего
|
||||
уехавший порт или путь остались бы незамеченными — а это единственный канал
|
||||
допуска пиров.
|
||||
|
||||
Сообщение об ошибке для `auth.http.url` намеренно не печатает сам токен: текст
|
||||
уходит в логи и в diagnostics-бандл. Это закреплено отдельным тестом.
|
||||
|
||||
## C2. End-to-end с реальным клиентом
|
||||
|
||||
`tools/test/e2e-hysteria.sh`, отдельно для Gecko и Salamander:
|
||||
|
||||
1. сервер принимает сгенерированный конфиг и стартует;
|
||||
2. TLS handshake;
|
||||
3. handshake с обфускацией;
|
||||
4. HTTP auth HY2XS: разрешённый пир принят;
|
||||
5. HTTP auth HY2XS: неразрешённый пир отклонён;
|
||||
6. клиент подключается **именно по ссылке, которую выдаёт production-код**;
|
||||
7. TCP forwarding;
|
||||
8. UDP forwarding;
|
||||
9. `trafficStats` с валидным secret;
|
||||
10. `trafficStats` с невалидным secret отклоняется;
|
||||
11. per-peer accounting содержит аутентифицированного пира;
|
||||
12. перезапуск сервера;
|
||||
13. быстрое переподключение клиента (поведение stateless reset).
|
||||
|
||||
Пункт 6 — тот самый, который ловит класс ошибок, неизбежный при наивном включении Gecko: сервер работает, ссылка формально валидна, а клиент по ней не подключается.
|
||||
|
||||
### Одна реализация URI, а не две
|
||||
|
||||
Ссылка берётся из production-генератора через `apps/tools/share-uri`, который
|
||||
вызывает ту же `service.BuildHysteria2ShareURI`, что и панель.
|
||||
|
||||
Раньше внутри e2e жила **вторая** реализация URI на bash. Go-юнит-тесты
|
||||
проверяли production-генератор, e2e проверял свою функцию — и дрейф любой из
|
||||
них оставлял обе группы тестов зелёными.
|
||||
|
||||
Единственное расхождение с пользовательской ссылкой — `insecure=1`: e2e
|
||||
работает на самоподписанном сертификате. Это расхождение ограничено с двух
|
||||
сторон:
|
||||
|
||||
- e2e отдельно печатает и проверяет **production-вариант** ссылки
|
||||
(`insecure=0`, корректные `obfs` и `sni`);
|
||||
- Go-тест `TestBuildHysteria2ShareURI_InsecureDiffersOnlyInThatParam`
|
||||
доказывает, что кроме этого параметра ссылки совпадают побайтово;
|
||||
- Go-тест `TestBuildHysteria2Url_ProductionPathNeverDisablesVerification`
|
||||
фиксирует, что production-путь никогда не передаёт `insecure=1`.
|
||||
|
||||
Для запуска e2e нужен Go (`GO_BIN`).
|
||||
|
||||
## C3. Share URI (unit)
|
||||
|
||||
`apps/service/hysteria2_api_test.go`:
|
||||
|
||||
- Gecko URI содержит `obfs=gecko` и `obfs-password`;
|
||||
- Salamander URI содержит `obfs=salamander` и `obfs-password`;
|
||||
- конфиг без обфускации даёт ссылку без `obfs`;
|
||||
- неизвестный тип обфускации в ссылку не попадает;
|
||||
- обфускация без пароля в ссылку не попадает;
|
||||
- SNI: ACME-домен → `HY2XS_DOMAIN` → `HY2XS_PUBLIC_HOST`, IP не используется;
|
||||
- спецсимволы в credentials и obfs-пароле переживают round-trip: `+`, пробел, `#`, `@`, `/`, `?`, `&`, `=`, `%`, кириллица;
|
||||
- литеральный `+` кодируется как `%2B` и не схлопывается с пробелом (регрессия на upstream-баг 2.9.3).
|
||||
|
||||
## C4. Экспорт конфига (unit)
|
||||
|
||||
`apps/service/hysteria2_export_test.go`:
|
||||
|
||||
- неизвестные upstream-секции переживают экспорт целиком, включая вложенные карты и списки;
|
||||
- операционные поля остаются читаемыми;
|
||||
- вырезаются: obfs-пароль, `trafficStats.secret`, `access_token`, `auth.userpass`, учётные данные ACME DNS, пароли outbound;
|
||||
- вырезается **неизвестное** поле с секретным именем;
|
||||
- URL под произвольным именем ключа (`endpoint:`) теряет учётные данные и
|
||||
`access_token`, но сохраняет адрес; то же для URL внутри списка;
|
||||
- не-URL скаляры (`50 mbps`, `0.0.0.0:443`, `10.0.0.1:1080`, `30s`, числа)
|
||||
проходят санитайзер без изменений;
|
||||
- пути к файлам (`tls.key`, `ech.keyPath`, `clientCA`) остаются видимыми.
|
||||
|
||||
Го- и TS-санитайзеры описывают один контракт и покрыты зеркальными тестами:
|
||||
граница определяется значением, а не именем ключа.
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
# D0-D2. Живой сервер и fault injection
|
||||
|
||||
Часть набора проверок HY2XS. Карта всех частей — [docs/testing/README.md](README.md).
|
||||
|
||||
## D0. Граница установки на живом сервере
|
||||
|
||||
Проверяется на хосте, где уже стоит предыдущая установка:
|
||||
|
||||
1. `install.sh` завершается отказом на PHASE 0;
|
||||
2. `/usr/local/lib/hy2xs` **не создан и не изменён**;
|
||||
3. `/var/lib/hy2xs/install-state.json` не перезаписан;
|
||||
4. `hysteria-server` и `hy2xs-admin` остались `active`;
|
||||
5. в тексте отказа перечислены найденные маркеры и указан
|
||||
`docs/operations/14-legacy-cleanup.md`;
|
||||
6. после `tools/legacy/purge-v0.sh --apply --yes-i-know` установка проходит.
|
||||
|
||||
Пункты 2–4 — прямая регрессия: прежний установщик успевал переписать
|
||||
`/usr/local/lib/hy2xs` и `install-state.json`, а затем откатом останавливал и
|
||||
выключал работающие службы старой установки.
|
||||
|
||||
## D1. Отказ сразу после успешной PHASE 0 (fault injection)
|
||||
|
||||
Проверяется на чистом хосте. Это узкая щель между «PHASE 0 прошла» и «первая
|
||||
мутирующая операция упала» — место, где установщик раньше врал.
|
||||
|
||||
1. PHASE 0 проходит успешно;
|
||||
2. `installDeps` ломается искусственно (например, недоступный apt-репозиторий
|
||||
или временно испорченный `/etc/apt/sources.list.d/`);
|
||||
3. установка завершается отказом;
|
||||
4. в выводе **нет** `fatal_pre_apply` и нет фразы про «ничего не применялось»;
|
||||
5. `/var/lib/hy2xs/install-state.json` существует и честно показывает
|
||||
`phase: failed` с текстом ошибки;
|
||||
6. `owned_paths` в маркере содержит `/usr/local/lib/hy2xs`,
|
||||
`/usr/local/bin/hy2xs-orchestrator` и `/usr/local/lib/hy2xs/package` —
|
||||
всё, что операция действительно создала;
|
||||
7. diagnostics-бандл собран;
|
||||
8. `hy2xs-orchestrator status` не заявляет установку успешной.
|
||||
|
||||
До исправления шаги 4–7 давали противоположный результат: `install-state.json`
|
||||
уже лежал на диске, но отказ классифицировался как pre-apply, обработка
|
||||
состояния пропускалась, а следующая установка на этой машине отказывалась по
|
||||
clean-host контракту из-за оставшегося маркера.
|
||||
|
||||
Пункт 6 закрывает вторую половину той же щели. Пока раскладку оркестратора и
|
||||
runtime-пакета выполнял `install.sh`, эти пути не принадлежали никому: они не
|
||||
попадали в `owned_paths`, а отказ **второго** preflight (сменился DNS, занялся
|
||||
порт, не ответил резолвер) объявлялся `fatal_pre_apply` — «на сервере ничего не
|
||||
изменено» — при уже созданном каталоге оркестратора.
|
||||
|
||||
## D1b. Откат при невозможности записать состояние отказа (fault injection)
|
||||
|
||||
Проверяется на чистом хосте. Это доказательство того, что телеметрия состояния
|
||||
больше не стоит перед восстановлением.
|
||||
|
||||
**Тайминг здесь — часть сценария, и его легко испортить.**
|
||||
|
||||
`tmpfs` НЕЛЬЗЯ монтировать заранее: первая же запись маркера (`preflight_ok`)
|
||||
получит `ENOSPC`, установка отвалится до firewall, и проверяться будет совсем
|
||||
другой путь — обычный `fatal_post_apply` на ранней стадии.
|
||||
|
||||
Порядок строго такой:
|
||||
|
||||
1. запустить установку и дождаться в журнале `step=firewall status=done`;
|
||||
2. **только теперь**, во втором терминале:
|
||||
|
||||
```bash
|
||||
mount -t tmpfs -o size=16k tmpfs /var/lib/hy2xs
|
||||
dd if=/dev/zero of=/var/lib/hy2xs/filler bs=1k count=64 2>/dev/null || true
|
||||
```
|
||||
|
||||
3. вызвать искусственный отказ следующего шага установки.
|
||||
|
||||
Ловить это окно руками неудобно, поэтому тот же сценарий имеет смысл прогнать и
|
||||
через отказ на более длинном шаге (`smoke`), где времени заметно больше:
|
||||
дождаться `step=smoke checks`, смонтировать `tmpfs` и остановить один из
|
||||
сервисов, чтобы smoke не сошёлся.
|
||||
|
||||
Сценарий:
|
||||
|
||||
1. установка доходит **дальше** шага firewall (то есть `firewallTouched`
|
||||
взведён, правила применены);
|
||||
2. следующий шаг ломается искусственно;
|
||||
3. запись `phase: failed` в маркер падает по `ENOSPC`;
|
||||
4. в журнале есть `failed to persist failure state, continuing with the
|
||||
mandatory rollback`;
|
||||
5. **откат всё равно выполняется**: `rollbackFirewallNow` снимает применённые
|
||||
правила, `/etc/nftables.conf` возвращается к прежнему состоянию, а
|
||||
развёрнутые этой операцией юниты останавливаются и выключаются;
|
||||
6. SSH остаётся доступным;
|
||||
7. в журнале перечислены отказавшие стадии отката, если они были, и наружу
|
||||
ушла **исходная** ошибка операции, а не `ENOSPC`.
|
||||
|
||||
До исправления шаги 4–6 давали противоположный результат: бросок из записи
|
||||
состояния уносил управление наружу, и сервер оставался с применённым firewall
|
||||
неудавшейся установки.
|
||||
|
||||
Тот же сценарий повторяется для `reconfigure`, где цена выше: там откат
|
||||
дополнительно возвращает конфиги из `/etc/hy2xs/backups`, и оба восстановления
|
||||
отменялись разом.
|
||||
|
||||
Дополнительно проверяется независимость стадий: если сделать неработоспособной
|
||||
первую стадию (например, сделать `/etc/nftables.conf` неперезаписываемым через
|
||||
`chattr +i` между применением firewall и отказом), восстановление конфигов и
|
||||
остановка сервисов обязаны выполниться всё равно, а в журнале обязаны появиться
|
||||
`rollback stage "…" failed, continuing with the remaining stages` и итоговое
|
||||
`rollback finished with N failed stage(s)`.
|
||||
|
||||
## D1c. Данные отката переживают отказ фиксации успеха
|
||||
|
||||
Проверяется на чистом хосте. Это второй сценарий того же класса, но на
|
||||
противоположном конце операции: отказывает не промежуточный шаг, а **запись
|
||||
успеха**.
|
||||
|
||||
1. установка доходит до успешного `smoke`, в маркере появляется
|
||||
`phase: smoke_ok`;
|
||||
2. сразу после этого `/var/lib/hy2xs` делается недоступным для записи (тот же
|
||||
`tmpfs`, смонтированный по появлению `step=smoke checks status=done`);
|
||||
3. запись `phase: installed` падает;
|
||||
4. `/run/hy2xs/rollback/<op>/prepared` и обе резервные копии firewall **всё ещё
|
||||
существуют** — это и есть проверяемое свойство;
|
||||
5. откат выполняется полностью: `/etc/nftables.conf` возвращается к прежнему
|
||||
содержимому, развёрнутые юниты останавливаются;
|
||||
6. в журнале **нет** строки `no HY2XS rollback markers found`.
|
||||
|
||||
До исправления пункты 4–6 давали противоположный результат: снятие таймера и
|
||||
удаление копий выполнял один вызов, стоявший до записи `installed`, поэтому
|
||||
откат запускался, но откатывать ему было нечем.
|
||||
|
||||
Обратная проверка — успешный путь: после нормально завершённой установки
|
||||
`/run/hy2xs/rollback/` пуст, а `phase: installed` записан.
|
||||
|
||||
## D1d. Отказ снятия резервной копии останавливает reconfigure до мутации
|
||||
|
||||
Проверяется на рабочей установке.
|
||||
|
||||
1. `/etc/hy2xs/backups` делается недоступным для записи (`chattr +i` или
|
||||
заполненный `tmpfs`);
|
||||
2. запускается `reconfigure --apply`;
|
||||
3. операция отказывает на шаге `backup` с сообщением про несозданную копию;
|
||||
4. `/etc/hysteria/config.yaml`, unit-файлы и `/etc/nftables.conf` **не
|
||||
изменены**, сервисы не перезапускались.
|
||||
|
||||
Отдельно проверяется привязка копии к операции: после успешного `reconfigure`
|
||||
в `/etc/hy2xs/backups/` остаётся ровно один каталог — текущей операции — с
|
||||
`manifest.json`, и в нём перечислены все семь путей, включая отсутствовавшие с
|
||||
`"present": false`.
|
||||
|
||||
## D1e. Guard доходит до дедлайна — фиксация успеха запрещена
|
||||
|
||||
Проверяется на чистом хосте. Это сценарий гонки между автоматическим откатом
|
||||
firewall и успешным smoke.
|
||||
|
||||
Окно guard — 45 секунд, и оно намеренно короче худшего случая smoke: на
|
||||
медленном, но исправном сервере retry-бюджеты дают заметно больше. Раньше это
|
||||
означало, что автоматический откат мог вернуть прежний firewall, пока smoke
|
||||
продолжает идти, а единственной проверкой firewall в smoke был `nft -c` — разбор
|
||||
текущего файла, каким бы он ни был. Прежний валидный ruleset проходил её
|
||||
зелёным, и сервер объявлялся успешно настроенным с **предыдущим** firewall.
|
||||
|
||||
Сценарий:
|
||||
|
||||
1. установка доходит до шага `firewall`, в журнале появляется
|
||||
`firewall rollback guard armed: … fires in 45s (timer accuracy 1s)`.
|
||||
Пока guard ждёт, свойства таймера проверяются напрямую — обещанное окно
|
||||
обязано быть контрактом systemd, а не намерением:
|
||||
|
||||
```bash
|
||||
systemctl show hy2xs-fw-rollback-<op-id>.timer \
|
||||
-p ActiveState -p SubState -p AccuracyUSec -p RemainAfterElapse
|
||||
```
|
||||
|
||||
Ожидается `ActiveState=active`, `SubState=waiting`, `AccuracyUSec=1s`,
|
||||
`RemainAfterElapse=no`. Без явной точности systemd вправе сработать в окне
|
||||
`[45s; 45s + AccuracySec]`, а умолчание `AccuracySec=` — одна минута, то
|
||||
есть реальное окно было бы 45–105 секунд;
|
||||
2. smoke искусственно замедляется дольше 45 секунд. Проще всего задержать один
|
||||
из сервисов — например, добавить в `hy2xs-admin.service` временный
|
||||
`ExecStartPre=/bin/sleep 60` и выполнить `systemctl daemon-reload` до запуска
|
||||
установки;
|
||||
3. guard срабатывает: в journal появляется юнит
|
||||
`hy2xs-fw-rollback-<op-id>.service`, а на диске —
|
||||
`/run/hy2xs/rollback/<op-id>/auto-rollback-fired`;
|
||||
4. установка **обязана** завершиться отказом, даже если smoke успел сойтись;
|
||||
5. в маркере установки стоит `phase: firewall_guard_fired`, а не
|
||||
`installed`, и не `smoke_failed`;
|
||||
6. `installed: true` не записан;
|
||||
7. выполняется обычный откат операции: firewall возвращается к прежнему
|
||||
состоянию, развёрнутые этой операцией юниты останавливаются;
|
||||
8. SSH остаётся доступным.
|
||||
|
||||
Отдельно проверяется вторая половина того же дефекта — семантический smoke.
|
||||
Если на рабочей установке подменить `/etc/nftables.d/hy2xs.nft` на прежний
|
||||
валидный ruleset и выполнить `hy2xs-orchestrator doctor`, диагностика обязана
|
||||
отказать с сообщением про несовпадение эффективного firewall, а не пройти по
|
||||
`nft -c`.
|
||||
|
||||
## D1f. Конкурентная операция отказывает до первой мутации
|
||||
|
||||
Проверяется на рабочей установке. Проверяемое свойство — отказ происходит
|
||||
**до** снятия резервной копии и до первой мутации, а не в середине транзакции.
|
||||
|
||||
1. запускается длинный `reconfigure --apply` (например, с задержкой в
|
||||
`ExecStartPre`, как в D1e);
|
||||
2. во втором терминале, пока первый идёт, запускается второй
|
||||
`reconfigure --apply`;
|
||||
3. второй отказывает сразу, с текстом
|
||||
`another HY2XS operation is already in progress: reconfigure (pid …)`;
|
||||
4. `/etc/hy2xs/backups/` **не** пополнился каталогом второй операции;
|
||||
5. `/etc/hysteria/config.yaml`, unit-файлы и `/etc/nftables.conf` изменены
|
||||
ровно один раз — первой операцией;
|
||||
6. `/run/hy2xs/rollback/` содержит каталог только первой операции.
|
||||
|
||||
Те же проверки для пар:
|
||||
|
||||
```text
|
||||
install идёт -> doctor отказывает
|
||||
install идёт -> install.sh отказывает на PHASE 0, до собственных проверок
|
||||
reconfigure идёт -> repair отказывает
|
||||
```
|
||||
|
||||
И обратная проверка — наблюдающие команды не блокируются:
|
||||
|
||||
```text
|
||||
reconfigure идёт -> hy2xs-orchestrator status
|
||||
→ выполняется
|
||||
→ в отчёте operation_in_progress = "reconfigure (pid …)"
|
||||
→ human_status предупреждает, что это снимок незавершённой транзакции
|
||||
|
||||
reconfigure идёт -> diagnostics collect
|
||||
→ выполняется
|
||||
→ в stderr есть note об идущей операции
|
||||
```
|
||||
|
||||
Отдельно проверяется, что замок не переживает своего держателя. **Важно:**
|
||||
прерывать операцию нужно ДО шага `firewall`, иначе проверяется уже сценарий
|
||||
D1h, а не этот.
|
||||
|
||||
1. `reconfigure --apply` прерывается `Ctrl+C` на шаге `config generation` —
|
||||
замок снят, следующий `reconfigure` проходит;
|
||||
2. процесс убивается `kill -9` на том же шаге, после чего следующая операция
|
||||
сообщает `is held by … which is no longer running; reclaiming it` и
|
||||
продолжает;
|
||||
3. `/run/lock/hy2xs-orchestrator.lock` не остаётся после завершения операции.
|
||||
|
||||
## D1h. Аварийно умершая операция с вооружённым guard
|
||||
|
||||
Проверяется на рабочей установке. Это стык двух защитных механизмов, и до его
|
||||
закрытия каждый из них по отдельности работал правильно, а вместе они
|
||||
оставляли дыру.
|
||||
|
||||
Замок защищает production paths, пока **жив процесс-держатель**. Rollback guard
|
||||
firewall — отдельный systemd-объект, который свой процесс переживает. Поэтому:
|
||||
|
||||
```text
|
||||
A берёт замок -> применяет firewall -> вооружает guard на 45 секунд
|
||||
A аварийно умирает
|
||||
B берёт замок (снятый обработчиком сигнала либо переиспользованный)
|
||||
B начинает менять production paths
|
||||
guard A срабатывает и возвращает firewall, который был ДО A
|
||||
```
|
||||
|
||||
Уникальные `op-id` здесь не помогают: каталоги копий разные, а
|
||||
`/etc/nftables.conf`, `/etc/nftables.d/hy2xs.nft` и ruleset в ядре — общие.
|
||||
|
||||
Сценарий:
|
||||
|
||||
1. `reconfigure --apply` доводится до появления в журнале
|
||||
`firewall rollback guard armed`;
|
||||
2. процесс убивается `kill -9` (замок остаётся устаревшим) — и, отдельным
|
||||
прогоном, `kill -TERM` (замок снимается обработчиком, то есть его вообще не
|
||||
будет; это и есть случай, который проверка живости держателя не ловит);
|
||||
3. **до истечения 45 секунд** запускается `repair` или `reconfigure --apply`;
|
||||
4. новая операция обязана отказать:
|
||||
|
||||
```text
|
||||
previous HY2XS operation is no longer running, but its firewall rollback guard
|
||||
is still armed: hy2xs-fw-rollback-<op-id>.timer (active/waiting)
|
||||
```
|
||||
|
||||
5. отказ происходит **до** снятия резервной копии и до первой мутации;
|
||||
6. `install.sh` в том же окне отказывает на PHASE 0 по той же причине;
|
||||
7. после срабатывания guard транзиентный таймер выгружается
|
||||
(`RemainAfterElapse=no`), и `repair` проходит. Проверяется наблюдением, а не
|
||||
ожиданием на глаз:
|
||||
|
||||
```bash
|
||||
systemctl show hy2xs-fw-rollback-<op-id>.timer -p LoadState -p ActiveState
|
||||
systemctl list-units --all --plain 'hy2xs-fw-rollback-*'
|
||||
```
|
||||
|
||||
Ожидается, что таймера в списке больше нет; оставшийся `.service` в
|
||||
состоянии `failed` (частичное восстановление) операцию не блокирует.
|
||||
|
||||
Обратная проверка: на сервере без вооружённого guard барьер молчит и ни одну
|
||||
операцию не задерживает, а `failed` от уже отработавшего guard **не** считается
|
||||
непокоем — иначе он заблокировал бы `repair`, которым и чинят последствия.
|
||||
|
||||
Отдельная проверка того же барьера — недоказуемое состояние. Барьер обязан
|
||||
различать «guard вооружён» и «спросить не удалось»: это разные утверждения, и
|
||||
оператору по ним нужны разные действия.
|
||||
|
||||
1. на рабочей установке без вооружённого guard делается недоступным запрос к
|
||||
systemd — проще всего временно подложить в `PATH` оркестратора `systemctl`,
|
||||
завершающийся ненулевым кодом;
|
||||
2. любая операция жизненного цикла (`repair`, `reconfigure --apply`, `doctor`,
|
||||
`install.sh` на PHASE 0) обязана отказать:
|
||||
|
||||
```text
|
||||
unable to verify firewall rollback guard state; systemd query failed,
|
||||
refusing to start a lifecycle operation
|
||||
```
|
||||
|
||||
3. отказ происходит **до** первой мутации, и тип ошибки —
|
||||
`GuardStateUnknownError`, а не `PendingRecoveryError`: ждать окна отката
|
||||
здесь бессмысленно;
|
||||
4. `hy2xs-orchestrator status` при этом **не** падает: он замок не берёт и
|
||||
существует в том числе для сломанного хоста, поэтому сообщает
|
||||
`rollback_guard_state: "unknown"` и `firewall_state: "guard_unknown"`;
|
||||
5. после возврата рабочего `systemctl` операция проходит без дополнительных
|
||||
действий.
|
||||
|
||||
Смысл проверки — в том, что прежнее поведение было противоположным: отказ
|
||||
запроса давал пустой список guard'ов, барьер считал систему спокойной и
|
||||
пропускал операцию, а взведённый таймер предыдущей операции срабатывал уже
|
||||
посреди неё.
|
||||
|
||||
## D1g. Успешная установка не оставляет следов транзакции
|
||||
|
||||
Проверяется на чистом хосте, обычной успешной установкой. Это обратная проверка
|
||||
к D1c и D1e: она ловит противоположную ошибку — данные транзакции, пережившие
|
||||
её завершение.
|
||||
|
||||
После `installed`:
|
||||
|
||||
```text
|
||||
systemctl list-units --all --plain 'hy2xs-fw-rollback-*' → пусто
|
||||
ls /run/hy2xs/rollback/ → пусто
|
||||
ls /run/lock/hy2xs-orchestrator.lock → отсутствует
|
||||
ls /etc/nftables.conf.candidate → отсутствует
|
||||
ls /etc/nftables.d/hy2xs.nft.candidate → отсутствует
|
||||
```
|
||||
|
||||
и `/var/lib/hy2xs/install-state.json` содержит `phase: installed`,
|
||||
`installed: true`, а `op_id` в нём совпадает с именем каталога, который лежал в
|
||||
`/run/hy2xs/rollback/` во время установки.
|
||||
|
||||
`/etc/nftables.conf.candidate` — прямая регрессия: он не удалялся вообще, и
|
||||
успешная установка оставляла его на сервере навсегда.
|
||||
|
||||
## D1a. Проход установки не спотыкается о собственный маркер
|
||||
|
||||
Проверяется на чистом хосте, обычной успешной установкой.
|
||||
|
||||
1. `install.sh` доходит до `preflight capabilities` **после** `apt-get`;
|
||||
2. установка на этом шаге **не** падает с текстом «обнаружена предыдущая или
|
||||
посторонняя установка»;
|
||||
3. установка доходит до `installed`.
|
||||
|
||||
Это сценарий, который не воспроизводится ни на одном dry-run: clean-host внутри
|
||||
`install` проверялся дважды, и ко второму разу на диске уже лежал собственный
|
||||
`/var/lib/hy2xs/install-state.json`, записанный после первого preflight. Каждая
|
||||
чистая установка падала сразу после `apt-get`, получала `fatal_post_apply` и
|
||||
оставляла сервер наполовину настроенным. Структурно закреплено в
|
||||
`orchestrator/test/install-sequence.test.ts`.
|
||||
|
||||
## D2. Устаревший DNS после смены IPv4 провайдером
|
||||
|
||||
Проверяется на рабочей установке.
|
||||
|
||||
```text
|
||||
сервер: текущий публичный IPv4 = B
|
||||
DNS: A-запись = A (старый адрес)
|
||||
|
||||
hy2xs-orchestrator doctor
|
||||
→ FAIL
|
||||
→ в выводе присутствуют и A, и B
|
||||
|
||||
обновить A-запись на B, дождаться TTL
|
||||
|
||||
hy2xs-orchestrator doctor
|
||||
→ PASS
|
||||
```
|
||||
|
||||
Дополнительно: `reconfigure --apply` при устаревшей A-записи тоже обязан
|
||||
отказать — инвариант живёт в общем `preflight`, а не в одном `doctor`.
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
# D, E. Негативные тесты и матрица приёмки
|
||||
|
||||
Часть набора проверок HY2XS. Карта всех частей — [docs/testing/README.md](README.md).
|
||||
|
||||
## D. Negative tests
|
||||
|
||||
1. не Debian 13
|
||||
2. порт уже занят
|
||||
3. старое конфликтующее состояние уже существует
|
||||
4. домен / SNI заданы некорректно
|
||||
5. bundled UI отсутствует в пакете
|
||||
6. Hysteria upstream недоступен
|
||||
7. firewall применился частично
|
||||
8. install flow прерван посередине
|
||||
9. попытка использовать `HY2XS_IPV6_ENABLED=true`
|
||||
10. `HY2XS_PUBLIC_HOST=0.0.0.0`
|
||||
11. неизвестный `HY2XS_HYSTERIA_OBFS_TYPE`
|
||||
12. конфигурация со схемой `HY2XS_CONFIG_SCHEMA_VERSION` из линейки `0.x`
|
||||
13. upstream `latest` несовместим с шаблоном HY2XS — падает сборка, не установка
|
||||
14. `HY2XS_PUBLIC_HOST` резолвится не на этот сервер
|
||||
15. `HY2XS_DOMAIN` резолвится не на этот сервер при отличном от него `PUBLIC_HOST`
|
||||
16. A-запись содержит правильный адрес и чужой одновременно
|
||||
17. неизвестное значение `HY2XS_PUBLIC_ENDPOINT_POLICY`
|
||||
18. импорт пиров с невалидной записью — файл не применяется частично
|
||||
19. импорт пиров, пытающийся перезаписать `bootstrap-admin-peer`
|
||||
|
||||
## E. Fix20 production matrix (обязательные сценарии)
|
||||
|
||||
1. **Clean Debian 13 minimal**:
|
||||
- только SSH, без ручной установки зависимостей;
|
||||
- default `/etc/nftables.conf` stub;
|
||||
- install проходит полностью;
|
||||
- `doctor`/`status` показывают рабочее состояние.
|
||||
|
||||
2. **Non-systemd container**:
|
||||
- fail-fast до destructive шагов;
|
||||
- диагностическое сообщение с причиной capability/systemd.
|
||||
|
||||
3. **Foreign nftables**:
|
||||
- при `HY2XS_FIREWALL_MODE=managed` install/reconfigure блокируются;
|
||||
- при `HY2XS_FIREWALL_MODE=takeover` создаются backup/rollback guard и apply проходит.
|
||||
|
||||
4. **Rollback guard cleanup** (сценарий D1g):
|
||||
- после успешного apply/smoke не остаются `hy2xs-fw-rollback-*.timer/.service`;
|
||||
- `/run/hy2xs/rollback/`, `/run/lock/hy2xs-orchestrator.lock` и
|
||||
`*.candidate` не переживают успешную операцию.
|
||||
|
||||
4a. **Guard доходит до дедлайна** (сценарий D1e):
|
||||
- `auto-rollback-fired` создан, операция завершается отказом с
|
||||
`phase: firewall_guard_fired`;
|
||||
- `installed: true` не записан, даже если smoke успел сойтись.
|
||||
|
||||
4b. **Конкурентные операции** (сценарий D1f):
|
||||
- вторая операция отказывает **до** снятия резервной копии и первой мутации;
|
||||
- `status`/`diagnostics` не блокируются и сообщают об идущей операции;
|
||||
- замок не переживает своего держателя.
|
||||
|
||||
4c. **Аварийная смерть с вооружённым guard** (сценарий D1h):
|
||||
- новая операция отказывает, пока `hy2xs-fw-rollback-*` ещё активен, в том
|
||||
числе когда замка не осталось вовсе;
|
||||
- после срабатывания guard `repair` проходит.
|
||||
|
||||
5. **Partial install + repair**:
|
||||
- состояние `install-state` фиксирует промежуточную фазу;
|
||||
- `repair` завершает граф до `installed=true`.
|
||||
|
||||
6. **AAAA при IPv4-only**:
|
||||
- policy строго валидируется preflight;
|
||||
- soft warning path не используется в production baseline.
|
||||
|
||||
7. **Slow-start admin readiness**:
|
||||
- install не падает на race после restart;
|
||||
- readiness waiters дожидаются listener/healthz.
|
||||
|
||||
8. **Отказ между PHASE 0 и первой мутацией** (сценарий D1):
|
||||
- `install-state.json` честно показывает `failed`;
|
||||
- установщик не заявляет, что хост не изменён.
|
||||
|
||||
9. **Устаревший DNS после смены IPv4** (сценарий D2):
|
||||
- `doctor` и `reconfigure` отказывают;
|
||||
- в выводе присутствуют оба адреса.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
Система принимается, если:
|
||||
|
||||
1. production builder на Debian 13 amd64 выдаёт переносимый install package
|
||||
2. target server не выполняет build step
|
||||
3. Hysteria2 получена из official upstream
|
||||
4. HY2XS admin поставлен из install package
|
||||
5. `post-install.env` отражает фактическое deploy-состояние
|
||||
6. оркестратор зафиксирован как Bun/TypeScript stack и поставляется как готовый install-артефакт
|
||||
7. оркестратор не требует standalone update / rollback / uninstall subcommands
|
||||
8. bounded rollback в install/reconfigure корректно отрабатывает failure-сценарии firewall/systemd/config/smoke, и ни один его собственный отказ не отменяет остальные стадии
|
||||
9. Telegram/access layer не требуется для прохождения install acceptance
|
||||
10. отсутствует production path для port hopping
|
||||
11. UI не запускается от root
|
||||
12. клиентские endpoint не зависят от request `Host`/`hostname`
|
||||
13. production build verify падает, если `config/hy2xs.env` содержит placeholder-значения
|
||||
14. production build verify падает при dirty git tree (кроме `ALLOW_DIRTY_BUILD=true`)
|
||||
15. metadata содержит `source_git_commit`, `dirty_tree`, `build_profile=production`
|
||||
16. builder без override на сегодняшний день автоматически выбирает последнюю стабильную версию Hysteria
|
||||
17. собранный пакет содержит **точные** версию, URL и SHA-256
|
||||
18. выход новой версии Hysteria после сборки не меняет содержимое старого пакета
|
||||
19. новая установка генерирует Gecko
|
||||
20. Gecko использует `512/1200`
|
||||
21. установленная Hysteria реально принимает сгенерированный YAML
|
||||
22. сервис запускается под существующим непривилегированным пользователем `hysteria`
|
||||
23. созданный пользователь получает `hysteria2://` с `obfs=gecko` и `obfs-password`
|
||||
24. совместимый клиент Hysteria подключается напрямую по этой ссылке
|
||||
25. после перезапуска Hysteria клиент быстро восстанавливает соединение
|
||||
26. режим `HY2XS_HYSTERIA_OBFS_TYPE=salamander` полностью работоспособен
|
||||
27. admin читает Gecko-конфиг без ошибок
|
||||
28. экспорт не уничтожает современные и неизвестные upstream-поля
|
||||
29. экспорт не содержит секретов
|
||||
30. frontend отображает Gecko
|
||||
31. `namedotcom` удалён, актуальные ACME-провайдеры отражены
|
||||
32. документация нигде не утверждает, что Salamander — фиксированный инвариант
|
||||
33. документация не фиксирует конкретный номер версии как «текущую версию», а объясняет latest-stable build policy
|
||||
34. форма создания пира содержит примеры значений и пояснения для полей «Пир», «Комментарий» и «Секрет»
|
||||
35. `hy2xs-orchestrator doctor` не перезапускает сервисы и не рвёт живые соединения, и это обеспечено read-only guard'ом, а не соглашением о выборе раннера
|
||||
36. удаление `bootstrap-admin-peer` переживает `systemctl restart` и `reboot`: пир не воскресает
|
||||
37. отключённый `bootstrap-admin-peer` остаётся отключённым после перезапуска
|
||||
38. резервная копия с `includeSecrets=true` завершается ошибкой целиком, если секрет хотя бы одного пира недоступен
|
||||
39. админка не генерирует `HYSTERIA2_TRAFFIC_STATS_SECRET` сама: пустой env при пустой базе — отказ старта
|
||||
40. проверка зависимостей на уязвимости не имеет обходов ни в сборке, ни в документации, и покрывает весь lock-граф frontend
|
||||
41. `apps/go.mod` объявляет `toolchain`, совпадающий с `GO_VERSION` из `versions.env`
|
||||
42. `tools/dev/doctor.sh` / `doctor.ps1` показывают расхождение среды разработки с `versions.env`
|
||||
43. маршруты-алиасы `/:id/client-url` и `/:id/qr` удалены и не входят в публичный API v1
|
||||
44. `pnpm run typecheck` (`vue-tsc --noEmit`) проходит без ошибок и является обязательным шагом сборки
|
||||
45. проверка типов идёт до сборки bundle, а не после
|
||||
46. `vue-tsc` версии 3 и выше: 0.x проверку шаблонов не выполняет
|
||||
47. `pnpm audit` по всему графу зависимостей frontend не находит уязвимостей
|
||||
48. локальные SVG-иконки собираются спрайтом из репозитория, без `vite-plugin-svg-icons`
|
||||
49. каждая иконка задаёт систему координат: `viewBox` либо пара `width`/`height`
|
||||
50. страница конфига Hysteria не содержит элементов управления, которые ничего не сохраняют
|
||||
51. невозможность записать состояние отказа не отменяет откат: восстановление выполняется, в журнале остаётся отметка о неудавшейся записи
|
||||
52. `install-state.json` пишется одним писателем, атомарно и с `fsync` файла и каталога: после потери питания на диске лежит либо прежний полный документ, либо новый полный
|
||||
53. ownership-флаг маркера установки взводится **до** записи, поэтому отказ на `chown` не даёт `fatal_pre_apply` при уже созданном файле
|
||||
54. тесты и проверка типов не имеют обходов ни в сборке, ни в документации; `metadata/package.env` содержит `tests_gate=true`, и это утверждение опирается на фактический прогон
|
||||
55. `reset-admin` при недоступной базе отказывает, а не создаёт вторую учётную запись администратора; ошибка хеширования не приводит к пустому `password_hash`
|
||||
56. данные для отката переживают долговечную фиксацию успеха: снятие таймера автоотката и удаление резервных копий разделены записью `phase: installed`
|
||||
57. резервная копия снимается строго и до первой мутации; несозданная копия останавливает операцию, а не игнорируется
|
||||
58. копия привязана к операции: откат восстанавливает состояние непосредственно перед текущим проходом, а не сохранённое предыдущим
|
||||
59. ни одна команда отката не глушит свой код возврата; отказавшие стадии перечисляются, а артефакты восстановления удаляются только после подтверждённого успеха
|
||||
60. `doctor` не выполняет проб, изменяющих данные в админке: авторизация действующим паролем пира ограничена режимом `install`
|
||||
61. снятие rollback guard доказывается, а не объявляется: отсутствие маркера `auto-rollback-fired` и `ActiveState=inactive` обоих юнитов — предусловие записи `phase: installed`
|
||||
62. сработавший guard запрещает фиксацию успеха, каким бы ни был результат smoke, и получает собственную причину отказа `firewall_guard_fired`
|
||||
63. smoke сверяет **эффективный** firewall с конфигурацией операции, а не только разбирает `/etc/nftables.conf`
|
||||
64. автоматический откат firewall сообщает о частичном восстановлении отказом юнита, а не молчаливым кодом 0, и сохраняет данные восстановления
|
||||
65. откат восстанавливает `enabled`/`active` состояние `nftables.service`, а не только файлы правил
|
||||
66. операции жизненного цикла сериализованы эксклюзивным замком: вторая операция отказывает до первой мутации, а `status`/`diagnostics` не блокируются
|
||||
67. замок снимается при любом завершении держателя, включая `Ctrl+C`, SIGTERM и обрыв SSH; замок мёртвого держателя переиспользуется безопасно
|
||||
68. новая операция не начинается, пока у предыдущей остаётся вооружённый rollback guard: условие старта — «у предыдущей нет исполнителей, способных изменить систему», а не «её PID мёртв»
|
||||
69. отказ записи маркера `auto-rollback-fired` не может привести к фиксации успеха: он переводит юнит guard в `failed`, а `failed` фиксацию запрещает
|
||||
70. восстановление `UnitFileState` у `nftables.service` не обещает точности, которой не даёт: восстанавливаются `enabled`/`disabled`, остальные состояния называются оператору и не трогаются
|
||||
71. отказ запроса к systemd не выдаётся за покой: барьер обязан **доказать** отсутствие исполнителей предыдущей операции, а при невозможности получить доказательство отказывает с `GuardStateUnknownError`, а не разрешает операцию
|
||||
72. покой guard перечисляется белым списком (`inactive`, `failed`): незнакомое состояние systemd блокирует операцию, а не проходит молча по принципу «его нет в списке опасных»
|
||||
73. отработавший таймер не блокирует операцию навсегда: `RemainAfterElapse=no` выгружает его, а барьер дополнительно опознаёт `SubState=elapsed` у `*.timer` как покой
|
||||
74. обещанное окно отката — контракт systemd, а не намерение: у транзиентного таймера явно задан `AccuracySec=1s`, иначе умолчание `AccuracySec=1min` превращало «45 секунд» в 45–105
|
||||
75. состояние guard читает один наблюдатель: `status` берёт его у того же кода, что и барьер, и сообщает `unknown` вместо тихого «guard'ов нет» при отказе systemd
|
||||
76. ни один релизный гейт не подаёт вывод в поиск с флагом `-q` через пайплайн: под `set -o pipefail` оборванный продюсер отдаёт 141, и «совпадение найдено» превращается в ненулевой код — для отрицательных проверок это ложный PASS. Сравнение идёт через here-string, и возврат пайплайна запрещён отдельной приёмкой
|
||||
77. отрицательные сканы по дереву исходников формулируют **синтаксическую форму**, а не подстроку: вызов — имя со скобкой или обратной кавычкой, импорт — `import` со спецификатором, зависимость — ключ в `package.json`. Прозаическое упоминание удалённой вещи разрешено, иначе гейт запрещает документировать собственную работу
|
||||
|
||||
### Почему отрицательный скан не ищет подстроку
|
||||
|
||||
Комментарий, объясняющий, почему чего-то больше нет, обязан называть это по
|
||||
имени. Скан по голой подстроке такой комментарий не отличает от кода и падает
|
||||
ровно на документации к выполненной им же работе. В этом файле урок оплачен
|
||||
пять раз: скан versions contract ловил сам себя на `/hui`; dead-route скан
|
||||
падал на `router_test.go`, который перечисляет удалённые маршруты, чтобы
|
||||
доказать их отсутствие; скан иконок — на блочном комментарии о замене плагина;
|
||||
скан прежних имён раннеров — на слове `systemd-run` в прозе; скан
|
||||
`cancelFirewallRollback` — на комментарии о её разделении.
|
||||
|
||||
`code_has` отбрасывает **строчные** комментарии (`//`, `#`), но не блочные
|
||||
`/* … */`. Блок-парсер сознательно не заводится: наивный стриппер спотыкается
|
||||
о `/*` внутри строк и регулярных выражений и может вычистить настоящий код —
|
||||
а это ложный PASS, то есть лекарство хуже болезни. Для файлов с блочными
|
||||
комментариями формулируется синтаксическая форма либо утверждение опирается на
|
||||
более сильный гейт.
|
||||
|
||||
Пример последнего: литерального скана по `virtual:svg-icons-register` больше
|
||||
нет. Его роль исполняет production `vite build`, который проходит раньше:
|
||||
активный `import "virtual:svg-icons-register"` при отсутствующем плагине не
|
||||
разрешается резолвером, и сборка bundle падает. Проверяется исполняемый импорт,
|
||||
а не совпадение подстроки.
|
||||
|
||||
### Почему пайплайн в `grep -q` запрещён
|
||||
|
||||
Поиск с флагом `-q` прекращает чтение на **первом** совпадении и закрывает свой
|
||||
конец канала. Продюсер, которому осталось что писать, получает `SIGPIPE` и
|
||||
завершается кодом 141, а `set -o pipefail` делает 141 статусом всей
|
||||
конструкции. Смысл инвертируется:
|
||||
|
||||
```text
|
||||
совпадение НАЙДЕНО -> продюсер оборван -> статус 141 -> «не найдено»
|
||||
```
|
||||
|
||||
Для утвердительной проверки это ложный FAIL — гейт отвергает корректный
|
||||
артефакт. Для отрицательной («такой конструкции в коде нет») — **ложный PASS**:
|
||||
запрещённая конструкция найдена, а гейт зелёный.
|
||||
|
||||
Порог измерим и резкий. Пока весь вывод продюсера помещается в буфер канала —
|
||||
64 KiB на Linux, — он записывает всё, не блокируясь, и успевает завершиться
|
||||
раньше, чем потребитель вообще начнёт читать. Замер на 60 прогонах каждого
|
||||
размера:
|
||||
|
||||
```text
|
||||
4 KiB … 60 KiB отказов 0
|
||||
64 KiB отказов 58/60
|
||||
96 KiB и больше отказов 60/60
|
||||
```
|
||||
|
||||
Поэтому такая проверка годами выглядит исправной, а переворачивается на первом
|
||||
источнике крупнее буфера. В этом репозитории файлы такого размера уже есть
|
||||
(`tools/build/lib/acceptance.sh` — 123 KiB, `orchestrator/src/steps/firewall.ts`
|
||||
— 67 KiB).
|
||||
|
||||
Правильная форма — here-string, у которого пайплайна нет вовсе:
|
||||
|
||||
```bash
|
||||
grep -q 'PATTERN' <<<"$content" || fail "..."
|
||||
```
|
||||
|
||||
Для содержимого файла есть `code_has FILE [флаги] -- PATTERN`: он читает код без
|
||||
комментариев в переменную **отдельным оператором** и сравнивает через
|
||||
here-string. Отдельный оператор важен: в контексте `! code_has …` bash отключает
|
||||
`errexit` на весь вызов, поэтому неудачное чтение проверяется явно, а не
|
||||
рассчитывает на `set -e`.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Проверки и приёмка HY2XS
|
||||
|
||||
Набор проверок разложен по слоям, на которых они выполняются. Раньше он был
|
||||
одним файлом на 117 КБ и 57 разделов; ориентироваться в нём приходилось
|
||||
поиском по строке.
|
||||
|
||||
Нумерация `11-*` сохранена: это стабильный идентификатор документа, под
|
||||
которым на него ссылаются CHANGELOG и релизные гейты.
|
||||
|
||||
| Документ | Слой | Что закрывает |
|
||||
| --- | --- | --- |
|
||||
| [11-1-how-to-run.md](11-1-how-to-run.md) | — | команды запуска всех наборов |
|
||||
| [11-2-builder-layer.md](11-2-builder-layer.md) | builder | резолвер Hysteria, контракт версий, юнит-тесты оркестратора и админки, гейты сборки |
|
||||
| [11-3-target-and-runtime.md](11-3-target-and-runtime.md) | target | установка на чистый хост, состояние сервисов, конфиг, share URI |
|
||||
| [11-4-fault-injection.md](11-4-fault-injection.md) | target | D0 на живом сервере, D1a-D1h — отказы и откат, D2 — устаревший DNS |
|
||||
| [11-5-negative-and-matrix.md](11-5-negative-and-matrix.md) | target | негативные сценарии, production-матрица, критерии приёмки |
|
||||
|
||||
## Отчёты о фактических прогонах
|
||||
|
||||
Проверки описывают, ЧТО должно выполняться. Результаты конкретных прогонов на
|
||||
конкретных сборках лежат отдельно — см. [docs/acceptance/](../acceptance/README.md).
|
||||
|
||||
Разделение намеренное: документ проверок переживает релизы, а отчёт о прогоне
|
||||
относится к одному артефакту и одному хосту и после релиза не редактируется.
|
||||
Reference in New Issue
Block a user