Files
HY2XS_flamy/docs/runtime/08-orchestrator-spec.md
T
founder a8407cf16b fix(admin): вход в панель падал на теге правила, пережившего переименование
RC2 на чистом Debian 13 завершался INSTALL EXIT CODE: 0 при полностью
недоступной панели. На LoginDto.Username стоял тег `validateStr` — правило с
таким именем не регистрировалось: при переименовании в `credentialStr` правка
не доехала до одного файла, оставив мёртвую регистрацию и живую ссылку на
несуществующее имя. go-playground/validator на неизвестный тег ПАНИКУЕТ при
разборе структуры, то есть до всякой проверки логина и пароля, а gin.Recovery
превращал панику в HTTP 500 на каждый POST /api/auth/login.

Дефект пережил 311 Go-тестов, и это главное, что здесь чинится. Проверялся сам
регексп, в обход валидатора, а обработчика входа не касался ни один тест.
Очевидная замена не помогла бы: цепочка правил поля обрывается на первом
несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного
тега не доходит. Теперь TestEveryValidationTagIsRegistered обходит исходники
apps/model/**, вытаскивает каждый тег `validate:"…"` и предъявляет его
валидатору отдельно — незарегистрированное правило паникует так же, как в бою,
но на сборке. Барьер проверен возвратом исходного тега.

Установка тоже не отвечала на вопрос, ради которого проверялась. Smoke считал
панель работающей по трём признакам — юнит активен, порт в LISTEN, /healthz
отвечает ok, — и все три были истинны. Теперь smoke выполняет настоящий вход
bootstrap-учётными данными и требует конверт успеха с непустым токеном: по коду
HTTP это неотличимо, админка отвечает 200 OK и на отказ. Отрицательная проба
идёт в любом режиме операции и от актуальности пароля не зависит.

Рядом лежали три расхождения того же класса, найденные при разборе.

Оркестратор не знал контракта, который сам порождает: HY2XS_ADMIN_USER по
умолчанию был `admin` — пять символов при минимуме панели в шесть, — и такая
установка проходила целиком, создавая учётную запись, под которой невозможно
войти. Про одно имя существовало три расходящихся умолчания. Оба значения
теперь проверяются при разборе окружения — той стороной, которая их порождает:
отказ, пришедший установщику, чинится строкой в hy2xs.env, а неработающий вход
на готовом сервере — переустановкой.

Панель была строже сервера. Форма входа ограничивала пароль 32 символами при
серверном пределе в 64, а форма смены пароля назначала до 64: пароль,
назначенный штатной операцией, после этого не вводился. Набор символов на
пароле отвергал значение, которое сервер принял бы, — сервер его не
ограничивает нигде. Контракт учётных данных объявлен один раз в
service/admin_credentials.go, копии в панели и оркестраторе сверяются с ним
тестами, читающими Go-исходник.

Класс символов логина был записан диапазоном по опечатке: неэкранированный
дефис превращал `+-=` в диапазон, впускающий `, - . / 0-9 : ; < =`. С серверным
набором это совпадало только потому, что обе стороны несли одну опечатку. Набор
записан явно и НЕ сужен — он уже действует на установленных серверах.

Визуально: красная рамка отказа обводила не то, что видит оператор. Element Plus
рисует состояние ошибки на el-input__wrapper селектором из четырёх классов, а
форма входа рисует видимую рамку поля на el-form-item — внутрь поля кладутся
иконка, ввод и переключатель видимости — и гасила чужую тень селектором из трёх,
проигрывая по специфичности. Рамка ложилась вокруг одного лишь ввода: у логина
начиналась после иконки, у пароля обрывалась перед «глазом». Индикация
перенесена на элемент, который оператор и видит полем; чужая тень гасится
селектором, повторяющим её собственный и добавляющим атрибут scoped-стиля, —
конкретностью, а не !important. Остальные формы панели проверены: собственная
рамка на el-form-item есть только на форме входа.

Заодно: `last_login_at` объявлен в схеме и в entity, а писать его было некому —
UpdateAdminLastLoginAt не вызывался ниоткуда. Отметка ставится в service.Login
сразу после успешной проверки пароля; отказ записи вход не отменяет, но
попадает в журнал. Обработчик входа переехал из controller/peer.go в
controller/auth.go: стек в journal указывал на управление пирами.

Требование теперь называется, а не сообщается фактом нарушения. «Неверный
формат логина» и «Некорректное значение» не давали оператору способа узнать,
что от него хотят: набор символов приходит из hy2xs.env и в панели нигде не
показан. Фразы форм и серверная причина credential_format перечисляют границы
и набор.

Гейт сборки run_admin_login_acceptance удерживает барьеры от тихого удаления —
по той же причине, что и гейт детектора гонок. Каждое из его утверждений
проверено мутационной пробой на реальный отказ; две первые редакции оказались
вакуумными и переписаны.

Прогнано: go vet + go test ./... , bun test оркестратора (427) и контрактов
панели (66), vue-tsc --noEmit, production-сборка frontend, гейт приёмки
целиком. `go test -race` не прогонялся — на машине нет C-компилятора, это
релизный гейт сборщика.

Прогон задокументирован в
docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md.
2026-09-04 02:32:50 +05:00

37 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-бандл.

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:

Проба Когда Что требуется
заведомо неверные учётные данные всегда HTTP 200 с конвертом отказа
bootstrap-учётные данные из bootstrap-admin.secret только install code: 20000 и непустой accessToken

Детали, которые здесь существенны:

  • успех определяется конвертом, а не кодом HTTP. Админка отвечает 200 OK и на отказ тоже — причина живёт в поле code. Проверка «HTTP 200» приняла бы за успешный вход любой отказ, то есть не проверяла бы ничего;
  • токен требуется отдельно. code: 20000 без accessToken означал бы панель, которая пускает и не выдаёт сессию;
  • тело собирается JSON.stringify, а не интерполяцией в строку: пароль задаёт оператор, и кавычка в нём сломала бы сам запрос, а не панель — проверка объявила бы рабочую установку сломанной;
  • обе команды идут через runReadOnlySecret: он не кладёт команду в текст ошибки, а команда несёт пароль администратора. Наружу отдаётся только код ответа: тело успешного входа содержит токен доступа, а текст ошибки уезжает в журнал установки и в diagnostics-бандл;
  • положительная проба install-only. На reconfigure пароль в bootstrap-admin.secret устаревает в тот момент, когда оператор сменил его в панели, и требовать по нему вход значило бы ронять законную операцию. Отрицательная проба от пароля не зависит и выполняется всегда — именно она воспроизводит дефект RC2.

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

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