fix: закрыть каналы утечки секретов и сделать PHASE 1 владением оркестратора

Hardening-проход перед первой сборкой на Debian. Три из найденного не
воспроизводились ни на одном dry-run и проявились бы только на живом сервере.

Установка

* preflight внутри install вызывался дважды и оба раза проверял clean-host.
  Ко второму вызову на диске лежал собственный /var/lib/hy2xs/install-state.json,
  записанный после первого preflight, и опознавался как маркер посторонней
  установки: КАЖДАЯ чистая установка падала сразу после apt-get с
  fatal_post_apply и оставляла сервер наполовину настроенным. Чистота хоста —
  условие входа в операцию, возможности платформы проверяются уже внутри
  PHASE 1, поэтому checkCleanHost стал отдельным параметром без умолчания.

* PHASE 1 начиналась в install.sh: shell сам создавал /usr/local/lib/hy2xs,
  ставил бинарник, вешал symlink и копировал runtime-пакет, и только потом
  запускал оркестратор с его собственным preflight. Отказ того preflight
  объявлялся fatal_pre_apply — «на сервере ничего не изменено» — при уже
  созданном каталоге оркестратора. Отследить владение мутацией невозможно,
  пока мутируют двое: install.sh больше не изменяет ничего, раскладку
  выполняет steps/bootstrap.ts под ownership.bootstrapTouched, пути попали
  в owned_paths. Как следствие удалено деление clean-host на фазы.

* diagnosticsCollect стояла перед rollback обычным await в install и в
  reconfigure. На заполненном диске она падает сама и отменяла откат целиком.
  Диагностика — best effort, откат — обязателен.

* reconfigure/repair выбирали записываемую фазу отказа регулярным выражением
  по тексту ошибки. Переведено на ownership-флаги.

Секреты

* Журнал админки писал RequestURI, то есть путь вместе с query. Hysteria
  обращается к /internal/hysteria/auth?access_token=<секрет> при каждом
  подключении пира, поэтому действующий machine token оседал открытым текстом
  в hy2xs-admin.log, который отдаётся через ExportLog и попадает в
  diagnostics-бандл. Логируется путь; значения query не пишутся, имена —
  пишутся. Канала было два: gin.Default() печатает path?query в stdout,
  оттуда в journald и в тот же бандл, — панель переведена на gin.New() +
  Recovery(). Журналы внутри бандла и журнал Hysteria из ExportLog теперь
  проходят санитайз. Сравнение токена — constant time.

* Config API позволял прочитать и подменить ключи приложения: getConfig и
  listConfig принимали произвольный ключ, а проверка записи была denylist'ом
  из трёх ключей оркестратора. Запрос ?key=PEER_SECRET_ENCRYPTION_KEY отдавал
  master-key шифрования секретов пиров. Доступ переведён на allowlist, маршрут
  getConfig удалён целиком — потребителей у него не было ни одного.

Пиры

* Импорт применялся по одной записи вне транзакции, вопреки собственному
  контракту. Валидация не знает, что уже лежит в базе: cross-conflict по
  UNIQUE(name) оставлял часть файла применённой. Применение выполняется одной
  транзакцией, криптоматериал считается до её открытия.

* Файл импорта мог содержать хвостовой JSON-документ, который молча не
  применялся. После разбора проверяется io.EOF.

* Экспорт разделён на «Экспорт настроек» и «Резервная копия» с секретами и
  подтверждением: обычный экспорт выдаёт пирам новые секреты при импорте, и
  прежние клиентские ссылки после переноса переставали работать.

Сборка

* Два stale-грепа в приёмке роняли build.sh в самом конце, внутри
  verify_archive. Первый искал в smoke.ts исчезнувший литерал URL, второй
  совпадал с router_test.go, который перечисляет удалённые маршруты, потому
  что проверяет их отсутствие: добавление регрессионного теста ломало сборку.

