From 086b5d66240ebfe175825cc5f2bc235c4697902d Mon Sep 17 00:00:00 2001 From: Crimson Date: Thu, 27 Aug 2026 20:50:03 +0500 Subject: [PATCH] =?UTF-8?q?build:=20=D0=B7=D0=B0=D0=BA=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=B8?= =?UTF-8?q?=D0=BD=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82=D1=8B=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D1=91=D0=BC=D0=BA=D0=BE=D0=B9=20=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 133 ++++++++++++++++++++++ README.md | 32 ++++++ docs/02-build-layer-and-package.md | 20 +++- docs/04-admin-panel.md | 82 +++++++++++++ docs/08-orchestrator-spec.md | 97 ++++++++++++++-- docs/09-post-install-env.md | 23 ++++ docs/11-testing-and-acceptance.md | 133 ++++++++++++++++++++-- docs/12-operations-and-troubleshooting.md | 33 +++++- docs/13-production-runbook.md | 17 +++ docs/14-legacy-cleanup.md | 25 ++-- tools/build/lib/acceptance.sh | 116 ++++++++++++++++++- tools/build/lib/package.sh | 4 +- tools/build/lib/verify.sh | 4 +- tools/build/lib/versions.sh | 54 +++++++++ tools/test/e2e-hysteria.sh | 4 +- 15 files changed, 740 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88e60e7..710e222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/README.md b/README.md index 9a0b398..800ac46 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,20 @@ vpn.example.com -> SERVER_IP Если у домена есть AAAA‑запись, при строгой политике `HY2XS_DNS_AAAA_POLICY=strict` установка будет остановлена, потому что текущий production‑профиль HY2XS является IPv4‑only. +A‑запись должна указывать именно на этот сервер, а не просто существовать. Preflight сверяет её с публичными IPv4, назначенными интерфейсам машины, и останавливает установку при расхождении: + +```text +DNS IPv4 mismatch for HY2XS_PUBLIC_HOST vpn.example.com: + DNS A records: 185.xxx.xxx.10 + server public IPv4: 185.xxx.xxx.27 + +Update the DNS A record before using this server. +``` + +Та же проверка выполняется в `reconfigure` и `doctor`, поэтому принудительная смена IPv4 провайдером не остаётся незамеченной. Адрес сервера определяется локально, без обращения к внешним сервисам определения IP. + +Если сервер работает за NAT или на floating IP — это топология вне текущего baseline; осознанное решение оформляется значением `HY2XS_PUBLIC_ENDPOINT_POLICY=warn`. + ### 2. Создайте SSH‑ключ на Windows Откройте PowerShell: @@ -605,6 +619,7 @@ hy2xs-orchestrator status \ | `HY2XS_IPV6_ENABLED` | IPv6‑режим. В production baseline должен быть `false` | `false` | | `HY2XS_DOMAIN` | Домен для ACME и deploy‑профиля | `fi.api.withen.pro` | | `HY2XS_DNS_AAAA_POLICY` | Поведение при наличии AAAA‑записи: `strict`, `warn`, `off` | `strict` | +| `HY2XS_PUBLIC_ENDPOINT_POLICY` | Строгость проверки того, что A‑записи публичного endpoint ведут на IPv4 этого сервера: `strict`, `warn`, `off` | `strict` | | `HY2XS_PUBLIC_HOST` | Публичный host без схемы, порта и path | `fi.api.withen.pro` | | `HY2XS_PUBLIC_PORT` | Публичный порт Hysteria2 endpoint | `443` | | `HY2XS_SSH_PORT` | SSH‑порт, который будет разрешён firewall‑правилами | `2323` | @@ -777,6 +792,23 @@ HY2XS v1 не поддерживает установку поверх и не 1. удалить AAAA‑запись у домена; 2. либо временно установить `HY2XS_DNS_AAAA_POLICY=warn`, если оператор осознанно принимает риск клиентских IPv6‑маршрутов вне текущего baseline. +### `DNS IPv4 mismatch`: DNS ведёт не на этот сервер + +Причина: A‑запись публичного endpoint указывает на адрес, которого нет среди публичных IPv4 этого сервера. Типичный случай — провайдер принудительно сменил IP, а DNS остался старым: сервисы на машине живы, но клиентская ссылка отправляет людей на другой адрес. + +Решения: + +1. сверить фактический адрес сервера и обновить A‑запись: + +```bash +ip -4 addr show scope global +``` + +2. дождаться истечения TTL и повторить `hy2xs-orchestrator doctor`; +3. если в строке `server public IPv4:` пусто — на интерфейсах нет публичного IPv4 (сервер за NAT). Это вне baseline; при осознанном решении установите `HY2XS_PUBLIC_ENDPOINT_POLICY=warn`. + +Если A‑записей несколько и среди них есть посторонняя, проверка тоже отказывает: HY2XS — single‑host профиль, и второй backend за тем же именем означает, что часть клиентов попадёт не на этот сервер. + ### Установка падает на проверке SSH‑порта HY2XS firewall‑слой проверяет, что порт из `HY2XS_SSH_PORT` уже слушается. Если указано `2323`, но `sshd` продолжает слушать только `22`, установка остановится. diff --git a/docs/02-build-layer-and-package.md b/docs/02-build-layer-and-package.md index a7617ba..68652ce 100644 --- a/docs/02-build-layer-and-package.md +++ b/docs/02-build-layer-and-package.md @@ -104,7 +104,10 @@ HYSTERIA_CHANNEL=stable | `orchestrator/package.json` → `packageManager` | `bun@$BUN_VERSION` | | `apps/frontend/package.json` → `packageManager` | `pnpm@$PNPM_VERSION` | | `package/config/hy2xs.env` → схема | `HY2XS_CONFIG_SCHEMA_VERSION` | -| константы, **скомпилированные** в оркестратор | схема, release line, целевая платформа | +| константы, **скомпилированные** в оркестратор | схема, release line, целевая платформа, API namespace | +| `constant.AdminAPIBase` / `constant.HysteriaMachineAuthPath` (Go) | константы оркестратора | +| `API_BASE` фронтенда | `constant.AdminAPIBase` | +| шаблоны Hysteria и `post-install.env` | `HYSTERIA_MACHINE_AUTH_PATH` | | `apps/go.mod` → директива `go` | `GO_VERSION` | | `metadata/package.env` | версия, release line, схема, target | | `hy2xs-admin version` (готовый бинарь) | `v$HY2XS_VERSION` | @@ -113,6 +116,21 @@ HYSTERIA_CHANNEL=stable `orchestrator/tools/print-contract.ts`: это доказывает, что в бинарь попало то же значение. +API namespace попал в этот список не для красоты. Путь machine-auth +записывается в `/etc/hysteria/config.yaml` и в `post-install.env`, то есть по +нему Hysteria обращается к админке. Пока строка была продублирована в шаблонах, +smoke, тестах, приёмке и e2e, расхождение обнаруживалось только на живом +сервере. + +### Версии в `package.json` — не версия продукта + +`orchestrator/package.json` объявляет `version: 0.1.0`, а +`apps/frontend/package.json` — `version: 0.0.0`. Это placeholder'ы приватных +пакетов, которые никуда не публикуются; единственная версия продукта живёт в +`versions.env` (`HY2XS_VERSION`) и оттуда доезжает до `metadata/package.env`, +install-state и бинарника админки. Ни одно из этих двух чисел не участвует в +контракте версий и не должно восприниматься как release version. + Версия админки приходит в бинарь через ldflags: ```bash diff --git a/docs/04-admin-panel.md b/docs/04-admin-panel.md index 41ef947..0951659 100644 --- a/docs/04-admin-panel.md +++ b/docs/04-admin-panel.md @@ -53,6 +53,86 @@ HY2XS admin работает как надстройка над Hysteria YAML/AP Относительно конфигурации Hysteria панель **read-only**: конфиг генерирует оркестратор. +## Сетевая идентичность панели принадлежит оркестратору + +Панель не конфигурирует себя сама. + +| Величина | Источник | +| --- | --- | +| Порт панели | `HY2XS_UI_PORT` → `ExecStart … -p ` | +| Адрес привязки | `HY2XS_UI_BIND_HOST` из `/etc/hy2xs/hy2xs.env` | +| Каталог данных | `HY2XS_DATA_DIR` | +| Каталог логов | `HY2XS_LOG_DIR` | +| Маршрут панели | всегда `/` | +| TLS | терминируется снаружи (SSH-туннель или reverse proxy) | + +До v1 эти величины дублировались в таблице `config` под ключами +`H_UI_WEB_PORT`, `H_UI_WEB_CONTEXT`, `H_UI_CRT_PATH`, `H_UI_KEY_PATH` — +наследие H UI, где панель публиковалась наружу самостоятельно. Получался круг: +оркестратор передавал порт аргументом, панель записывала его в SQLite и тут же +читала обратно, а UI показывал поля в disabled-виде. Ни одного факта база при +этом не добавляла. + +В v1 этих ключей нет ни в схеме, ни в seed, ни в интерфейсе. Собственного +TLS-слоя у панели тоже нет: production-контракт — `HY2XS_UI_BIND_HOST=127.0.0.1` +и `HY2XS_UI_PUBLIC_ACCESS=false`, то есть внутренний сервис. Если панели +когда-нибудь понадобится публичный endpoint, TLS обязан заканчиваться на +ingress/reverse-proxy, а не возвращаться к модели H UI. + +## Пространства имён HTTP API + +| Префикс | Назначение | Middleware | +| --- | --- | --- | +| `/healthz` | liveness/readiness | нет | +| `/internal/hysteria/auth` | machine-to-machine: Hysteria спрашивает разрешение на подключение пира | `LocalOnly` + `MachineAuth` | +| `/api/...` | операторский и auth API | rate limiter, JWT, admin | + +Разделение отражает разницу в природе маршрутов. `/internal/hysteria/auth` — +не интерфейс для человека и не часть операторского API: это внутренний +IPC-подобный HTTP endpoint между двумя процессами на одной машине. До v1 он +лежал под тем же префиксом `hui`, что и JWT-защищённый админский API, хотя +middleware у них не пересекаются. + +Путь machine-auth — **runtime-контракт продукта**: он записывается в +`/etc/hysteria/config.yaml` и в `post-install.env`. Поэтому он объявлен ровно +в двух местах — `constant.HysteriaMachineAuthPath` в админке и +`HYSTERIA_MACHINE_AUTH_PATH` в оркестраторе, — а сборка сверяет их между собой +и с шаблонами. + +## Импорт и экспорт + +| Операция | Статус | +| --- | --- | +| Экспорт пиров (`POST /api/peer-export`) | есть | +| Импорт пиров (`POST /api/peer-import`) | есть | +| Экспорт конфига Hysteria (`POST /api/config/exportHysteria2Config`) | есть, с вырезанием секретов | +| Экспорт/импорт таблицы `config` | **удалён** | + +Generic-выгрузка таблицы `config` отдавала её целиком, исключая только сырой +Hysteria YAML. В той же таблице лежат `JWT_SECRET`, `PEER_SECRET_KEY`, +`PEER_SECRET_ENCRYPTION_KEY` и `HYSTERIA2_TRAFFIC_STATS_SECRET`: кнопка +«Export» выгружала их в открытом виде, а зеркальный импорт позволял их +подменить. Для `PEER_SECRET_ENCRYPTION_KEY` это не только вопрос секретности — +после подмены перестают расшифровываться секреты уже существующих пиров. + +Осмысленного production-сценария у этой пары не было: конфигурацией сервера +владеет оркестратор, перенос пиров делают `peer-import`/`peer-export`. + +Оба оставшихся экспорта формируются **в памяти** и отдаются прямо в ответ. +Раньше они шли через `os.Create` в `/var/lib/hy2xs-admin/export/`, и файл там +оставался навсегда — при `?includeSecrets=true` это означало расшифрованные +секреты пиров на диске, накапливающиеся с каждым нажатием кнопки. Каталога +`export/` больше не существует. + +Импорт пиров проверяется так же строго, как обычное создание пира: те же +правила для имени, quota, `maxDevices`, `disabled`, длины секрета. Дополнительно: + +- неизвестные поля в JSON отклоняются, а не игнорируются молча; +- партия проверяется целиком **до** первой записи в базу — файл применяется + полностью или не применяется вовсе; +- пир `bootstrap-admin-peer` защищён от перезаписи: его секрет продублирован + в `/etc/hy2xs/bootstrap-admin.secret`. + ## Два слоя работы с конфигом Hysteria Это важное архитектурное разделение. @@ -153,6 +233,8 @@ upstream выберет для нового секрета. Список мар | `POST /config/restartServer` | systemd | | `POST /config/uploadCertFile` | оператор + оркестратор | | `GET /config/hysteria2AcmePath` | не имел потребителя | +| `POST /config/exportConfig` | выгружал JWT- и peer-ключи в открытом виде | +| `POST /config/importConfig` | позволял подменить те же ключи | Причины две. diff --git a/docs/08-orchestrator-spec.md b/docs/08-orchestrator-spec.md index 7b0fdee..96217ac 100644 --- a/docs/08-orchestrator-spec.md +++ b/docs/08-orchestrator-spec.md @@ -85,6 +85,25 @@ PHASE 1 — MUTATION Полный список маркеров чужой установки и порядок очистки — [14-legacy-cleanup.md](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` отвечает на вопрос «эта машина — установка @@ -112,30 +131,94 @@ PHASE 1 — MUTATION ## Ownership и rollback -Операция ведёт учёт того, что она реально успела применить: +Операция ведёт учёт того, к чему она **могла прикоснуться**: ```text -depsInstalled -filesystemPrepared -unitsDeployed +stateWritten +depsTouched +filesystemTouched +uiTouched +hysteriaTouched +configTouched +unitsTouched firewallTouched -postInstallWritten +postInstallTouched +bootstrapSecretTouched servicesStarted ``` +Формулировка выбрана намеренно. Флаг «шаг успешно завершился» отвечает не на +тот вопрос: `apt-get install` умеет распаковать половину пакетов и упасть, и +хост уже изменён, хотя шаг не закончился. Поэтому **каждый флаг взводится перед +мутирующим вызовом**, а не после него. + +`stateWritten` — полноценный участник классификации. `install-state.json` +пишется сразу после успешного preflight, до `installDeps`; пока он в +классификации не учитывался, падение `apt-get` объявлялось «на сервере ничего +не изменено», rollback пропускался, а маркер оставался на хосте и ломал +следующую установку по clean-host контракту. + Классификация отказа строится **по этим флагам и фазе**, а не по тексту сообщения об ошибке. Ранее классификация шла по подстрокам, из-за чего preflight-ошибка со словом `nftables` приводила к откату чужого firewall. Инварианты rollback: -- `fatal_pre_apply` по определению означает «ничего не применялось»: - system rollback не выполняется, `install-state.json` не пишется, +- `fatal_pre_apply` по определению означает «ничего не применялось». Попасть в + него нельзя ни при одном взведённом флаге, включая `stateWritten`. В этом + случае system rollback не выполняется, `install-state.json` не пишется, diagnostics-бандл не собирается (его сбор сам создал бы каталоги в `/var/log/hy2xs`). - `systemctl stop/disable` выполняется **только если текущая операция сама развернула эти unit-файлы**. +## Инвариант публичного 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, содержащий: diff --git a/docs/09-post-install-env.md b/docs/09-post-install-env.md index 5f9b1ab..ffadc3b 100644 --- a/docs/09-post-install-env.md +++ b/docs/09-post-install-env.md @@ -114,6 +114,29 @@ 3. оператор запускает `reconfigure --dry-run`, затем `reconfigure --apply`; 4. оркестратор обновляет runtime и перезаписывает snapshot. +### Политики проверок DNS + +В `/etc/hy2xs/hy2xs.env` есть две независимые политики, обе по умолчанию +`strict`: + +| Переменная | Что проверяет | +| --- | --- | +| `HY2XS_DNS_AAAA_POLICY` | наличие AAAA-записи при IPv4-only профиле | +| `HY2XS_PUBLIC_ENDPOINT_POLICY` | что A-записи публичного endpoint ведут на публичные IPv4 этого сервера | + +`HY2XS_PUBLIC_ENDPOINT_POLICY` принимает `strict` / `warn` / `off`. Ослаблять +её имеет смысл только для топологий вне baseline: сервер за NAT, floating IP, +anycast. Отсутствие A-записи фатально при любом значении. + +Обе политики применяются в `install`, `reconfigure` и `doctor`, потому что +живут в общем `preflight`. + +### Сетевая идентичность админки + +`HY2XS_UI_PORT`, `HY2XS_UI_BIND_HOST`, `HY2XS_DATA_DIR` и `HY2XS_LOG_DIR` — +единственный источник истины для этих величин. Админка читает их из окружения +юнита и не хранит собственных копий в SQLite. + Важно: - `HY2XS_ADMIN_INITIAL_PASSWORD` используется только для первичного bootstrap seed; - `HY2XS_ADMIN_CON_PASS` — отдельная runtime-сущность для Hysteria auth/smoke; diff --git a/docs/11-testing-and-acceptance.md b/docs/11-testing-and-acceptance.md index 29e633d..4519441 100644 --- a/docs/11-testing-and-acceptance.md +++ b/docs/11-testing-and-acceptance.md @@ -125,19 +125,28 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh - чистый хост проходит; - **каждый** маркер по отдельности останавливает установку; - список покрывает состояние, юниты, бинарник Hysteria и наследие 0.x; -- пути из конфигурации (`HY2XS_INSTALL_DIR`, `HY2XS_DATA_DIR`) попадают в - список, а не только значения по умолчанию; -- `/usr/local/lib/hy2xs/package` — маркер в PHASE 0, но не в PHASE 1: между - фазами его создаёт сам `install.sh`; +- пути из конфигурации (`HY2XS_INSTALL_DIR`, `HY2XS_DATA_DIR`, `HY2XS_LOG_DIR`) + попадают в список, а не только значения по умолчанию; +- всё, что удаляет `purge-v0.sh`, покрыто маркерами clean-host: два списка + описывают одну границу и не имеют права разъезжаться; +- пути, созданные `install.sh` между фазами (`/usr/local/lib/hy2xs`, + `/usr/local/lib/hy2xs/package`, `/usr/local/bin/hy2xs-orchestrator`), — + маркеры в PHASE 0, но не в PHASE 1; - сообщение перечисляет найденные маркеры и говорит, что хост не изменён. `orchestrator/test/install-boundary.test.ts`: -- под read-only guard недоступны `writeText`, `writeTextAtomic`, `runVisible`, - `runHidden`, `runRawVisible`; +- под read-only guard недоступны `writeText`, `writeTextAtomic` и все + `runMutating*`-раннеры; +- read-only раннеры под guard'ом продолжают работать: разделение API — это не + запрет наблюдения, а запрет мутации; - классификация отказа зависит от ownership-флагов и фазы, а **не** от текста ошибки; -- пока операция ничего не применила, отказ — `fatal_pre_apply`. +- `fatal_pre_apply` недостижим ни при одном взведённом флаге, включая + `stateWritten`: записанный `install-state.json` уже делает хост изменённым; +- начатая (не обязательно завершённая) установка пакетов уже даёт + `fatal_post_apply` — регрессия на сценарий «PHASE 0 прошла, apt-get упал, + установщик заявил, что ничего не тронул». `orchestrator/test/install-state.test.ts`: @@ -160,7 +169,51 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh - `acme.dns.config` вырезается целиком; - невалидный YAML не роняет редакцию и всё равно чистится; - секрет внутри URL-значения в env вырезается, даже если имя ключа несекретное - (`HY2_AUTH_URL`). + (`HY2_AUTH_URL`); +- URL под **произвольным** именем ключа теряет встроенные учётные данные и + секретные query-параметры, но сохраняет адрес; то же для URL внутри списка. + +## A8. Инвариант публичного endpoint (unit) + +`orchestrator/test/network-endpoint.test.ts` — проба подменяет и DNS, и список +локальных адресов, поэтому тест не зависит ни от сети, ни от интерфейсов машины +разработчика. + +| Сценарий | Результат | +| --- | --- | +| A-запись == текущий публичный IPv4 | PASS | +| A-запись == старый IPv4 | FAIL, в тексте оба адреса | +| A-запись отсутствует | FAIL | +| A == текущий + чужой | FAIL | +| у сервера 2 публичных IP, DNS использует один | PASS | +| `PUBLIC_HOST` — правильный IPv4-литерал | PASS | +| `PUBLIC_HOST` — устаревший IPv4-литерал | FAIL | +| `DOMAIN` совпадает, отдельный `PUBLIC_HOST` устарел | FAIL | +| `PUBLIC_HOST` совпадает, отдельный TLS-домен устарел | FAIL | +| нет ни одного локального публичного IPv4 | FAIL | +| `HY2XS_PUBLIC_ENDPOINT_POLICY` = strict / warn / off | fail / warn / skip | +| отсутствие A-записи при любой политике | FAIL | + +Отдельно проверяется классификация IPv4: приватные, CGNAT, link-local, +multicast и reserved диапазоны не считаются публичным адресом сервера, а +`172.32.0.0` и `172.15.255.255` — считаются (границы `172.16/12`). + +## A9. Импорт пиров (unit) + +`apps/service/peer_import_test.go`: + +- выгрузка, сделанная `ExportPeer`, принимается без правок; +- имя проверяется теми же правилами, что и при обычном создании пира: длина, + набор символов, отсутствие пробелов и переводов строки; +- `bootstrap-admin-peer` не может быть импортирован ни по имени, ни по `authId`: + его секрет продублирован в `/etc/hy2xs/bootstrap-admin.secret`; +- диапазоны `quotaBytes`, `expiresAt`, `maxDevices`, `disabled`, `bannedUntil`, + счётчиков трафика и длины секрета проверяются; +- sentinel-значения (`quotaBytes = -1`, `maxDevices = 0`) остаются валидными; +- дубликаты имени и `authId` внутри одной партии отклоняются; +- партия сверх лимита отклоняется; +- невалидная **последняя** запись отклоняет весь файл: импорт применяется + целиком или не применяется вовсе. ## A7. Контракт версий (build) @@ -248,7 +301,7 @@ trafficStats: auth: type == http - url == http://127.0.0.1:/hui/hysteria2/auth?access_token= + url == http://127.0.0.1:/internal/hysteria/auth?access_token= insecure == (tlsMode == self_signed_dev) TLS: @@ -332,8 +385,15 @@ idle timeout проходил семантическую проверку. То - операционные поля остаются читаемыми; - вырезаются: obfs-пароль, `trafficStats.secret`, `access_token`, `auth.userpass`, учётные данные ACME DNS, пароли outbound; - вырезается **неизвестное** поле с секретным именем; +- URL под произвольным именем ключа (`endpoint:`) теряет учётные данные и + `access_token`, но сохраняет адрес; то же для URL внутри списка; +- не-URL скаляры (`50 mbps`, `0.0.0.0:443`, `10.0.0.1:1080`, `30s`, числа) + проходят санитайзер без изменений; - пути к файлам (`tls.key`, `ech.keyPath`, `clientCA`) остаются видимыми. +Го- и TS-санитайзеры описывают один контракт и покрыты зеркальными тестами: +граница определяется значением, а не именем ключа. + ## D0. Граница установки на живом сервере Проверяется на хосте, где уже стоит предыдущая установка: @@ -350,6 +410,47 @@ idle timeout проходил семантическую проверку. То `/usr/local/lib/hy2xs` и `install-state.json`, а затем откатом останавливал и выключал работающие службы старой установки. +## D1. Отказ сразу после успешной PHASE 0 (fault injection) + +Проверяется на чистом хосте. Это узкая щель между «PHASE 0 прошла» и «первая +мутирующая операция упала» — место, где установщик раньше врал. + +1. PHASE 0 проходит успешно; +2. `installDeps` ломается искусственно (например, недоступный apt-репозиторий + или временно испорченный `/etc/apt/sources.list.d/`); +3. установка завершается отказом; +4. в выводе **нет** `fatal_pre_apply` и нет фразы про «ничего не применялось»; +5. `/var/lib/hy2xs/install-state.json` существует и честно показывает + `phase: failed` с текстом ошибки; +6. diagnostics-бандл собран; +7. `hy2xs-orchestrator status` не заявляет установку успешной. + +До исправления шаги 4–6 давали противоположный результат: `install-state.json` +уже лежал на диске, но отказ классифицировался как pre-apply, обработка +состояния пропускалась, а следующая установка на этой машине отказывалась по +clean-host контракту из-за оставшегося маркера. + +## D2. Устаревший DNS после смены IPv4 провайдером + +Проверяется на рабочей установке. + +```text +сервер: текущий публичный IPv4 = B +DNS: A-запись = A (старый адрес) + +hy2xs-orchestrator doctor + → FAIL + → в выводе присутствуют и A, и B + +обновить A-запись на B, дождаться TTL + +hy2xs-orchestrator doctor + → PASS +``` + +Дополнительно: `reconfigure --apply` при устаревшей A-записи тоже обязан +отказать — инвариант живёт в общем `preflight`, а не в одном `doctor`. + ## D. Negative tests 1. не Debian 13 @@ -365,6 +466,12 @@ idle timeout проходил семантическую проверку. То 11. неизвестный `HY2XS_HYSTERIA_OBFS_TYPE` 12. конфигурация со схемой `HY2XS_CONFIG_SCHEMA_VERSION` из линейки `0.x` 13. upstream `latest` несовместим с шаблоном HY2XS — падает сборка, не установка +14. `HY2XS_PUBLIC_HOST` резолвится не на этот сервер +15. `HY2XS_DOMAIN` резолвится не на этот сервер при отличном от него `PUBLIC_HOST` +16. A-запись содержит правильный адрес и чужой одновременно +17. неизвестное значение `HY2XS_PUBLIC_ENDPOINT_POLICY` +18. импорт пиров с невалидной записью — файл не применяется частично +19. импорт пиров, пытающийся перезаписать `bootstrap-admin-peer` ## E. Fix20 production matrix (обязательные сценарии) @@ -397,6 +504,14 @@ idle timeout проходил семантическую проверку. То - install не падает на race после restart; - readiness waiters дожидаются listener/healthz. +8. **Отказ между PHASE 0 и первой мутацией** (сценарий D1): + - `install-state.json` честно показывает `failed`; + - установщик не заявляет, что хост не изменён. + +9. **Устаревший DNS после смены IPv4** (сценарий D2): + - `doctor` и `reconfigure` отказывают; + - в выводе присутствуют оба адреса. + ## Acceptance criteria Система принимается, если: diff --git a/docs/12-operations-and-troubleshooting.md b/docs/12-operations-and-troubleshooting.md index b2e270e..6428dac 100644 --- a/docs/12-operations-and-troubleshooting.md +++ b/docs/12-operations-and-troubleshooting.md @@ -95,7 +95,7 @@ sudo -u hy2xs-admin test -r /etc/hysteria/config.yaml curl -sS -X POST \ -H 'Content-Type: application/json' \ --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' \ - http://127.0.0.1:8080/hui/hysteria2/auth + http://127.0.0.1:8080/internal/hysteria/auth curl -sS \ -H "Authorization: " \ @@ -230,6 +230,37 @@ ClientAliveCountMax 2 Для production baseline рекомендуется `strict`. +### `DNS IPv4 mismatch`: DNS ведёт не на этот сервер + +Сообщение выглядит так: + +```text +DNS IPv4 mismatch for HY2XS_PUBLIC_HOST fi.api.withen.pro: + DNS A records: 185.xxx.xxx.10 + server public IPv4: 185.xxx.xxx.27 + +Update the DNS A record before using this server. +``` + +Это не ложное срабатывание, а именно то, ради чего проверка сделана: сервисы на +машине живы, но публичный endpoint ведёт куда-то ещё. Чаще всего — после +принудительной смены IPv4 провайдером. + +Что делать: + +1. сверить фактический адрес сервера: `ip -4 addr show scope global`; +2. обновить A-запись у DNS-провайдера; +3. дождаться истечения TTL; +4. повторить `hy2xs-orchestrator doctor`. + +Вариант `server public IPv4:` пустой означает, что на интерфейсах нет ни одного +публичного маршрутизируемого IPv4 — сервер за NAT. Это топология вне baseline; +осознанное решение оформляется через `HY2XS_PUBLIC_ENDPOINT_POLICY=warn`. + +Если в A-записях присутствует правильный адрес **и** посторонний, проверка тоже +отказывает. HY2XS — single-host профиль: второй backend за тем же именем +означает, что часть клиентов попадёт не на этот сервер. + ### Скорость не соответствует ожиданиям Проверить: - `bandwidth.*` на сервере diff --git a/docs/13-production-runbook.md b/docs/13-production-runbook.md index a19638f..c8caf32 100644 --- a/docs/13-production-runbook.md +++ b/docs/13-production-runbook.md @@ -91,6 +91,23 @@ hy2xs-orchestrator reconfigure --package-dir /usr/local/lib/hy2xs/package --conf - `warn` — выводится warning и выполнение продолжается; - `off` — AAAA-проверка игнорируется. +## 11a. Публичный endpoint + +- preflight проверяет, что A-записи `HY2XS_PUBLIC_HOST` (и `HY2XS_DOMAIN`, если + он отличается) ведут на публичные IPv4 **этого** сервера; +- проверка работает в `install`, `reconfigure` и `doctor`; +- адрес сервера определяется локально по интерфейсам, без обращения к внешним + сервисам определения IP; +- `HY2XS_PUBLIC_ENDPOINT_POLICY` управляет строгостью: + - `strict` (default) — расхождение останавливает операцию; + - `warn` — warning и продолжение (NAT, floating IP, anycast); + - `off` — сравнение не выполняется; +- отсутствие A-записи остаётся фатальным при любом значении политики. + +Типичный сценарий, ради которого это сделано: провайдер принудительно сменил +IPv4, DNS остался старым. До v1 `doctor` в такой ситуации отвечал успехом, а +клиентская ссылка отправляла людей на чужую машину. + ## 12. Validation command ```bash diff --git a/docs/14-legacy-cleanup.md b/docs/14-legacy-cleanup.md index 56ef4dc..8b8c068 100644 --- a/docs/14-legacy-cleanup.md +++ b/docs/14-legacy-cleanup.md @@ -129,11 +129,12 @@ sudo ./purge-v0.sh sudo ./purge-v0.sh --apply --yes-i-know ``` -Если бинарник Hysteria нужно оставить (например, вы проверяете им что-то ещё): - -```bash -sudo ./purge-v0.sh --apply --yes-i-know --keep-hysteria-binary -``` +Возможности «оставить бинарник Hysteria» у скрипта нет намеренно. +`/usr/local/bin/hysteria` входит в clean-host контракт установщика: сервер, где +он остался, установку HY2XS v1 не пройдёт. Скрипт, который сохранял бы бинарник +и при этом сообщал «хост чист», прямо противоречил бы следующему запуску +`install.sh`. Свежая установка всё равно кладёт собственную версию Hysteria, +проверенную по SHA-256 против upstream `hashes.txt`. В конце скрипт сам проверяет, что хост стал чистым по тому же контракту, который применяет установщик. Если что-то осталось, он назовёт конкретные объекты и @@ -147,10 +148,18 @@ sudo ./purge-v0.sh --apply --yes-i-know --keep-hysteria-binary 2. снимает таймеры отката firewall `hy2xs-fw-rollback-*` — они переживают неудачную установку и иначе продолжили бы менять ruleset уже после очистки; 3. удаляет unit-файлы и выполняет `daemon-reload`; -4. удаляет каталоги приложения, конфигурации, данных и логов; -5. удаляет фрагмент `/etc/nftables.d/hy2xs.nft` и строку `include` для него из +4. удаляет каталоги приложения, конфигурации, данных и логов, включая + `/var/lib/hysteria` (там остаётся ACME-состояние и сертификаты Hysteria), + `/usr/local/lib/hy2xs` и symlink `/usr/local/bin/hy2xs-orchestrator`; +5. удаляет `/usr/local/bin/hysteria`; +6. удаляет фрагмент `/etc/nftables.d/hy2xs.nft` и строку `include` для него из `/etc/nftables.conf`, после чего перезагружает ruleset; -6. проверяет чистоту хоста. +7. проверяет чистоту хоста. + +Список удаляемых путей и список legacy-маркеров clean-host контракта описывают +одну и ту же границу: расхождение между ними ловится приёмкой сборки. Иначе +возможен сервер, с которого «всё удалено», но который установщик всё равно +считает грязным — или, что хуже, наоборот. Не делает: diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index 56c1bf0..07843a0 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -31,8 +31,15 @@ run_fix20_acceptance_subset() { grep -q '^HY2XS_FORCE_PASSWORD_CHANGE=false$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_FORCE_PASSWORD_CHANGE must default to false" log_step "Acceptance: config schema version is declared" - grep -q '^HY2XS_CONFIG_SCHEMA_VERSION=2$' "$package_dir/config/hy2xs.env" \ - || fail "acceptance: HY2XS_CONFIG_SCHEMA_VERSION must be 2 in the packaged baseline" + # Значение берётся из versions.env, а не пишется числом: захардкоженная + # двойка означала бы, что при переходе на schema 3 нужно помнить ещё и про + # эту строку. Источник истины у схемы ровно один. + grep -q "^HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}\$" "$package_dir/config/hy2xs.env" \ + || fail "acceptance: packaged baseline must declare HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}" + + log_step "Acceptance: public endpoint policy is declared and strict by default" + grep -q '^HY2XS_PUBLIC_ENDPOINT_POLICY=strict$' "$package_dir/config/hy2xs.env" \ + || fail "acceptance: packaged baseline must default to HY2XS_PUBLIC_ENDPOINT_POLICY=strict" log_step "Acceptance: fresh install defaults to Gecko obfuscation" grep -q '^HY2XS_HYSTERIA_OBFS_TYPE=gecko$' "$package_dir/config/hy2xs.env" \ @@ -118,8 +125,8 @@ run_fix20_acceptance_subset() { grep -q 'Fix20 production matrix' docs/11-testing-and-acceptance.md || fail "acceptance: fix20 matrix section missing" log_step "Acceptance: machine auth URL in templates" - grep -q '/hui/hysteria2/auth?access_token={{HYSTERIA_API_SECRET}}' "$package_dir/templates/hysteria/config.yaml.tpl" || fail "acceptance: machine token missing in hysteria auth URL template" - grep -q '^HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/hui/hysteria2/auth?access_token={{HYSTERIA_API_SECRET}}$' "$package_dir/templates/env/post-install.env.tpl" || fail "acceptance: machine token missing in post-install HY2_AUTH_URL" + grep -q '/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}' "$package_dir/templates/hysteria/config.yaml.tpl" || fail "acceptance: machine token missing in hysteria auth URL template" + grep -q '^HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}$' "$package_dir/templates/env/post-install.env.tpl" || fail "acceptance: machine token missing in post-install HY2_AUTH_URL" log_step "Acceptance: smoke auth checks are tokenized" grep -q 'unexpected auth status without machine token' orchestrator/src/steps/smoke.ts || fail "acceptance: missing 403 negative smoke for auth without machine token" @@ -177,6 +184,105 @@ run_clean_install_acceptance() { grep -q 'systemd units were not deployed by this operation' orchestrator/src/commands/install.ts \ || fail "acceptance: rollback must never stop services it did not deploy" + log_step "Acceptance: a written install-state already makes the failure post-apply" + # Регрессия: classifyFailure не учитывал stateWritten, поэтому падение + # apt-get объявлялось «на сервере ничего не изменено», rollback пропускался, + # а install-state.json оставался на хосте и ломал следующую установку. + grep -q 'ownership.stateWritten' orchestrator/src/commands/install.ts \ + || fail "acceptance: classifyFailure must account for a written install-state" + "$BUN_BIN" -e ' + const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8"); + const body = source.slice(source.indexOf("export function classifyFailure")); + const preApply = body.indexOf("return \"fatal_pre_apply\""); + const stateWritten = body.indexOf("ownership.stateWritten"); + if (preApply < 0 || stateWritten < 0) { + throw new Error("could not locate classifyFailure branches"); + } + if (stateWritten > preApply) { + throw new Error("stateWritten is checked after the fatal_pre_apply fallback"); + } + ' || fail "acceptance: fatal_pre_apply must be unreachable once install-state was written" + + log_step "Acceptance: mutating ownership flags are raised before the step, not after" + # Флаг «шаг завершился» отвечает не на тот вопрос: apt-get умеет изменить + # систему и упасть. Каждый флаг обязан стоять ПЕРЕД своим await. + "$BUN_BIN" -e ' + const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8"); + const steps = [ + ["depsTouched", "await installDeps("], + ["filesystemTouched", "await prepareFilesystem("], + ["uiTouched", "await deployUi("], + ["hysteriaTouched", "await installHysteria("], + ["configTouched", "await generateConfig("], + ["unitsTouched", "await deploySystemd("], + ["firewallTouched", "await applyFirewall("], + ["postInstallTouched", "await writePostInstallEnv("], + ["bootstrapSecretTouched", "await ensureBootstrapAdminSecret("] + ]; + for (const [flag, call] of steps) { + const flagAt = source.indexOf("ownership." + flag + " = true"); + const callAt = source.indexOf(call); + if (flagAt < 0) throw new Error("missing ownership flag: " + flag); + if (callAt < 0) throw new Error("missing step call: " + call); + if (flagAt > callAt) throw new Error(flag + " is raised after " + call); + } + ' || fail "acceptance: ownership flags must be raised before the mutating step they cover" + + log_step "Acceptance: the read-only phase has no universal runner to slip through" + # Пока существовал один `run`, под которым жили и `ss -ltn`, и `useradd`, + # guard держался на внимательности автора правки. + grep -q 'export async function runReadOnly' orchestrator/src/lib/process.ts \ + || fail "acceptance: process.ts must expose an explicit read-only runner" + grep -q 'export async function runMutating' orchestrator/src/lib/process.ts \ + || fail "acceptance: process.ts must expose an explicit mutating runner" + ! grep -rEq '(^|[^A-Za-z0-9_])(run|runVisible|runHidden|runSecret|runRawVisible)`' orchestrator/src \ + || fail "acceptance: the pre-split runner names must not come back" + local unguarded_runner + unguarded_runner="$(grep -c 'assertMutationAllowed' orchestrator/src/lib/process.ts || true)" + [ "$unguarded_runner" -ge 4 ] \ + || fail "acceptance: every mutating runner must ask the read-only guard for permission" + + log_step "Acceptance: the public endpoint invariant lives in preflight, not only in doctor" + grep -q 'assertPublicEndpoint' orchestrator/src/steps/preflight.ts \ + || fail "acceptance: preflight must verify that the public endpoint resolves to this server" + [ -f orchestrator/src/steps/networkEndpoint.ts ] \ + || fail "acceptance: the public endpoint invariant module is missing" + ! grep -rqE 'ifconfig\.me|ipify|checkip\.amazonaws' orchestrator/src \ + || fail "acceptance: the server address must be resolved locally, not via an external service" + grep -q 'networkInterfaces' orchestrator/src/steps/networkEndpoint.ts \ + || fail "acceptance: local public IPv4 set must come from the host interfaces" + + log_step "Acceptance: purge and clean-host describe the same boundary" + local purged_path + for purged_path in /var/lib/hysteria /usr/local/lib/hy2xs /usr/local/bin/hy2xs-orchestrator /var/log/hy2xs; do + grep -qF "$purged_path" tools/legacy/purge-v0.sh \ + || fail "acceptance: purge-v0.sh no longer removes $purged_path" + done + grep -qF '/var/lib/hysteria' orchestrator/src/steps/cleanHost.ts \ + || fail "acceptance: clean-host must treat leftover Hysteria runtime state as a legacy marker" + grep -qF '/usr/local/bin/hy2xs-orchestrator' orchestrator/src/steps/cleanHost.ts \ + || fail "acceptance: clean-host must treat a leftover orchestrator symlink as a legacy marker" + ! grep -q 'keep-hysteria-binary' tools/legacy/purge-v0.sh \ + || fail "acceptance: --keep-hysteria-binary contradicts the installer clean-host contract" + + log_step "Acceptance: admin secrets never reach a persistent export file" + # Экспорт формируется в памяти: os.Create в /var/lib/hy2xs-admin/export + # оставлял на диске JSON с расшифрованными секретами пиров. + [ ! -f apps/util/export.go ] \ + || fail "acceptance: the file-based export helper came back" + ! grep -rq 'ExportPathDir' apps/cmd apps/controller apps/service apps/dao apps/model apps/util \ + || fail "acceptance: the persistent export directory came back" + grep -q 'c.Data(200, "application/octet-stream", payload)' apps/controller/peer.go \ + || fail "acceptance: peer export must be served from memory" + + log_step "Acceptance: peer import is validated as strictly as peer creation" + grep -q 'ValidatePeerImportBatch' apps/service/peer.go \ + || fail "acceptance: peer import must validate the whole batch before writing" + grep -q 'DisallowUnknownFields' apps/controller/peer.go \ + || fail "acceptance: peer import must reject unknown fields instead of silently defaulting" + grep -q 'ReservedBootstrapPeerName' apps/service/peer_import.go \ + || fail "acceptance: peer import must refuse to overwrite the installer-owned bootstrap peer" + log_step "Acceptance: missing config schema marker is treated as legacy" grep -q 'HY2XS_CONFIG_SCHEMA_VERSION отсутствует' orchestrator/src/config/env.ts \ || fail "acceptance: a missing HY2XS_CONFIG_SCHEMA_VERSION must be rejected as legacy, not defaulted" @@ -199,7 +305,7 @@ run_clean_install_acceptance() { # Ищется регистрация маршрута (имя в кавычках), а не любое упоминание: # комментарий, объясняющий, почему маршрута нет, должен быть разрешён. local dead_route - for dead_route in hysteria2ChangeVersion listRelease updateHysteria2Config importHysteria2Config restartServer uploadCertFile hysteria2AcmePath; do + for dead_route in hysteria2ChangeVersion listRelease updateHysteria2Config importHysteria2Config restartServer uploadCertFile hysteria2AcmePath exportConfig importConfig; do ! grep -rqF "${dead_route}\"" apps/router apps/controller \ || fail "acceptance: removed route ${dead_route} came back" ! grep -rqF "${dead_route}\"" apps/frontend/src/api \ diff --git a/tools/build/lib/package.sh b/tools/build/lib/package.sh index 2c51f88..8ded74d 100644 --- a/tools/build/lib/package.sh +++ b/tools/build/lib/package.sh @@ -111,9 +111,9 @@ bundle_ui() { verify_admin_version_contract "$ADMIN_BUILD_DIR/hy2xs-admin" install -m 0755 "$ADMIN_BUILD_DIR/hy2xs-admin" "$STAGE_DIR/ui/hy2xs-admin/hy2xs-admin" - if [ -f "$ui_src/docs/sql/h_ui_db.sql" ]; then + if [ -f "$ui_src/docs/sql/schema.sql" ]; then mkdir -p "$STAGE_DIR/ui/hy2xs-admin/docs/sql" - install -m 0644 "$ui_src/docs/sql/h_ui_db.sql" "$STAGE_DIR/ui/hy2xs-admin/docs/sql/h_ui_db.sql" + install -m 0644 "$ui_src/docs/sql/schema.sql" "$STAGE_DIR/ui/hy2xs-admin/docs/sql/schema.sql" fi } diff --git a/tools/build/lib/verify.sh b/tools/build/lib/verify.sh index c5a01dd..62a9541 100644 --- a/tools/build/lib/verify.sh +++ b/tools/build/lib/verify.sh @@ -93,7 +93,7 @@ verify_archive() { local hysteria_tpl hysteria_tpl="$(tar -xOzf "$archive" hy2xs-install/templates/hysteria/config.yaml.tpl)" - printf '%s\n' "$hysteria_tpl" | grep -q '/hui/hysteria2/auth?access_token={{HYSTERIA_API_SECRET}}' || fail "hysteria auth template must include machine access_token" + printf '%s\n' "$hysteria_tpl" | grep -q '/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}' || fail "hysteria auth template must include machine access_token" printf '%s\n' "$hysteria_tpl" | grep -q '{{OBFS_BLOCK}}' || fail "hysteria template must render a typed obfs block" # Замороженная версия обязана совпадать во всех местах пакета. @@ -120,7 +120,7 @@ verify_archive() { local post_install_tpl post_install_tpl="$(tar -xOzf "$archive" hy2xs-install/templates/env/post-install.env.tpl)" - printf '%s\n' "$post_install_tpl" | grep -q '^HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/hui/hysteria2/auth?access_token={{HYSTERIA_API_SECRET}}$' || fail "post-install env template must include machine access_token in HY2_AUTH_URL" + printf '%s\n' "$post_install_tpl" | grep -q '^HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}$' || fail "post-install env template must include machine access_token in HY2_AUTH_URL" local tmp tmp="$(mktemp -d)" diff --git a/tools/build/lib/versions.sh b/tools/build/lib/versions.sh index f6cbec5..61202a1 100644 --- a/tools/build/lib/versions.sh +++ b/tools/build/lib/versions.sh @@ -164,11 +164,64 @@ verify_orchestrator_contract() { HY2XS_TARGET_ARCH) expect_equal "orchestrator target arch" "$value" "$HY2XS_TARGET_ARCH" ;; + HY2XS_ADMIN_API_BASE) + HY2XS_ADMIN_API_BASE="$value" + ;; + HY2XS_HYSTERIA_MACHINE_AUTH_PATH) + HY2XS_HYSTERIA_MACHINE_AUTH_PATH="$value" + ;; *) fail "versions contract: unexpected orchestrator contract line: $line" ;; esac done <<<"$output" + + [ -n "${HY2XS_ADMIN_API_BASE:-}" ] \ + || fail "versions contract: orchestrator did not report HY2XS_ADMIN_API_BASE" + [ -n "${HY2XS_HYSTERIA_MACHINE_AUTH_PATH:-}" ] \ + || fail "versions contract: orchestrator did not report HY2XS_HYSTERIA_MACHINE_AUTH_PATH" + export HY2XS_ADMIN_API_BASE HY2XS_HYSTERIA_MACHINE_AUTH_PATH +} + +# API namespace — это runtime-контракт между тремя компонентами. +# +# Путь machine-auth уезжает в /etc/hysteria/config.yaml и в post-install.env, +# то есть по нему Hysteria обращается к админке. Пока строка была размазана по +# шаблонам, smoke, тестам и e2e, любое расхождение обнаруживалось только на +# живом сервере. Здесь она сверяется во всех местах сразу — против константы, +# скомпилированной в оркестратор. +verify_api_namespace_contract() { + local go_auth_path go_api_base + + go_auth_path="$(grep -Eo 'HysteriaMachineAuthPath[[:space:]]*=[[:space:]]*"[^"]*"' apps/model/constant/api.go \ + | head -n1 | sed -E 's/.*"([^"]*)"/\1/')" + go_api_base="$(grep -Eo 'AdminAPIBase[[:space:]]*=[[:space:]]*"[^"]*"' apps/model/constant/api.go \ + | head -n1 | sed -E 's/.*"([^"]*)"/\1/')" + + expect_equal "apps constant.HysteriaMachineAuthPath" "$go_auth_path" "$HY2XS_HYSTERIA_MACHINE_AUTH_PATH" + expect_equal "apps constant.AdminAPIBase" "$go_api_base" "$HY2XS_ADMIN_API_BASE" + + local frontend_base + frontend_base="$(grep -Eo 'const API_BASE = "[^"]*"' apps/frontend/src/utils/request.ts \ + | head -n1 | sed -E 's/.*"([^"]*)"/\1/')" + expect_equal "frontend API_BASE" "$frontend_base" "$HY2XS_ADMIN_API_BASE" + + grep -qF "${HY2XS_HYSTERIA_MACHINE_AUTH_PATH}?access_token={{HYSTERIA_API_SECRET}}" \ + package/templates/hysteria/config.yaml.tpl \ + || fail "versions contract: hysteria template does not use ${HY2XS_HYSTERIA_MACHINE_AUTH_PATH}" + grep -qF "${HY2XS_HYSTERIA_MACHINE_AUTH_PATH}?access_token={{HYSTERIA_API_SECRET}}" \ + package/templates/env/post-install.env.tpl \ + || fail "versions contract: post-install env template does not use ${HY2XS_HYSTERIA_MACHINE_AUTH_PATH}" + + # Старое пространство имён не имеет права вернуться ни в один компонент. + # Историческое имя допустимо только в docs/14-legacy-cleanup.md и в + # legacy-маркерах clean-host: там это имя чужого артефакта, а не наше. + local legacy_hits + legacy_hits="$(grep -rlF '/hui' \ + apps/model apps/router apps/controller apps/service apps/frontend/src \ + orchestrator/src orchestrator/test package/templates tools/test 2>/dev/null || true)" + [ -z "$legacy_hits" ] \ + || fail "versions contract: legacy /hui namespace came back in: $legacy_hits" } verify_go_toolchain_contract() { @@ -206,6 +259,7 @@ verify_versions_contract() { "$HY2XS_CONFIG_SCHEMA_VERSION" verify_orchestrator_contract + verify_api_namespace_contract verify_go_toolchain_contract expect_equal "build host OS" "$HY2XS_BUILD_OS" "debian" diff --git a/tools/test/e2e-hysteria.sh b/tools/test/e2e-hysteria.sh index ed6daad..16cdc8e 100755 --- a/tools/test/e2e-hysteria.sh +++ b/tools/test/e2e-hysteria.sh @@ -143,7 +143,7 @@ wait_for_port() { die "$label did not start listening on port $port" } -# Mock HY2XS auth endpoint повторяет контракт /hui/hysteria2/auth: +# Mock HY2XS auth endpoint повторяет контракт /internal/hysteria/auth: # проверку machine access_token и отказ неразрешённому секрету. start_auth_endpoint() { cat >"$WORK_DIR/auth-server.ts" <