Files
HY2XS_flamy/docs/08-orchestrator-spec.md
T
founder 672d455467 fix: закрыть каналы утечки секретов и сделать PHASE 1 владением оркестратора
Hardening-проход перед первой сборкой на Debian. Три из найденного не
воспроизводились ни на одном dry-run и проявились бы только на живом сервере.

Установка

* preflight внутри install вызывался дважды и оба раза проверял clean-host.
  Ко второму вызову на диске лежал собственный /var/lib/hy2xs/install-state.json,
  записанный после первого preflight, и опознавался как маркер посторонней
  установки: КАЖДАЯ чистая установка падала сразу после apt-get с
  fatal_post_apply и оставляла сервер наполовину настроенным. Чистота хоста —
  условие входа в операцию, возможности платформы проверяются уже внутри
  PHASE 1, поэтому checkCleanHost стал отдельным параметром без умолчания.

* PHASE 1 начиналась в install.sh: shell сам создавал /usr/local/lib/hy2xs,
  ставил бинарник, вешал symlink и копировал runtime-пакет, и только потом
  запускал оркестратор с его собственным preflight. Отказ того preflight
  объявлялся fatal_pre_apply — «на сервере ничего не изменено» — при уже
  созданном каталоге оркестратора. Отследить владение мутацией невозможно,
  пока мутируют двое: install.sh больше не изменяет ничего, раскладку
  выполняет steps/bootstrap.ts под ownership.bootstrapTouched, пути попали
  в owned_paths. Как следствие удалено деление clean-host на фазы.

* diagnosticsCollect стояла перед rollback обычным await в install и в
  reconfigure. На заполненном диске она падает сама и отменяла откат целиком.
  Диагностика — best effort, откат — обязателен.

* reconfigure/repair выбирали записываемую фазу отказа регулярным выражением
  по тексту ошибки. Переведено на ownership-флаги.

Секреты

* Журнал админки писал RequestURI, то есть путь вместе с query. Hysteria
  обращается к /internal/hysteria/auth?access_token=<секрет> при каждом
  подключении пира, поэтому действующий machine token оседал открытым текстом
  в hy2xs-admin.log, который отдаётся через ExportLog и попадает в
  diagnostics-бандл. Логируется путь; значения query не пишутся, имена —
  пишутся. Канала было два: gin.Default() печатает path?query в stdout,
  оттуда в journald и в тот же бандл, — панель переведена на gin.New() +
  Recovery(). Журналы внутри бандла и журнал Hysteria из ExportLog теперь
  проходят санитайз. Сравнение токена — constant time.

* Config API позволял прочитать и подменить ключи приложения: getConfig и
  listConfig принимали произвольный ключ, а проверка записи была denylist'ом
  из трёх ключей оркестратора. Запрос ?key=PEER_SECRET_ENCRYPTION_KEY отдавал
  master-key шифрования секретов пиров. Доступ переведён на allowlist, маршрут
  getConfig удалён целиком — потребителей у него не было ни одного.

Пиры

* Импорт применялся по одной записи вне транзакции, вопреки собственному
  контракту. Валидация не знает, что уже лежит в базе: cross-conflict по
  UNIQUE(name) оставлял часть файла применённой. Применение выполняется одной
  транзакцией, криптоматериал считается до её открытия.

* Файл импорта мог содержать хвостовой JSON-документ, который молча не
  применялся. После разбора проверяется io.EOF.

* Экспорт разделён на «Экспорт настроек» и «Резервная копия» с секретами и
  подтверждением: обычный экспорт выдаёт пирам новые секреты при импорте, и
  прежние клиентские ссылки после переноса переставали работать.

Сборка

* Два stale-грепа в приёмке роняли build.sh в самом конце, внутри
  verify_archive. Первый искал в smoke.ts исчезнувший литерал URL, второй
  совпадал с router_test.go, который перечисляет удалённые маршруты, потому
  что проверяет их отсутствие: добавление регрессионного теста ломало сборку.

* verify_archive требовал наличия мутирующей строки в install.sh. Инвариант
  перевёрнут: их не должно быть ни одной.

Очистка

* Удалены entity.LegacyAccount, миграции 002/003 и мёртвые хелперы
  listSQLMigrationFiles и envInt: v1 не мигрирует базу 0.x ни при каком
  сценарии. Номера оставшихся миграций сохранены. H UI-словарь убран из
  обычных доков, в docs/14 он остаётся — там это имена объектов для удаления.

* Список непубличных IPv4 приведён к IANA Special-Purpose Address Registry:
  203.0.113.5 из RFC-примеров считался публичным адресом сервера. Отказ
  резолвера отделён от отсутствия A-записи.

Проверено: bun test 233, go test 71, tsc/vue-tsc, bash -n 11 скриптов,
приёмка прогнана против дерева.
2026-08-28 05:27:10 +05:00

22 KiB
Raw Blame History

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                                    владелец: 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.

Раннеры подпроцессов: два набора, а не один

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

Что делает оркестратор по шагам

  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