# 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 владелец: 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](../operations/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 и позволяло «чинить» чужое состояние. ### Запись маркера долговечна и имеет ровно одного владельца `install` и `reconfigure` пишут маркер через один и тот же `lib/installStateWriter.ts`. Раньше писателей было два, с разными гарантиями: `install` перезаписывал файл на месте, `reconfigure` подставлял его атомарно. Слабейшая гарантия досталась команде, которая этот файл создаёт. Перезапись на месте укорачивает файл до нуля и только потом наполняет. Любой отказ между этими моментами — потеря питания, `kill -9`, `ENOSPC` — оставляет на сервере половину документа: ```json { "product": "hy2xs", "release_line": ``` Такой маркер не разбирается: `reconfigure`/`repair` видят его как отсутствующий, а clean-host — как присутствующий, причём хост к этому моменту уже изменён. Атомарности при этом недостаточно, нужна **долговечность**. Порядок записи: ```text 1. запись во временный файл в том же каталоге 2. права и владелец ← до подстановки: иначе есть окно, в котором файл виден с чужими правами 3. fsync временного файла ← данные на носителе, а не в page cache 4. rename ← атомарная подстановка 5. fsync каталога ← сама запись каталога о новом имени ``` Без шагов 3 и 5 `rename()` даёт атомарность видимости, но после внезапной перезагрузки ext4 штатно отдаёт по этому пути нулевой файл или отсутствие файла. Для метаданных восстановления это неприемлемо. Есть ещё один уровень: при первой установке сам каталог `/var/lib/hy2xs` создаётся прямо сейчас, и запись «hy2xs» в `/var/lib` тоже обязана быть долговечной. Иначе возможно состояние, в котором и файл, и его каталог сброшены на носитель, а каталог из родителя исчез — то есть маркер пропал целиком. Поэтому `ensureDir` сообщает, был ли каталог **фактически создан**, и при создании синхронизирует родителя. На последующих обновлениях маркера каталог уже существует, и лишний `fsync` родителя не выполняется. ## Ownership и rollback Операция ведёт учёт того, к чему она **могла прикоснуться**: ```text 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/lib/hy2xs/diagnostics`). - `systemctl stop/disable` выполняется **только если текущая операция сама развернула эти unit-файлы**. ### После операционного отказа откат выполняется целиком Порядок в обработчике ошибки один и тот же в `install` и `reconfigure`: ```text запись состояния отказа → best effort сбор диагностики → best effort откат → обязателен ``` Обе первые операции пишут на диск (`/var/lib/hy2xs`, `/var/log/hy2xs`), то есть падают ровно на заполненном диске и read-only ФС — там, где откат нужнее всего. Пока хотя бы одна из них стояла обычным `await`, её собственный отказ уносил управление наружу, и восстановление не выполнялось вовсе: применённый firewall и развёрнутые сервисы оставались на сервере. Для диагностики это было закрыто раньше, для записи состояния — нет. Второй инвариант — **стадии отката независимы**: | Команда | Стадии | | ------------- | --------------------------------------------------------------------------------------------------------- | | `install` | firewall → stop services → disable services → reset failed `hysteria-server` → reset failed `hy2xs-admin` | | `reconfigure` | firewall → restore configuration | Каждая стадия — это `systemctl`, `cp`, `rm -rf` или `nft`, то есть каждая умеет упасть сама. Пока они стояли цепочкой `await`, отказ первой отменял все следующие. В `reconfigure` это означало сервер одновременно с применённым сломанным firewall **и** без восстановленных из `/etc/hy2xs/backups` конфигов — то есть худший сценарий отказа лишался обеих половин восстановления сразу. Стадии выполняются последовательно и в объявленном порядке; независимость означает «отказ не прерывает остальные», а не «выполняется как попало». Отказавшие стадии перечисляются в журнале, а наружу пробрасывается **исходная** ошибка операции: проблема внутри отката — это дополнительная информация о том, что осталось не восстановленным, а не замена диагноза. `reset-failed` для каждого сервиса является отдельной стадией и завершается проверкой `LoadState`/`ActiveState`. Ненулевой код команды допустим, если юнит уже выгружен (`not-found` + `inactive`): failed-состояния у него больше нет, а значит cleanup завершён. Текст `Unit … not loaded` намеренно не разбирается — он зависит от версии и локали systemd. Ошибка чтения состояния или сохранившийся `ActiveState=failed` остаются настоящим отказом и попадают в manual-recovery сводку. Команды внутри стадий **не глушат собственные ошибки**. Это правило обратно тому, что действовало раньше. Пока непрерывность держалась на `|| true` в каждой команде, стадия физически не могла сообщить, что восстановление не выполнилось: `cp`, `nft -f`, `systemctl daemon-reload` и `systemctl restart` возвращали ноль при любом исходе, и «restore configuration» никогда не попадала в список отказавших. Непрерывность обеспечивает стадийный раннер; подавление кода возврата после его появления стало не защитой, а маскировкой. ### Порядок фиксации успеха Данные, по которым выполняется откат, обязаны пережить долговечную запись успеха: ```text smoke PASS ↓ durable phase = smoke_ok ↓ disarm автоматического отката по таймеру ← резервные копии ОСТАЮТСЯ ↓ durable phase = installed ← точка фиксации ↓ cleanup резервных копий ← best effort ``` Раньше снятие таймера и удаление копий выполнял один вызов, стоявший **до** записи `installed`. Отсюда следовал разрыв: ```text 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`. Отсюда сценарий: ```text reconfigure A → config.yaml.bak создан reconfigure B → создание копии упало, ошибка скрыта → B меняет конфигурацию → B падает → откат восстанавливает копию, снятую операцией A ``` Сервер возвращался не в состояние «до B», а в более старое — и это выглядело успешным откатом. Теперь копия лежит в `/etc/hy2xs/backups//` с манифестом: ```json { "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`, инвариант действует во всех трёх сценариях. Алгоритм: ```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-бандл. ## Формат 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`; - **конверт разбирается как JSON**, а не ищется регулярным выражением в сыром тексте. Подстрока `invalid_credentials` внутри `message` или сломанный JSON не имеют права превратить неизвестный ответ в успешную проверку; - **обе пробы используют один request helper.** Wire-поле называется `pass`, а не `password`; `Content-Type`, User-Agent и настройки curl не дублируются и не могут разойтись между positive и negative ветками; - **smoke отправляет явный `HY2XS-Installer/1.0` User-Agent.** Стандартный `curl/` отклоняется действующим scanner middleware раньше DTO. UA установщика называется своим именем, не имитирует браузер и при этом проходит существующий фильтр; - **пароль отрицательной пробы генерируется**, а не записан литералом. Записанное в исходнике значение теоретически может оказаться настоящим паролем — и тогда проверка «неверные данные отвергаются» отчиталась бы об успешном входе. На `install`, где настоящий пароль известен, дополнительно утверждается, что проба ему не равна; - **bootstrap-секрет читается парсером формата**, а не `grep … | cut -d= -f2-`. Набор символов пароля не ограничен, пробелы по краям являются его частью, и шелл-конвейер срезал бы их — положительная проба взяла бы не тот пароль и объявила бы рабочую установку сломанной; - **токен требуется отдельно.** `code: 20000` без `accessToken` означал бы панель, которая пускает и не выдаёт сессию; - **тело общего helper'а собирается `JSON.stringify`**, а не интерполяцией в строку: пароль задаёт оператор, и кавычка в нём сломала бы сам запрос, а не панель — проверка объявила бы рабочую установку сломанной; - **общий helper идёт через `runReadOnlySecret`**: он не кладёт команду в текст ошибки, а команда несёт пароль администратора. Наружу отдаётся только код ответа: тело успешного входа содержит токен доступа, а текст ошибки уезжает в журнал установки и в diagnostics-бандл; - **положительная проба install-only.** На `reconfigure` пароль в `bootstrap-admin.secret` устаревает в тот момент, когда оператор сменил его в панели, и требовать по нему вход значило бы ронять законную операцию. Отрицательная проба от пароля не зависит и выполняется всегда — именно она воспроизводит дефект RC2. ## Редактирование секретов `redact-config` и diagnostics-бандл используют **структурную** редакцию: YAML разбирается и обходится как дерево. Diagnostics не копирует env/YAML и не перенаправляет сырой journal/systemctl сразу в staging. Сначала данные читаются или захватываются в память, проходят редакцию и лишь затем записываются с режимом `0600`. Некорректный UTF-8 в конфигурационном файле даёт безопасный маркер пропуска без исходных байтов. Вывод каждой внешней команды ограничен 8 МиБ на поток и при усечении явно помечается. Staging и архив лежат только в `/var/lib/hy2xs/diagnostics`, а не в service-writable `HY2XS_LOG_DIR`. Родитель проверяется через `lstat`: symlink, не-root владелец, доступ на запись для группы/остальных или режим дочернего каталога не `0700` останавливают сбор fail closed. Рабочий каталог получает непредсказуемое имя через `mkdtemp`, archive path заранее резервируется через эксклюзивный `open("wx")`, итоговый файл проверяется как обычный `root:root 0600`. После успешной упаковки staging удаляется. Это не косметика. Построчное правило `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