# 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 [--config ]` - `install --package-dir [--config ]` - `reconfigure --package-dir --config /etc/hy2xs/hy2xs.env --dry-run` - `reconfigure --package-dir --config /etc/hy2xs/hy2xs.env --apply` - `repair --package-dir --config /etc/hy2xs/hy2xs.env [--allow-partial-state]` - `redact-config --config (--in-place | --out ) [--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