766 lines
51 KiB
Markdown
766 lines
51 KiB
Markdown
# 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/lib/hy2xs/diagnostics`).
|
||
- `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 `hysteria-server` → reset failed `hy2xs-admin` |
|
||
| `reconfigure` | firewall → restore configuration |
|
||
|
||
Каждая стадия — это `systemctl`, `cp`, `rm -rf` или `nft`, то есть каждая умеет
|
||
упасть сама. Пока они стояли цепочкой `await`, отказ первой отменял все
|
||
следующие. В `reconfigure` это означало сервер одновременно с применённым
|
||
сломанным firewall **и** без восстановленных из `/etc/hy2xs/backups` конфигов —
|
||
то есть худший сценарий отказа лишался обеих половин восстановления сразу.
|
||
|
||
Стадии выполняются последовательно и в объявленном порядке; независимость
|
||
означает «отказ не прерывает остальные», а не «выполняется как попало».
|
||
Отказавшие стадии перечисляются в журнале, а наружу пробрасывается **исходная**
|
||
ошибка операции: проблема внутри отката — это дополнительная информация о том,
|
||
что осталось не восстановленным, а не замена диагноза.
|
||
|
||
`reset-failed` для каждого сервиса является отдельной стадией и завершается
|
||
проверкой `LoadState`/`ActiveState`. Ненулевой код команды допустим, если юнит
|
||
уже выгружен (`not-found` + `inactive`): failed-состояния у него больше нет, а
|
||
значит cleanup завершён. Текст `Unit … not loaded` намеренно не разбирается — он
|
||
зависит от версии и локали systemd. Ошибка чтения состояния или сохранившийся
|
||
`ActiveState=failed` остаются настоящим отказом и попадают в manual-recovery
|
||
сводку.
|
||
|
||
Команды внутри стадий **не глушат собственные ошибки**. Это правило обратно
|
||
тому, что действовало раньше. Пока непрерывность держалась на `|| 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-бандл.
|
||
|
||
## Формат env-файлов: у него два читателя
|
||
|
||
`/etc/hy2xs/hy2xs.env` разбирает не только оркестратор. Файл объявлен
|
||
`EnvironmentFile=` в юните `hy2xs-admin`, то есть его читает **systemd**, и
|
||
формат обязан совпадать у обоих. Пока значения писались интерполяцией
|
||
(`` `HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}` ``), а читались
|
||
построчным `split("=")` с `trim()`, форматом это не являлось: совпадение
|
||
поведения держалось на том, что в значениях не встречалось ни пробелов по краям,
|
||
ни кавычек, ни обратных слешей. Продукт при этом обещает оператору, что набор
|
||
символов пароля не ограничен, а краевой пробел — часть значения.
|
||
|
||
Запись и разбор живут в `orchestrator/src/lib/envFile.ts` и повторяют конечный
|
||
автомат `parse_env_file_internal` из systemd (`src/basic/env-file.c`).
|
||
Существенны четыре его свойства:
|
||
|
||
1. у **незакавыченного** значения срезаются пробелы в конце, `\` уводит в
|
||
escape, а `\<перевод строки>` склеивает строки;
|
||
2. в **одинарных** кавычках всё literal до закрывающей кавычки — escape там
|
||
нет (отличие от `sh`);
|
||
3. в **двойных** кавычках `\` уводит в escape, и обратный слеш снимается только
|
||
перед `"`, `\`, `` ` `` и `$` (`SHELL_NEED_ESCAPE`); перед любым другим
|
||
символом он СОХРАНЯЕТСЯ;
|
||
4. подстановки переменных в env-файле нет вовсе: `$` внутри значения — обычный
|
||
символ.
|
||
|
||
Из (3) и (4) следует кодирование, которое переживает любое издание systemd:
|
||
двойные кавычки и экранирование **только** `\` и `"`. Оба входят в
|
||
`SHELL_NEED_ESCAPE` и разворачиваются одинаково в действующей редакции и в тех,
|
||
где escape в двойных кавычках снимался безусловно.
|
||
|
||
Кавычки ставятся только там, где они нужны: обычные значения (порты, пути,
|
||
домены, `50 mbps`, base64url-секреты) остаются побайтово прежними, поэтому
|
||
релизные гейты и инструкции оператора вида `grep '^HY2XS_UI_PORT=8080$'`
|
||
продолжают работать. Тем же кодировщиком пишется `bootstrap-admin.secret`.
|
||
|
||
Расхождений с systemd ровно два, оба намеренные и оба **fail-closed**:
|
||
|
||
1. строка без `=` — **отказ**, а не пропуск. systemd такую строку молча
|
||
отбрасывает; молчаливая потеря строки из `hy2xs.env` означала бы установку с
|
||
настройкой, которую оператор задал, а продукт не увидел;
|
||
2. незакрытая кавычка или escape в конце файла — **отказ**. systemd в
|
||
состояниях `VALUE_ESCAPE` / `SINGLE_QUOTE_VALUE` / `DOUBLE_QUOTE_VALUE`
|
||
принимает на EOF то, что успел накопить; для конфигурации, от которой зависит
|
||
доступ в панель, «что успели накопить» — не ответ.
|
||
|
||
Оба останавливают операцию там, где её можно починить, вместо того чтобы
|
||
применить не то, что написано в файле.
|
||
|
||
### Домен значений принадлежит systemd, а не нам
|
||
|
||
Формат несёт не всякую строку, и граница здесь чужая. Перед тем как принять
|
||
пару, systemd прогоняет ключ и значение через `utf8_is_valid`
|
||
(`check_utf8ness_and_warn`), и отказ там — `-EINVAL`, то есть **незагруженный
|
||
файл окружения** и юнит, который не стартует. `unichar_is_valid` отвергает
|
||
суррогаты, `U+FDD0..U+FDEF` и все code points вида `*FFFE`/`*FFFF`, а сам
|
||
`utf8_is_valid` — встроенный NUL и невалидный UTF-8. Публичная документация
|
||
EnvironmentFile дополнительно запрещает U+FEFF. Реализация v257.13 случайно
|
||
пропускает его из-за маски; HY2XS следует документированному контракту.
|
||
|
||
`isEnvTransportable` в `lib/envFile.ts` повторяет документированное множество.
|
||
Управляющие символы формат несёт — внутри двойных кавычек перевод
|
||
строки накапливается как обычный байт и переживает round-trip, — и запрещает их
|
||
контракт учётных данных, а не транспорт. Приписывать формату чужие запреты
|
||
нельзя: именно так проверка и пропустила noncharacters, о которых ничего не
|
||
знала.
|
||
|
||
Одиночные суррогаты проверяются отдельно и по своей причине: строка JavaScript
|
||
вправе их содержать, а `TextEncoder` молча заменит непарный суррогат на
|
||
`U+FFFD` — то есть без проверки в файл уехал бы **другой** секрет, а не отказ.
|
||
|
||
Сам файл читается только как байты и декодируется через
|
||
`TextDecoder("utf-8", { fatal: true, ignoreBOM: true })`. Обычный
|
||
`Bun.file(...).text()` запрещён на этой границе: он заменяет повреждённые байты
|
||
на U+FFFD. `ignoreBOM: true` сохраняет BOM как U+FEFF, чтобы тот не исчез до
|
||
транспортной проверки. Исходный текст целиком проверяется **до** разбора ключей:
|
||
запрещённый символ не может спрятаться в комментарии или неизвестной переменной.
|
||
|
||
### Непригодная конфигурация отвергается до первой мутации
|
||
|
||
`validateRuntimeEnvTransport` вызывается из `parseRuntimeEnv`, а не при записи
|
||
файла, и проходит по **всем** парам `runtimeEnvEntries` — не только по паролю
|
||
администратора.
|
||
|
||
Раньше проверка жила только внутри `renderRuntimeEnv`, то есть срабатывала на
|
||
шаге «write runtime env» — уже после bootstrap оркестратора, установки пакетов и
|
||
раскладки файловой системы. Read-only `preflight-install` при этом говорил PASS:
|
||
он зовёт `parseRuntimeEnv` и ничего не рендерит. Детерминированно известная
|
||
ошибка конфигурации роняла операцию, оставив за собой изменённый хост, — что
|
||
прямо противоречит контракту PHASE 0.
|
||
|
||
## 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`:
|
||
|
||
| Проба | Когда | Что требуется |
|
||
| ---------------------------------------------------- | ---------------- | ----------------------------------------------------------------------- |
|
||
| настоящий логин + СЛУЧАЙНЫЙ пароль | всегда | `code: 50000`, причина `invalid_credentials`, `accessToken` отсутствует |
|
||
| bootstrap-учётные данные из `bootstrap-admin.secret` | только `install` | `code: 20000` и непустой `accessToken` |
|
||
|
||
Детали, которые здесь существенны:
|
||
|
||
- **успех определяется конвертом, а не кодом HTTP.** Админка отвечает `200 OK` и
|
||
на отказ тоже — причина живёт в поле `code`. Проверка «HTTP 200» приняла бы за
|
||
успешный вход любой отказ, то есть не проверяла бы ничего;
|
||
- **отказ определяется конвертом по той же причине.** Отрицательная проба
|
||
сверяла `%{http_code}` с `200` и доказывала ровно одно — что запрос не
|
||
закончился пятисоткой. Теперь требуются три признака сразу: код конверта
|
||
`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
|
||
установщика называется своим именем, не имитирует браузер и при этом проходит
|
||
существующий фильтр;
|
||
- **пароль отрицательной пробы генерируется**, а не записан литералом. Записанное
|
||
в исходнике значение теоретически может оказаться настоящим паролем — и тогда
|
||
проверка «неверные данные отвергаются» отчиталась бы об успешном входе. На
|
||
`install`, где настоящий пароль известен, дополнительно утверждается, что
|
||
проба ему не равна;
|
||
- **bootstrap-секрет читается парсером формата**, а не `grep … | cut -d= -f2-`.
|
||
Набор символов пароля не ограничен, пробелы по краям являются его частью, и
|
||
шелл-конвейер срезал бы их — положительная проба взяла бы не тот пароль и
|
||
объявила бы рабочую установку сломанной;
|
||
- **токен требуется отдельно.** `code: 20000` без `accessToken` означал бы
|
||
панель, которая пускает и не выдаёт сессию;
|
||
- **тело общего helper'а собирается `JSON.stringify`**, а не интерполяцией в строку: пароль
|
||
задаёт оператор, и кавычка в нём сломала бы сам запрос, а не панель — проверка
|
||
объявила бы рабочую установку сломанной;
|
||
- **общий helper идёт через `runReadOnlySecret`**: он не кладёт команду в текст
|
||
ошибки, а команда несёт пароль администратора. Наружу отдаётся только код
|
||
ответа: тело успешного входа содержит токен доступа, а текст ошибки уезжает в
|
||
журнал установки и в diagnostics-бандл;
|
||
- **положительная проба install-only.** На `reconfigure` пароль в
|
||
`bootstrap-admin.secret` устаревает в тот момент, когда оператор сменил его в
|
||
панели, и требовать по нему вход значило бы ронять законную операцию.
|
||
Отрицательная проба от пароля не зависит и выполняется всегда — именно она
|
||
воспроизводит дефект RC2.
|
||
|
||
## Редактирование секретов
|
||
|
||
`redact-config` и diagnostics-бандл используют **структурную** редакцию: YAML
|
||
разбирается и обходится как дерево.
|
||
|
||
Diagnostics не копирует env/YAML и не перенаправляет сырой journal/systemctl
|
||
сразу в staging. Сначала данные читаются или захватываются в память, проходят
|
||
редакцию и лишь затем записываются с режимом `0600`. Некорректный UTF-8 в
|
||
конфигурационном файле даёт безопасный маркер пропуска без исходных байтов.
|
||
Вывод каждой внешней команды ограничен 8 МиБ на поток и при усечении явно
|
||
помечается.
|
||
|
||
Staging и архив лежат только в `/var/lib/hy2xs/diagnostics`, а не в
|
||
service-writable `HY2XS_LOG_DIR`. Родитель проверяется через `lstat`: symlink,
|
||
не-root владелец, доступ на запись для группы/остальных или режим дочернего
|
||
каталога не `0700` останавливают сбор fail closed. Рабочий каталог получает
|
||
непредсказуемое имя через `mkdtemp`, archive path заранее резервируется через
|
||
эксклюзивный `open("wx")`, итоговый файл проверяется как обычный
|
||
`root:root 0600`. После успешной упаковки staging удаляется.
|
||
|
||
Это не косметика. Построчное правило `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
|