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:
+133
@@ -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`, раскладывал
|
трогать.** `install.sh` переписывал `/usr/local/lib/hy2xs`, раскладывал
|
||||||
runtime-пакет и перезаписывал `/var/lib/hy2xs/install-state.json`, и лишь
|
runtime-пакет и перезаписывал `/var/lib/hy2xs/install-state.json`, и лишь
|
||||||
@@ -101,8 +174,54 @@ Hardening-проход перед релизом `1.0.0`. Основная те
|
|||||||
- **Явный флаг `--allow-partial-state` для `repair`.** Прежде согласие на
|
- **Явный флаг `--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-кода.** Внутри
|
- **E2E подключается по ссылке из production-кода.** Внутри
|
||||||
`tools/test/e2e-hysteria.sh` жила вторая реализация `hysteria2://` URI на
|
`tools/test/e2e-hysteria.sh` жила вторая реализация `hysteria2://` URI на
|
||||||
bash: дрейф любой из двух реализаций оставлял обе группы тестов зелёными.
|
bash: дрейф любой из двух реализаций оставлял обе группы тестов зелёными.
|
||||||
@@ -129,6 +248,20 @@ Hardening-проход перед релизом `1.0.0`. Основная те
|
|||||||
не должен обещать updater, которого у продукта нет, а неиспользуемый маршрут
|
не должен обещать updater, которого у продукта нет, а неиспользуемый маршрут
|
||||||
остаётся attack surface.
|
остаётся 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
|
## [1.0.0] — 2026-08-27
|
||||||
|
|
||||||
Первый релиз линейки `v1`.
|
Первый релиз линейки `v1`.
|
||||||
|
|||||||
@@ -263,6 +263,20 @@ vpn.example.com -> SERVER_IP
|
|||||||
|
|
||||||
Если у домена есть AAAA‑запись, при строгой политике `HY2XS_DNS_AAAA_POLICY=strict` установка будет остановлена, потому что текущий production‑профиль HY2XS является IPv4‑only.
|
Если у домена есть 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
|
### 2. Создайте SSH‑ключ на Windows
|
||||||
|
|
||||||
Откройте PowerShell:
|
Откройте PowerShell:
|
||||||
@@ -605,6 +619,7 @@ hy2xs-orchestrator status \
|
|||||||
| `HY2XS_IPV6_ENABLED` | IPv6‑режим. В production baseline должен быть `false` | `false` |
|
| `HY2XS_IPV6_ENABLED` | IPv6‑режим. В production baseline должен быть `false` | `false` |
|
||||||
| `HY2XS_DOMAIN` | Домен для ACME и deploy‑профиля | `fi.api.withen.pro` |
|
| `HY2XS_DOMAIN` | Домен для ACME и deploy‑профиля | `fi.api.withen.pro` |
|
||||||
| `HY2XS_DNS_AAAA_POLICY` | Поведение при наличии AAAA‑записи: `strict`, `warn`, `off` | `strict` |
|
| `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_HOST` | Публичный host без схемы, порта и path | `fi.api.withen.pro` |
|
||||||
| `HY2XS_PUBLIC_PORT` | Публичный порт Hysteria2 endpoint | `443` |
|
| `HY2XS_PUBLIC_PORT` | Публичный порт Hysteria2 endpoint | `443` |
|
||||||
| `HY2XS_SSH_PORT` | SSH‑порт, который будет разрешён firewall‑правилами | `2323` |
|
| `HY2XS_SSH_PORT` | SSH‑порт, который будет разрешён firewall‑правилами | `2323` |
|
||||||
@@ -777,6 +792,23 @@ HY2XS v1 не поддерживает установку поверх и не
|
|||||||
1. удалить AAAA‑запись у домена;
|
1. удалить AAAA‑запись у домена;
|
||||||
2. либо временно установить `HY2XS_DNS_AAAA_POLICY=warn`, если оператор осознанно принимает риск клиентских IPv6‑маршрутов вне текущего baseline.
|
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‑порта
|
### Установка падает на проверке SSH‑порта
|
||||||
|
|
||||||
HY2XS firewall‑слой проверяет, что порт из `HY2XS_SSH_PORT` уже слушается. Если указано `2323`, но `sshd` продолжает слушать только `22`, установка остановится.
|
HY2XS firewall‑слой проверяет, что порт из `HY2XS_SSH_PORT` уже слушается. Если указано `2323`, но `sshd` продолжает слушать только `22`, установка остановится.
|
||||||
|
|||||||
@@ -104,7 +104,10 @@ HYSTERIA_CHANNEL=stable
|
|||||||
| `orchestrator/package.json` → `packageManager` | `bun@$BUN_VERSION` |
|
| `orchestrator/package.json` → `packageManager` | `bun@$BUN_VERSION` |
|
||||||
| `apps/frontend/package.json` → `packageManager` | `pnpm@$PNPM_VERSION` |
|
| `apps/frontend/package.json` → `packageManager` | `pnpm@$PNPM_VERSION` |
|
||||||
| `package/config/hy2xs.env` → схема | `HY2XS_CONFIG_SCHEMA_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` |
|
| `apps/go.mod` → директива `go` | `GO_VERSION` |
|
||||||
| `metadata/package.env` | версия, release line, схема, target |
|
| `metadata/package.env` | версия, release line, схема, target |
|
||||||
| `hy2xs-admin version` (готовый бинарь) | `v$HY2XS_VERSION` |
|
| `hy2xs-admin version` (готовый бинарь) | `v$HY2XS_VERSION` |
|
||||||
@@ -113,6 +116,21 @@ HYSTERIA_CHANNEL=stable
|
|||||||
`orchestrator/tools/print-contract.ts`: это доказывает, что в бинарь попало то
|
`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:
|
Версия админки приходит в бинарь через ldflags:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -53,6 +53,86 @@ HY2XS admin работает как надстройка над Hysteria YAML/AP
|
|||||||
|
|
||||||
Относительно конфигурации Hysteria панель **read-only**: конфиг генерирует оркестратор.
|
Относительно конфигурации Hysteria панель **read-only**: конфиг генерирует оркестратор.
|
||||||
|
|
||||||
|
## Сетевая идентичность панели принадлежит оркестратору
|
||||||
|
|
||||||
|
Панель не конфигурирует себя сама.
|
||||||
|
|
||||||
|
| Величина | Источник |
|
||||||
|
| --- | --- |
|
||||||
|
| Порт панели | `HY2XS_UI_PORT` → `ExecStart … -p <port>` |
|
||||||
|
| Адрес привязки | `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
|
## Два слоя работы с конфигом Hysteria
|
||||||
|
|
||||||
Это важное архитектурное разделение.
|
Это важное архитектурное разделение.
|
||||||
@@ -153,6 +233,8 @@ upstream выберет для нового секрета. Список мар
|
|||||||
| `POST /config/restartServer` | systemd |
|
| `POST /config/restartServer` | systemd |
|
||||||
| `POST /config/uploadCertFile` | оператор + оркестратор |
|
| `POST /config/uploadCertFile` | оператор + оркестратор |
|
||||||
| `GET /config/hysteria2AcmePath` | не имел потребителя |
|
| `GET /config/hysteria2AcmePath` | не имел потребителя |
|
||||||
|
| `POST /config/exportConfig` | выгружал JWT- и peer-ключи в открытом виде |
|
||||||
|
| `POST /config/importConfig` | позволял подменить те же ключи |
|
||||||
|
|
||||||
Причины две.
|
Причины две.
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,25 @@ PHASE 1 — MUTATION
|
|||||||
Полный список маркеров чужой установки и порядок очистки —
|
Полный список маркеров чужой установки и порядок очистки —
|
||||||
[14-legacy-cleanup.md](14-legacy-cleanup.md).
|
[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` отвечает на вопрос «эта машина — установка
|
`/var/lib/hy2xs/install-state.json` отвечает на вопрос «эта машина — установка
|
||||||
@@ -112,30 +131,94 @@ PHASE 1 — MUTATION
|
|||||||
|
|
||||||
## Ownership и rollback
|
## Ownership и rollback
|
||||||
|
|
||||||
Операция ведёт учёт того, что она реально успела применить:
|
Операция ведёт учёт того, к чему она **могла прикоснуться**:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
depsInstalled
|
stateWritten
|
||||||
filesystemPrepared
|
depsTouched
|
||||||
unitsDeployed
|
filesystemTouched
|
||||||
|
uiTouched
|
||||||
|
hysteriaTouched
|
||||||
|
configTouched
|
||||||
|
unitsTouched
|
||||||
firewallTouched
|
firewallTouched
|
||||||
postInstallWritten
|
postInstallTouched
|
||||||
|
bootstrapSecretTouched
|
||||||
servicesStarted
|
servicesStarted
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Формулировка выбрана намеренно. Флаг «шаг успешно завершился» отвечает не на
|
||||||
|
тот вопрос: `apt-get install` умеет распаковать половину пакетов и упасть, и
|
||||||
|
хост уже изменён, хотя шаг не закончился. Поэтому **каждый флаг взводится перед
|
||||||
|
мутирующим вызовом**, а не после него.
|
||||||
|
|
||||||
|
`stateWritten` — полноценный участник классификации. `install-state.json`
|
||||||
|
пишется сразу после успешного preflight, до `installDeps`; пока он в
|
||||||
|
классификации не учитывался, падение `apt-get` объявлялось «на сервере ничего
|
||||||
|
не изменено», rollback пропускался, а маркер оставался на хосте и ломал
|
||||||
|
следующую установку по clean-host контракту.
|
||||||
|
|
||||||
Классификация отказа строится **по этим флагам и фазе**, а не по тексту
|
Классификация отказа строится **по этим флагам и фазе**, а не по тексту
|
||||||
сообщения об ошибке. Ранее классификация шла по подстрокам, из-за чего
|
сообщения об ошибке. Ранее классификация шла по подстрокам, из-за чего
|
||||||
preflight-ошибка со словом `nftables` приводила к откату чужого firewall.
|
preflight-ошибка со словом `nftables` приводила к откату чужого firewall.
|
||||||
|
|
||||||
Инварианты rollback:
|
Инварианты rollback:
|
||||||
|
|
||||||
- `fatal_pre_apply` по определению означает «ничего не применялось»:
|
- `fatal_pre_apply` по определению означает «ничего не применялось». Попасть в
|
||||||
system rollback не выполняется, `install-state.json` не пишется,
|
него нельзя ни при одном взведённом флаге, включая `stateWritten`. В этом
|
||||||
|
случае system rollback не выполняется, `install-state.json` не пишется,
|
||||||
diagnostics-бандл не собирается (его сбор сам создал бы каталоги в
|
diagnostics-бандл не собирается (его сбор сам создал бы каталоги в
|
||||||
`/var/log/hy2xs`).
|
`/var/log/hy2xs`).
|
||||||
- `systemctl stop/disable` выполняется **только если текущая операция сама
|
- `systemctl stop/disable` выполняется **только если текущая операция сама
|
||||||
развернула эти unit-файлы**.
|
развернула эти 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
|
||||||
|
|
||||||
На target должен попадать уже готовый package, содержащий:
|
На target должен попадать уже готовый package, содержащий:
|
||||||
|
|||||||
@@ -114,6 +114,29 @@
|
|||||||
3. оператор запускает `reconfigure --dry-run`, затем `reconfigure --apply`;
|
3. оператор запускает `reconfigure --dry-run`, затем `reconfigure --apply`;
|
||||||
4. оркестратор обновляет runtime и перезаписывает snapshot.
|
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_INITIAL_PASSWORD` используется только для первичного bootstrap seed;
|
||||||
- `HY2XS_ADMIN_CON_PASS` — отдельная runtime-сущность для Hysteria auth/smoke;
|
- `HY2XS_ADMIN_CON_PASS` — отдельная runtime-сущность для Hysteria auth/smoke;
|
||||||
|
|||||||
@@ -125,19 +125,28 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
|
|||||||
- чистый хост проходит;
|
- чистый хост проходит;
|
||||||
- **каждый** маркер по отдельности останавливает установку;
|
- **каждый** маркер по отдельности останавливает установку;
|
||||||
- список покрывает состояние, юниты, бинарник Hysteria и наследие 0.x;
|
- список покрывает состояние, юниты, бинарник Hysteria и наследие 0.x;
|
||||||
- пути из конфигурации (`HY2XS_INSTALL_DIR`, `HY2XS_DATA_DIR`) попадают в
|
- пути из конфигурации (`HY2XS_INSTALL_DIR`, `HY2XS_DATA_DIR`, `HY2XS_LOG_DIR`)
|
||||||
список, а не только значения по умолчанию;
|
попадают в список, а не только значения по умолчанию;
|
||||||
- `/usr/local/lib/hy2xs/package` — маркер в PHASE 0, но не в PHASE 1: между
|
- всё, что удаляет `purge-v0.sh`, покрыто маркерами clean-host: два списка
|
||||||
фазами его создаёт сам `install.sh`;
|
описывают одну границу и не имеют права разъезжаться;
|
||||||
|
- пути, созданные `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`:
|
`orchestrator/test/install-boundary.test.ts`:
|
||||||
|
|
||||||
- под read-only guard недоступны `writeText`, `writeTextAtomic`, `runVisible`,
|
- под read-only guard недоступны `writeText`, `writeTextAtomic` и все
|
||||||
`runHidden`, `runRawVisible`;
|
`runMutating*`-раннеры;
|
||||||
|
- read-only раннеры под guard'ом продолжают работать: разделение API — это не
|
||||||
|
запрет наблюдения, а запрет мутации;
|
||||||
- классификация отказа зависит от ownership-флагов и фазы, а **не** от текста
|
- классификация отказа зависит от ownership-флагов и фазы, а **не** от текста
|
||||||
ошибки;
|
ошибки;
|
||||||
- пока операция ничего не применила, отказ — `fatal_pre_apply`.
|
- `fatal_pre_apply` недостижим ни при одном взведённом флаге, включая
|
||||||
|
`stateWritten`: записанный `install-state.json` уже делает хост изменённым;
|
||||||
|
- начатая (не обязательно завершённая) установка пакетов уже даёт
|
||||||
|
`fatal_post_apply` — регрессия на сценарий «PHASE 0 прошла, apt-get упал,
|
||||||
|
установщик заявил, что ничего не тронул».
|
||||||
|
|
||||||
`orchestrator/test/install-state.test.ts`:
|
`orchestrator/test/install-state.test.ts`:
|
||||||
|
|
||||||
@@ -160,7 +169,51 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
|
|||||||
- `acme.dns.config` вырезается целиком;
|
- `acme.dns.config` вырезается целиком;
|
||||||
- невалидный YAML не роняет редакцию и всё равно чистится;
|
- невалидный YAML не роняет редакцию и всё равно чистится;
|
||||||
- секрет внутри URL-значения в env вырезается, даже если имя ключа несекретное
|
- секрет внутри 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)
|
## A7. Контракт версий (build)
|
||||||
|
|
||||||
@@ -248,7 +301,7 @@ trafficStats:
|
|||||||
|
|
||||||
auth:
|
auth:
|
||||||
type == http
|
type == http
|
||||||
url == http://127.0.0.1:<UI_PORT>/hui/hysteria2/auth?access_token=<machine token>
|
url == http://127.0.0.1:<UI_PORT>/internal/hysteria/auth?access_token=<machine token>
|
||||||
insecure == (tlsMode == self_signed_dev)
|
insecure == (tlsMode == self_signed_dev)
|
||||||
|
|
||||||
TLS:
|
TLS:
|
||||||
@@ -332,8 +385,15 @@ idle timeout проходил семантическую проверку. То
|
|||||||
- операционные поля остаются читаемыми;
|
- операционные поля остаются читаемыми;
|
||||||
- вырезаются: obfs-пароль, `trafficStats.secret`, `access_token`, `auth.userpass`, учётные данные ACME DNS, пароли outbound;
|
- вырезаются: 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`) остаются видимыми.
|
- пути к файлам (`tls.key`, `ech.keyPath`, `clientCA`) остаются видимыми.
|
||||||
|
|
||||||
|
Го- и TS-санитайзеры описывают один контракт и покрыты зеркальными тестами:
|
||||||
|
граница определяется значением, а не именем ключа.
|
||||||
|
|
||||||
## D0. Граница установки на живом сервере
|
## D0. Граница установки на живом сервере
|
||||||
|
|
||||||
Проверяется на хосте, где уже стоит предыдущая установка:
|
Проверяется на хосте, где уже стоит предыдущая установка:
|
||||||
@@ -350,6 +410,47 @@ idle timeout проходил семантическую проверку. То
|
|||||||
`/usr/local/lib/hy2xs` и `install-state.json`, а затем откатом останавливал и
|
`/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
|
## D. Negative tests
|
||||||
|
|
||||||
1. не Debian 13
|
1. не Debian 13
|
||||||
@@ -365,6 +466,12 @@ idle timeout проходил семантическую проверку. То
|
|||||||
11. неизвестный `HY2XS_HYSTERIA_OBFS_TYPE`
|
11. неизвестный `HY2XS_HYSTERIA_OBFS_TYPE`
|
||||||
12. конфигурация со схемой `HY2XS_CONFIG_SCHEMA_VERSION` из линейки `0.x`
|
12. конфигурация со схемой `HY2XS_CONFIG_SCHEMA_VERSION` из линейки `0.x`
|
||||||
13. upstream `latest` несовместим с шаблоном HY2XS — падает сборка, не установка
|
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 (обязательные сценарии)
|
## E. Fix20 production matrix (обязательные сценарии)
|
||||||
|
|
||||||
@@ -397,6 +504,14 @@ idle timeout проходил семантическую проверку. То
|
|||||||
- install не падает на race после restart;
|
- install не падает на race после restart;
|
||||||
- readiness waiters дожидаются listener/healthz.
|
- readiness waiters дожидаются listener/healthz.
|
||||||
|
|
||||||
|
8. **Отказ между PHASE 0 и первой мутацией** (сценарий D1):
|
||||||
|
- `install-state.json` честно показывает `failed`;
|
||||||
|
- установщик не заявляет, что хост не изменён.
|
||||||
|
|
||||||
|
9. **Устаревший DNS после смены IPv4** (сценарий D2):
|
||||||
|
- `doctor` и `reconfigure` отказывают;
|
||||||
|
- в выводе присутствуют оба адреса.
|
||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
Система принимается, если:
|
Система принимается, если:
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ sudo -u hy2xs-admin test -r /etc/hysteria/config.yaml
|
|||||||
curl -sS -X POST \
|
curl -sS -X POST \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
--data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' \
|
--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 \
|
curl -sS \
|
||||||
-H "Authorization: <trafficStatsSecret>" \
|
-H "Authorization: <trafficStatsSecret>" \
|
||||||
@@ -230,6 +230,37 @@ ClientAliveCountMax 2
|
|||||||
|
|
||||||
Для production baseline рекомендуется `strict`.
|
Для 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.*` на сервере
|
- `bandwidth.*` на сервере
|
||||||
|
|||||||
@@ -91,6 +91,23 @@ hy2xs-orchestrator reconfigure --package-dir /usr/local/lib/hy2xs/package --conf
|
|||||||
- `warn` — выводится warning и выполнение продолжается;
|
- `warn` — выводится warning и выполнение продолжается;
|
||||||
- `off` — AAAA-проверка игнорируется.
|
- `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
|
## 12. Validation command
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -129,11 +129,12 @@ sudo ./purge-v0.sh
|
|||||||
sudo ./purge-v0.sh --apply --yes-i-know
|
sudo ./purge-v0.sh --apply --yes-i-know
|
||||||
```
|
```
|
||||||
|
|
||||||
Если бинарник Hysteria нужно оставить (например, вы проверяете им что-то ещё):
|
Возможности «оставить бинарник Hysteria» у скрипта нет намеренно.
|
||||||
|
`/usr/local/bin/hysteria` входит в clean-host контракт установщика: сервер, где
|
||||||
```bash
|
он остался, установку HY2XS v1 не пройдёт. Скрипт, который сохранял бы бинарник
|
||||||
sudo ./purge-v0.sh --apply --yes-i-know --keep-hysteria-binary
|
и при этом сообщал «хост чист», прямо противоречил бы следующему запуску
|
||||||
```
|
`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-*` — они переживают
|
2. снимает таймеры отката firewall `hy2xs-fw-rollback-*` — они переживают
|
||||||
неудачную установку и иначе продолжили бы менять ruleset уже после очистки;
|
неудачную установку и иначе продолжили бы менять ruleset уже после очистки;
|
||||||
3. удаляет unit-файлы и выполняет `daemon-reload`;
|
3. удаляет unit-файлы и выполняет `daemon-reload`;
|
||||||
4. удаляет каталоги приложения, конфигурации, данных и логов;
|
4. удаляет каталоги приложения, конфигурации, данных и логов, включая
|
||||||
5. удаляет фрагмент `/etc/nftables.d/hy2xs.nft` и строку `include` для него из
|
`/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;
|
`/etc/nftables.conf`, после чего перезагружает ruleset;
|
||||||
6. проверяет чистоту хоста.
|
7. проверяет чистоту хоста.
|
||||||
|
|
||||||
|
Список удаляемых путей и список legacy-маркеров clean-host контракта описывают
|
||||||
|
одну и ту же границу: расхождение между ними ловится приёмкой сборки. Иначе
|
||||||
|
возможен сервер, с которого «всё удалено», но который установщик всё равно
|
||||||
|
считает грязным — или, что хуже, наоборот.
|
||||||
|
|
||||||
Не делает:
|
Не делает:
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
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"
|
log_step "Acceptance: config schema version is declared"
|
||||||
grep -q '^HY2XS_CONFIG_SCHEMA_VERSION=2$' "$package_dir/config/hy2xs.env" \
|
# Значение берётся из versions.env, а не пишется числом: захардкоженная
|
||||||
|| fail "acceptance: HY2XS_CONFIG_SCHEMA_VERSION must be 2 in the packaged baseline"
|
# двойка означала бы, что при переходе на 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"
|
log_step "Acceptance: fresh install defaults to Gecko obfuscation"
|
||||||
grep -q '^HY2XS_HYSTERIA_OBFS_TYPE=gecko$' "$package_dir/config/hy2xs.env" \
|
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"
|
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"
|
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 '/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}}/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 '^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"
|
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"
|
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 \
|
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"
|
|| 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"
|
log_step "Acceptance: missing config schema marker is treated as legacy"
|
||||||
grep -q 'HY2XS_CONFIG_SCHEMA_VERSION отсутствует' orchestrator/src/config/env.ts \
|
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"
|
|| 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
|
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 \
|
! grep -rqF "${dead_route}\"" apps/router apps/controller \
|
||||||
|| fail "acceptance: removed route ${dead_route} came back"
|
|| fail "acceptance: removed route ${dead_route} came back"
|
||||||
! grep -rqF "${dead_route}\"" apps/frontend/src/api \
|
! grep -rqF "${dead_route}\"" apps/frontend/src/api \
|
||||||
|
|||||||
@@ -111,9 +111,9 @@ bundle_ui() {
|
|||||||
verify_admin_version_contract "$ADMIN_BUILD_DIR/hy2xs-admin"
|
verify_admin_version_contract "$ADMIN_BUILD_DIR/hy2xs-admin"
|
||||||
|
|
||||||
install -m 0755 "$ADMIN_BUILD_DIR/hy2xs-admin" "$STAGE_DIR/ui/hy2xs-admin/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"
|
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
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ verify_archive() {
|
|||||||
|
|
||||||
local hysteria_tpl
|
local hysteria_tpl
|
||||||
hysteria_tpl="$(tar -xOzf "$archive" hy2xs-install/templates/hysteria/config.yaml.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"
|
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
|
local post_install_tpl
|
||||||
post_install_tpl="$(tar -xOzf "$archive" hy2xs-install/templates/env/post-install.env.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
|
local tmp
|
||||||
tmp="$(mktemp -d)"
|
tmp="$(mktemp -d)"
|
||||||
|
|||||||
@@ -164,11 +164,64 @@ verify_orchestrator_contract() {
|
|||||||
HY2XS_TARGET_ARCH)
|
HY2XS_TARGET_ARCH)
|
||||||
expect_equal "orchestrator target arch" "$value" "$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"
|
fail "versions contract: unexpected orchestrator contract line: $line"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done <<<"$output"
|
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() {
|
verify_go_toolchain_contract() {
|
||||||
@@ -206,6 +259,7 @@ verify_versions_contract() {
|
|||||||
"$HY2XS_CONFIG_SCHEMA_VERSION"
|
"$HY2XS_CONFIG_SCHEMA_VERSION"
|
||||||
|
|
||||||
verify_orchestrator_contract
|
verify_orchestrator_contract
|
||||||
|
verify_api_namespace_contract
|
||||||
verify_go_toolchain_contract
|
verify_go_toolchain_contract
|
||||||
|
|
||||||
expect_equal "build host OS" "$HY2XS_BUILD_OS" "debian"
|
expect_equal "build host OS" "$HY2XS_BUILD_OS" "debian"
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ wait_for_port() {
|
|||||||
die "$label did not start listening on port $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 и отказ неразрешённому секрету.
|
# проверку machine access_token и отказ неразрешённому секрету.
|
||||||
start_auth_endpoint() {
|
start_auth_endpoint() {
|
||||||
cat >"$WORK_DIR/auth-server.ts" <<EOF
|
cat >"$WORK_DIR/auth-server.ts" <<EOF
|
||||||
@@ -242,7 +242,7 @@ render_server_config() {
|
|||||||
|
|
||||||
grep -q "type: $obfs_type" "$WORK_DIR/server.yaml" \
|
grep -q "type: $obfs_type" "$WORK_DIR/server.yaml" \
|
||||||
|| die "rendered server config does not use obfs type $obfs_type"
|
|| die "rendered server config does not use obfs type $obfs_type"
|
||||||
grep -q "127.0.0.1:${AUTH_PORT}/hui/hysteria2/auth?access_token=" "$WORK_DIR/server.yaml" \
|
grep -q "127.0.0.1:${AUTH_PORT}/internal/hysteria/auth?access_token=" "$WORK_DIR/server.yaml" \
|
||||||
|| die "server config lost the HY2XS machine auth token"
|
|| die "server config lost the HY2XS machine auth token"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user