fix(admin): закрыть обещания панели, которые продукт не выполнял

Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.

Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».

Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.

Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.

Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.

Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.

Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.

docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
This commit is contained in:
2026-09-01 07:27:15 +05:00
parent a1f0db22c2
commit c0a43ae915
86 changed files with 6237 additions and 1819 deletions
+573
View File
@@ -0,0 +1,573 @@
# 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/log/hy2xs`).
- `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 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» никогда не попадала в список
отказавших. Непрерывность обеспечивает стадийный раннер; подавление кода
возврата после его появления стало не защитой, а маскировкой.
### Порядок фиксации успеха
Данные, по которым выполняется откат, обязаны пережить долговечную запись
успеха:
```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/<op-id>/` с
манифестом:
```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 <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