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.
19 KiB
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 не обещает корректной автоадаптации.
Двухфазный контракт установки
Установка разделена на две фазы с жёсткой границей между ними:
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кидают ошибку. Это проверяется тестами. - Внутри
installpreflight()выполняется раньше первой записиinstall-state.json. Отказ на этом этапе означает, что на сервере не изменено ничего.
Полный список маркеров чужой установки и порядок очистки — 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, и в каком она состоянии». Поэтому кроме фазы он
несёт идентификацию поколения:
{
"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
Операция ведёт учёт того, к чему она могла прикоснуться:
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, инвариант действует во
всех трёх сценариях.
Алгоритм:
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
Что делает оркестратор по шагам
- Проверяет, что ОС — Debian 13, и что хост чист (до любой мутации).
- Проверяет базовые зависимости и install context.
- Создаёт каталоги установки.
- Разворачивает bundled HY2XS admin.
- Скачивает pinned Hysteria2 binary из package metadata, проверяет SHA256 и выполняет install.
- Генерирует Hysteria config.
- Создаёт systemd unit для Hysteria.
- Создаёт systemd unit для HY2XS admin.
- Применяет nftables baseline.
- Создаёт
post-install.env. - Запускает сервисы и выполняет 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-runreconfigure --package-dir <path> --config /etc/hy2xs/hy2xs.env --applyrepair --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