Files
HY2XS_flamy/docs/runtime/08-orchestrator-spec.md
T
founder a8407cf16b fix(admin): вход в панель падал на теге правила, пережившего переименование
RC2 на чистом Debian 13 завершался INSTALL EXIT CODE: 0 при полностью
недоступной панели. На LoginDto.Username стоял тег `validateStr` — правило с
таким именем не регистрировалось: при переименовании в `credentialStr` правка
не доехала до одного файла, оставив мёртвую регистрацию и живую ссылку на
несуществующее имя. go-playground/validator на неизвестный тег ПАНИКУЕТ при
разборе структуры, то есть до всякой проверки логина и пароля, а gin.Recovery
превращал панику в HTTP 500 на каждый POST /api/auth/login.

Дефект пережил 311 Go-тестов, и это главное, что здесь чинится. Проверялся сам
регексп, в обход валидатора, а обработчика входа не касался ни один тест.
Очевидная замена не помогла бы: цепочка правил поля обрывается на первом
несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного
тега не доходит. Теперь TestEveryValidationTagIsRegistered обходит исходники
apps/model/**, вытаскивает каждый тег `validate:"…"` и предъявляет его
валидатору отдельно — незарегистрированное правило паникует так же, как в бою,
но на сборке. Барьер проверен возвратом исходного тега.

Установка тоже не отвечала на вопрос, ради которого проверялась. Smoke считал
панель работающей по трём признакам — юнит активен, порт в LISTEN, /healthz
отвечает ok, — и все три были истинны. Теперь smoke выполняет настоящий вход
bootstrap-учётными данными и требует конверт успеха с непустым токеном: по коду
HTTP это неотличимо, админка отвечает 200 OK и на отказ. Отрицательная проба
идёт в любом режиме операции и от актуальности пароля не зависит.

Рядом лежали три расхождения того же класса, найденные при разборе.

Оркестратор не знал контракта, который сам порождает: HY2XS_ADMIN_USER по
умолчанию был `admin` — пять символов при минимуме панели в шесть, — и такая
установка проходила целиком, создавая учётную запись, под которой невозможно
войти. Про одно имя существовало три расходящихся умолчания. Оба значения
теперь проверяются при разборе окружения — той стороной, которая их порождает:
отказ, пришедший установщику, чинится строкой в hy2xs.env, а неработающий вход
на готовом сервере — переустановкой.

Панель была строже сервера. Форма входа ограничивала пароль 32 символами при
серверном пределе в 64, а форма смены пароля назначала до 64: пароль,
назначенный штатной операцией, после этого не вводился. Набор символов на
пароле отвергал значение, которое сервер принял бы, — сервер его не
ограничивает нигде. Контракт учётных данных объявлен один раз в
service/admin_credentials.go, копии в панели и оркестраторе сверяются с ним
тестами, читающими Go-исходник.

Класс символов логина был записан диапазоном по опечатке: неэкранированный
дефис превращал `+-=` в диапазон, впускающий `, - . / 0-9 : ; < =`. С серверным
набором это совпадало только потому, что обе стороны несли одну опечатку. Набор
записан явно и НЕ сужен — он уже действует на установленных серверах.

Визуально: красная рамка отказа обводила не то, что видит оператор. Element Plus
рисует состояние ошибки на el-input__wrapper селектором из четырёх классов, а
форма входа рисует видимую рамку поля на el-form-item — внутрь поля кладутся
иконка, ввод и переключатель видимости — и гасила чужую тень селектором из трёх,
проигрывая по специфичности. Рамка ложилась вокруг одного лишь ввода: у логина
начиналась после иконки, у пароля обрывалась перед «глазом». Индикация
перенесена на элемент, который оператор и видит полем; чужая тень гасится
селектором, повторяющим её собственный и добавляющим атрибут scoped-стиля, —
конкретностью, а не !important. Остальные формы панели проверены: собственная
рамка на el-form-item есть только на форме входа.

Заодно: `last_login_at` объявлен в схеме и в entity, а писать его было некому —
UpdateAdminLastLoginAt не вызывался ниоткуда. Отметка ставится в service.Login
сразу после успешной проверки пароля; отказ записи вход не отменяет, но
попадает в журнал. Обработчик входа переехал из controller/peer.go в
controller/auth.go: стек в journal указывал на управление пирами.

Требование теперь называется, а не сообщается фактом нарушения. «Неверный
формат логина» и «Некорректное значение» не давали оператору способа узнать,
что от него хотят: набор символов приходит из hy2xs.env и в панели нигде не
показан. Фразы форм и серверная причина credential_format перечисляют границы
и набор.

Гейт сборки run_admin_login_acceptance удерживает барьеры от тихого удаления —
по той же причине, что и гейт детектора гонок. Каждое из его утверждений
проверено мутационной пробой на реальный отказ; две первые редакции оказались
вакуумными и переписаны.

Прогнано: go vet + go test ./... , bun test оркестратора (427) и контрактов
панели (66), vue-tsc --noEmit, production-сборка frontend, гейт приёмки
целиком. `go test -race` не прогонялся — на машине нет C-компилятора, это
релизный гейт сборщика.

Прогон задокументирован в
docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md.
2026-09-04 02:32:50 +05:00

612 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Install-only orchestrator spec
## Цель документа
Зафиксировать ТЗ на оркестратор с учётом двухслойной архитектуры: builder отдельно, target install отдельно.
## Технологический стек оркестратора
Оркестратор фиксируется как:
- **Bun + TypeScript** по исходникам
- локальная сборка builder layer'ом
- поставка на target в виде **готового install-артефакта**
Это означает:
- на target нет `npm`, `pnpm`, `yarn` или `bun install`
- на target нет transpile/build step
- shell на target допустим только как thin wrapper entrypoint
## Главная роль оркестратора
Оркестратор работает **только на target machine** и умеет:
- выполнить read-only проверку чистоты хоста (`preflight-install`)
- выполнить первичную установку (`install`)
- выполнить явную реконфигурацию (`reconfigure --dry-run|--apply`)
- разложить bundled UI
- скачать Hysteria2 из official upstream
- создать/обновить конфиги
- создать unit-файлы
- применить staged firewall
- создать `post-install.env` и runtime env-файл
## Оркестратор не умеет
- upgrade
- standalone rollback subcommands
- uninstall
- repair старых неизвестных состояний
- target-side build
- target-side git clone исходного кода HY2XS admin
- Telegram-бот / access delivery
## Предусловия
Оркестратор рассчитан только на:
- чистый Debian 13
- root/sudo install context
- один сервер
- одну baseline-схему
Если машина уже «жила своей жизнью», baseline не обещает корректной автоадаптации.
## Двухфазный контракт установки
Установка разделена на две фазы с жёсткой границей между ними:
```text
PHASE 0 — READ ONLY владелец: install.sh
проверка прав
sha256sum -c metadata/checksums.txt
./orchestrator/hy2xs-orchestrator preflight-install --package-dir <распакованный пакет>
├── платформа Debian 13 amd64
├── clean-host контракт
└── валидация конфигурации
↓ ноль persistent writes
PHASE 0 PASSED
↓ exec
PHASE 1 — MUTATION владелец: оркестратор
preflight (clean-host — последний раз за операцию)
bootstrapRuntime: /usr/local/lib/hy2xs, symlink, runtime-пакет
installDeps → filesystem → UI → Hysteria → config → units → firewall → smoke
```
Ключевые свойства:
- `preflight-install` запускается **из распакованного пакета**, а не из
установленного `/usr/local/lib/hy2xs`: до PHASE 1 этого каталога может не
существовать, и создавать его нельзя.
- Граница держится не соглашением, а **read-only guard** (`lib/guard.ts`):
под ним `writeText`/`writeTextAtomic` и мутирующие раннеры `lib/process`
кидают ошибку. Это проверяется тестами.
- Внутри `install` **`preflight()` выполняется раньше первой записи
`install-state.json`**. Отказ на этом этапе означает, что на сервере не
изменено ничего.
### У мутации ровно один владелец
`install.sh` не изменяет на сервере ничего. Он проверяет и делает `exec`.
Раньше PHASE 1 начиналась в shell: установщик сам создавал
`/usr/local/lib/hy2xs`, ставил туда бинарник, вешал symlink и копировал
runtime-пакет, и только после этого запускал оркестратор, который выполнял
собственный preflight. Между двумя фазами возникало окно: если второй preflight
отказывал — сменился DNS, занялся порт, не ответил резолвер, — у оркестратора не
был взведён ни один флаг владения, отказ классифицировался как
`fatal_pre_apply`, и оператор читал «на сервере ничего не изменено». Хост при
этом уже нёс каталог оркестратора, symlink и runtime-пакет, а следующий запуск
упирался в них как в маркеры чужой установки.
Владение мутацией невозможно отследить, пока мутируют двое. Поэтому раскладку
выполняет шаг `steps/bootstrap.ts` под флагом `ownership.bootstrapTouched`, и
эти пути попадают в `owned_paths` install-state наравне со всеми остальными.
Сборка проверяет структурно, что в `install.sh` не осталось ни одной мутирующей
команды.
### clean-host проверяется до первой мутации и только там
`preflight()` принимает `checkCleanHost` явно, без значения по умолчанию.
Причина в том, что clean-host — условие **входа** в операцию, а проверка
возможностей платформы (`systemd-run`, `nftables`, OpenSSL 3) выполняется уже
после `installDeps`, то есть внутри PHASE 1. Пока обе проверки ехали одним
параметром, `install` вызывал preflight дважды и оба раза с включённым
clean-host. Ко второму вызову на диске лежал собственный
`/var/lib/hy2xs/install-state.json`, записанный после первого preflight, — и он
опознавался как маркер посторонней установки. Каждая чистая установка падала
сразу после `apt-get`, получала `fatal_post_apply` и оставляла сервер
наполовину настроенным.
По той же причине у списка маркеров больше нет «мягкой» версии для PHASE 1:
пути, которые раньше приходилось исключать, теперь создаются после проверки.
Полный список маркеров чужой установки и порядок очистки —
[14-legacy-cleanup.md](../operations/14-legacy-cleanup.md).
### Раннеры подпроцессов: два набора, а не один
Guard умеет останавливать только то, что через него проходит. Поэтому
универсального раннера в `lib/process.ts` нет — есть два явных набора:
| Набор | Guard | Назначение |
| --- | --- | --- |
| `runReadOnly`, `runReadOnlySecret` | не трогает | наблюдение за системой: `ss`, `systemctl is-active`, `curl`, `getent` |
| `runMutating`, `runMutatingVisible`, `runMutatingHidden`, `runMutatingRaw` | спрашивает разрешение | всё, что может изменить хост |
`*Secret`-варианты не печатают команду в текст ошибки: их аргументы несут
machine token или пароль пира, а сообщение уходит в логи и диагностику.
До разделения существовал один `run`, под которым одинаково жили `ss -ltn` и
`useradd`/`install -d`/`mkdir`. Guard стоял только на части раннеров, поэтому
утверждение «PHASE 0 ничего не пишет» держалось на внимательности автора
следующей правки. Выбор набора теперь — обязательное решение на месте вызова;
возвращение старых имён ломает приёмку сборки.
## Маркер состояния установки
`/var/lib/hy2xs/install-state.json` отвечает на вопрос «эта машина — установка
**текущего поколения** HY2XS, и в каком она состоянии». Поэтому кроме фазы он
несёт идентификацию поколения:
```json
{
"product": "hy2xs",
"release_line": 1,
"config_schema_version": 2,
"product_version": "1.0.0",
"installed": true,
"phase": "installed"
}
```
`reconfigure` и `repair` проверяют `product` / `release_line` /
`config_schema_version` **до** всего остального. Флага `installed: true`
недостаточно: такой же маркер мог остаться от 0.x.
`repair` дополнительно требует явного `--allow-partial-state`, чтобы работать
поверх незавершённой установки. Разрешение не подразумевается: молчаливое
согласие на произвольный partial marker и позволяло «чинить» чужое состояние.
### Запись маркера долговечна и имеет ровно одного владельца
`install` и `reconfigure` пишут маркер через один и тот же
`lib/installStateWriter.ts`. Раньше писателей было два, с разными гарантиями:
`install` перезаписывал файл на месте, `reconfigure` подставлял его атомарно.
Слабейшая гарантия досталась команде, которая этот файл создаёт.
Перезапись на месте укорачивает файл до нуля и только потом наполняет. Любой
отказ между этими моментами — потеря питания, `kill -9`, `ENOSPC` — оставляет на
сервере половину документа:
```json
{
"product": "hy2xs",
"release_line":
```
Такой маркер не разбирается: `reconfigure`/`repair` видят его как отсутствующий,
а clean-host — как присутствующий, причём хост к этому моменту уже изменён.
Атомарности при этом недостаточно, нужна **долговечность**. Порядок записи:
```text
1. запись во временный файл в том же каталоге
2. права и владелец ← до подстановки: иначе есть окно,
в котором файл виден с чужими правами
3. fsync временного файла ← данные на носителе, а не в page cache
4. rename ← атомарная подстановка
5. fsync каталога ← сама запись каталога о новом имени
```
Без шагов 3 и 5 `rename()` даёт атомарность видимости, но после внезапной
перезагрузки ext4 штатно отдаёт по этому пути нулевой файл или отсутствие файла.
Для метаданных восстановления это неприемлемо.
Есть ещё один уровень: при первой установке сам каталог `/var/lib/hy2xs`
создаётся прямо сейчас, и запись «hy2xs» в `/var/lib` тоже обязана быть
долговечной. Иначе возможно состояние, в котором и файл, и его каталог сброшены
на носитель, а каталог из родителя исчез — то есть маркер пропал целиком.
Поэтому `ensureDir` сообщает, был ли каталог **фактически создан**, и при
создании синхронизирует родителя. На последующих обновлениях маркера каталог уже
существует, и лишний `fsync` родителя не выполняется.
## Ownership и rollback
Операция ведёт учёт того, к чему она **могла прикоснуться**:
```text
stateTouched
depsTouched
filesystemTouched
uiTouched
hysteriaTouched
configTouched
unitsTouched
firewallTouched
postInstallTouched
bootstrapSecretTouched
servicesStarted
```
Формулировка выбрана намеренно. Флаг «шаг успешно завершился» отвечает не на
тот вопрос: `apt-get install` умеет распаковать половину пакетов и упасть, и
хост уже изменён, хотя шаг не закончился. Поэтому **каждый флаг взводится перед
мутирующим вызовом**, а не после него.
`stateTouched` — полноценный участник классификации. `install-state.json`
пишется сразу после успешного preflight, до `installDeps`; пока он в
классификации не учитывался, падение `apt-get` объявлялось «на сервере ничего
не изменено», rollback пропускался, а маркер оставался на хосте и ломал
следующую установку по clean-host контракту.
Флаг называется `touched`, а не `written`, и это не косметика. Запись маркера —
три операции (`mkdir`, `write`, `chown`), и отказ последней оставляет файл на
диске. Пока флаг взводился **после** успешной записи, такой отказ давал
классификацию `fatal_pre_apply` — «на сервере ничего не изменено» — при уже
существующем `/var/lib/hy2xs/install-state.json`.
Классификация отказа строится **по этим флагам и фазе**, а не по тексту
сообщения об ошибке. Ранее классификация шла по подстрокам, из-за чего
preflight-ошибка со словом `nftables` приводила к откату чужого firewall.
Инварианты rollback:
- `fatal_pre_apply` по определению означает «ничего не применялось». Попасть в
него нельзя ни при одном взведённом флаге, включая `stateTouched`. В этом
случае system rollback не выполняется, `install-state.json` не пишется,
diagnostics-бандл не собирается (его сбор сам создал бы каталоги в
`/var/log/hy2xs`).
- `systemctl stop/disable` выполняется **только если текущая операция сама
развернула эти unit-файлы**.
### После операционного отказа откат выполняется целиком
Порядок в обработчике ошибки один и тот же в `install` и `reconfigure`:
```text
запись состояния отказа → best effort
сбор диагностики → best effort
откат → обязателен
```
Обе первые операции пишут на диск (`/var/lib/hy2xs`, `/var/log/hy2xs`), то есть
падают ровно на заполненном диске и read-only ФС — там, где откат нужнее всего.
Пока хотя бы одна из них стояла обычным `await`, её собственный отказ уносил
управление наружу, и восстановление не выполнялось вовсе: применённый firewall и
развёрнутые сервисы оставались на сервере. Для диагностики это было закрыто
раньше, для записи состояния — нет.
Второй инвариант — **стадии отката независимы**:
| Команда | Стадии |
| --- | --- |
| `install` | firewall → stop services → disable services → reset failed services |
| `reconfigure` | firewall → restore configuration |
Каждая стадия — это `systemctl`, `cp`, `rm -rf` или `nft`, то есть каждая умеет
упасть сама. Пока они стояли цепочкой `await`, отказ первой отменял все
следующие. В `reconfigure` это означало сервер одновременно с применённым
сломанным firewall **и** без восстановленных из `/etc/hy2xs/backups` конфигов —
то есть худший сценарий отказа лишался обеих половин восстановления сразу.
Стадии выполняются последовательно и в объявленном порядке; независимость
означает «отказ не прерывает остальные», а не «выполняется как попало».
Отказавшие стадии перечисляются в журнале, а наружу пробрасывается **исходная**
ошибка операции: проблема внутри отката — это дополнительная информация о том,
что осталось не восстановленным, а не замена диагноза.
Команды внутри стадий **не глушат собственные ошибки**. Это правило обратно
тому, что действовало раньше. Пока непрерывность держалась на `|| true` в каждой
команде, стадия физически не могла сообщить, что восстановление не выполнилось:
`cp`, `nft -f`, `systemctl daemon-reload` и `systemctl restart` возвращали ноль
при любом исходе, и «restore configuration» никогда не попадала в список
отказавших. Непрерывность обеспечивает стадийный раннер; подавление кода
возврата после его появления стало не защитой, а маскировкой.
### Порядок фиксации успеха
Данные, по которым выполняется откат, обязаны пережить долговечную запись
успеха:
```text
smoke PASS
durable phase = smoke_ok
disarm автоматического отката по таймеру ← резервные копии ОСТАЮТСЯ
durable phase = installed ← точка фиксации
cleanup резервных копий ← best effort
```
Раньше снятие таймера и удаление копий выполнял один вызов, стоявший **до**
записи `installed`. Отсюда следовал разрыв:
```text
smoke PASS
→ таймер снят, резервные копии УДАЛЕНЫ
→ запись "installed" падает (ENOSPC / EIO / read-only ФС)
→ обработчик ошибки → обязательный откат
→ "firewall rollback skipped: no HY2XS rollback markers found"
```
То есть ровно тот отказ записи маркера, который был специально сделан
безопасным, случался после уничтожения единственных данных для отката: откат
запускался, но откатывать ему было нечем.
Уборка после точки фиксации выполняется best-effort намеренно: невозможность
удалить временные данные в `/run` — мусор, а не причина объявить успешную
установку неуспешной.
### Резервные копии: строгие и привязанные к операции
Две отдельные гарантии, которых раньше не было ни у firewall, ни у
`reconfigure`.
**Копия обязана существовать до первой мутации.** Копирование выполнялось как
`cp ... || true`, поэтому отказ (заполненный `/run`, ошибка ввода-вывода, права)
игнорировался, а операция шла менять систему, не имея того, на что рассчитывает
откат. Теперь копирование строгое, факт создания проверяется, а маркер
готовности `prepared` ставится **после** проверенных копий, а не до них.
**Копия принадлежит конкретной операции.** `reconfigure` хранил копии всех
операций одним общим набором `*.bak` в `/etc/hy2xs/backups`. Отсюда сценарий:
```text
reconfigure A → config.yaml.bak создан
reconfigure B → создание копии упало, ошибка скрыта
→ B меняет конфигурацию
→ B падает → откат восстанавливает копию, снятую операцией A
```
Сервер возвращался не в состояние «до B», а в более старое — и это выглядело
успешным откатом. Теперь копия лежит в `/etc/hy2xs/backups/<op-id>/` с
манифестом:
```json
{
"version": 1,
"opId": "2026-08-30T10-00-00.000Z",
"entries": [
{ "path": "/etc/hysteria/config.yaml", "present": true, "stored": "etc_hysteria_config.yaml" },
{ "path": "/etc/nftables.d/hy2xs.nft", "present": false, "stored": null }
]
}
```
Отсутствие файла — **записанный факт**, а не вывод из неудачи `cp`: по этому
полю откат решает, восстанавливать файл или удалять его. Разбор манифеста
строгий, включая проверку `opId`: восстановление по частично понятому манифесту
или по копии чужой операции опаснее отказа.
**Артефакты восстановления удаляются только после подтверждённого
восстановления.** `rollbackFirewallNow` раньше скрывала ошибки `cp` и `nft`, а
затем безусловно удаляла копии — худшая комбинация, при которой неудача
восстановления не видна, а данные для ручной починки уничтожены. Теперь при
любом отказе стадии копии сохраняются, и в журнале появляется
`manual recovery data preserved at …`.
## Инвариант публичного endpoint
`preflight` проверяет, что публичный endpoint ведёт **на этот сервер**. Так как
preflight общий для `install`, `reconfigure` и `doctor`, инвариант действует во
всех трёх сценариях.
Алгоритм:
```text
1. локальные публичные IPv4 из node:os networkInterfaces()
(минус 0/8, 10/8, 100.64/10, 127/8, 169.254/16,
172.16/12, 192.168/16, 224/4, 240/4)
2. HY2XS_PUBLIC_HOST
IPv4-литерал → обязан быть в локальном множестве
домен → все A-записи обязаны быть в локальном множестве
3. HY2XS_DOMAIN, если задан и отличается от publicHost → та же проверка
4. AAAA-политика остаётся отдельной
```
Проверяется именно `HY2XS_PUBLIC_HOST`, потому что в `hysteria2://` уезжает он,
а не TLS-домен. По умолчанию они совпадают, но архитектурно это разные
сущности, и до v1 проверялся только `HY2XS_DOMAIN`.
Адрес сервера определяется **локально**. Внешние сервисы определения IP не
используются: они добавили бы `doctor` сетевую зависимость и превратили бы
недоступность стороннего сервиса в ложный отказ установки.
Несколько публичных IPv4 у сервера — норма: достаточно, чтобы DNS указывал на
один из них. Обратное неверно: лишняя A-запись рядом с правильной означает
второй, чужой backend за тем же именем. HY2XS — single-host профиль, поэтому
это ошибка конфигурации DNS, а не балансировка.
Строгость управляется `HY2XS_PUBLIC_ENDPOINT_POLICY`:
| Значение | Поведение |
| --- | --- |
| `strict` (по умолчанию) | расхождение останавливает операцию |
| `warn` | печатается предупреждение, операция продолжается |
| `off` | сравнение не выполняется |
Ослабление предназначено для топологий вне baseline (NAT, floating IP, anycast).
Отсутствие A-записи остаётся фатальным при любом значении: имя без A-записи не
работает ни в какой топологии.
## Что приходит на target
На target должен попадать уже готовый package, содержащий:
- thin install entrypoint
- compiled orchestrator artifact
- bundled HY2XS admin
- templates
- unit files
- docs/examples
- metadata package version / build id
## Логическая модульность
Даже если на target приезжает один собранный артефакт, внутри исходников оркестратор должен быть разложен по шагам:
- preflight
- deps
- filesystem
- hysteria
- ui
- systemd
- firewall
- env
- smoke
## Что делает оркестратор по шагам
1. Проверяет, что ОС — Debian 13, и что хост чист (**до любой мутации**).
2. Проверяет базовые зависимости и install context.
3. Создаёт каталоги установки.
4. Разворачивает bundled HY2XS admin.
5. Скачивает pinned Hysteria2 binary из package metadata, проверяет SHA256 и выполняет install.
6. Генерирует Hysteria config.
7. Создаёт systemd unit для Hysteria.
8. Создаёт systemd unit для HY2XS admin.
9. Применяет nftables baseline.
10. Создаёт `post-install.env`.
11. Запускает сервисы и выполняет smoke-check.
## Модель поставки
Рекомендуемая baseline-модель:
- исходники оркестратора хранятся в `orchestrator/`
- builder выполняет локальную сборку через Bun
- в install package кладётся готовый артефакт, который запускается thin wrapper'ом
Например:
- `package/install.sh` — проверка контекста и вызов оркестратора
- `package/orchestrator/hy2xs-orchestrator` — собранный артефакт
## Логирование и коды возврата
Оркестратор должен:
- печатать понятные step-based сообщения
- завершаться ненулевым кодом при ошибке
- не скрывать первичный источник падения
- разделять preflight/config/runtime ошибки хотя бы на уровне текста
## Политика ошибок
- Любой конфликт неизвестного старого состояния = stop with error.
- Никакой сложной автомиграции.
- Ошибки должны быть текстовыми и пригодными для диагностики.
- Для install/reconfigure допустим bounded rollback при failure-сценариях firewall/systemd/config/smoke.
## CLI baseline
Команды:
- `preflight-install --package-dir <path> [--config <source-env>]`
- `install --package-dir <path> [--config <source-env>]`
- `reconfigure --package-dir <path> --config /etc/hy2xs/hy2xs.env --dry-run`
- `reconfigure --package-dir <path> --config /etc/hy2xs/hy2xs.env --apply`
- `repair --package-dir <path> --config /etc/hy2xs/hy2xs.env [--allow-partial-state]`
- `redact-config --config <path> (--in-place | --out <path>) [--format auto|env|yaml]`
`preflight-install` не принимает `--skip-*`: эти флаги влияют на мутацию, а
PHASE 0 ничего не меняет.
`--allow-partial-state` допустим только для `repair`.
Инварианты:
- только IPv4 bind/listen;
- TLS modes: `acme | file | self_signed_dev`;
- `trafficStats.secret` отдельный от `JWT_SECRET`;
- `HY2XS_CONFIG_SCHEMA_VERSION` — обязательное поле; его отсутствие трактуется
как legacy-конфигурация и отклоняется, а не заменяется значением по умолчанию;
- install flow фиксирует фактически установленную версию Hysteria в snapshot;
- версия/URL/SHA256 Hysteria берутся из metadata install package;
- `reconfigure` не обновляет бинарник Hysteria, только runtime-слой;
- при `reconfigure --apply`: backup -> staged apply -> smoke -> rollback on fail.
## Семантическая проверка сгенерированного конфига
`assertHysteriaConfigMatchesProfile` разбирает YAML и сверяет его с
production-профилем, а не ищет подстроки. Проверяются, в частности:
- `listen`, ровно один подтип `obfs` и его соответствие `obfs.type`;
- размеры пакетов Gecko;
- `bandwidth`, `disableLossCompensation`, `ignoreClientBandwidth`;
- `congestion.type` / `bbrProfile`;
- весь QUIC baseline, **включая `maxIdleTimeout`**;
- `trafficStats.listen` и непустой `secret`;
- `auth.type`, **точный** `auth.http.url` (host/port/path/token) и
`auth.http.insecure`;
- ACME: `type`, `email`, `ca`, `dir`, `listenHost`, первый домен;
- отсутствие посторонних секций верхнего уровня.
Сообщение об ошибке для `auth.http.url` намеренно не печатает сам токен: текст
уходит в логи и в diagnostics-бандл.
## Smoke проверяет, что панель ВПУСКАЕТ
Открытый порт — это не работающая панель.
До RC3 установка отвечала на вопрос «работает ли панель» тремя фактами: юнит
активен, `127.0.0.1:8080` в `LISTEN`, `/healthz` отвечает `ok: true`. RC2
доказал, что все три бывают истинными одновременно с полностью недоступной
панелью: на поле логина стоял тег незарегистрированного правила валидации,
`POST /api/auth/login` паниковал ещё до проверки учётных данных, `gin.Recovery`
превращал панику в HTTP 500 — и установка завершалась `INSTALL EXIT CODE: 0`.
Поэтому smoke выполняет **настоящий вход** на `POST /api/auth/login`:
| Проба | Когда | Что требуется |
| --- | --- | --- |
| заведомо неверные учётные данные | всегда | HTTP 200 с конвертом отказа |
| bootstrap-учётные данные из `bootstrap-admin.secret` | только `install` | `code: 20000` и непустой `accessToken` |
Детали, которые здесь существенны:
- **успех определяется конвертом, а не кодом HTTP.** Админка отвечает `200 OK` и
на отказ тоже — причина живёт в поле `code`. Проверка «HTTP 200» приняла бы за
успешный вход любой отказ, то есть не проверяла бы ничего;
- **токен требуется отдельно.** `code: 20000` без `accessToken` означал бы
панель, которая пускает и не выдаёт сессию;
- **тело собирается `JSON.stringify`**, а не интерполяцией в строку: пароль
задаёт оператор, и кавычка в нём сломала бы сам запрос, а не панель — проверка
объявила бы рабочую установку сломанной;
- **обе команды идут через `runReadOnlySecret`**: он не кладёт команду в текст
ошибки, а команда несёт пароль администратора. Наружу отдаётся только код
ответа: тело успешного входа содержит токен доступа, а текст ошибки уезжает в
журнал установки и в diagnostics-бандл;
- **положительная проба install-only.** На `reconfigure` пароль в
`bootstrap-admin.secret` устаревает в тот момент, когда оператор сменил его в
панели, и требовать по нему вход значило бы ронять законную операцию.
Отрицательная проба от пароля не зависит и выполняется всегда — именно она
воспроизводит дефект RC2.
## Редактирование секретов
`redact-config` и diagnostics-бандл используют **структурную** редакцию: YAML
разбирается и обходится как дерево.
Это не косметика. Построчное правило `auth:\s*(.*)` подставляло маркер в
заголовок mapping'а и оставляло нетронутым вложенный
`auth.http.url` с `access_token=<секрет>`, то есть бандл уносил machine token
наружу. Значение может лежать где угодно в дереве, поэтому обходить нужно
дерево.
Редактируются:
- поля с секретоподобным именем (`password`, `secret`, `token`, `apiKey`,
`privateKey`, `authorization`, `cookie`, `bearer`, `signature`, …);
- карты, где секретны все значения (`auth.userpass`, `acme.dns.config`);
- учётные данные и секретные query-параметры внутри URL — в том числе в
env-файлах, где имя ключа (`HY2_AUTH_URL`) ни под один маркер не подходит.
Гарантия формулируется честно: **known secrets + secret-shaped unknown
fields**. Обобщённый sanitizer не может пообещать, что под правило попадёт
любой будущий секрет.
## Что не реализовывать
- update subcommands
- rollback subcommands
- uninstall subcommands
- reconcile logic
- выдачу пользовательских ключей или bot workflow