* verify_archive требовал наличия мутирующей строки в install.sh. Инвариант
  перевёрнут: их не должно быть ни одной.

Очистка

* Удалены entity.LegacyAccount, миграции 002/003 и мёртвые хелперы
  listSQLMigrationFiles и envInt: v1 не мигрирует базу 0.x ни при каком
  сценарии. Номера оставшихся миграций сохранены. H UI-словарь убран из
  обычных доков, в docs/14 он остаётся — там это имена объектов для удаления.

* Список непубличных IPv4 приведён к IANA Special-Purpose Address Registry:
  203.0.113.5 из RFC-примеров считался публичным адресом сервера. Отказ
  резолвера отделён от отсутствия A-записи.

Проверено: bun test 233, go test 71, tsc/vue-tsc, bash -n 11 скриптов,
приёмка прогнана против дерева.
This commit is contained in:
2026-08-28 05:27:10 +05:00
parent 5574b7c89a
commit 672d455467
55 changed files with 3161 additions and 582 deletions
+118 -14
View File
@@ -66,18 +66,20 @@ HY2XS admin работает как надстройка над Hysteria YAML/AP
| Маршрут панели | всегда `/` |
| 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 эти величины дублировались в таблице `config` собственными ключами
панели: оркестратор передавал порт аргументом, панель записывала его в SQLite и
тут же читала обратно, а UI показывал поля в disabled-виде. Ни одного факта база
при этом не добавляла — это был второй источник истины без содержания.
В v1 этих ключей нет ни в схеме, ни в seed, ни в интерфейсе. Собственного
В v1 таких ключей нет ни в схеме, ни в seed, ни в интерфейсе. Собственного
TLS-слоя у панели тоже нет: production-контракт — `HY2XS_UI_BIND_HOST=127.0.0.1`
и `HY2XS_UI_PUBLIC_ACCESS=false`, то есть внутренний сервис. Если панели
когда-нибудь понадобится публичный endpoint, TLS обязан заканчиваться на
ingress/reverse-proxy, а не возвращаться к модели H UI.
ingress/reverse-proxy, а не возвращаться к модели «панель публикует себя сама».
Имена ключей предыдущего поколения намеренно не приводятся: в обычных v1-доках
их словаря нет. Всё, что нужно для распознавания и удаления старой установки, —
в [14-legacy-cleanup.md](14-legacy-cleanup.md).
## Пространства имён HTTP API
@@ -90,7 +92,7 @@ ingress/reverse-proxy, а не возвращаться к модели H UI.
Разделение отражает разницу в природе маршрутов. `/internal/hysteria/auth`
не интерфейс для человека и не часть операторского API: это внутренний
IPC-подобный HTTP endpoint между двумя процессами на одной машине. До v1 он
лежал под тем же префиксом `hui`, что и JWT-защищённый админский API, хотя
лежал под тем же префиксом, что и JWT-защищённый админский API, хотя
middleware у них не пересекаются.
Путь machine-auth — **runtime-контракт продукта**: он записывается в
@@ -99,11 +101,43 @@ middleware у них не пересекаются.
`HYSTERIA_MACHINE_AUTH_PATH` в оркестраторе, — а сборка сверяет их между собой
и с шаблонами.
## Журнал запросов не содержит значений query-параметров
Hysteria обращается к машинному endpoint'у как
`/internal/hysteria/auth?access_token=<machine token>` — при каждом подключении
пира. Поэтому в журнале админки пишется **путь**, а не `RequestURI`:
```json
{ "reqMethod": "POST", "reqPath": "/internal/hysteria/auth", "reqQueryKeys": "access_token" }
```
Пока логировался `RequestURI`, действующий machine token оседал открытым
текстом в `/var/log/hy2xs/hy2xs-admin.log`. Этот файл отдаётся оператору через
`ExportLog` и попадает в diagnostics-бандл, то есть секрет утекал наружу в
штатном режиме работы — мимо всей структурной редакции, сделанной для конфигов
и env.
Значения query-параметров не логируются вовсе: список «что можно» пришлось бы
вести вручную, и он неизбежно разошёлся бы с набором маршрутов. Имена
параметров сохранены — для диагностики их достаточно.
Каналов журналирования у панели ровно один. Админка запускается через
`gin.New()` + `gin.Recovery()`, а не `gin.Default()`: штатный `gin.Logger()`
печатает путь **вместе с query string** в stdout, откуда он уходит в journald, а
оттуда — в diagnostics-бандл. Это был второй, независимый канал той же утечки, и
починка собственного логгера его бы не закрыла.
Журнал Hysteria (`ExportLog`, вкладка логов) проходит через санитайз
`service.SanitizeLogText`: `HY2_AUTH_URL` несёт `access_token`, и upstream волен
упомянуть его в сообщении об ошибке обращения к auth-backend. Санитайз
сохраняет host, port и path — диагностика от него не страдает. Тот же проход
применяется к `journal-*.log` внутри diagnostics-бандла оркестратора.
## Импорт и экспорт
| Операция | Статус |
| --- | --- |
| Экспорт пиров (`POST /api/peer-export`) | есть |
| Экспорт пиров (`POST /api/peer-export`) | есть, в двух режимах |
| Импорт пиров (`POST /api/peer-import`) | есть |
| Экспорт конфига Hysteria (`POST /api/config/exportHysteria2Config`) | есть, с вырезанием секретов |
| Экспорт/импорт таблицы `config` | **удалён** |
@@ -118,21 +152,91 @@ Hysteria YAML. В той же таблице лежат `JWT_SECRET`, `PEER_SECR
Осмысленного production-сценария у этой пары не было: конфигурацией сервера
владеет оркестратор, перенос пиров делают `peer-import`/`peer-export`.
### Точечный доступ к таблице `config` — по allowlist
Удаления generic-пары оказалось недостаточно. Опасность осталась в точечном
API: `getConfig` и `listConfig` принимали произвольный ключ, а проверка записи
работала denylist'ом из трёх ключей оркестратора. То есть авторизованный запрос
`?key=PEER_SECRET_ENCRYPTION_KEY` отдавал master-key шифрования секретов пиров,
а `updateConfigs` позволял подменить `JWT_SECRET` и оба peer-ключа. Отверстие
сменило размер, но не исчезло.
В v1:
| Ключ | Чтение | Запись |
| --- | --- | --- |
| `HYSTERIA2_TRAFFIC_TIME` | да | да |
| `RESET_TRAFFIC_CRON` | да | да |
| `HYSTERIA2_CONFIG_REMARK` | да | нет |
| `HYSTERIA2_ENABLE`, `HYSTERIA2_CONFIG`, `HYSTERIA2_TRAFFIC_STATS_SECRET` | нет | нет, владелец — оркестратор |
| `JWT_SECRET`, `PEER_SECRET_KEY`, `PEER_SECRET_ENCRYPTION_KEY` | нет | нет |
| любой другой | нет | нет |
Список — **allowlist**, и это структурное решение, а не стилистическое.
Denylist требует, чтобы автор каждого нового ключа вспомнил про этот файл:
забытый ключ при denylist сразу публичен, при allowlist — сразу закрыт. Отказ
по умолчанию не зависит от внимательности.
Маршрут `GET /api/config/getConfig` **удалён целиком**: потребителей у него не
было ни одного, а фильтр на неиспользуемой двери — это по-прежнему дверь. Право
записи `HYSTERIA2_CONFIG_REMARK` тоже убрано: панель его только отображает.
Ключи оркестратора отклоняются отдельным сообщением, называющим владельца, —
«этим значением владеет оркестратор» это другой ответ, чем «такого ключа нет»,
и он ведёт оператора к `hy2xs-orchestrator reconfigure`.
Оба оставшихся экспорта формируются **в памяти** и отдаются прямо в ответ.
Раньше они шли через `os.Create` в `/var/lib/hy2xs-admin/export/`, и файл там
оставался навсегда — при `?includeSecrets=true` это означало расшифрованные
секреты пиров на диске, накапливающиеся с каждым нажатием кнопки. Каталога
`export/` больше не существует.
Импорт пиров проверяется так же строго, как обычное создание пира: те же
правила для имени, quota, `maxDevices`, `disabled`, длины секрета. Дополнительно:
### Экспорт пиров: два режима, а не флаг
| Кнопка | Запрос | Что внутри |
| --- | --- | --- |
| **Экспорт настроек** | `POST /api/peer-export` | список пиров без секретов |
| **Резервная копия** | `POST /api/peer-export?includeSecrets=true` | то же плюс действующие секреты подключения |
Разница здесь продуктовая, а не техническая, и её нельзя оставлять неявной.
Записи с пустым секретом при импорте получают **новые** секреты. То есть
перенос обычным экспортом восстанавливает пиров, их квоты, лимиты и счётчики —
но все существующие клиентские ссылки после него перестают работать.
Раньше кнопка в панели была одна и всегда звала маршрут без `includeSecrets`,
хотя документация называла эту пару механизмом переноса пиров. Оператор
переносил пиров и обнаруживал, что все клиенты отвалились.
Резервная копия содержит фактические учётные данные доступа к VPN в открытом
виде, поэтому запускается только через явное подтверждение с описанием риска.
Такой файл следует хранить как пароль и удалять после завершения переноса.
### Импорт пиров
Импорт проверяется так же строго, как обычное создание пира: те же правила для
имени, quota, `maxDevices`, `disabled`, длины секрета. Дополнительно:
- неизвестные поля в JSON отклоняются, а не игнорируются молча;
- партия проверяется целиком **до** первой записи в базу — файл применяется
полностью или не применяется вовсе;
- файл обязан содержать **ровно один** JSON-документ. `json.Decoder` читает
первый документ и останавливается, поэтому файл с хвостом принимался целиком,
а его вторая половина молча не применялась;
- партия проверяется целиком **до** первой записи в базу;
- применение идёт **одной транзакцией**;
- пир `bootstrap-admin-peer` защищён от перезаписи: его секрет продублирован
в `/etc/hy2xs/bootstrap-admin.secret`.
Транзакция — не дублирование проверки, а закрытие другого класса отказов.
Валидация проверяет содержимое файла и ничего не знает о том, что уже лежит в
базе. Пусть существуют `A(auth_id=aaa, name=alice1)` и
`B(auth_id=bbb, name=bob123)`, а файл несёт `(auth_id=aaa, name=bob123)`: поиск
найдёт A по `auth_id` и попытается переименовать её в `bob123` — прямо в
`UNIQUE(name)`. Пока записи применялись по одной, всё, что шло в файле до
конфликтной строки, оставалось применённым, и откатить это оператор уже не мог.
Криптоматериал (digest и шифртекст секретов) считается **до** открытия
транзакции: эти операции читают ключи из той же таблицы `config`, и держать на
ней открытую запись во время AES по каждой из тысяч записей незачем.
## Два слоя работы с конфигом Hysteria
Это важное архитектурное разделение.
+43 -6
View File
@@ -54,7 +54,7 @@
Установка разделена на две фазы с жёсткой границей между ними:
```text
PHASE 0 — READ ONLY
PHASE 0 — READ ONLY владелец: install.sh
проверка прав
sha256sum -c metadata/checksums.txt
./orchestrator/hy2xs-orchestrator preflight-install --package-dir <распакованный пакет>
@@ -63,11 +63,11 @@ PHASE 0 — READ ONLY
└── валидация конфигурации
↓ ноль persistent writes
PHASE 0 PASSED
PHASE 1 — MUTATION
install -d /usr/local/lib/hy2xs
раскладка оркестратора и runtime-пакета
hy2xs-orchestrator install
exec
PHASE 1 — MUTATION владелец: оркестратор
preflight (clean-host — последний раз за операцию)
bootstrapRuntime: /usr/local/lib/hy2xs, symlink, runtime-пакет
installDeps → filesystem → UI → Hysteria → config → units → firewall → smoke
```
Ключевые свойства:
@@ -82,6 +82,43 @@ PHASE 1 — MUTATION
`install-state.json`**. Отказ на этом этапе означает, что на сервере не
изменено ничего.
### У мутации ровно один владелец
`install.sh` не изменяет на сервере ничего. Он проверяет и делает `exec`.
Раньше PHASE 1 начиналась в shell: установщик сам создавал
`/usr/local/lib/hy2xs`, ставил туда бинарник, вешал symlink и копировал
runtime-пакет, и только после этого запускал оркестратор, который выполнял
собственный preflight. Между двумя фазами возникало окно: если второй preflight
отказывал — сменился DNS, занялся порт, не ответил резолвер, — у оркестратора не
был взведён ни один флаг владения, отказ классифицировался как
`fatal_pre_apply`, и оператор читал «на сервере ничего не изменено». Хост при
этом уже нёс каталог оркестратора, symlink и runtime-пакет, а следующий запуск
упирался в них как в маркеры чужой установки.
Владение мутацией невозможно отследить, пока мутируют двое. Поэтому раскладку
выполняет шаг `steps/bootstrap.ts` под флагом `ownership.bootstrapTouched`, и
эти пути попадают в `owned_paths` install-state наравне со всеми остальными.
Сборка проверяет структурно, что в `install.sh` не осталось ни одной мутирующей
команды.
### clean-host проверяется до первой мутации и только там
`preflight()` принимает `checkCleanHost` явно, без значения по умолчанию.
Причина в том, что clean-host — условие **входа** в операцию, а проверка
возможностей платформы (`systemd-run`, `nftables`, OpenSSL 3) выполняется уже
после `installDeps`, то есть внутри PHASE 1. Пока обе проверки ехали одним
параметром, `install` вызывал preflight дважды и оба раза с включённым
clean-host. Ко второму вызову на диске лежал собственный
`/var/lib/hy2xs/install-state.json`, записанный после первого preflight, — и он
опознавался как маркер посторонней установки. Каждая чистая установка падала
сразу после `apt-get`, получала `fatal_post_apply` и оставляла сервер
наполовину настроенным.
По той же причине у списка маркеров больше нет «мягкой» версии для PHASE 1:
пути, которые раньше приходилось исключать, теперь создаются после проверки.
Полный список маркеров чужой установки и порядок очистки —
[14-legacy-cleanup.md](14-legacy-cleanup.md).
+144 -12
View File
@@ -129,9 +129,13 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
попадают в список, а не только значения по умолчанию;
- всё, что удаляет `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;
- bootstrap-пути (`/usr/local/lib/hy2xs`, `/usr/local/lib/hy2xs/package`,
`/usr/local/bin/hy2xs-orchestrator`) остаются маркерами **без исключений**:
их создаёт оркестратор уже после проверки чистоты хоста, поэтому «мягкой»
версии списка для PHASE 1 больше не существует;
- эти пути берутся из `config/profile.ts`, а не из копий строк: шаг, который
их создаёт, и контракт, который на них отказывает, обязаны читать одно
значение;
- сообщение перечисляет найденные маркеры и говорит, что хост не изменён.
`orchestrator/test/install-boundary.test.ts`:
@@ -146,7 +150,29 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
`stateWritten`: записанный `install-state.json` уже делает хост изменённым;
- начатая (не обязательно завершённая) установка пакетов уже даёт
`fatal_post_apply` — регрессия на сценарий «PHASE 0 прошла, apt-get упал,
установщик заявил, что ничего не тронул».
установщик заявил, что ничего не тронул»;
- начатый bootstrap (`bootstrapTouched`) тоже даёт `fatal_post_apply`: раскладку
выполняет оркестратор, и она учитывается наравне с остальными шагами.
`orchestrator/test/install-sequence.test.ts` — порядок фаз, который иначе
проверяется только на живом сервере:
- `preflight()` в режиме install **отказывается работать без явного
`checkCleanHost`**, и отказ наступает до любой работы с системой;
- clean-host запрашивается ровно один раз за операцию и **до** первой записи
install-state — регрессия на сценарий, где повторный preflight после
`installDeps` опознавал собственный `install-state.json` как маркер чужой
установки и валил каждую чистую установку;
- проход capabilities явно отказывается от clean-host;
- `bootstrapTouched` взводится **перед** `bootstrapRuntime`, а сам bootstrap
идёт до `installDeps`;
- дальнейшая установка работает от установленного runtime-пакета;
- `diagnosticsCollect` обёрнута в `try/catch`, и `catch` стоит **до** отката:
диагностика — best effort, откат — обязателен;
- в `package/install.sh` не осталось ни одной мутирующей команды, и он
передаёт управление оркестратору через `exec`;
- классификация отказа `reconfigure`/`repair` идёт по ownership-флагам, а не по
регулярному выражению над текстом ошибки.
`orchestrator/test/install-state.test.ts`:
@@ -171,7 +197,26 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
- секрет внутри URL-значения в env вырезается, даже если имя ключа несекретное
(`HY2_AUTH_URL`);
- URL под **произвольным** именем ключа теряет встроенные учётные данные и
секретные query-параметры, но сохраняет адрес; то же для URL внутри списка.
секретные query-параметры, но сохраняет адрес; то же для URL внутри списка;
- `redactLogText` вырезает machine token из строки journald, сохраняя host,
port и path; ловит секрет и вне URL; не трогает обычные строки; сохраняет
хвостовую пунктуацию; идемпотентен — регрессия на diagnostics-бандл, где
редактировались env и YAML, а `journal-admin.log` копировался как есть.
## A7. Machine token в журналах (unit)
`apps/middleware/log_test.go` — запрос
`/internal/hysteria/auth?access_token=SUPER_SECRET_SENTINEL`:
- sentinel **не появляется** в журнале ни в каком виде;
- в журнале есть `reqPath`, поля `reqUri` нет;
- имя query-параметра сохраняется (`reqQueryKeys`), значение — нет;
- пустой список параметров в журнал не пишется;
- то же правило действует на операторских маршрутах, а не только на машинном.
`apps/service/log_sanitize_test.go` — тот же санитайз на стороне админки: журнал
Hysteria покидает сервер через `ExportLog`, а `HY2_AUTH_URL` несёт
`access_token`, который upstream волен упомянуть в сообщении об ошибке.
## A8. Инвариант публичного endpoint (unit)
@@ -193,10 +238,23 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
| нет ни одного локального публичного IPv4 | FAIL |
| `HY2XS_PUBLIC_ENDPOINT_POLICY` = strict / warn / off | fail / warn / skip |
| отсутствие A-записи при любой политике | FAIL |
| отказ резолвера (SERVFAIL/таймаут/отказ) при любой политике | FAIL, отдельный текст |
Отдельно проверяется классификация IPv4: приватные, CGNAT, link-local,
multicast и reserved диапазоны не считаются публичным адресом сервера, а
`172.32.0.0` и `172.15.255.255` — считаются (границы `172.16/12`).
Отдельно проверяется классификация IPv4. Список исключений приведён к IANA
Special-Purpose Address Registry: приватные, CGNAT, link-local, multicast,
reserved, benchmarking (`198.18/15`), 6to4-anycast и **документационные**
диапазоны (`192.0.2/24`, `198.51.100/24`, `203.0.113/24`) не считаются
публичным адресом сервера. Регрессия: `203.0.113.5` из RFC-примеров раньше
проходил проверку как обычный публичный адрес. Границы проверяются с обеих
сторон — `172.32.0.0`, `192.0.1.1`, `198.20.0.1` и `203.0.112.255` считаются
публичными.
Отказ резолвера отделён от отсутствия записи: `ENODATA`/`ENOTFOUND`/`NXDOMAIN`
— это «нет A-записи» и чинится в DNS-панели, всё остальное — «резолвер не
ответил» и чинится в `/etc/resolv.conf`. Раньше оба случая печатались как
«has no A-record», и при сломанном резолвере оператор шёл править запись,
которая была на месте. Фатальны оба: без ответа резолвера проверка не выполнена,
а не «выполнена с замечанием».
## A9. Регистрация маршрутов (unit)
@@ -209,9 +267,34 @@ wildcard-маршрутом фронтенда или дублирующая р
- machine-auth зарегистрирован ровно на `constant.HysteriaMachineAuthPath`;
- операторский и auth API — под `constant.AdminAPIBase`;
- ни один маршрут не начинается со старого пространства имён;
- удалённые маршруты (включая `exportConfig`/`importConfig`) не вернулись;
- удалённые маршруты (включая `exportConfig`/`importConfig` и `getConfig`) не
вернулись;
- пространство `/api/config` закрыто: в нём ровно четыре маршрута, и любой
новый обязан быть добавлен в тест осознанно;
- `/healthz` на месте.
## A9a. Доступ к таблице `config` (unit)
`apps/model/constant/config_test.go` — allowlist как структура, а не как
соглашение:
- ни один внутренний ключ не читается и не записывается через API;
- `JWT_SECRET`, `PEER_SECRET_KEY`, `PEER_SECRET_ENCRYPTION_KEY` и
`HYSTERIA2_TRAFFIC_STATS_SECRET` поимённо объявлены внутренними;
- пользовательские настройки остаются доступными;
- множество записываемых ключей — подмножество читаемых;
- неизвестный ключ закрыт **по умолчанию**: забытый при denylist ключ был бы
сразу публичным.
`apps/controller/config_test.go` — то же на уровне HTTP:
- чтение и запись каждого секрета отклоняются;
- секрет, спрятанный среди разрешённых ключей, отклоняет весь запрос;
- отказ наступает **до** обращения к базе (тест работает без SQLite — сам факт,
что обработчик не падает, это и доказывает);
- ключи оркестратора отклоняются с указанием владельца, а не общим «нет такого
ключа»: оператор должен быть отправлен к `hy2xs-orchestrator reconfigure`.
## A10. Импорт пиров (unit)
`apps/service/peer_import_test.go`:
@@ -229,6 +312,30 @@ wildcard-маршрутом фронтенда или дублирующая р
- невалидная **последняя** запись отклоняет весь файл: импорт применяется
целиком или не применяется вовсе.
`apps/service/peer_import_tx_test.go` — та же гарантия уже на уровне базы, на
настоящей SQLite. Валидация не даёт применить испорченный файл, но она ничего
не говорит о конфликте с тем, что УЖЕ лежит в базе:
- валидная партия применяется целиком;
- **cross-conflict откатывается полностью**: пусть в базе есть
`A(auth_id=aaa, name=alice1)` и `B(auth_id=bbb, name=bob123)`, а файл несёт
`(auth_id=aaa, name=bob123)` — поиск найдёт A по `auth_id` и попытается
переименовать её в `bob123`, прямо в `UNIQUE(name)`. Записи, шедшие в файле
до конфликтной, не должны остаться применёнными. Тест дополнительно
убеждается, что отказ пришёл **из базы**, а не из валидации;
- дубликат `auth_id` на вставке ведёт себя так же;
- отказ валидации не доходит до базы вовсе;
- пир установщика защищён и внутри транзакции;
- обновление без секрета в файле не перезаписывает существующий секрет.
`apps/controller/peer_test.go` — разбор загруженного файла:
- файл с **хвостовым** JSON-документом отклоняется: `json.Decoder` читает первый
документ и останавливается, поэтому раньше оператор видел «импорт выполнен»,
а вторая половина файла молча не применялась;
- неизвестные поля и файл не с расширением `.json` отклоняются;
- корректный одиночный документ доходит до базы и создаёт пира.
## A7. Контракт версий (build)
Шаг `verify_versions_contract` (`tools/build/lib/versions.sh`) роняет сборку до
@@ -436,14 +543,39 @@ idle timeout проходил семантическую проверку. То
4. в выводе **нет** `fatal_pre_apply` и нет фразы про «ничего не применялось»;
5. `/var/lib/hy2xs/install-state.json` существует и честно показывает
`phase: failed` с текстом ошибки;
6. diagnostics-бандл собран;
7. `hy2xs-orchestrator status` не заявляет установку успешной.
6. `owned_paths` в маркере содержит `/usr/local/lib/hy2xs`,
`/usr/local/bin/hy2xs-orchestrator` и `/usr/local/lib/hy2xs/package`
всё, что операция действительно создала;
7. diagnostics-бандл собран;
8. `hy2xs-orchestrator status` не заявляет установку успешной.
До исправления шаги 4–6 давали противоположный результат: `install-state.json`
До исправления шаги 4–7 давали противоположный результат: `install-state.json`
уже лежал на диске, но отказ классифицировался как pre-apply, обработка
состояния пропускалась, а следующая установка на этой машине отказывалась по
clean-host контракту из-за оставшегося маркера.
Пункт 6 закрывает вторую половину той же щели. Пока раскладку оркестратора и
runtime-пакета выполнял `install.sh`, эти пути не принадлежали никому: они не
попадали в `owned_paths`, а отказ **второго** preflight (сменился DNS, занялся
порт, не ответил резолвер) объявлялся `fatal_pre_apply` — «на сервере ничего не
изменено» — при уже созданном каталоге оркестратора.
## D1a. Проход установки не спотыкается о собственный маркер
Проверяется на чистом хосте, обычной успешной установкой.
1. `install.sh` доходит до `preflight capabilities` **после** `apt-get`;
2. установка на этом шаге **не** падает с текстом «обнаружена предыдущая или
посторонняя установка»;
3. установка доходит до `installed`.
Это сценарий, который не воспроизводится ни на одном dry-run: clean-host внутри
`install` проверялся дважды, и ко второму разу на диске уже лежал собственный
`/var/lib/hy2xs/install-state.json`, записанный после первого preflight. Каждая
чистая установка падала сразу после `apt-get`, получала `fatal_post_apply` и
оставляла сервер наполовину настроенным. Структурно закреплено в
`orchestrator/test/install-sequence.test.ts`.
## D2. Устаревший DNS после смены IPv4 провайдером
Проверяется на рабочей установке.
+4 -2
View File
@@ -44,8 +44,10 @@
которые она не разворачивала, не останавливаются никогда.
7. Установка двухфазная: **PHASE 0 — read only**, **PHASE 1 — mutation**.
До успешного clean-host preflight на сервере не изменяется ни один
persistent path. Очистка предыдущей установки — отдельная явная операция
оператора, см. [14-legacy-cleanup.md](14-legacy-cleanup.md).
persistent path. У мутирующей фазы ровно один владелец — оркестратор:
`install.sh` проверяет и передаёт управление, не изменяя ничего сам.
Очистка предыдущей установки — отдельная явная операция оператора,
см. [14-legacy-cleanup.md](14-legacy-cleanup.md).
8. Выдача доступа пользователям, Telegram-бот, billing, backend профилей и похожие контуры **не входят** в этот baseline.
## Состав документов