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:
@@ -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 провайдером
|
||||
|
||||
Проверяется на рабочей установке.
|
||||
|
||||
Reference in New Issue
Block a user