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

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

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

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

Документация: контракт раннеров и ownership в 08, инвариант публичного
endpoint в 08/09/12/13 и README, сетевая идентичность панели и удалённые
export/import в 04, сценарии D1 (отказ сразу после PHASE 0) и D2 (устаревший
DNS после смены IPv4) в 11, версии package.json как не-версия продукта в 02.
This commit is contained in:
2026-08-27 20:50:03 +05:00
parent a88268b0cd
commit 086b5d6624
15 changed files with 740 additions and 37 deletions
+19 -1
View File
@@ -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
+82
View File
@@ -53,6 +53,86 @@ HY2XS admin работает как надстройка над Hysteria YAML/AP
Относительно конфигурации 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
Это важное архитектурное разделение.
@@ -153,6 +233,8 @@ upstream выберет для нового секрета. Список мар
| `POST /config/restartServer` | systemd |
| `POST /config/uploadCertFile` | оператор + оркестратор |
| `GET /config/hysteria2AcmePath` | не имел потребителя |
| `POST /config/exportConfig` | выгружал JWT- и peer-ключи в открытом виде |
| `POST /config/importConfig` | позволял подменить те же ключи |
Причины две.
+90 -7
View File
@@ -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, содержащий:
+23
View File
@@ -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;
+124 -9
View File
@@ -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:<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)
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
Система принимается, если:
+32 -1
View File
@@ -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: <trafficStatsSecret>" \
@@ -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.*` на сервере
+17
View File
@@ -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
+17 -8
View File
@@ -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 контракта описывают
одну и ту же границу: расхождение между ними ловится приёмкой сборки. Иначе
возможен сервер, с которого «всё удалено», но который установщик всё равно
считает грязным — или, что хуже, наоборот.
Не делает: