fix(installer): harden admin smoke and rollback cleanup

This commit is contained in:
2026-09-07 22:39:30 +05:00
parent bf10810cfc
commit 079094591b
15 changed files with 1095 additions and 261 deletions
+54 -20
View File
@@ -7,11 +7,13 @@
## Технологический стек оркестратора
Оркестратор фиксируется как:
- **Bun + TypeScript** по исходникам
- локальная сборка builder layer'ом
- поставка на target в виде **готового install-артефакта**
Это означает:
- на target нет `npm`, `pnpm`, `yarn` или `bun install`
- на target нет transpile/build step
- shell на target допустим только как thin wrapper entrypoint
@@ -19,6 +21,7 @@
## Главная роль оркестратора
Оркестратор работает **только на target machine** и умеет:
- выполнить read-only проверку чистоты хоста (`preflight-install`)
- выполнить первичную установку (`install`)
- выполнить явную реконфигурацию (`reconfigure --dry-run|--apply`)
@@ -42,6 +45,7 @@
## Предусловия
Оркестратор рассчитан только на:
- чистый Debian 13
- root/sudo install context
- один сервер
@@ -127,10 +131,10 @@ clean-host. Ко второму вызову на диске лежал собс
Guard умеет останавливать только то, что через него проходит. Поэтому
универсального раннера в `lib/process.ts` нет — есть два явных набора:
| Набор | Guard | Назначение |
| --- | --- | --- |
| `runReadOnly`, `runReadOnlySecret` | не трогает | наблюдение за системой: `ss`, `systemctl is-active`, `curl`, `getent` |
| `runMutating`, `runMutatingVisible`, `runMutatingHidden`, `runMutatingRaw` | спрашивает разрешение | всё, что может изменить хост |
| Набор | Guard | Назначение |
| -------------------------------------------------------------------------- | --------------------- | --------------------------------------------------------------------- |
| `runReadOnly`, `runReadOnlySecret` | не трогает | наблюдение за системой: `ss`, `systemctl is-active`, `curl`, `getent` |
| `runMutating`, `runMutatingVisible`, `runMutatingHidden`, `runMutatingRaw` | спрашивает разрешение | всё, что может изменить хост |
`*Secret`-варианты не печатают команду в текст ошибки: их аргументы несут
machine token или пароль пира, а сообщение уходит в логи и диагностику.
@@ -277,10 +281,10 @@ preflight-ошибка со словом `nftables` приводила к отк
Второй инвариант — **стадии отката независимы**:
| Команда | Стадии |
| --- | --- |
| `install` | firewall → stop services → disable services → reset failed services |
| `reconfigure` | firewall → restore configuration |
| Команда | Стадии |
| ------------- | --------------------------------------------------------------------------------------------------------- |
| `install` | firewall → stop services → disable services → reset failed `hysteria-server` → reset failed `hy2xs-admin` |
| `reconfigure` | firewall → restore configuration |
Каждая стадия — это `systemctl`, `cp`, `rm -rf` или `nft`, то есть каждая умеет
упасть сама. Пока они стояли цепочкой `await`, отказ первой отменял все
@@ -294,6 +298,14 @@ preflight-ошибка со словом `nftables` приводила к отк
ошибка операции: проблема внутри отката — это дополнительная информация о том,
что осталось не восстановленным, а не замена диагноза.
`reset-failed` для каждого сервиса является отдельной стадией и завершается
проверкой `LoadState`/`ActiveState`. Ненулевой код команды допустим, если юнит
уже выгружен (`not-found` + `inactive`): failed-состояния у него больше нет, а
значит cleanup завершён. Текст `Unit … not loaded` намеренно не разбирается — он
зависит от версии и локали systemd. Ошибка чтения состояния или сохранившийся
`ActiveState=failed` остаются настоящим отказом и попадают в manual-recovery
сводку.
Команды внутри стадий **не глушат собственные ошибки**. Это правило обратно
тому, что действовало раньше. Пока непрерывность держалась на `|| true` в каждой
команде, стадия физически не могла сообщить, что восстановление не выполнилось:
@@ -368,7 +380,11 @@ reconfigure B → создание копии упало, ошибка скры
"version": 1,
"opId": "2026-08-30T10-00-00.000Z",
"entries": [
{ "path": "/etc/hysteria/config.yaml", "present": true, "stored": "etc_hysteria_config.yaml" },
{
"path": "/etc/hysteria/config.yaml",
"present": true,
"stored": "etc_hysteria_config.yaml"
},
{ "path": "/etc/nftables.d/hy2xs.nft", "present": false, "stored": null }
]
}
@@ -423,11 +439,11 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
Строгость управляется `HY2XS_PUBLIC_ENDPOINT_POLICY`:
| Значение | Поведение |
| --- | --- |
| `strict` (по умолчанию) | расхождение останавливает операцию |
| `warn` | печатается предупреждение, операция продолжается |
| `off` | сравнение не выполняется |
| Значение | Поведение |
| ----------------------- | ------------------------------------------------ |
| `strict` (по умолчанию) | расхождение останавливает операцию |
| `warn` | печатается предупреждение, операция продолжается |
| `off` | сравнение не выполняется |
Ослабление предназначено для топологий вне baseline (NAT, floating IP, anycast).
Отсутствие A-записи остаётся фатальным при любом значении: имя без A-записи не
@@ -436,6 +452,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
## Что приходит на target
На target должен попадать уже готовый package, содержащий:
- thin install entrypoint
- compiled orchestrator artifact
- bundled HY2XS admin
@@ -447,6 +464,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
## Логическая модульность
Даже если на target приезжает один собранный артефакт, внутри исходников оркестратор должен быть разложен по шагам:
- preflight
- deps
- filesystem
@@ -474,17 +492,20 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
## Модель поставки
Рекомендуемая baseline-модель:
- исходники оркестратора хранятся в `orchestrator/`
- builder выполняет локальную сборку через Bun
- в install package кладётся готовый артефакт, который запускается thin wrapper'ом
Например:
- `package/install.sh` — проверка контекста и вызов оркестратора
- `package/orchestrator/hy2xs-orchestrator` — собранный артефакт
## Логирование и коды возврата
Оркестратор должен:
- печатать понятные step-based сообщения
- завершаться ненулевым кодом при ошибке
- не скрывать первичный источник падения
@@ -500,6 +521,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
## 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`
@@ -513,6 +535,7 @@ PHASE 0 ничего не меняет.
`--allow-partial-state` допустим только для `repair`.
Инварианты:
- только IPv4 bind/listen;
- TLS modes: `acme | file | self_signed_dev`;
- `trafficStats.secret` отдельный от `JWT_SECRET`;
@@ -645,10 +668,10 @@ EnvironmentFile дополнительно запрещает U+FEFF. Реали
Поэтому smoke выполняет **настоящий вход** на `POST /api/auth/login`:
| Проба | Когда | Что требуется |
| --- | --- | --- |
| настоящий логин + СЛУЧАЙНЫЙ пароль | всегда | `code: 50000`, причина `invalid_credentials`, `accessToken` отсутствует |
| bootstrap-учётные данные из `bootstrap-admin.secret` | только `install` | `code: 20000` и непустой `accessToken` |
| Проба | Когда | Что требуется |
| ---------------------------------------------------- | ---------------- | ----------------------------------------------------------------------- |
| настоящий логин + СЛУЧАЙНЫЙ пароль | всегда | `code: 50000`, причина `invalid_credentials`, `accessToken` отсутствует |
| bootstrap-учётные данные из `bootstrap-admin.secret` | только `install` | `code: 20000` и непустой `accessToken` |
Детали, которые здесь существенны:
@@ -661,6 +684,16 @@ EnvironmentFile дополнительно запрещает U+FEFF. Реали
`50000` (отказ операции, а не успех и не отказ валидации, который означал бы
негодный запрос), доменная причина `invalid_credentials` и ОТСУТСТВИЕ
`accessToken`;
- **конверт разбирается как JSON**, а не ищется регулярным выражением в сыром
тексте. Подстрока `invalid_credentials` внутри `message` или сломанный JSON не
имеют права превратить неизвестный ответ в успешную проверку;
- **обе пробы используют один request helper.** Wire-поле называется `pass`, а
не `password`; `Content-Type`, User-Agent и настройки curl не дублируются и не
могут разойтись между positive и negative ветками;
- **smoke отправляет явный `HY2XS-Installer/1.0` User-Agent.** Стандартный
`curl/<version>` отклоняется действующим scanner middleware раньше DTO. UA
установщика называется своим именем, не имитирует браузер и при этом проходит
существующий фильтр;
- **пароль отрицательной пробы генерируется**, а не записан литералом. Записанное
в исходнике значение теоретически может оказаться настоящим паролем — и тогда
проверка «неверные данные отвергаются» отчиталась бы об успешном входе. На
@@ -672,10 +705,10 @@ EnvironmentFile дополнительно запрещает U+FEFF. Реали
объявила бы рабочую установку сломанной;
- **токен требуется отдельно.** `code: 20000` без `accessToken` означал бы
панель, которая пускает и не выдаёт сессию;
- **тело собирается `JSON.stringify`**, а не интерполяцией в строку: пароль
- **тело общего helper'а собирается `JSON.stringify`**, а не интерполяцией в строку: пароль
задаёт оператор, и кавычка в нём сломала бы сам запрос, а не панель — проверка
объявила бы рабочую установку сломанной;
- **обе команды идут через `runReadOnlySecret`**: он не кладёт команду в текст
- **общий helper идёт через `runReadOnlySecret`**: он не кладёт команду в текст
ошибки, а команда несёт пароль администратора. Наружу отдаётся только код
ответа: тело успешного входа содержит токен доступа, а текст ошибки уезжает в
журнал установки и в diagnostics-бандл;
@@ -712,6 +745,7 @@ service-writable `HY2XS_LOG_DIR`. Родитель проверяется чер
дерево.
Редактируются:
- поля с секретоподобным именем (`password`, `secret`, `token`, `apiKey`,
`privateKey`, `authorization`, `cookie`, `bearer`, `signature`, …);
- карты, где секретны все значения (`auth.userpass`, `acme.dns.config`);