47 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 владелец: 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кидают ошибку. Это проверяется тестами. - Внутри
installpreflight()выполняется раньше первой записи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
Что делает оркестратор по шагам
- Проверяет, что ОС — 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-бандл.
Формат 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).
Существенны четыре его свойства:
- у незакавыченного значения срезаются пробелы в конце,
\уводит в escape, а\<перевод строки>склеивает строки; - в одинарных кавычках всё literal до закрывающей кавычки — escape там
нет (отличие от
sh); - в двойных кавычках
\уводит в escape, и обратный слеш снимается только перед",\,`и$(SHELL_NEED_ESCAPE); перед любым другим символом он СОХРАНЯЕТСЯ; - подстановки переменных в env-файле нет вовсе:
$внутри значения — обычный символ.
Из (3) и (4) следует кодирование, которое переживает любое издание systemd:
двойные кавычки и экранирование только \ и ". Оба входят в
SHELL_NEED_ESCAPE и разворачиваются одинаково в действующей редакции и в тех,
где escape в двойных кавычках снимался безусловно.
Кавычки ставятся только там, где они нужны: обычные значения (порты, пути,
домены, 50 mbps, base64url-секреты) остаются побайтово прежними, поэтому
релизные гейты и инструкции оператора вида grep '^HY2XS_UI_PORT=8080$'
продолжают работать. Тем же кодировщиком пишется bootstrap-admin.secret.
Расхождений с systemd ровно два, оба намеренные и оба fail-closed:
- строка без
=— отказ, а не пропуск. systemd такую строку молча отбрасывает; молчаливая потеря строки изhy2xs.envозначала бы установку с настройкой, которую оператор задал, а продукт не увидел; - незакрытая кавычка или 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