Files
HY2XS_flamy/docs/runtime/08-orchestrator-spec.md
T

47 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 и позволяло «чинить» чужое состояние.

Запись маркера долговечна и имеет ровно одного владельца

install и reconfigure пишут маркер через один и тот же lib/installStateWriter.ts. Раньше писателей было два, с разными гарантиями: install перезаписывал файл на месте, reconfigure подставлял его атомарно. Слабейшая гарантия досталась команде, которая этот файл создаёт.

Перезапись на месте укорачивает файл до нуля и только потом наполняет. Любой отказ между этими моментами — потеря питания, kill -9, ENOSPC — оставляет на сервере половину документа:

{
  "product": "hy2xs",
  "release_line":

Такой маркер не разбирается: reconfigure/repair видят его как отсутствующий, а clean-host — как присутствующий, причём хост к этому моменту уже изменён.

Атомарности при этом недостаточно, нужна долговечность. Порядок записи:

1. запись во временный файл в том же каталоге
2. права и владелец            ← до подстановки: иначе есть окно,
                                 в котором файл виден с чужими правами
3. fsync временного файла      ← данные на носителе, а не в page cache
4. rename                      ← атомарная подстановка
5. fsync каталога              ← сама запись каталога о новом имени

Без шагов 3 и 5 rename() даёт атомарность видимости, но после внезапной перезагрузки ext4 штатно отдаёт по этому пути нулевой файл или отсутствие файла. Для метаданных восстановления это неприемлемо.

Есть ещё один уровень: при первой установке сам каталог /var/lib/hy2xs создаётся прямо сейчас, и запись «hy2xs» в /var/lib тоже обязана быть долговечной. Иначе возможно состояние, в котором и файл, и его каталог сброшены на носитель, а каталог из родителя исчез — то есть маркер пропал целиком. Поэтому ensureDir сообщает, был ли каталог фактически создан, и при создании синхронизирует родителя. На последующих обновлениях маркера каталог уже существует, и лишний fsync родителя не выполняется.

Ownership и rollback

Операция ведёт учёт того, к чему она могла прикоснуться:

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/log/hy2xs).
  • systemctl stop/disable выполняется только если текущая операция сама развернула эти unit-файлы.

После операционного отказа откат выполняется целиком

Порядок в обработчике ошибки один и тот же в install и reconfigure:

запись состояния отказа   → best effort
сбор диагностики          → best effort
откат                     → обязателен

Обе первые операции пишут на диск (/var/lib/hy2xs, /var/log/hy2xs), то есть падают ровно на заполненном диске и read-only ФС — там, где откат нужнее всего. Пока хотя бы одна из них стояла обычным await, её собственный отказ уносил управление наружу, и восстановление не выполнялось вовсе: применённый firewall и развёрнутые сервисы оставались на сервере. Для диагностики это было закрыто раньше, для записи состояния — нет.

Второй инвариант — стадии отката независимы:

Команда Стадии
install firewall → stop services → disable services → reset failed services
reconfigure firewall → restore configuration

Каждая стадия — это systemctl, cp, rm -rf или nft, то есть каждая умеет упасть сама. Пока они стояли цепочкой await, отказ первой отменял все следующие. В reconfigure это означало сервер одновременно с применённым сломанным firewall и без восстановленных из /etc/hy2xs/backups конфигов — то есть худший сценарий отказа лишался обеих половин восстановления сразу.

Стадии выполняются последовательно и в объявленном порядке; независимость означает «отказ не прерывает остальные», а не «выполняется как попало». Отказавшие стадии перечисляются в журнале, а наружу пробрасывается исходная ошибка операции: проблема внутри отката — это дополнительная информация о том, что осталось не восстановленным, а не замена диагноза.

Команды внутри стадий не глушат собственные ошибки. Это правило обратно тому, что действовало раньше. Пока непрерывность держалась на || true в каждой команде, стадия физически не могла сообщить, что восстановление не выполнилось: cp, nft -f, systemctl daemon-reload и systemctl restart возвращали ноль при любом исходе, и «restore configuration» никогда не попадала в список отказавших. Непрерывность обеспечивает стадийный раннер; подавление кода возврата после его появления стало не защитой, а маскировкой.

Порядок фиксации успеха

Данные, по которым выполняется откат, обязаны пережить долговечную запись успеха:

smoke PASS
      ↓
durable phase = smoke_ok
      ↓
disarm автоматического отката по таймеру   ← резервные копии ОСТАЮТСЯ
      ↓
durable phase = installed                  ← точка фиксации
      ↓
cleanup резервных копий                    ← best effort

Раньше снятие таймера и удаление копий выполнял один вызов, стоявший до записи installed. Отсюда следовал разрыв:

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. Отсюда сценарий:

reconfigure A → config.yaml.bak создан
reconfigure B → создание копии упало, ошибка скрыта
             → B меняет конфигурацию
             → B падает → откат восстанавливает копию, снятую операцией A

Сервер возвращался не в состояние «до B», а в более старое — и это выглядело успешным откатом. Теперь копия лежит в /etc/hy2xs/backups/<op-id>/ с манифестом:

{
  "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, инвариант действует во всех трёх сценариях.

Алгоритм:

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;
  • пароль отрицательной пробы генерируется, а не записан литералом. Записанное в исходнике значение теоретически может оказаться настоящим паролем — и тогда проверка «неверные данные отвергаются» отчиталась бы об успешном входе. На install, где настоящий пароль известен, дополнительно утверждается, что проба ему не равна;
  • bootstrap-секрет читается парсером формата, а не grep … | cut -d= -f2-. Набор символов пароля не ограничен, пробелы по краям являются его частью, и шелл-конвейер срезал бы их — положительная проба взяла бы не тот пароль и объявила бы рабочую установку сломанной;
  • токен требуется отдельно. code: 20000 без accessToken означал бы панель, которая пускает и не выдаёт сессию;
  • тело собирается JSON.stringify, а не интерполяцией в строку: пароль задаёт оператор, и кавычка в нём сломала бы сам запрос, а не панель — проверка объявила бы рабочую установку сломанной;
  • обе команды идут через runReadOnlySecret: он не кладёт команду в текст ошибки, а команда несёт пароль администратора. Наружу отдаётся только код ответа: тело успешного входа содержит токен доступа, а текст ошибки уезжает в журнал установки и в diagnostics-бандл;
  • положительная проба install-only. На reconfigure пароль в bootstrap-admin.secret устаревает в тот момент, когда оператор сменил его в панели, и требовать по нему вход значило бы ронять законную операцию. Отрицательная проба от пароля не зависит и выполняется всегда — именно она воспроизводит дефект RC2.

Редактирование секретов

redact-config и diagnostics-бандл используют структурную редакцию: YAML разбирается и обходится как дерево.

Diagnostics не копирует env/YAML и не перенаправляет сырой journal/systemctl сразу в staging. Сначала данные читаются или захватываются в память, проходят редакцию и лишь затем записываются с режимом 0600. Некорректный UTF-8 в конфигурационном файле даёт безопасный маркер пропуска без исходных байтов. Вывод каждой внешней команды ограничен 8 МиБ на поток и при усечении явно помечается; архив создаётся сразу под umask 077.

Это не косметика. Построчное правило 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