086b5d6624
verify_versions_contract получил сверку API namespace. Путь machine-auth
записывается в /etc/hysteria/config.yaml и в post-install.env, то есть по нему
Hysteria обращается к админке. Пока строка была продублирована в шаблонах,
smoke, тестах, приёмке и e2e, расхождение обнаруживалось только на живом
сервере. Теперь Go-константы, API_BASE фронтенда и оба шаблона сверяются
против значений, скомпилированных в оркестратор.
Приёмка проверяет, что:
- fatal_pre_apply недостижим после записи install-state;
- каждый ownership-флаг взводится раньше своего шага;
- у read-only фазы нет универсального раннера, через который можно
проскользнуть;
- инвариант публичного endpoint живёт в preflight и не обращается к внешним
сервисам определения IP;
- purge-v0.sh и clean-host описывают одну границу;
- секреты не попадают в персистентный файл экспорта;
- импорт пиров валидируется так же строго, как их создание;
- удалённые exportConfig/importConfig не вернулись.
Захардкоженная схема =2 в приёмке заменена на значение из versions.env: при
переходе на schema 3 пришлось бы помнить ещё и про эту строку.
Документация: контракт раннеров и ownership в 08, инвариант публичного
endpoint в 08/09/12/13 и README, сетевая идентичность панели и удалённые
export/import в 04, сценарии D1 (отказ сразу после PHASE 0) и D2 (устаревший
DNS после смены IPv4) в 11, версии package.json как не-версия продукта в 02.
360 lines
19 KiB
Markdown
360 lines
19 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
|
||
проверка прав
|
||
sha256sum -c metadata/checksums.txt
|
||
./orchestrator/hy2xs-orchestrator preflight-install --package-dir <распакованный пакет>
|
||
├── платформа Debian 13 amd64
|
||
├── clean-host контракт
|
||
└── валидация конфигурации
|
||
↓ ноль persistent writes
|
||
PHASE 0 PASSED
|
||
↓
|
||
PHASE 1 — MUTATION
|
||
install -d /usr/local/lib/hy2xs
|
||
раскладка оркестратора и runtime-пакета
|
||
hy2xs-orchestrator install
|
||
```
|
||
|
||
Ключевые свойства:
|
||
|
||
- `preflight-install` запускается **из распакованного пакета**, а не из
|
||
установленного `/usr/local/lib/hy2xs`: до PHASE 1 этого каталога может не
|
||
существовать, и создавать его нельзя.
|
||
- Граница держится не соглашением, а **read-only guard** (`lib/guard.ts`):
|
||
под ним `writeText`/`writeTextAtomic` и мутирующие раннеры `lib/process`
|
||
кидают ошибку. Это проверяется тестами.
|
||
- Внутри `install` **`preflight()` выполняется раньше первой записи
|
||
`install-state.json`**. Отказ на этом этапе означает, что на сервере не
|
||
изменено ничего.
|
||
|
||
Полный список маркеров чужой установки и порядок очистки —
|
||
[14-legacy-cleanup.md](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 и позволяло «чинить» чужое состояние.
|
||
|
||
## Ownership и rollback
|
||
|
||
Операция ведёт учёт того, к чему она **могла прикоснуться**:
|
||
|
||
```text
|
||
stateWritten
|
||
depsTouched
|
||
filesystemTouched
|
||
uiTouched
|
||
hysteriaTouched
|
||
configTouched
|
||
unitsTouched
|
||
firewallTouched
|
||
postInstallTouched
|
||
bootstrapSecretTouched
|
||
servicesStarted
|
||
```
|
||
|
||
Формулировка выбрана намеренно. Флаг «шаг успешно завершился» отвечает не на
|
||
тот вопрос: `apt-get install` умеет распаковать половину пакетов и упасть, и
|
||
хост уже изменён, хотя шаг не закончился. Поэтому **каждый флаг взводится перед
|
||
мутирующим вызовом**, а не после него.
|
||
|
||
`stateWritten` — полноценный участник классификации. `install-state.json`
|
||
пишется сразу после успешного preflight, до `installDeps`; пока он в
|
||
классификации не учитывался, падение `apt-get` объявлялось «на сервере ничего
|
||
не изменено», rollback пропускался, а маркер оставался на хосте и ломал
|
||
следующую установку по clean-host контракту.
|
||
|
||
Классификация отказа строится **по этим флагам и фазе**, а не по тексту
|
||
сообщения об ошибке. Ранее классификация шла по подстрокам, из-за чего
|
||
preflight-ошибка со словом `nftables` приводила к откату чужого firewall.
|
||
|
||
Инварианты rollback:
|
||
|
||
- `fatal_pre_apply` по определению означает «ничего не применялось». Попасть в
|
||
него нельзя ни при одном взведённом флаге, включая `stateWritten`. В этом
|
||
случае system rollback не выполняется, `install-state.json` не пишется,
|
||
diagnostics-бандл не собирается (его сбор сам создал бы каталоги в
|
||
`/var/log/hy2xs`).
|
||
- `systemctl stop/disable` выполняется **только если текущая операция сама
|
||
развернула эти unit-файлы**.
|
||
|
||
## Инвариант публичного 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-бандл.
|
||
|
||
## Редактирование секретов
|
||
|
||
`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
|