build: закрепить новые инварианты приёмкой и документацией

verify_versions_contract получил сверку API namespace. Путь machine-auth
записывается в /etc/hysteria/config.yaml и в post-install.env, то есть по нему
Hysteria обращается к админке. Пока строка была продублирована в шаблонах,
smoke, тестах, приёмке и e2e, расхождение обнаруживалось только на живом
сервере. Теперь Go-константы, API_BASE фронтенда и оба шаблона сверяются
против значений, скомпилированных в оркестратор.

Приёмка проверяет, что:
  - fatal_pre_apply недостижим после записи install-state;
  - каждый ownership-флаг взводится раньше своего шага;
  - у read-only фазы нет универсального раннера, через который можно
    проскользнуть;
  - инвариант публичного endpoint живёт в preflight и не обращается к внешним
    сервисам определения IP;
  - purge-v0.sh и clean-host описывают одну границу;
  - секреты не попадают в персистентный файл экспорта;
  - импорт пиров валидируется так же строго, как их создание;
  - удалённые exportConfig/importConfig не вернулись.

Захардкоженная схема =2 в приёмке заменена на значение из versions.env: при
переходе на schema 3 пришлось бы помнить ещё и про эту строку.

Документация: контракт раннеров и ownership в 08, инвариант публичного
endpoint в 08/09/12/13 и README, сетевая идентичность панели и удалённые
export/import в 04, сценарии D1 (отказ сразу после PHASE 0) и D2 (устаревший
DNS после смены IPv4) в 11, версии package.json как не-версия продукта в 02.
This commit is contained in:
2026-08-27 20:50:03 +05:00
parent a88268b0cd
commit 086b5d6624
15 changed files with 740 additions and 37 deletions
+133
View File
@@ -14,6 +14,79 @@ Hardening-проход перед релизом `1.0.0`. Основная те
### Исправлено
- **`fatal_pre_apply` мог означать «хост уже изменён».** `install-state.json`
пишется сразу после успешного preflight, до установки пакетов, но
классификация отказа его не учитывала. Падение `apt-get update` или
`apt-get install` объявлялось как «на сервере ничего не изменено»: откат и
обработка состояния пропускались, а маркер оставался на диске и ломал
следующую установку по clean-host контракту.
Ownership-флаги переформулированы с «шаг успешно завершился» на «операция
могла начать менять систему» и взводятся **перед** мутирующим вызовом:
`apt-get` умеет изменить систему и упасть. `fatal_pre_apply` теперь
недостижим ни при одном взведённом флаге, включая запись состояния.
- **Экспорт в админке оставлял секреты на диске навсегда.** `ExportPeer` и
выгрузка системного конфига шли через `os.Create` в
`/var/lib/hy2xs-admin/export/`, и файл там не удалялся. При
`?includeSecrets=true` это означало расшифрованные секреты пиров — фактические
учётные данные доступа — в открытом виде, накапливающиеся с каждым нажатием
кнопки. Экспорт формируется в памяти; каталога `export/` больше нет.
- **Generic export/import таблицы `config` выгружал и позволял подменить
криптографические ключи приложения.** Выгрузка исключала только сырой Hysteria
YAML, а в той же таблице лежат `JWT_SECRET`, `PEER_SECRET_KEY`,
`PEER_SECRET_ENCRYPTION_KEY` и `HYSTERIA2_TRAFFIC_STATS_SECRET`. Импорт их не
блокировал: подмена `PEER_SECRET_ENCRYPTION_KEY` ломает расшифровку секретов
уже существующих пиров. Оба маршрута и их UI удалены — production-сценария у
них не было, перенос пиров делают `peer-import`/`peer-export`.
- **Импорт пиров шёл мимо всей валидации.** Обычное создание пира проходит через
`dto.PeerSaveDto`, импорт JSON — нет: в базу попадало имя любой длины и с
любыми символами, `disabled` с произвольным числом, отрицательные счётчики.
Файл применялся построчно, поэтому ошибка в середине оставляла список пиров
наполовину изменённым, а импорт мог перезаписать `bootstrap-admin-peer`, чей
секрет продублирован в `/etc/hy2xs/bootstrap-admin.secret`. Партия теперь
проверяется целиком до первой записи, неизвестные поля отклоняются,
bootstrap-пир защищён.
- **DNS проверялся на существование A-записи, но не на то, куда она ведёт.**
После принудительной смены IPv4 провайдером `doctor` отвечал успехом, хотя
клиентская ссылка отправляла людей на чужую машину. Проверялся при этом
`HY2XS_DOMAIN`, тогда как в `hysteria2://` уезжает `HY2XS_PUBLIC_HOST`.
Добавлен инвариант публичного endpoint: A-записи обязаны принадлежать
множеству публичных IPv4, назначенных интерфейсам этого сервера. Проверка
живёт в общем `preflight`, поэтому действует в `install`, `reconfigure` и
`doctor`. Адрес определяется локально, без внешних сервисов определения IP.
Строгость управляется `HY2XS_PUBLIC_ENDPOINT_POLICY` (`strict` по умолчанию).
- **Read-only guard PHASE 0 можно было обойти.** Guard стоял на `writeText`,
`writeTextAtomic`, `runVisible`, `runHidden` и `runRawVisible`, но не на
универсальном `run`, через который в коде проходили и наблюдение (`ss`,
`systemctl is-active`), и настоящие мутации (`useradd`, `install -d`,
`mkdir`, `cp -a`, `tar`). Универсального раннера больше нет: есть
`runReadOnly*` без guard'а и `runMutating*` под guard'ом, а выбор — явное
решение на месте вызова.
- **Go-санитайзер конфига вырезал секреты из URL только у ключей `url`/`addr`.**
Будущее upstream-поле с другим именем (`endpoint:`) уносило встроенные
учётные данные и `access_token` наружу целиком; URL внутри списков не
обрабатывались вовсе. Граница определяется значением, а не именем ключа —
как в TS-санитайзере оркестратора; обе реализации покрыты зеркальными тестами.
- **`purge-v0.sh --keep-hysteria-binary` противоречил установщику.** Скрипт
сохранял `/usr/local/bin/hysteria` и сообщал «хост чист для установки
HY2XS v1», хотя clean-host контракт считает этот бинарник legacy-маркером и
следующая установка отказалась бы. Флаг удалён.
- **clean-host не замечал часть того, что удаляет purge.** `/var/lib/hysteria`
(ACME-состояние и сертификаты Hysteria), `/var/log/hy2xs`,
`/usr/local/lib/hy2xs` и `/usr/local/bin/hy2xs-orchestrator` не были
маркерами: сервер, где остался только старый runtime-state Hysteria, проходил
проверку и получал свежую установку поверх чужого состояния. Оба списка
теперь описывают одну границу, и приёмка это проверяет.
- **Установщик мог повредить работающий сервер до того, как откажется его
трогать.** `install.sh` переписывал `/usr/local/lib/hy2xs`, раскладывал
runtime-пакет и перезаписывал `/var/lib/hy2xs/install-state.json`, и лишь
@@ -101,8 +174,54 @@ Hardening-проход перед релизом `1.0.0`. Основная те
- **Явный флаг `--allow-partial-state` для `repair`.** Прежде согласие на
работу поверх незавершённой установки подразумевалось молча.
- **`HY2XS_PUBLIC_ENDPOINT_POLICY`** (`strict` | `warn` | `off`, по умолчанию
`strict`) — строгость проверки того, что публичный endpoint ведёт на этот
сервер. Ослабление предназначено для топологий вне baseline: NAT, floating IP,
anycast. Отсутствие A-записи фатально при любом значении.
- **Раздельные API подпроцессов в оркестраторе**: `runReadOnly` /
`runReadOnlySecret` для наблюдения и `runMutating*` под read-only guard'ом.
- **Сверка API namespace на сборке.** Путь machine-auth и базовый префикс
админского API объявлены по одной константе на компонент, а
`verify_versions_contract` сверяет Go, фронтенд и шаблоны против значений,
скомпилированных в оркестратор.
### Изменено
- **Пространства имён HTTP API.** Операторский и auth API переехали с `/hui` на
`/api`, machine-auth endpoint Hysteria — на `/internal/hysteria/auth`. Прежний
общий префикс был наследием H UI: под ним лежали и machine-to-machine auth, и
JWT-защищённый админский API, хотя middleware у них не пересекаются. Момент
выбран до первого clean-install релиза: после `1.0.0` эти строки стали бы
частью фактического v1 compatibility contract.
- **Сетевая идентичность админки принадлежит оркестратору.** Ключи
`H_UI_WEB_PORT`, `H_UI_WEB_CONTEXT`, `H_UI_CRT_PATH`, `H_UI_KEY_PATH` удалены
из схемы, seed и интерфейса вместе с собственным TLS-слоем панели. Раньше
оркестратор передавал порт аргументом, админка записывала его в SQLite и тут
же читала обратно, а UI показывал поля в disabled-виде: второй источник истины,
из которого ничего нельзя было изменить. Панель всегда монтируется в `/`.
- **`HUI_DATA`/`HUI_LOG``HY2XS_DATA_DIR`/`HY2XS_LOG_DIR`.** Мост в
systemd-юните, перекладывавший canonical env HY2XS в имена старого H UI,
удалён.
- **База админки — `hy2xs-admin.db`** вместо `h_ui.db`; reference-схема —
`apps/docs/sql/schema.sql` вместо `h_ui_db.sql`. Совместимость сохранять не
требуется: v1 ставится только с нуля. Историческое имя `h_ui.db` остаётся в
[docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md) — там это имя чужого
артефакта, который очистка должна найти.
- **Индикатор загрузки и legacy-цвета переведены на брендовый токен.**
NProgress приходил со своим `#29d` и был единственным элементом интерфейса вне
палитры HY2XS; страницы `401`/`404` и подсветка выбранной строки таблицы несли
цвета исходного admin-шаблона. Все они привязаны к `--el-color-primary`, а не
переписаны вторым литералом.
- **Приёмка сверяет схему конфигурации с `versions.env`**, а не с числом `2`
в тексте проверки.
- **E2E подключается по ссылке из production-кода.** Внутри
`tools/test/e2e-hysteria.sh` жила вторая реализация `hysteria2://` URI на
bash: дрейф любой из двух реализаций оставлял обе группы тестов зелёными.
@@ -129,6 +248,20 @@ Hardening-проход перед релизом `1.0.0`. Основная те
не должен обещать updater, которого у продукта нет, а неиспользуемый маршрут
остаётся attack surface.
- `POST /config/exportConfig` и `POST /config/importConfig` — generic-выгрузка и
загрузка таблицы `config` вместе с криптографическими ключами приложения.
Вместе с ними — кнопки Import/Export в настройках, клиентские функции и
строки i18n.
- Персистентный каталог выгрузок `/var/lib/hy2xs-admin/export/` и
файловый helper `util.ExportFile`. Артефакт, который покидает сервер, не
должен существовать на сервере дольше самого запроса.
- Флаг `purge-v0.sh --keep-hysteria-binary`.
- Мёртвые строки i18n, оставшиеся от H UI: `noHttpsTip`, `defaultPassTip`,
`hui*`, `useHysteria2Cert`, `invalidWebContext`, `mustBeInteger`.
## [1.0.0] — 2026-08-27
Первый релиз линейки `v1`.