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:
+145
@@ -14,6 +14,103 @@ Hardening-проход перед релизом `1.0.0`. Основная те
|
|||||||
|
|
||||||
### Исправлено
|
### Исправлено
|
||||||
|
|
||||||
|
- **Каждая чистая установка падала сразу после `apt-get`.** Внутри `install`
|
||||||
|
`preflight()` вызывался дважды, и оба раза проверял контракт чистого хоста.
|
||||||
|
Ко второму вызову на диске уже лежал собственный
|
||||||
|
`/var/lib/hy2xs/install-state.json`, записанный после первого preflight, — и
|
||||||
|
он опознавался как маркер посторонней установки. Отказ приходил уже как
|
||||||
|
`fatal_post_apply`: сервер оставался наполовину настроенным, а повторный
|
||||||
|
запуск упирался в тот же маркер.
|
||||||
|
|
||||||
|
Причина в том, что clean-host и проверка возможностей платформы ехали одним
|
||||||
|
параметром, хотя отвечают на разные вопросы: чистота хоста — условие **входа**
|
||||||
|
в операцию, а `systemd-run`/`nftables`/OpenSSL 3 проверяются уже после
|
||||||
|
`installDeps`, то есть внутри PHASE 1. `preflight()` теперь принимает
|
||||||
|
`checkCleanHost` явно и **без значения по умолчанию** в режиме install: любое
|
||||||
|
умолчание здесь неверно, решение обязано приниматься на месте вызова.
|
||||||
|
|
||||||
|
- **PHASE 1 начиналась вне зоны ответственности оркестратора.** `install.sh`
|
||||||
|
сам создавал `/usr/local/lib/hy2xs`, ставил туда бинарник, вешал symlink в
|
||||||
|
`/usr/local/bin` и копировал runtime-пакет — и только потом запускал
|
||||||
|
оркестратор, у которого дальше шёл собственный preflight. Если тот отказывал
|
||||||
|
(сменился DNS, занялся порт, не ответил резолвер), ни один ownership-флаг не
|
||||||
|
был взведён: отказ классифицировался как `fatal_pre_apply`, и оператор читал
|
||||||
|
«на сервере ничего не изменено» при уже созданном каталоге оркестратора.
|
||||||
|
Следующий запуск упирался в эти пути как в маркеры чужой установки.
|
||||||
|
|
||||||
|
Отследить владение мутацией невозможно, пока мутируют двое. Теперь
|
||||||
|
`install.sh` не изменяет на сервере **ничего**: он проверяет и передаёт
|
||||||
|
управление через `exec`. Раскладку выполняет сам оркестратор — шаг
|
||||||
|
`steps/bootstrap.ts` под флагом `ownership.bootstrapTouched`, а сами пути
|
||||||
|
попадают в `owned_paths` install-state наравне с остальными. Сборка проверяет
|
||||||
|
структурно, что в установщике не осталось ни одной мутирующей команды.
|
||||||
|
|
||||||
|
Побочный эффект: у списка clean-host маркеров больше нет «мягкой» версии для
|
||||||
|
PHASE 1. Она существовала только затем, чтобы установка не отказала на путях,
|
||||||
|
которые shell создал между фазами.
|
||||||
|
|
||||||
|
- **Machine token утекал в обычные логи при каждом подключении пира.** Hysteria
|
||||||
|
обращается к машинному endpoint'у как
|
||||||
|
`/internal/hysteria/auth?access_token=<секрет>`, а журнал админки писал
|
||||||
|
`c.Request.RequestURI` — то есть путь вместе с query string. Действующий
|
||||||
|
токен оседал открытым текстом в `/var/log/hy2xs/hy2xs-admin.log`, который
|
||||||
|
отдаётся оператору через `ExportLog` и попадает в diagnostics-бандл. Вся
|
||||||
|
структурная редакция, сделанная для конфигов и env, этот канал не закрывала.
|
||||||
|
|
||||||
|
Логируется путь; значения query-параметров не пишутся вовсе, имена —
|
||||||
|
пишутся (`reqQueryKeys`). Поле `reqUri` удалено из модели журнала.
|
||||||
|
|
||||||
|
Каналов было два: `gin.Default()` подключает `gin.Logger()`, который печатает
|
||||||
|
путь вместе с query в stdout, откуда он уходит в journald, а оттуда — в
|
||||||
|
diagnostics-бандл. Панель запускается через `gin.New()` + `gin.Recovery()`,
|
||||||
|
и HTTP-логгер у продукта остался ровно один.
|
||||||
|
|
||||||
|
Дополнительно: журналы внутри diagnostics-бандла (`journal-admin.log`,
|
||||||
|
`journal-hysteria.log`, вывод `systemctl status`) больше не копируются как
|
||||||
|
есть, а проходят санитайз; тот же проход применяется к журналу Hysteria,
|
||||||
|
который админка отдаёт через `ExportLog`. Сравнение machine token переведено
|
||||||
|
на `subtle.ConstantTimeCompare`.
|
||||||
|
|
||||||
|
- **Config API позволял прочитать и подменить криптографические ключи
|
||||||
|
приложения.** Generic export/import таблицы `config` удалили, но точечный API
|
||||||
|
остался прежним: `getConfig`/`listConfig` принимали произвольный ключ, а
|
||||||
|
проверка записи работала denylist'ом из трёх ключей оркестратора. Запрос
|
||||||
|
`?key=PEER_SECRET_ENCRYPTION_KEY` отдавал master-key шифрования секретов
|
||||||
|
пиров, а `updateConfigs` позволял подменить `JWT_SECRET` и оба peer-ключа.
|
||||||
|
|
||||||
|
Доступ переведён на **allowlist**: наружу открыты только
|
||||||
|
`HYSTERIA2_TRAFFIC_TIME`, `RESET_TRAFFIC_CRON` (чтение и запись) и
|
||||||
|
`HYSTERIA2_CONFIG_REMARK` (только чтение). Denylist требует, чтобы автор
|
||||||
|
каждого нового ключа вспомнил про этот файл; при allowlist забытый ключ
|
||||||
|
закрыт. Маршрут `GET /api/config/getConfig` удалён целиком — потребителей у
|
||||||
|
него не было ни одного, а фильтр на неиспользуемой двери остаётся дверью.
|
||||||
|
|
||||||
|
- **Импорт пиров не был атомарным, вопреки собственному контракту.** Партия
|
||||||
|
проверялась целиком до первой записи, но применялась по одной записи, каждая
|
||||||
|
своим оператором. Валидация ничего не знает о том, что уже лежит в базе:
|
||||||
|
пусть есть `A(auth_id=aaa, name=alice1)` и `B(auth_id=bbb, name=bob123)`, а
|
||||||
|
файл несёт `(auth_id=aaa, name=bob123)` — поиск найдёт A по `auth_id` и
|
||||||
|
попытается переименовать её в `bob123`, прямо в `UNIQUE(name)`. Всё, что шло
|
||||||
|
в файле до конфликтной строки, оставалось применённым, и откатить это
|
||||||
|
оператор уже не мог.
|
||||||
|
|
||||||
|
Применение выполняется одной транзакцией (`dao.WithPeerTx`). Криптоматериал
|
||||||
|
считается до её открытия: digest и шифрование читают ключи из той же таблицы
|
||||||
|
`config`, и держать на ней открытую запись во время AES по каждой из тысяч
|
||||||
|
записей незачем.
|
||||||
|
|
||||||
|
- **Файл импорта мог содержать хвост, который молча не применялся.**
|
||||||
|
`json.Decoder` читает первый документ и останавливается, поэтому файл вида
|
||||||
|
`[{...}]\n{"что-то":"ещё"}` принимался целиком: оператор видел «импорт
|
||||||
|
выполнен» и не узнавал, что применилась половина. После разбора проверяется
|
||||||
|
`io.EOF`.
|
||||||
|
|
||||||
|
- **Отказ сбора диагностики отменял откат.** В `install` и `reconfigure`
|
||||||
|
`diagnosticsCollect()` стояла перед rollback обычным `await`. Она создаёт
|
||||||
|
каталог, копирует файлы и упаковывает tar — на заполненном диске падает сама,
|
||||||
|
и тогда худший сценарий отказа установки гарантированно лишался единственного
|
||||||
|
механизма восстановления. Диагностика — best effort, откат — обязателен.
|
||||||
|
|
||||||
- **`fatal_pre_apply` мог означать «хост уже изменён».** `install-state.json`
|
- **`fatal_pre_apply` мог означать «хост уже изменён».** `install-state.json`
|
||||||
пишется сразу после успешного preflight, до установки пакетов, но
|
пишется сразу после успешного preflight, до установки пакетов, но
|
||||||
классификация отказа его не учитывала. Падение `apt-get update` или
|
классификация отказа его не учитывала. Падение `apt-get update` или
|
||||||
@@ -207,6 +304,35 @@ Hardening-проход перед релизом `1.0.0`. Основная те
|
|||||||
systemd-юните, перекладывавший canonical env HY2XS в имена старого H UI,
|
systemd-юните, перекладывавший canonical env HY2XS в имена старого H UI,
|
||||||
удалён.
|
удалён.
|
||||||
|
|
||||||
|
- **Экспорт пиров разделён на два явных режима.** «Экспорт настроек» — без
|
||||||
|
секретов, «Резервная копия» — с ними, через подтверждение с описанием риска.
|
||||||
|
|
||||||
|
Кнопка была одна и всегда звала маршрут без `includeSecrets`, хотя
|
||||||
|
документация называла эту пару механизмом переноса пиров. Записи с пустым
|
||||||
|
секретом при импорте получают **новые** секреты, поэтому перенос обычным
|
||||||
|
экспортом восстанавливал пиров, но все существующие клиентские ссылки после
|
||||||
|
него переставали работать. Разница продуктовая, и оставлять её неявной нельзя.
|
||||||
|
|
||||||
|
- **`reconfigure`/`repair` больше не классифицируют отказ по тексту ошибки.**
|
||||||
|
Записываемая фаза выбиралась регулярным выражением
|
||||||
|
`/firewall|nft|ssh port check failed/i` по сообщению — тот же приём, который
|
||||||
|
уже убрали из `install`. Классификация переведена на ownership-флаги, а откат
|
||||||
|
firewall выполняется только если эта операция его трогала.
|
||||||
|
|
||||||
|
- **Список непубличных IPv4 приведён к IANA Special-Purpose Address Registry.**
|
||||||
|
Функция называлась «маршрутизируемый публичный IPv4», а исключения покрывали
|
||||||
|
только приватные диапазоны: `203.0.113.5` (TEST-NET-3 из RFC-примеров)
|
||||||
|
считался нормальным публичным адресом сервера. Добавлены документационные
|
||||||
|
(`192.0.2/24`, `198.51.100/24`, `203.0.113/24`), benchmarking (`198.18/15`),
|
||||||
|
6to4-anycast и IETF protocol assignments.
|
||||||
|
|
||||||
|
- **Отказ DNS-резолвера отличается от отсутствия A-записи.** Любая ошибка
|
||||||
|
`resolve4` печаталась как «has no A-record», поэтому при сломанном
|
||||||
|
`/etc/resolv.conf` оператор шёл править запись, которая была на месте.
|
||||||
|
`ENODATA`/`ENOTFOUND`/`NXDOMAIN` — это «нет записи», всё остальное —
|
||||||
|
«резолвер не ответил», с отдельным текстом. Фатальны оба: без ответа
|
||||||
|
резолвера проверка не выполнена, а не «выполнена с замечанием».
|
||||||
|
|
||||||
- **База админки — `hy2xs-admin.db`** вместо `h_ui.db`; reference-схема —
|
- **База админки — `hy2xs-admin.db`** вместо `h_ui.db`; reference-схема —
|
||||||
`apps/docs/sql/schema.sql` вместо `h_ui_db.sql`. Совместимость сохранять не
|
`apps/docs/sql/schema.sql` вместо `h_ui_db.sql`. Совместимость сохранять не
|
||||||
требуется: v1 ставится только с нуля. Историческое имя `h_ui.db` остаётся в
|
требуется: v1 ставится только с нуля. Историческое имя `h_ui.db` остаётся в
|
||||||
@@ -262,6 +388,25 @@ Hardening-проход перед релизом `1.0.0`. Основная те
|
|||||||
- Мёртвые строки i18n, оставшиеся от H UI: `noHttpsTip`, `defaultPassTip`,
|
- Мёртвые строки i18n, оставшиеся от H UI: `noHttpsTip`, `defaultPassTip`,
|
||||||
`hui*`, `useHysteria2Cert`, `invalidWebContext`, `mustBeInteger`.
|
`hui*`, `useHysteria2Cert`, `invalidWebContext`, `mustBeInteger`.
|
||||||
|
|
||||||
|
- `GET /api/config/getConfig` — точечное чтение произвольного ключа таблицы
|
||||||
|
`config`. Потребителей у маршрута не было ни одного, а список ключей в этой
|
||||||
|
таблице включает `JWT_SECRET`, `PEER_SECRET_KEY` и
|
||||||
|
`PEER_SECRET_ENCRYPTION_KEY`. Вместе с ним удалены `dto.ConfigDto`,
|
||||||
|
клиентская функция `getConfigApi` и её тип.
|
||||||
|
|
||||||
|
- Compatibility-слой аккаунтов предыдущего поколения: сущность
|
||||||
|
`entity.LegacyAccount`, миграции `002_migrate_legacy_accounts` и
|
||||||
|
`003_archive_legacy_account`, а также мёртвые helpers `listSQLMigrationFiles`
|
||||||
|
и `envInt`. HY2XS v1 не мигрирует базу `0.x` ни при каком сценарии, и
|
||||||
|
clean-host контракт отказывает ещё до создания базы — живого пути, по
|
||||||
|
которому таблица `account` могла бы оказаться в `hy2xs-admin.db`, не
|
||||||
|
существует. Номера оставшихся миграций сохранены: перенумерация заставила бы
|
||||||
|
их примениться повторно.
|
||||||
|
|
||||||
|
В `docs/14-legacy-cleanup.md` имена предыдущего поколения остаются — там они
|
||||||
|
обозначают реальные объекты, которые нужно удалить с сервера. Из остальных
|
||||||
|
v1-доков этот словарь убран.
|
||||||
|
|
||||||
## [1.0.0] — 2026-08-27
|
## [1.0.0] — 2026-08-27
|
||||||
|
|
||||||
Первый релиз линейки `v1`.
|
Первый релиз линейки `v1`.
|
||||||
|
|||||||
@@ -138,12 +138,17 @@ hy2xs-install/
|
|||||||
└── metadata/
|
└── metadata/
|
||||||
```
|
```
|
||||||
|
|
||||||
При запуске `install.sh` пакет сначала проверяет `metadata/checksums.txt` и
|
При запуске `install.sh` пакет проверяет `metadata/checksums.txt` и выполняет
|
||||||
выполняет read‑only clean‑host preflight **из распакованного архива**. Только
|
read‑only clean‑host preflight **из распакованного архива**. После этого он
|
||||||
после этого он устанавливает orchestrator в
|
передаёт управление install‑only orchestrator через `exec` — и больше не делает
|
||||||
|
ничего: сам `install.sh` не изменяет на сервере ни одного файла.
|
||||||
|
|
||||||
|
Всю раскладку выполняет уже оркестратор: ставит себя в
|
||||||
`/usr/local/lib/hy2xs/hy2xs-orchestrator`, создаёт symlink
|
`/usr/local/lib/hy2xs/hy2xs-orchestrator`, создаёт symlink
|
||||||
`/usr/local/bin/hy2xs-orchestrator`, копирует package assets в
|
`/usr/local/bin/hy2xs-orchestrator`, копирует package assets в
|
||||||
`/usr/local/lib/hy2xs/package` и передаёт управление install‑only orchestrator.
|
`/usr/local/lib/hy2xs/package` и продолжает установку. Это сделано ради одного
|
||||||
|
свойства: у изменений сервера ровно один владелец, поэтому при любом отказе
|
||||||
|
известно, что именно было создано и что откатывать.
|
||||||
|
|
||||||
## Сетевая модель по умолчанию
|
## Сетевая модель по умолчанию
|
||||||
|
|
||||||
@@ -493,9 +498,10 @@ HY2XS_UI_PUBLIC_ACCESS=false
|
|||||||
3. запустит clean‑host preflight **из распакованного архива**: платформа
|
3. запустит clean‑host preflight **из распакованного архива**: платформа
|
||||||
Debian 13 amd64, отсутствие предыдущей установки, валидность конфигурации.
|
Debian 13 amd64, отсутствие предыдущей установки, валидность конфигурации.
|
||||||
|
|
||||||
**PHASE 1 — применение изменений:**
|
**PHASE 1 — применение изменений.** Её целиком выполняет оркестратор, которому
|
||||||
|
`install.sh` передал управление через `exec`:
|
||||||
|
|
||||||
4. установит orchestrator в `/usr/local/lib/hy2xs` и разложит runtime‑пакет;
|
4. установит сам себя в `/usr/local/lib/hy2xs` и разложит runtime‑пакет;
|
||||||
5. создаст runtime‑каталоги и service users;
|
5. создаст runtime‑каталоги и service users;
|
||||||
6. запишет `/etc/hy2xs/hy2xs.env`;
|
6. запишет `/etc/hy2xs/hy2xs.env`;
|
||||||
7. разложит bundled HY2XS admin;
|
7. разложит bundled HY2XS admin;
|
||||||
|
|||||||
+15
-1
@@ -76,7 +76,21 @@ func runServer(port string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
r := gin.Default()
|
// gin.New(), а не gin.Default().
|
||||||
|
//
|
||||||
|
// gin.Default() подключает gin.Logger(), который печатает в stdout путь
|
||||||
|
// ВМЕСТЕ с query string. Для админки это второй, независимый от logrus
|
||||||
|
// канал журналирования: stdout юнита уезжает в journald, а diagnostics
|
||||||
|
// кладёт `journalctl -u hy2xs-admin` в бандл. Пока он существовал,
|
||||||
|
// machine token из /internal/hysteria/auth?access_token=… утекал в
|
||||||
|
// журнал даже после того, как из собственного логгера админки query
|
||||||
|
// string убрали.
|
||||||
|
//
|
||||||
|
// HTTP-логгер у продукта ровно один — middleware.LogHandler, и он
|
||||||
|
// подключается пофазно в router.Router. Здесь остаётся только Recovery:
|
||||||
|
// паника в обработчике не должна ронять сервис.
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(gin.Recovery())
|
||||||
router.Router(r)
|
router.Router(r)
|
||||||
|
|
||||||
// TLS внутри админки не поддерживается намеренно: панель слушает
|
// TLS внутри админки не поддерживается намеренно: панель слушает
|
||||||
|
|||||||
+65
-41
@@ -2,29 +2,41 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"hy2xs-admin/model/constant"
|
"hy2xs-admin/model/constant"
|
||||||
"hy2xs-admin/model/dto"
|
"hy2xs-admin/model/dto"
|
||||||
"hy2xs-admin/model/vo"
|
"hy2xs-admin/model/vo"
|
||||||
"hy2xs-admin/service"
|
"hy2xs-admin/service"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ключи, которыми владеет install-оркестратор: панель обязана отказать в
|
// Доступ операторского API к таблице `config` — строго по allowlist.
|
||||||
// записи, а не молча создать второй источник истины.
|
|
||||||
//
|
//
|
||||||
// Сетевых настроек самой панели в этом списке больше нет, потому что их нет
|
// Что было. Проверка работала denylist'ом из трёх orchestrator-ключей, а
|
||||||
// и в базе: порт и bind приходят из контракта запуска, TLS терминируется
|
// GetConfig/ListConfig принимали произвольную строку. В той же таблице лежат
|
||||||
// снаружи. См. model/constant/config.go.
|
// JWT_SECRET, PEER_SECRET_KEY, PEER_SECRET_ENCRYPTION_KEY и
|
||||||
func isOrchestratorManagedConfigKey(key string) bool {
|
// HYSTERIA2_TRAFFIC_STATS_SECRET, поэтому авторизованный запрос
|
||||||
switch key {
|
// `?key=PEER_SECRET_ENCRYPTION_KEY` отдавал master-key шифрования секретов
|
||||||
case constant.Hysteria2Enable,
|
// пиров, а updateConfigs позволял подменить JWT_SECRET и оба peer-ключа.
|
||||||
constant.Hysteria2Config,
|
//
|
||||||
constant.Hysteria2TrafficStatsSecret:
|
// То есть опасность, ради которой удаляли generic export/import таблицы
|
||||||
return true
|
// `config`, никуда не делась — она осталась в точечном API.
|
||||||
default:
|
//
|
||||||
return false
|
// Список ключей ведётся в model/constant/config.go: там же, где сами ключи, а
|
||||||
}
|
// не в слое HTTP.
|
||||||
|
|
||||||
|
func denyUnknownConfigKey(key string, allowed []string, operation string, c *gin.Context) {
|
||||||
|
sort.Strings(allowed)
|
||||||
|
vo.Fail(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"config key %q is not available for %s via API (allowed: %s)",
|
||||||
|
key, operation, strings.Join(allowed, ", "),
|
||||||
|
),
|
||||||
|
c,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateConfigs(c *gin.Context) {
|
func UpdateConfigs(c *gin.Context) {
|
||||||
@@ -39,11 +51,18 @@ func UpdateConfigs(c *gin.Context) {
|
|||||||
key := *item.Key
|
key := *item.Key
|
||||||
value := *item.Value
|
value := *item.Value
|
||||||
|
|
||||||
|
// Отдельное сообщение для ключей, которыми владеет оркестратор: их
|
||||||
|
// отказ — это не «нет такого ключа», а указание на владельца.
|
||||||
if isOrchestratorManagedConfigKey(key) {
|
if isOrchestratorManagedConfigKey(key) {
|
||||||
vo.Fail(fmt.Sprintf("%s managed by orchestrator: use hy2xs-orchestrator reconfigure", key), c)
|
vo.Fail(fmt.Sprintf("%s managed by orchestrator: use hy2xs-orchestrator reconfigure", key), c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !constant.IsPublicWritableConfigKey(key) {
|
||||||
|
denyUnknownConfigKey(key, constant.PublicWritableConfigKeys(), "write", c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if key == constant.ResetTrafficCron {
|
if key == constant.ResetTrafficCron {
|
||||||
resetTrafficCron, err := service.GetConfig(constant.ResetTrafficCron)
|
resetTrafficCron, err := service.GetConfig(constant.ResetTrafficCron)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -70,38 +89,45 @@ func UpdateConfigs(c *gin.Context) {
|
|||||||
vo.Success(nil, c)
|
vo.Success(nil, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetConfig(c *gin.Context) {
|
// isOrchestratorManagedConfigKey — ключи, которыми владеет install-оркестратор.
|
||||||
configDto, err := validateField(c, dto.ConfigDto{})
|
//
|
||||||
if err != nil {
|
// Формально они и так не входят в allowlist, но отказ по ним обязан объяснять
|
||||||
return
|
// ПОЧЕМУ: «этим значением владеет оркестратор» — это другой ответ, чем «такого
|
||||||
|
// ключа в API нет», и он ведёт оператора к `hy2xs-orchestrator reconfigure`.
|
||||||
|
func isOrchestratorManagedConfigKey(key string) bool {
|
||||||
|
switch key {
|
||||||
|
case constant.Hysteria2Enable,
|
||||||
|
constant.Hysteria2Config,
|
||||||
|
constant.Hysteria2TrafficStatsSecret:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
config, err := service.GetConfig(*configDto.Key)
|
|
||||||
if err != nil {
|
|
||||||
vo.Fail(err.Error(), c)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
configVo := vo.ConfigVo{
|
|
||||||
Key: *config.Key,
|
|
||||||
Value: *config.Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
running := service.Hysteria2IsRunning()
|
|
||||||
if *config.Key == constant.Hysteria2Enable {
|
|
||||||
if running {
|
|
||||||
configVo.Value = "1"
|
|
||||||
} else {
|
|
||||||
configVo.Value = "0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
vo.Success(configVo, c)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Маршрута GET /config/getConfig здесь больше нет.
|
||||||
|
//
|
||||||
|
// Он принимал произвольный ключ и был вторым, менее заметным входом в ту же
|
||||||
|
// таблицу секретов, что и удалённый generic export. При этом ни одного
|
||||||
|
// потребителя у него не было: панель читает настройки только через listConfig.
|
||||||
|
// Маршрут не оставлен с фильтром, а удалён — точка входа, которой никто не
|
||||||
|
// пользуется, не должна существовать.
|
||||||
|
|
||||||
func ListConfig(c *gin.Context) {
|
func ListConfig(c *gin.Context) {
|
||||||
configsDto, err := validateField(c, dto.ConfigsDto{})
|
configsDto, err := validateField(c, dto.ConfigsDto{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверка идёт до обращения к базе: отказ не должен зависеть от того,
|
||||||
|
// существует ли строка с таким ключом.
|
||||||
|
for _, key := range configsDto.Keys {
|
||||||
|
if !constant.IsPublicReadableConfigKey(key) {
|
||||||
|
denyUnknownConfigKey(key, constant.PublicReadableConfigKeys(), "read", c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
configs, err := service.ListConfig(configsDto.Keys)
|
configs, err := service.ListConfig(configsDto.Keys)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
vo.Fail(err.Error(), c)
|
vo.Fail(err.Error(), c)
|
||||||
@@ -159,5 +185,3 @@ func ExportHysteria2Config(c *gin.Context) {
|
|||||||
// Осмысленного production-сценария у этой пары не было: конфигурацией сервера
|
// Осмысленного production-сценария у этой пары не было: конфигурацией сервера
|
||||||
// владеет install-оркестратор, перенос пиров делают ImportPeer/ExportPeer, а
|
// владеет install-оркестратор, перенос пиров делают ImportPeer/ExportPeer, а
|
||||||
// серверный конфиг Hysteria выгружается отдельным санитизирующим маршрутом.
|
// серверный конфиг Hysteria выгружается отдельным санитизирующим маршрутом.
|
||||||
// Поэтому маршруты удалены, а не оставлены с фильтром: список ключей,
|
|
||||||
// требующий ручного сопровождения, снова разошёлся бы со схемой базы.
|
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"hy2xs-admin/model/constant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Секреты, которые лежат в таблице `config` рядом с пользовательскими
|
||||||
|
// настройками. Каждый из них раньше можно было прочитать через
|
||||||
|
// GET /api/config/getConfig?key=… и подменить через updateConfigs.
|
||||||
|
var secretConfigKeys = []string{
|
||||||
|
constant.JwtSecret,
|
||||||
|
constant.PeerSecretKey,
|
||||||
|
constant.PeerSecretEncryptionKey,
|
||||||
|
constant.Hysteria2TrafficStatsSecret,
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiResult struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func postJSON(t *testing.T, handler gin.HandlerFunc, path string, body any) apiResult {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("не удалось собрать тело запроса: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := gin.New()
|
||||||
|
engine.POST(path, handler)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
var result apiResult
|
||||||
|
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
||||||
|
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отказ обязан наступать ДО обращения к базе: тест выполняется без
|
||||||
|
// инициализированного SQLite, и любой поход в dao здесь уронил бы обработчик.
|
||||||
|
// Это и есть доказательство того, что проверка стоит на входе.
|
||||||
|
func TestListConfigRefusesInternalKeys(t *testing.T) {
|
||||||
|
for _, key := range secretConfigKeys {
|
||||||
|
result := postJSON(t, ListConfig, "/config/listConfig", map[string]any{"keys": []string{key}})
|
||||||
|
|
||||||
|
if result.Type != "no" {
|
||||||
|
t.Errorf("чтение %s не отклонено: %+v", key, result)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Message, "not available for read") {
|
||||||
|
t.Errorf("невнятный отказ для %s: %s", key, result.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Секретный ключ, спрятанный среди разрешённых, обязан отклонить весь запрос.
|
||||||
|
func TestListConfigRefusesMixedBatch(t *testing.T) {
|
||||||
|
result := postJSON(t, ListConfig, "/config/listConfig", map[string]any{
|
||||||
|
"keys": []string{constant.Hysteria2TrafficTime, constant.PeerSecretEncryptionKey},
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.Type != "no" {
|
||||||
|
t.Fatalf("смешанная партия не отклонена: %+v", result)
|
||||||
|
}
|
||||||
|
if len(result.Data) != 0 && string(result.Data) != "null" {
|
||||||
|
t.Fatalf("отказ вернул данные: %s", string(result.Data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListConfigRefusesUnknownKey(t *testing.T) {
|
||||||
|
result := postJSON(t, ListConfig, "/config/listConfig", map[string]any{
|
||||||
|
"keys": []string{"TOTALLY_UNKNOWN_KEY"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.Type != "no" {
|
||||||
|
t.Fatalf("неизвестный ключ не отклонён: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigsRefusesInternalKeys(t *testing.T) {
|
||||||
|
for _, key := range secretConfigKeys {
|
||||||
|
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
|
||||||
|
"configUpdateDtos": []map[string]string{{"key": key, "value": "attacker-controlled"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.Type != "no" {
|
||||||
|
t.Errorf("запись %s не отклонена: %+v", key, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ключи оркестратора отклоняются с указанием владельца, а не общим «нет
|
||||||
|
// такого ключа»: оператор должен понять, что менять их надо через reconfigure.
|
||||||
|
func TestUpdateConfigsPointsAtOrchestratorForItsOwnKeys(t *testing.T) {
|
||||||
|
for _, key := range []string{
|
||||||
|
constant.Hysteria2Enable,
|
||||||
|
constant.Hysteria2Config,
|
||||||
|
constant.Hysteria2TrafficStatsSecret,
|
||||||
|
} {
|
||||||
|
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
|
||||||
|
"configUpdateDtos": []map[string]string{{"key": key, "value": "x"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.Type != "no" {
|
||||||
|
t.Errorf("запись %s не отклонена: %+v", key, result)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Message, "hy2xs-orchestrator reconfigure") {
|
||||||
|
t.Errorf("отказ по %s не называет владельца: %s", key, result.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigsRefusesUnknownKey(t *testing.T) {
|
||||||
|
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
|
||||||
|
"configUpdateDtos": []map[string]string{{"key": "TOTALLY_UNKNOWN_KEY", "value": "x"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.Type != "no" {
|
||||||
|
t.Fatalf("неизвестный ключ не отклонён: %+v", result)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Message, "not available for write") {
|
||||||
|
t.Fatalf("невнятный отказ: %s", result.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отказ на любой записи партии обязан отменить партию целиком: иначе первые
|
||||||
|
// ключи применились бы, а оператор получил бы ошибку.
|
||||||
|
func TestUpdateConfigsRefusesWholeBatchOnFirstForbiddenKey(t *testing.T) {
|
||||||
|
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
|
||||||
|
"configUpdateDtos": []map[string]string{
|
||||||
|
{"key": constant.JwtSecret, "value": "attacker-controlled"},
|
||||||
|
{"key": constant.Hysteria2TrafficTime, "value": "10"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.Type != "no" {
|
||||||
|
t.Fatalf("партия с секретом не отклонена: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -178,6 +178,19 @@ func ImportPeer(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// json.Decoder читает ПЕРВЫЙ документ и останавливается. Файл вида
|
||||||
|
//
|
||||||
|
// [{"name":"peer"}]
|
||||||
|
// {"что-то":"ещё"}
|
||||||
|
//
|
||||||
|
// принимался целиком, а хвост просто не читался: оператор видел «импорт
|
||||||
|
// выполнен» и не узнавал, что применилась половина файла. Файл обязан
|
||||||
|
// содержать ровно один JSON-документ.
|
||||||
|
if err = decoder.Decode(&struct{}{}); err != io.EOF {
|
||||||
|
vo.Fail("json file must contain exactly one JSON document", c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err = service.UpsertPeerExport(peerExports); err != nil {
|
if err = service.UpsertPeerExport(peerExports); err != nil {
|
||||||
vo.Fail(err.Error(), c)
|
vo.Fail(err.Error(), c)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"hy2xs-admin/dao"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Разбор загруженного файла проверяется без базы: все три отказа наступают до
|
||||||
|
// первого обращения к слою данных, и это часть контракта — испорченный файл не
|
||||||
|
// должен доходить до транзакции.
|
||||||
|
func postImportFile(t *testing.T, fileName string, content string) apiResult {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
part, err := writer.CreateFormFile("file", fileName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("не удалось собрать форму: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write([]byte(content)); err != nil {
|
||||||
|
t.Fatalf("не удалось записать файл в форму: %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("не удалось закрыть форму: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := gin.New()
|
||||||
|
engine.POST("/peer-import", ImportPeer)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/peer-import", body)
|
||||||
|
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
var result apiResult
|
||||||
|
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
||||||
|
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Регрессия: json.Decoder читает ПЕРВЫЙ документ и останавливается. Файл с
|
||||||
|
// хвостом принимался целиком, оператор видел «импорт выполнен», а вторая
|
||||||
|
// половина файла молча не применялась.
|
||||||
|
func TestImportPeerRejectsTrailingDocument(t *testing.T) {
|
||||||
|
result := postImportFile(t, "peers.json", `[{"name":"alpha1"}]
|
||||||
|
{"name":"ignored"}`)
|
||||||
|
|
||||||
|
if result.Type != "no" {
|
||||||
|
t.Fatalf("файл с хвостовым документом не отклонён: %+v", result)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Message, "exactly one JSON document") {
|
||||||
|
t.Fatalf("невнятный отказ: %s", result.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportPeerRejectsTrailingGarbage(t *testing.T) {
|
||||||
|
for _, content := range []string{
|
||||||
|
"[{\"name\":\"alpha1\"}] trailing",
|
||||||
|
"[{\"name\":\"alpha1\"}][{\"name\":\"bravo2\"}]",
|
||||||
|
"[{\"name\":\"alpha1\"}]\n[]",
|
||||||
|
} {
|
||||||
|
result := postImportFile(t, "peers.json", content)
|
||||||
|
if result.Type != "no" {
|
||||||
|
t.Errorf("файл %q не отклонён: %+v", content, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportPeerRejectsUnknownFields(t *testing.T) {
|
||||||
|
result := postImportFile(t, "peers.json", `[{"name":"alpha1","totallyUnknown":1}]`)
|
||||||
|
|
||||||
|
if result.Type != "no" {
|
||||||
|
t.Fatalf("неизвестное поле не отклонено: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportPeerRejectsNonJsonFileName(t *testing.T) {
|
||||||
|
result := postImportFile(t, "peers.txt", `[{"name":"alpha1"}]`)
|
||||||
|
|
||||||
|
if result.Type != "no" {
|
||||||
|
t.Fatalf("файл не с расширением .json не отклонён: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Положительный путь целиком: корректный одиночный документ обязан не только
|
||||||
|
// разобраться, но и доехать до базы. Без него проверки выше доказывали бы
|
||||||
|
// только то, что отклоняется всё подряд.
|
||||||
|
func TestImportPeerAcceptsSingleDocument(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||||
|
if err := dao.InitSqliteDBAt(dbPath); err != nil {
|
||||||
|
t.Fatalf("не удалось открыть тестовую базу: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.RunMigrations(); err != nil {
|
||||||
|
t.Fatalf("не удалось применить миграции: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
|
||||||
|
|
||||||
|
result := postImportFile(t, "peers.json", `[{"name":"alpha1","quotaBytes":-1,"maxDevices":3}]`)
|
||||||
|
|
||||||
|
if result.Type != "ok" {
|
||||||
|
t.Fatalf("корректный файл отклонён: %+v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
peers, err := dao.ListPeer("name = ?", "alpha1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("не удалось прочитать пиров: %v", err)
|
||||||
|
}
|
||||||
|
if len(peers) != 1 {
|
||||||
|
t.Fatalf("импорт не создал пира: найдено %d", len(peers))
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
-26
@@ -11,14 +11,91 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SavePeer(peer entity.Peer) (int64, error) {
|
// PeerTx — операции над пирами в границах одной единицы работы.
|
||||||
if tx := sqliteDB.Save(&peer); tx.Error != nil {
|
//
|
||||||
|
// Интерфейс существует, чтобы транзакция была доступна слою service, но
|
||||||
|
// *gorm.DB туда не протекал: выбор ORM остаётся деталью dao. Реализация одна и
|
||||||
|
// та же и для глобального соединения, и для транзакции — за счёт этого
|
||||||
|
// невозможна ситуация, когда «обычный» путь и «транзакционный» расходятся в
|
||||||
|
// поведении.
|
||||||
|
type PeerTx interface {
|
||||||
|
GetPeer(query interface{}, args ...interface{}) (entity.Peer, error)
|
||||||
|
SavePeer(peer entity.Peer) (int64, error)
|
||||||
|
UpdatePeer(ids []int64, updates map[string]interface{}) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type peerTx struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPeerTx выполняет fn в одной транзакции: либо применяются все записи,
|
||||||
|
// либо ни одной.
|
||||||
|
//
|
||||||
|
// Что было. Импорт пиров валидировал партию целиком до первой записи, но сами
|
||||||
|
// записи шли по одной, каждая своим оператором. Комментарий обещал «либо
|
||||||
|
// целиком, либо никак», а на практике UNIQUE-конфликт на 37-й записи оставлял
|
||||||
|
// 36 применённых. Конфликт при этом совершенно реальный: если в базе есть
|
||||||
|
// A(auth_id=a, name=alice) и B(auth_id=b, name=bob), а импорт несёт
|
||||||
|
// (auth_id=a, name=bob), то поиск найдёт A по auth_id и попытается
|
||||||
|
// переименовать его в bob — прямо в UNIQUE(name).
|
||||||
|
func WithPeerTx(fn func(tx PeerTx) error) error {
|
||||||
|
if sqliteDB == nil {
|
||||||
|
return errors.New("sqlite is not initialized")
|
||||||
|
}
|
||||||
|
return sqliteDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
return fn(peerTx{db: tx})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p peerTx) SavePeer(peer entity.Peer) (int64, error) {
|
||||||
|
if tx := p.db.Save(&peer); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return 0, errors.New(constant.SysError)
|
return 0, errors.New(constant.SysError)
|
||||||
}
|
}
|
||||||
return *peer.Id, nil
|
return *peer.Id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p peerTx) UpdatePeer(ids []int64, updates map[string]interface{}) error {
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
if tx := p.db.Model(&entity.Peer{}).Where("id in ?", ids).Updates(updates); tx.Error != nil {
|
||||||
|
logrus.Errorf("%v", tx.Error)
|
||||||
|
return errors.New(constant.SysError)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p peerTx) GetPeer(query interface{}, args ...interface{}) (entity.Peer, error) {
|
||||||
|
var peer entity.Peer
|
||||||
|
if tx := p.db.Model(&entity.Peer{}).Where(query, args...).First(&peer); tx.Error != nil {
|
||||||
|
if tx.Error == gorm.ErrRecordNotFound {
|
||||||
|
return peer, errors.New(constant.WrongPassword)
|
||||||
|
}
|
||||||
|
logrus.Errorf("%v", tx.Error)
|
||||||
|
return peer, errors.New(constant.SysError)
|
||||||
|
}
|
||||||
|
return peer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// global — операции вне транзакции. Одна и та же реализация, другое соединение.
|
||||||
|
func global() peerTx {
|
||||||
|
return peerTx{db: sqliteDB}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SavePeer(peer entity.Peer) (int64, error) {
|
||||||
|
return global().SavePeer(peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdatePeer(ids []int64, updates map[string]interface{}) error {
|
||||||
|
return global().UpdatePeer(ids, updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPeer(query interface{}, args ...interface{}) (entity.Peer, error) {
|
||||||
|
return global().GetPeer(query, args...)
|
||||||
|
}
|
||||||
|
|
||||||
func DeletePeer(ids []int64) error {
|
func DeletePeer(ids []int64) error {
|
||||||
if tx := sqliteDB.Where("id in ?", ids).Delete(&entity.Peer{}); tx.Error != nil {
|
if tx := sqliteDB.Where("id in ?", ids).Delete(&entity.Peer{}); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
@@ -27,30 +104,6 @@ func DeletePeer(ids []int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdatePeer(ids []int64, updates map[string]interface{}) error {
|
|
||||||
if len(updates) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
|
|
||||||
if tx := sqliteDB.Model(&entity.Peer{}).Where("id in ?", ids).Updates(updates); tx.Error != nil {
|
|
||||||
logrus.Errorf("%v", tx.Error)
|
|
||||||
return errors.New(constant.SysError)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetPeer(query interface{}, args ...interface{}) (entity.Peer, error) {
|
|
||||||
var peer entity.Peer
|
|
||||||
if tx := sqliteDB.Model(&entity.Peer{}).Where(query, args...).First(&peer); tx.Error != nil {
|
|
||||||
if tx.Error == gorm.ErrRecordNotFound {
|
|
||||||
return peer, errors.New(constant.WrongPassword)
|
|
||||||
}
|
|
||||||
logrus.Errorf("%v", tx.Error)
|
|
||||||
return peer, errors.New(constant.SysError)
|
|
||||||
}
|
|
||||||
return peer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ListPeer(query interface{}, args ...interface{}) ([]entity.Peer, error) {
|
func ListPeer(query interface{}, args ...interface{}) ([]entity.Peer, error) {
|
||||||
var peers []entity.Peer
|
var peers []entity.Peer
|
||||||
if tx := sqliteDB.Model(&entity.Peer{}).Where(query, args...).Order("create_time desc").Find(&peers); tx.Error != nil {
|
if tx := sqliteDB.Model(&entity.Peer{}).Where(query, args...).Order("create_time desc").Find(&peers); tx.Error != nil {
|
||||||
|
|||||||
+31
-167
@@ -3,7 +3,6 @@ package dao
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -14,9 +13,6 @@ import (
|
|||||||
"hy2xs-admin/util"
|
"hy2xs-admin/util"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -24,8 +20,17 @@ import (
|
|||||||
var sqliteDB *gorm.DB
|
var sqliteDB *gorm.DB
|
||||||
|
|
||||||
func InitSqliteDB() error {
|
func InitSqliteDB() error {
|
||||||
|
return InitSqliteDBAt(constant.SqliteDBPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitSqliteDBAt открывает базу по указанному пути.
|
||||||
|
//
|
||||||
|
// Путь параметризован, а не зашит: без этого единственным способом проверить
|
||||||
|
// поведение слоя данных (транзакционность импорта пиров, в первую очередь)
|
||||||
|
// было бы обращение к реальной /var/lib/hy2xs-admin/hy2xs-admin.db.
|
||||||
|
func InitSqliteDBAt(path string) error {
|
||||||
var err error
|
var err error
|
||||||
sqliteDB, err = gorm.Open(sqlite.Open(constant.SqliteDBPath), &gorm.Config{
|
sqliteDB, err = gorm.Open(sqlite.Open(path), &gorm.Config{
|
||||||
TranslateError: true,
|
TranslateError: true,
|
||||||
Logger: logger.New(
|
Logger: logger.New(
|
||||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||||
@@ -76,15 +81,6 @@ func InitSql() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func envInt(name string, fallback int) int {
|
|
||||||
if raw := strings.TrimSpace(os.Getenv(name)); raw != "" {
|
|
||||||
if value, err := strconv.Atoi(raw); err == nil {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
func envBoolAsInt(name string, fallback int) int {
|
func envBoolAsInt(name string, fallback int) int {
|
||||||
raw := strings.TrimSpace(strings.ToLower(os.Getenv(name)))
|
raw := strings.TrimSpace(strings.ToLower(os.Getenv(name)))
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -264,6 +260,15 @@ func getOrCreateConfigKey(key string, size int) (string, error) {
|
|||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunMigrations приводит схему базы к текущему поколению.
|
||||||
|
//
|
||||||
|
// Экспортирована, чтобы схему можно было поднять отдельно от InitSql: тестам
|
||||||
|
// слоя данных нужна таблица, но не нужны bootstrap-администратор, bootstrap-пир
|
||||||
|
// и генерация JWT-секрета.
|
||||||
|
func RunMigrations() error {
|
||||||
|
return runMigrations()
|
||||||
|
}
|
||||||
|
|
||||||
func runMigrations() error {
|
func runMigrations() error {
|
||||||
if tx := sqliteDB.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
if tx := sqliteDB.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
version TEXT PRIMARY KEY,
|
version TEXT PRIMARY KEY,
|
||||||
@@ -278,11 +283,21 @@ func runMigrations() error {
|
|||||||
apply func() error
|
apply func() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Миграций 002_migrate_legacy_accounts и 003_archive_legacy_account здесь
|
||||||
|
// больше нет.
|
||||||
|
//
|
||||||
|
// Они переносили таблицу `account` предыдущего поколения в admin_user/peer
|
||||||
|
// и переименовывали её в бэкап. Это compatibility-слой продукта, который
|
||||||
|
// HY2XS v1 принципиально не поддерживает: установка возможна только на
|
||||||
|
// чистый хост, база 0.x не мигрируется ни при каком сценарии, а clean-host
|
||||||
|
// контракт отказывает ещё до создания базы. Живого пути, по которому такая
|
||||||
|
// таблица могла бы оказаться в hy2xs-admin.db, не существует.
|
||||||
|
//
|
||||||
|
// Номера оставшихся миграций сохранены: перенумерация заставила бы их
|
||||||
|
// примениться повторно на уже установленных машинах.
|
||||||
migrations := []migration{
|
migrations := []migration{
|
||||||
{version: "000_base_config", apply: migrateBaseConfig},
|
{version: "000_base_config", apply: migrateBaseConfig},
|
||||||
{version: "001_admin_peer_split", apply: migrateAdminPeerSplit},
|
{version: "001_admin_peer_split", apply: migrateAdminPeerSplit},
|
||||||
{version: "002_migrate_legacy_accounts", apply: migrateLegacyAccounts},
|
|
||||||
{version: "003_archive_legacy_account", apply: archiveLegacyAccount},
|
|
||||||
{version: "004_traffic_samples_and_aggregates", apply: migrateTrafficTables},
|
{version: "004_traffic_samples_and_aggregates", apply: migrateTrafficTables},
|
||||||
{version: "005_metric_sample", apply: migrateMetricSampleTable},
|
{version: "005_metric_sample", apply: migrateMetricSampleTable},
|
||||||
}
|
}
|
||||||
@@ -403,125 +418,6 @@ func migrateAdminPeerSplit() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateLegacyAccounts() error {
|
|
||||||
if !tableExists("account") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var accounts []entity.LegacyAccount
|
|
||||||
if tx := sqliteDB.Model(&entity.LegacyAccount{}).Order("id asc").Find(&accounts); tx.Error != nil {
|
|
||||||
logrus.Errorf("sqlite legacy account query err: %v", tx.Error)
|
|
||||||
return errors.New("sqlite legacy account query err")
|
|
||||||
}
|
|
||||||
nowMs := time.Now().UnixMilli()
|
|
||||||
for _, acc := range accounts {
|
|
||||||
if acc.Role != nil && *acc.Role == "admin" {
|
|
||||||
if acc.Username == nil || acc.Pass == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_, getErr := GetAdminUser("username = ?", *acc.Username)
|
|
||||||
if getErr == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
status := int64(1)
|
|
||||||
if acc.Deleted != nil && *acc.Deleted == 1 {
|
|
||||||
status = 0
|
|
||||||
}
|
|
||||||
lastLogin := int64(0)
|
|
||||||
if acc.LoginAt != nil {
|
|
||||||
lastLogin = *acc.LoginAt
|
|
||||||
}
|
|
||||||
passwordChangedAt := nowMs
|
|
||||||
admin := entity.AdminUser{
|
|
||||||
Username: acc.Username,
|
|
||||||
PasswordHash: acc.Pass,
|
|
||||||
Status: &status,
|
|
||||||
ForcePasswordChange: acc.ForcePasswordChange,
|
|
||||||
LastLoginAt: &lastLogin,
|
|
||||||
PasswordChangedAt: &passwordChangedAt,
|
|
||||||
TokenVersion: func() *int64 { v := int64(1); return &v }(),
|
|
||||||
}
|
|
||||||
if _, saveErr := SaveAdminUser(admin); saveErr != nil {
|
|
||||||
return saveErr
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if acc.Username == nil || acc.ConPass == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_, getPeerErr := GetPeer("name = ?", *acc.Username)
|
|
||||||
if getPeerErr == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
authId, authErr := util.RandomString(18)
|
|
||||||
if authErr != nil {
|
|
||||||
return authErr
|
|
||||||
}
|
|
||||||
peerSecretKey, keyErr := getOrCreatePeerSecretDigestKey()
|
|
||||||
if keyErr != nil {
|
|
||||||
return keyErr
|
|
||||||
}
|
|
||||||
secretDigest := util.HmacSHA256Hex(*acc.ConPass, peerSecretKey)
|
|
||||||
secretEncrypted := *acc.ConPass
|
|
||||||
quota := int64(0)
|
|
||||||
if acc.Quota != nil {
|
|
||||||
quota = *acc.Quota
|
|
||||||
}
|
|
||||||
download := int64(0)
|
|
||||||
if acc.Download != nil {
|
|
||||||
download = *acc.Download
|
|
||||||
}
|
|
||||||
upload := int64(0)
|
|
||||||
if acc.Upload != nil {
|
|
||||||
upload = *acc.Upload
|
|
||||||
}
|
|
||||||
expires := int64(0)
|
|
||||||
if acc.ExpireTime != nil {
|
|
||||||
expires = *acc.ExpireTime
|
|
||||||
}
|
|
||||||
maxDevices := int64(3)
|
|
||||||
if acc.DeviceNo != nil {
|
|
||||||
maxDevices = *acc.DeviceNo
|
|
||||||
}
|
|
||||||
disabled := int64(0)
|
|
||||||
if acc.Deleted != nil {
|
|
||||||
disabled = *acc.Deleted
|
|
||||||
}
|
|
||||||
bannedUntil := int64(0)
|
|
||||||
if acc.KickUtilTime != nil {
|
|
||||||
bannedUntil = *acc.KickUtilTime
|
|
||||||
}
|
|
||||||
lastConnection := int64(0)
|
|
||||||
if acc.ConAt != nil {
|
|
||||||
lastConnection = *acc.ConAt
|
|
||||||
}
|
|
||||||
remark := ""
|
|
||||||
if acc.Remark != nil {
|
|
||||||
remark = *acc.Remark
|
|
||||||
}
|
|
||||||
peer := entity.Peer{
|
|
||||||
Name: acc.Username,
|
|
||||||
Remark: &remark,
|
|
||||||
AuthId: &authId,
|
|
||||||
SecretDigest: &secretDigest,
|
|
||||||
SecretEncrypted: &secretEncrypted,
|
|
||||||
QuotaBytes: "a,
|
|
||||||
DownloadBytes: &download,
|
|
||||||
UploadBytes: &upload,
|
|
||||||
ExpiresAt: &expires,
|
|
||||||
MaxDevices: &maxDevices,
|
|
||||||
Disabled: &disabled,
|
|
||||||
BannedUntil: &bannedUntil,
|
|
||||||
LastConnectionAt: &lastConnection,
|
|
||||||
}
|
|
||||||
if _, saveErr := SavePeer(peer); saveErr != nil {
|
|
||||||
return saveErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getOrCreateJwtSecret() (string, error) {
|
func getOrCreateJwtSecret() (string, error) {
|
||||||
if existing, err := GetConfig("key = ?", constant.JwtSecret); err == nil {
|
if existing, err := GetConfig("key = ?", constant.JwtSecret); err == nil {
|
||||||
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
|
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
|
||||||
@@ -562,18 +458,6 @@ func getOrCreatePeerSecretDigestKey() (string, error) {
|
|||||||
return keyValue, nil
|
return keyValue, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func archiveLegacyAccount() error {
|
|
||||||
if !tableExists("account") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
backupName := fmt.Sprintf("legacy_account_backup_%d", time.Now().Unix())
|
|
||||||
if tx := sqliteDB.Exec("ALTER TABLE account RENAME TO " + backupName); tx.Error != nil {
|
|
||||||
logrus.Errorf("sqlite legacy archive err: %v", tx.Error)
|
|
||||||
return errors.New("sqlite legacy archive err")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateTrafficTables() error {
|
func migrateTrafficTables() error {
|
||||||
stmts := []string{
|
stmts := []string{
|
||||||
`CREATE TABLE IF NOT EXISTS traffic_sample (
|
`CREATE TABLE IF NOT EXISTS traffic_sample (
|
||||||
@@ -658,26 +542,6 @@ func tableExists(tableName string) bool {
|
|||||||
}
|
}
|
||||||
return count > 0
|
return count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func listSQLMigrationFiles(dir string) ([]string, error) {
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
files := make([]string, 0)
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := entry.Name()
|
|
||||||
if strings.HasSuffix(strings.ToLower(name), ".sql") {
|
|
||||||
files = append(files, filepath.Join(dir, name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(files)
|
|
||||||
return files, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureTrafficStatsSecret() error {
|
func ensureTrafficStatsSecret() error {
|
||||||
envSecret := strings.TrimSpace(os.Getenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET"))
|
envSecret := strings.TrimSpace(os.Getenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET"))
|
||||||
if envSecret != "" {
|
if envSecret != "" {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { AxiosPromise } from "axios";
|
import { AxiosPromise } from "axios";
|
||||||
import request from "@/utils/request";
|
import request from "@/utils/request";
|
||||||
import {
|
import {
|
||||||
ConfigDto,
|
|
||||||
ConfigsDto,
|
ConfigsDto,
|
||||||
ConfigUpdateDto,
|
ConfigUpdateDto,
|
||||||
ConfigVo,
|
ConfigVo,
|
||||||
@@ -14,6 +13,10 @@ import {
|
|||||||
//
|
//
|
||||||
// Generic-выгрузки таблицы `config` здесь тоже нет: она отдавала JWT- и
|
// Generic-выгрузки таблицы `config` здесь тоже нет: она отдавала JWT- и
|
||||||
// peer-ключи в открытом виде. Перенос пиров делают ImportPeer/ExportPeer.
|
// peer-ключи в открытом виде. Перенос пиров делают ImportPeer/ExportPeer.
|
||||||
|
//
|
||||||
|
// getConfigApi удалён вместе с маршрутом: он принимал произвольный ключ той же
|
||||||
|
// таблицы и не вызывался ни из одного экрана. Чтение настроек идёт через
|
||||||
|
// listConfigApi, на стороне сервера — по allowlist.
|
||||||
|
|
||||||
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ServerConfig> {
|
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ServerConfig> {
|
||||||
return request({
|
return request({
|
||||||
@@ -22,14 +25,6 @@ export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ServerConfig> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConfigApi(data: ConfigDto): AxiosPromise<ConfigVo> {
|
|
||||||
return request({
|
|
||||||
url: "/config/getConfig",
|
|
||||||
method: "get",
|
|
||||||
params: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listConfigApi(data: ConfigsDto): AxiosPromise<Array<ConfigVo>> {
|
export function listConfigApi(data: ConfigsDto): AxiosPromise<Array<ConfigVo>> {
|
||||||
return request({
|
return request({
|
||||||
url: "/config/listConfig",
|
url: "/config/listConfig",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export interface ConfigDto {
|
// ConfigDto удалён вместе с маршрутом getConfig: чтение произвольного ключа
|
||||||
key: string;
|
// таблицы `config` было входом к JWT- и peer-ключам.
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConfigsDto {
|
export interface ConfigsDto {
|
||||||
keys: Array<string>;
|
keys: Array<string>;
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ export interface LogSystemVo {
|
|||||||
level: string;
|
level: string;
|
||||||
msg: string;
|
msg: string;
|
||||||
reqMethod: string;
|
reqMethod: string;
|
||||||
reqUri: string;
|
// Путь без query string: в query по машинному endpoint'у приезжает
|
||||||
|
// действующий machine token, и в журнале его быть не должно.
|
||||||
|
reqPath: string;
|
||||||
|
// Только имена переданных query-параметров, без значений.
|
||||||
|
reqQueryKeys: string;
|
||||||
statusCode: string;
|
statusCode: string;
|
||||||
time: string;
|
time: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,10 +89,26 @@ export function importPeerApi(data: FormData): AxiosPromise {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportPeerApi(): AxiosPromise {
|
/**
|
||||||
|
* Экспорт пиров в двух режимах, и это продуктовое различие, а не флаг.
|
||||||
|
*
|
||||||
|
* Без секретов — список настроек пиров. Файл безопасно хранить, но при импорте
|
||||||
|
* на другой сервер записям с пустым секретом выдаются НОВЫЕ секреты, то есть
|
||||||
|
* прежние клиентские ссылки перестают работать.
|
||||||
|
*
|
||||||
|
* С секретами — резервная копия, сохраняющая работоспособность существующих
|
||||||
|
* ссылок. Файл содержит действующие учётные данные доступа, поэтому запускается
|
||||||
|
* только через явное подтверждение оператора.
|
||||||
|
*
|
||||||
|
* Раньше кнопка была одна и всегда звала маршрут без includeSecrets, а
|
||||||
|
* документация называла эту пару механизмом переноса пиров. Оператор
|
||||||
|
* переносил пиров и обнаруживал, что все клиенты отвалились.
|
||||||
|
*/
|
||||||
|
export function exportPeerApi(includeSecrets = false): AxiosPromise {
|
||||||
return request({
|
return request({
|
||||||
url: "/peer-export",
|
url: "/peer-export",
|
||||||
method: "post",
|
method: "post",
|
||||||
|
params: includeSecrets ? { includeSecrets: "true" } : undefined,
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,13 @@ export default {
|
|||||||
unlimited: "Unlimited",
|
unlimited: "Unlimited",
|
||||||
secretRotateConfirm:
|
secretRotateConfirm:
|
||||||
"Rotate peer secret? Existing client configurations will stop working until updated.",
|
"Rotate peer secret? Existing client configurations will stop working until updated.",
|
||||||
|
exportSettings: "Export settings",
|
||||||
|
exportBackup: "Backup",
|
||||||
|
exportSettingsHint:
|
||||||
|
"Peer list without secrets. Safe to store and share, but after importing on another server the peers get new secrets, so existing client links stop working.",
|
||||||
|
exportBackupConfirm:
|
||||||
|
"A backup contains the LIVE connection secrets of every peer in plain text: anyone who gets this file gets VPN access. Treat it like a password and delete it once the migration is done. Only this kind of copy keeps existing client links working after moving to another server.",
|
||||||
|
exportBackupConfirmButton: "I understand, download with secrets",
|
||||||
quota: "Quota",
|
quota: "Quota",
|
||||||
download: "Download",
|
download: "Download",
|
||||||
upload: "Upload",
|
upload: "Upload",
|
||||||
|
|||||||
@@ -146,6 +146,13 @@ export default {
|
|||||||
unlimited: "Безлимит",
|
unlimited: "Безлимит",
|
||||||
secretRotateConfirm:
|
secretRotateConfirm:
|
||||||
"Сменить секрет пира? Текущие клиентские конфиги перестанут работать до обновления.",
|
"Сменить секрет пира? Текущие клиентские конфиги перестанут работать до обновления.",
|
||||||
|
exportSettings: "Экспорт настроек",
|
||||||
|
exportBackup: "Резервная копия",
|
||||||
|
exportSettingsHint:
|
||||||
|
"Список пиров без секретов. Безопасно хранить и передавать, но после импорта на другой сервер пирам будут выданы новые секреты, и прежние клиентские ссылки работать не будут.",
|
||||||
|
exportBackupConfirm:
|
||||||
|
"Резервная копия содержит ДЕЙСТВУЮЩИЕ секреты подключения всех пиров в открытом виде: любой, кто получит этот файл, получит доступ к VPN. Храните его как пароль и удалите, когда перенос завершён. Только такая копия сохраняет работоспособность существующих клиентских ссылок после переноса на другой сервер.",
|
||||||
|
exportBackupConfirmButton: "Понимаю, скачать копию с секретами",
|
||||||
quota: "Квота",
|
quota: "Квота",
|
||||||
download: "Скачано",
|
download: "Скачано",
|
||||||
upload: "Отдано",
|
upload: "Отдано",
|
||||||
|
|||||||
@@ -30,9 +30,14 @@
|
|||||||
>
|
>
|
||||||
<el-button>{{ $t("common.import") }}</el-button>
|
<el-button>{{ $t("common.import") }}</el-button>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
|
<el-tooltip :content="$t('peer.exportSettingsHint')" placement="bottom">
|
||||||
<el-button class="ml-2" @click="handleExport">{{
|
<el-button class="ml-2" @click="handleExport">{{
|
||||||
$t("common.export")
|
$t("peer.exportSettings")
|
||||||
}}</el-button>
|
}}</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-button class="ml-2" type="warning" plain @click="handleExportBackup">
|
||||||
|
{{ $t("peer.exportBackup") }}
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table v-loading="loading" :data="records">
|
<el-table v-loading="loading" :data="records">
|
||||||
<el-table-column :label="$t('peer.name')" min-width="220">
|
<el-table-column :label="$t('peer.name')" min-width="220">
|
||||||
@@ -558,9 +563,9 @@ function beforeImport(file: UploadRawFile) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleExport() {
|
async function downloadExport(includeSecrets: boolean) {
|
||||||
try {
|
try {
|
||||||
const response = await exportPeerApi();
|
const response = await exportPeerApi(includeSecrets);
|
||||||
const blob = new Blob([response.data], {
|
const blob = new Blob([response.data], {
|
||||||
type: "application/octet-stream",
|
type: "application/octet-stream",
|
||||||
});
|
});
|
||||||
@@ -578,6 +583,28 @@ async function handleExport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обычный экспорт: настройки пиров без секретов.
|
||||||
|
async function handleExport() {
|
||||||
|
await downloadExport(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Резервная копия: файл содержит действующие секреты подключения всех пиров.
|
||||||
|
// Подтверждение здесь обязательно — это единственное место в панели, где
|
||||||
|
// оператор осознанно выгружает наружу работающие учётные данные доступа.
|
||||||
|
async function handleExportBackup() {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
t("peer.exportBackupConfirm"),
|
||||||
|
t("common.securityRisk"),
|
||||||
|
{
|
||||||
|
type: "warning",
|
||||||
|
confirmButtonText: t("peer.exportBackupConfirmButton"),
|
||||||
|
cancelButtonText: t("common.cancel"),
|
||||||
|
confirmButtonClass: "el-button--danger",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await downloadExport(true);
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(handleQuery);
|
onMounted(handleQuery);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+46
-4
@@ -1,11 +1,14 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"gopkg.in/natefinch/lumberjack.v2"
|
"gopkg.in/natefinch/lumberjack.v2"
|
||||||
"hy2xs-admin/model/constant"
|
"hy2xs-admin/model/constant"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func InitLog() {
|
func InitLog() {
|
||||||
@@ -21,6 +24,40 @@ func InitLog() {
|
|||||||
logrus.SetLevel(logrus.InfoLevel)
|
logrus.SetLevel(logrus.InfoLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requestQueryKeys возвращает ИМЕНА query-параметров запроса, без значений.
|
||||||
|
//
|
||||||
|
// Для диагностики важно, был ли параметр передан, а не что в нём лежало.
|
||||||
|
// Список сортируется, чтобы одинаковые запросы давали одинаковую строку в
|
||||||
|
// логе и её можно было группировать.
|
||||||
|
func requestQueryKeys(c *gin.Context) string {
|
||||||
|
query := c.Request.URL.Query()
|
||||||
|
if len(query) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(query))
|
||||||
|
for key := range query {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return strings.Join(keys, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogHandler пишет журнал HTTP-запросов админки.
|
||||||
|
//
|
||||||
|
// В лог попадает ПУТЬ, а не RequestURI.
|
||||||
|
//
|
||||||
|
// Разница здесь не косметическая. RequestURI включает query string, а Hysteria
|
||||||
|
// обращается к машинному endpoint'у как
|
||||||
|
// /internal/hysteria/auth?access_token=<machine token> — то есть при каждом
|
||||||
|
// подключении пира. Пока логировался RequestURI, действующий machine token
|
||||||
|
// оседал открытым текстом в /var/log/hy2xs/hy2xs-admin.log, а этот файл
|
||||||
|
// отдаётся оператору через ExportLog и попадает в diagnostics-бандл.
|
||||||
|
// Получался постоянный канал утечки секрета в обычной штатной работе, мимо
|
||||||
|
// всей структурной редакции, сделанной для конфигов и env.
|
||||||
|
//
|
||||||
|
// Значения query-параметров не логируются вовсе: список того, «что можно», при
|
||||||
|
// таком подходе пришлось бы поддерживать вручную и он неизбежно разошёлся бы с
|
||||||
|
// набором маршрутов. Имена параметров сохранены — их для диагностики хватает.
|
||||||
func LogHandler() gin.HandlerFunc {
|
func LogHandler() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
@@ -30,13 +67,18 @@ func LogHandler() gin.HandlerFunc {
|
|||||||
statusCode := c.Writer.Status()
|
statusCode := c.Writer.Status()
|
||||||
latencyTime := time.Since(startTime)
|
latencyTime := time.Since(startTime)
|
||||||
|
|
||||||
entry := logrus.WithFields(logrus.Fields{
|
fields := logrus.Fields{
|
||||||
"statusCode": statusCode,
|
"statusCode": statusCode,
|
||||||
"latencyTime": latencyTime.Milliseconds(),
|
"latencyTime": latencyTime.Milliseconds(),
|
||||||
"clientIP": c.ClientIP(),
|
"clientIP": c.ClientIP(),
|
||||||
"reqMethod": c.Request.Method,
|
"reqMethod": c.Request.Method,
|
||||||
"reqUri": c.Request.RequestURI,
|
"reqPath": c.Request.URL.Path,
|
||||||
})
|
}
|
||||||
|
if keys := requestQueryKeys(c); keys != "" {
|
||||||
|
fields["reqQueryKeys"] = keys
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := logrus.WithFields(fields)
|
||||||
|
|
||||||
if statusCode >= 500 {
|
if statusCode >= 500 {
|
||||||
entry.Error()
|
entry.Error()
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sentinel имитирует действующий machine token: если он окажется в журнале —
|
||||||
|
// значит секрет утёк в /var/log/hy2xs/hy2xs-admin.log, который отдаётся
|
||||||
|
// оператору через ExportLog и попадает в diagnostics-бандл.
|
||||||
|
const sentinel = "SUPER_SECRET_SENTINEL"
|
||||||
|
|
||||||
|
func captureLog(t *testing.T, request *http.Request) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
previousOut := logrus.StandardLogger().Out
|
||||||
|
previousFormatter := logrus.StandardLogger().Formatter
|
||||||
|
t.Cleanup(func() {
|
||||||
|
logrus.SetOutput(previousOut)
|
||||||
|
logrus.SetFormatter(previousFormatter)
|
||||||
|
})
|
||||||
|
|
||||||
|
buffer := &bytes.Buffer{}
|
||||||
|
logrus.SetOutput(buffer)
|
||||||
|
logrus.SetFormatter(&logrus.JSONFormatter{})
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
engine := gin.New()
|
||||||
|
engine.Use(LogHandler())
|
||||||
|
engine.POST("/internal/hysteria/auth", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||||
|
engine.GET("/api/peers", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||||
|
|
||||||
|
engine.ServeHTTP(httptest.NewRecorder(), request)
|
||||||
|
return buffer.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Регрессия: логировался c.Request.RequestURI, то есть путь ВМЕСТЕ с query.
|
||||||
|
// Hysteria обращается к машинному endpoint'у при каждом подключении пира, так
|
||||||
|
// что токен оседал в журнале в штатном режиме работы.
|
||||||
|
func TestLogHandlerNeverLogsQueryValues(t *testing.T) {
|
||||||
|
request := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/internal/hysteria/auth?access_token="+sentinel,
|
||||||
|
strings.NewReader("{}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
logged := captureLog(t, request)
|
||||||
|
|
||||||
|
if strings.Contains(logged, sentinel) {
|
||||||
|
t.Fatalf("machine token попал в журнал: %s", logged)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logged, `"reqPath":"/internal/hysteria/auth"`) {
|
||||||
|
t.Fatalf("в журнале нет пути запроса: %s", logged)
|
||||||
|
}
|
||||||
|
if strings.Contains(logged, "reqUri") {
|
||||||
|
t.Fatalf("поле reqUri вернулось в журнал: %s", logged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Имена параметров для диагностики сохраняются: важно видеть, что параметр был
|
||||||
|
// передан, а не что в нём лежало.
|
||||||
|
func TestLogHandlerKeepsQueryKeyNames(t *testing.T) {
|
||||||
|
request := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/internal/hysteria/auth?access_token="+sentinel,
|
||||||
|
strings.NewReader("{}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
logged := captureLog(t, request)
|
||||||
|
|
||||||
|
if !strings.Contains(logged, `"reqQueryKeys":"access_token"`) {
|
||||||
|
t.Fatalf("имя query-параметра не попало в журнал: %s", logged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogHandlerOmitsQueryKeysWhenThereAreNone(t *testing.T) {
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/peers", nil)
|
||||||
|
|
||||||
|
logged := captureLog(t, request)
|
||||||
|
|
||||||
|
if strings.Contains(logged, "reqQueryKeys") {
|
||||||
|
t.Fatalf("пустой список параметров не должен появляться в журнале: %s", logged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Значение секретоподобного параметра не должно попасть в журнал ни на каком
|
||||||
|
// маршруте, а не только на машинном.
|
||||||
|
func TestLogHandlerRedactsQueryValuesOnOperatorRoutes(t *testing.T) {
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/peers?includeSecrets=true&name="+sentinel, nil)
|
||||||
|
|
||||||
|
logged := captureLog(t, request)
|
||||||
|
|
||||||
|
if strings.Contains(logged, sentinel) {
|
||||||
|
t.Fatalf("значение query-параметра попало в журнал: %s", logged)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logged, `"reqPath":"/api/peers"`) {
|
||||||
|
t.Fatalf("в журнале нет пути запроса: %s", logged)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -38,7 +39,11 @@ func MachineAuthHandler() gin.HandlerFunc {
|
|||||||
provided = strings.TrimSpace(c.GetHeader("X-HY2XS-Machine-Token"))
|
provided = strings.TrimSpace(c.GetHeader("X-HY2XS-Machine-Token"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if provided == "" || provided != expected {
|
// Сравнение постоянного времени: обычное `!=` завершается на первом
|
||||||
|
// несовпавшем байте. Endpoint слушает только loopback, но локальный
|
||||||
|
// непривилегированный процесс — ровно та позиция, из которой такой
|
||||||
|
// замер и делается, а ценой ошибки здесь является machine token.
|
||||||
|
if provided == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 {
|
||||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||||
"code": http.StatusForbidden,
|
"code": http.StatusForbidden,
|
||||||
"type": "no",
|
"type": "no",
|
||||||
|
|||||||
@@ -19,3 +19,85 @@ const (
|
|||||||
Hysteria2ConfigRemark = "HYSTERIA2_CONFIG_REMARK"
|
Hysteria2ConfigRemark = "HYSTERIA2_CONFIG_REMARK"
|
||||||
ResetTrafficCron = "RESET_TRAFFIC_CRON"
|
ResetTrafficCron = "RESET_TRAFFIC_CRON"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Что операторский API имеет право прочитать и записать в таблице `config`.
|
||||||
|
//
|
||||||
|
// Список — allowlist, и это принципиально. Раньше здесь работал denylist из
|
||||||
|
// трёх orchestrator-ключей, а `getConfig`/`listConfig` принимали произвольную
|
||||||
|
// строку. В той же таблице лежат JWT_SECRET, PEER_SECRET_KEY,
|
||||||
|
// PEER_SECRET_ENCRYPTION_KEY и HYSTERIA2_TRAFFIC_STATS_SECRET — то есть
|
||||||
|
// авторизованный запрос `?key=PEER_SECRET_ENCRYPTION_KEY` отдавал master-key
|
||||||
|
// шифрования секретов пиров, а updateConfigs позволял его подменить. После
|
||||||
|
// подмены перестают расшифровываться секреты уже существующих пиров.
|
||||||
|
//
|
||||||
|
// Именно ради этого удаляли generic export/import таблицы `config`, но точечный
|
||||||
|
// API остался прежним, и дыра просто сменила размер отверстия.
|
||||||
|
//
|
||||||
|
// Denylist здесь неверен структурно: он требует, чтобы автор КАЖДОГО нового
|
||||||
|
// ключа вспомнил про этот файл. Забытый ключ при denylist сразу публичен, при
|
||||||
|
// allowlist — сразу закрыт. Отказ по умолчанию — единственное поведение,
|
||||||
|
// которое не зависит от внимательности.
|
||||||
|
var (
|
||||||
|
// publicReadableConfigKeys — настройки, которые панель показывает оператору.
|
||||||
|
publicReadableConfigKeys = map[string]struct{}{
|
||||||
|
Hysteria2TrafficTime: {},
|
||||||
|
ResetTrafficCron: {},
|
||||||
|
Hysteria2ConfigRemark: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicWritableConfigKeys — подмножество читаемых, которые оператор
|
||||||
|
// действительно редактирует в интерфейсе.
|
||||||
|
//
|
||||||
|
// HYSTERIA2_CONFIG_REMARK сюда не входит: панель его только отображает.
|
||||||
|
// Право записи, которым никто не пользуется, — это attack surface без
|
||||||
|
// пользователя.
|
||||||
|
publicWritableConfigKeys = map[string]struct{}{
|
||||||
|
Hysteria2TrafficTime: {},
|
||||||
|
ResetTrafficCron: {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsPublicReadableConfigKey сообщает, можно ли отдать значение ключа наружу.
|
||||||
|
func IsPublicReadableConfigKey(key string) bool {
|
||||||
|
_, ok := publicReadableConfigKeys[key]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPublicWritableConfigKey сообщает, можно ли принять значение ключа снаружи.
|
||||||
|
func IsPublicWritableConfigKey(key string) bool {
|
||||||
|
_, ok := publicWritableConfigKeys[key]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicReadableConfigKeys возвращает копию списка читаемых ключей.
|
||||||
|
// Используется тестами и текстами ошибок.
|
||||||
|
func PublicReadableConfigKeys() []string {
|
||||||
|
return keysOf(publicReadableConfigKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicWritableConfigKeys возвращает копию списка записываемых ключей.
|
||||||
|
func PublicWritableConfigKeys() []string {
|
||||||
|
return keysOf(publicWritableConfigKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InternalConfigKeys — ключи, которые не покидают процесс ни при каком
|
||||||
|
// сценарии. Объявлены явно, чтобы тест мог проверить их поимённо, а не
|
||||||
|
// полагаться на то, что их «случайно нет» в allowlist.
|
||||||
|
func InternalConfigKeys() []string {
|
||||||
|
return []string{
|
||||||
|
JwtSecret,
|
||||||
|
PeerSecretKey,
|
||||||
|
PeerSecretEncryptionKey,
|
||||||
|
Hysteria2TrafficStatsSecret,
|
||||||
|
Hysteria2Enable,
|
||||||
|
Hysteria2Config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keysOf(set map[string]struct{}) []string {
|
||||||
|
out := make([]string, 0, len(set))
|
||||||
|
for key := range set {
|
||||||
|
out = append(out, key)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package constant
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Allowlist существует ровно для того, чтобы забытый ключ был закрыт, а не
|
||||||
|
// открыт. Тест закрепляет обе стороны этого утверждения поимённо.
|
||||||
|
func TestInternalKeysAreNeitherReadableNorWritable(t *testing.T) {
|
||||||
|
for _, key := range InternalConfigKeys() {
|
||||||
|
if IsPublicReadableConfigKey(key) {
|
||||||
|
t.Errorf("внутренний ключ %s доступен на чтение через API", key)
|
||||||
|
}
|
||||||
|
if IsPublicWritableConfigKey(key) {
|
||||||
|
t.Errorf("внутренний ключ %s доступен на запись через API", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecretKeysAreListedAsInternal(t *testing.T) {
|
||||||
|
required := []string{
|
||||||
|
JwtSecret,
|
||||||
|
PeerSecretKey,
|
||||||
|
PeerSecretEncryptionKey,
|
||||||
|
Hysteria2TrafficStatsSecret,
|
||||||
|
}
|
||||||
|
internal := make(map[string]struct{}, len(InternalConfigKeys()))
|
||||||
|
for _, key := range InternalConfigKeys() {
|
||||||
|
internal[key] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, key := range required {
|
||||||
|
if _, ok := internal[key]; !ok {
|
||||||
|
t.Errorf("секрет %s не объявлен внутренним ключом", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperatorSettingsRemainAvailable(t *testing.T) {
|
||||||
|
for _, key := range []string{Hysteria2TrafficTime, ResetTrafficCron, Hysteria2ConfigRemark} {
|
||||||
|
if !IsPublicReadableConfigKey(key) {
|
||||||
|
t.Errorf("пользовательская настройка %s недоступна на чтение", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range []string{Hysteria2TrafficTime, ResetTrafficCron} {
|
||||||
|
if !IsPublicWritableConfigKey(key) {
|
||||||
|
t.Errorf("пользовательская настройка %s недоступна на запись", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Право записи без потребителя — это attack surface без пользователя.
|
||||||
|
// HYSTERIA2_CONFIG_REMARK панель только показывает.
|
||||||
|
func TestWritableIsSubsetOfReadable(t *testing.T) {
|
||||||
|
for _, key := range PublicWritableConfigKeys() {
|
||||||
|
if !IsPublicReadableConfigKey(key) {
|
||||||
|
t.Errorf("ключ %s можно записать, но нельзя прочитать", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if IsPublicWritableConfigKey(Hysteria2ConfigRemark) {
|
||||||
|
t.Error("HYSTERIA2_CONFIG_REMARK не редактируется в панели и не должен быть записываемым")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownKeyIsClosedByDefault(t *testing.T) {
|
||||||
|
if IsPublicReadableConfigKey("SOME_FUTURE_KEY") || IsPublicWritableConfigKey("SOME_FUTURE_KEY") {
|
||||||
|
t.Fatal("неизвестный ключ обязан быть закрыт по умолчанию")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
type ConfigDto struct {
|
// ConfigDto удалён вместе с маршрутом GET /config/getConfig: чтение
|
||||||
Key *string `json:"key" form:"key" validate:"required,min=1,max=128"`
|
// произвольного ключа таблицы `config` было точечным входом к JWT- и
|
||||||
}
|
// peer-ключам. Чтение идёт через ConfigsDto и allowlist.
|
||||||
|
|
||||||
type ConfigsDto struct {
|
type ConfigsDto struct {
|
||||||
Keys []string `json:"keys" form:"keys" validate:"required"`
|
Keys []string `json:"keys" form:"keys" validate:"required"`
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
package entity
|
|
||||||
|
|
||||||
type LegacyAccount struct {
|
|
||||||
Username *string `gorm:"column:username;default:''" json:"username"`
|
|
||||||
Pass *string `gorm:"column:pass;default:''" json:"pass"`
|
|
||||||
ConPass *string `gorm:"column:con_pass;default:''" json:"conPass"`
|
|
||||||
Quota *int64 `gorm:"column:quota;default:0" json:"quota"`
|
|
||||||
Download *int64 `gorm:"column:download;default:0" json:"download"`
|
|
||||||
Upload *int64 `gorm:"column:upload;default:0" json:"upload"`
|
|
||||||
ExpireTime *int64 `gorm:"column:expire_time;default:0" json:"expireTime"`
|
|
||||||
KickUtilTime *int64 `gorm:"column:kick_util_time;default:0" json:"kickUtilTime"`
|
|
||||||
DeviceNo *int64 `gorm:"column:device_no;default:3" json:"deviceNo"`
|
|
||||||
Role *string `gorm:"column:role;default:'user'" json:"role"`
|
|
||||||
Deleted *int64 `gorm:"column:deleted;default:0" json:"deleted"`
|
|
||||||
BaseEntity `gorm:"embedded"`
|
|
||||||
|
|
||||||
LoginAt *int64 `gorm:"column:login_at;default:0" json:"loginAt"`
|
|
||||||
ConAt *int64 `gorm:"column:con_at;default:0" json:"conAt"`
|
|
||||||
Remark *string `gorm:"column:remark;default:''" json:"remark"`
|
|
||||||
ForcePasswordChange *int64 `gorm:"column:force_password_change;default:0" json:"forcePasswordChange"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (LegacyAccount) TableName() string {
|
|
||||||
return "account"
|
|
||||||
}
|
|
||||||
@@ -5,13 +5,20 @@ type LogSystemPage[T LogSystemVo | LogHysteria2Vo] struct {
|
|||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LogSystemVo — строка журнала админки в том виде, в каком её пишет
|
||||||
|
// middleware.LogHandler.
|
||||||
|
//
|
||||||
|
// Поля reqUri здесь нет намеренно: RequestURI несёт query string, а по
|
||||||
|
// машинному endpoint'у в ней приезжает действующий machine token. Логируется
|
||||||
|
// путь и, отдельно, имена переданных query-параметров без значений.
|
||||||
type LogSystemVo struct {
|
type LogSystemVo struct {
|
||||||
ClientIP string `json:"clientIp"`
|
ClientIP string `json:"clientIp"`
|
||||||
LatencyTime int64 `json:"latencyTime"`
|
LatencyTime int64 `json:"latencyTime"`
|
||||||
Level string `json:"level"`
|
Level string `json:"level"`
|
||||||
Msg string `json:"msg"`
|
Msg string `json:"msg"`
|
||||||
ReqMethod string `json:"reqMethod"`
|
ReqMethod string `json:"reqMethod"`
|
||||||
ReqUri string `json:"reqUri"`
|
ReqPath string `json:"reqPath"`
|
||||||
|
ReqQueryKeys string `json:"reqQueryKeys"`
|
||||||
StatusCode int64 `json:"statusCode"`
|
StatusCode int64 `json:"statusCode"`
|
||||||
Time string `json:"time"`
|
Time string `json:"time"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,16 @@ import (
|
|||||||
// По той же причине здесь нет generic exportConfig/importConfig: они отдавали
|
// По той же причине здесь нет generic exportConfig/importConfig: они отдавали
|
||||||
// и принимали таблицу `config` целиком, вместе с JWT- и peer-ключами.
|
// и принимали таблицу `config` целиком, вместе с JWT- и peer-ключами.
|
||||||
// См. комментарий в controller/config.go.
|
// См. комментарий в controller/config.go.
|
||||||
|
//
|
||||||
|
// Маршрута getConfig тоже нет. Он принимал произвольный ключ и был вторым
|
||||||
|
// входом в ту же таблицу секретов, только точечным: `?key=JWT_SECRET`
|
||||||
|
// возвращал подписывающий ключ, `?key=PEER_SECRET_ENCRYPTION_KEY` — master-key
|
||||||
|
// шифрования секретов пиров. Потребителей у маршрута не было ни одного:
|
||||||
|
// панель читает настройки через listConfig, у которого теперь allowlist.
|
||||||
func initConfigRouter(configApi *gin.RouterGroup) {
|
func initConfigRouter(configApi *gin.RouterGroup) {
|
||||||
config := configApi.Group("/config")
|
config := configApi.Group("/config")
|
||||||
{
|
{
|
||||||
config.POST("/updateConfigs", controller.UpdateConfigs)
|
config.POST("/updateConfigs", controller.UpdateConfigs)
|
||||||
config.GET("/getConfig", controller.GetConfig)
|
|
||||||
config.POST("/listConfig", controller.ListConfig)
|
config.POST("/listConfig", controller.ListConfig)
|
||||||
config.GET("/getHysteria2Config", controller.GetHysteria2Config)
|
config.GET("/getHysteria2Config", controller.GetHysteria2Config)
|
||||||
config.POST("/exportHysteria2Config", controller.ExportHysteria2Config)
|
config.POST("/exportHysteria2Config", controller.ExportHysteria2Config)
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ func TestRouterHasNoRemovedRoutes(t *testing.T) {
|
|||||||
"restartServer",
|
"restartServer",
|
||||||
"uploadCertFile",
|
"uploadCertFile",
|
||||||
"hysteria2AcmePath",
|
"hysteria2AcmePath",
|
||||||
|
// getConfig принимал произвольный ключ таблицы `config` и был точечным
|
||||||
|
// входом к JWT_SECRET, PEER_SECRET_KEY и PEER_SECRET_ENCRYPTION_KEY.
|
||||||
|
// Потребителей у него не было ни одного.
|
||||||
|
"getConfig",
|
||||||
}
|
}
|
||||||
for _, route := range buildRoutes(t) {
|
for _, route := range buildRoutes(t) {
|
||||||
for _, dead := range removed {
|
for _, dead := range removed {
|
||||||
@@ -94,6 +98,37 @@ func TestRouterHasNoRemovedRoutes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Оставшиеся маршруты /config — это чтение по allowlist, запись по allowlist и
|
||||||
|
// санитизирующая выгрузка серверного конфига. Никакого generic-доступа к
|
||||||
|
// таблице `config` у API быть не должно.
|
||||||
|
func TestRouterConfigNamespaceIsClosed(t *testing.T) {
|
||||||
|
allowed := map[string]bool{
|
||||||
|
constant.AdminAPIBase + "/config/updateConfigs": true,
|
||||||
|
constant.AdminAPIBase + "/config/listConfig": true,
|
||||||
|
constant.AdminAPIBase + "/config/getHysteria2Config": true,
|
||||||
|
constant.AdminAPIBase + "/config/exportHysteria2Config": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, route := range buildRoutes(t) {
|
||||||
|
if !strings.HasPrefix(route.Path, constant.AdminAPIBase+"/config") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !allowed[route.Path] {
|
||||||
|
t.Errorf("в пространстве /config появился неожиданный маршрут: %s %s", route.Method, route.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
present := make(map[string]bool)
|
||||||
|
for _, route := range buildRoutes(t) {
|
||||||
|
present[route.Path] = true
|
||||||
|
}
|
||||||
|
for path := range allowed {
|
||||||
|
if !present[path] {
|
||||||
|
t.Errorf("маршрут %s пропал", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRouterExposesHealthz(t *testing.T) {
|
func TestRouterExposesHealthz(t *testing.T) {
|
||||||
found := false
|
found := false
|
||||||
for _, route := range buildRoutes(t) {
|
for _, route := range buildRoutes(t) {
|
||||||
|
|||||||
+12
-2
@@ -58,10 +58,19 @@ func ReadHysteriaJournalLogs(numLine int) ([]vo.LogHysteria2Vo, int, error) {
|
|||||||
return result, len(result), nil
|
return result, len(result), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportHysteriaJournalLogs отдаёт журнал Hysteria оператору.
|
||||||
|
//
|
||||||
|
// Файл покидает сервер, поэтому проходит тот же санитайз, что и остальные
|
||||||
|
// выгружаемые артефакты: HY2_AUTH_URL несёт access_token, и upstream волен
|
||||||
|
// упомянуть его в сообщении об ошибке обращения к auth-backend.
|
||||||
func ExportHysteriaJournalLogs(numLine int) (string, error) {
|
func ExportHysteriaJournalLogs(numLine int) (string, error) {
|
||||||
lines := normalizeJournalLineLimit(numLine, 5000, 10000)
|
lines := normalizeJournalLineLimit(numLine, 5000, 10000)
|
||||||
|
|
||||||
return util.Exec(fmt.Sprintf("journalctl --no-pager -u hysteria-server.service -n %d -o short-iso", lines))
|
output, err := util.Exec(fmt.Sprintf("journalctl --no-pager -u hysteria-server.service -n %d -o short-iso", lines))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return SanitizeLogText(output), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseHysteriaJournalRecord(item journalctlLogLine) vo.LogHysteria2Vo {
|
func parseHysteriaJournalRecord(item journalctlLogLine) vo.LogHysteria2Vo {
|
||||||
@@ -87,12 +96,13 @@ func parseHysteriaJournalRecord(item journalctlLogLine) vo.LogHysteria2Vo {
|
|||||||
if parsed.Msg == "" {
|
if parsed.Msg == "" {
|
||||||
parsed.Msg = item.Message
|
parsed.Msg = item.Message
|
||||||
}
|
}
|
||||||
|
parsed.Msg = SanitizeLogText(parsed.Msg)
|
||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
return vo.LogHysteria2Vo{
|
return vo.LogHysteria2Vo{
|
||||||
Level: fallbackLevel,
|
Level: fallbackLevel,
|
||||||
Msg: item.Message,
|
Msg: SanitizeLogText(item.Message),
|
||||||
Time: fallbackTime,
|
Time: fallbackTime,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Санитайз свободного текста логов.
|
||||||
|
//
|
||||||
|
// Отличается от YAML-санитайзера тем, что у него нет структуры: журнал — это
|
||||||
|
// строки, а не документ. Поэтому правил ровно два и оба консервативные:
|
||||||
|
//
|
||||||
|
// 1. всё, что разбирается как URL, прогоняется через тот же sanitizeURLValue,
|
||||||
|
// что и экспорт конфига: host/port/path остаются читаемыми, встроенные
|
||||||
|
// учётные данные и секретные query-параметры вырезаются;
|
||||||
|
// 2. отдельно ловятся пары `имя=значение` с секретоподобным именем, которые
|
||||||
|
// встречаются в тексте вне URL.
|
||||||
|
//
|
||||||
|
// Зачем это журналу Hysteria: HY2_AUTH_URL содержит access_token, и upstream
|
||||||
|
// волен упомянуть его в сообщении об ошибке обращения к auth-backend. Свой
|
||||||
|
// журнал админки мы контролируем, чужой — нет, а ExportLog отдаёт его
|
||||||
|
// оператору тем же файлом.
|
||||||
|
|
||||||
|
var logURLPattern = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9+.\-]*://[^\s"'<>` + "`" + `]+`)
|
||||||
|
|
||||||
|
// Имена подобраны по тем же маркерам, что и secretKeyMarkers, но записаны
|
||||||
|
// явным перечислением: в свободном тексте подстрочный поиск по «token» дал бы
|
||||||
|
// слишком много ложных срабатываний на словах вроде «tokenizer».
|
||||||
|
var logSecretAssignmentPattern = regexp.MustCompile(
|
||||||
|
`(?i)([a-z0-9_\-]*(?:access_token|token|secret|password|passwd|passphrase|credential|api_?key|authorization|signature)[a-z0-9_\-]*)\s*[=:]\s*([^\s&,;"'<>` + "`" + `]+)`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// SanitizeLogText вырезает секреты из произвольного текста журнала.
|
||||||
|
func SanitizeLogText(raw string) string {
|
||||||
|
if raw == "" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
withSafeURLs := logURLPattern.ReplaceAllStringFunc(raw, func(match string) string {
|
||||||
|
body, trailing := splitTrailingPunctuation(match)
|
||||||
|
return sanitizeURLValue(body) + trailing
|
||||||
|
})
|
||||||
|
|
||||||
|
return logSecretAssignmentPattern.ReplaceAllStringFunc(withSafeURLs, func(match string) string {
|
||||||
|
groups := logSecretAssignmentPattern.FindStringSubmatch(match)
|
||||||
|
if len(groups) != 3 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
value, trailing := splitTrailingPunctuation(groups[2])
|
||||||
|
|
||||||
|
// Значение уже вырезано URL-санитайзером: percent-кодированный маркер
|
||||||
|
// внутри готового URL трогать нельзя, иначе адрес перестанет быть
|
||||||
|
// валидным URL.
|
||||||
|
if value == RedactedPlaceholder || strings.EqualFold(value, encodedRedactedPlaceholder) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
separator := "="
|
||||||
|
if strings.Contains(match, ":") && !strings.Contains(match, "=") {
|
||||||
|
separator = ": "
|
||||||
|
}
|
||||||
|
return groups[1] + separator + RedactedPlaceholder + trailing
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodedRedactedPlaceholder — то, во что превращается маркер внутри query
|
||||||
|
// после url.Values.Encode().
|
||||||
|
const encodedRedactedPlaceholder = "%3Credacted%3E"
|
||||||
|
|
||||||
|
// splitTrailingPunctuation отделяет хвостовую пунктуацию предложения от
|
||||||
|
// значения: в «...access_token=abc.» точка принадлежит тексту, а не токену.
|
||||||
|
func splitTrailingPunctuation(value string) (string, string) {
|
||||||
|
end := len(value)
|
||||||
|
for end > 0 && strings.ContainsRune(".,;:)]}!?", rune(value[end-1])) {
|
||||||
|
end--
|
||||||
|
}
|
||||||
|
return value[:end], value[end:]
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const journalSentinel = "SUPER_SECRET_SENTINEL"
|
||||||
|
|
||||||
|
func TestSanitizeLogTextRemovesMachineTokenFromUrl(t *testing.T) {
|
||||||
|
raw := `Aug 27 12:00:01 host hysteria[123]: {"level":"error","msg":"auth backend error: ` +
|
||||||
|
`Post \"http://127.0.0.1:8080/internal/hysteria/auth?access_token=` + journalSentinel + `\": EOF"}`
|
||||||
|
|
||||||
|
sanitized := SanitizeLogText(raw)
|
||||||
|
|
||||||
|
if strings.Contains(sanitized, journalSentinel) {
|
||||||
|
t.Fatalf("machine token остался в журнале: %s", sanitized)
|
||||||
|
}
|
||||||
|
// Адрес обязан остаться читаемым: диагностика теряет смысл, если из строки
|
||||||
|
// пропадает host и path.
|
||||||
|
if !strings.Contains(sanitized, "127.0.0.1:8080/internal/hysteria/auth") {
|
||||||
|
t.Fatalf("санитайз съел адрес целиком: %s", sanitized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeLogTextRemovesBareAssignments(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"connecting with access_token=" + journalSentinel,
|
||||||
|
"trafficStats secret=" + journalSentinel + " rejected",
|
||||||
|
"obfs password: " + journalSentinel,
|
||||||
|
"Authorization=" + journalSentinel,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, raw := range cases {
|
||||||
|
sanitized := SanitizeLogText(raw)
|
||||||
|
if strings.Contains(sanitized, journalSentinel) {
|
||||||
|
t.Errorf("секрет остался в строке %q: %s", raw, sanitized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeLogTextKeepsOrdinaryLines(t *testing.T) {
|
||||||
|
raw := "server up and running, listening on 0.0.0.0:443"
|
||||||
|
|
||||||
|
if sanitized := SanitizeLogText(raw); sanitized != raw {
|
||||||
|
t.Fatalf("обычная строка изменена: %q -> %q", raw, sanitized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Хвостовая пунктуация не является частью адреса.
|
||||||
|
func TestSanitizeLogTextKeepsTrailingPunctuation(t *testing.T) {
|
||||||
|
raw := "failed to call http://127.0.0.1:8080/internal/hysteria/auth?access_token=" + journalSentinel + "."
|
||||||
|
|
||||||
|
sanitized := SanitizeLogText(raw)
|
||||||
|
|
||||||
|
if strings.Contains(sanitized, journalSentinel) {
|
||||||
|
t.Fatalf("machine token остался: %s", sanitized)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(sanitized, ".") {
|
||||||
|
t.Fatalf("потеряна хвостовая пунктуация: %s", sanitized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Двойной проход не должен ломать уже вырезанное значение.
|
||||||
|
func TestSanitizeLogTextIsIdempotent(t *testing.T) {
|
||||||
|
raw := "http://127.0.0.1:8080/internal/hysteria/auth?access_token=" + journalSentinel
|
||||||
|
|
||||||
|
once := SanitizeLogText(raw)
|
||||||
|
twice := SanitizeLogText(once)
|
||||||
|
|
||||||
|
if once != twice {
|
||||||
|
t.Fatalf("санитайз не идемпотентен: %q -> %q", once, twice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeLogTextEmptyInput(t *testing.T) {
|
||||||
|
if SanitizeLogText("") != "" {
|
||||||
|
t.Fatal("пустой вход должен оставаться пустым")
|
||||||
|
}
|
||||||
|
}
|
||||||
+176
-79
@@ -204,119 +204,216 @@ func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// preparedPeerImport — запись импорта со всем криптоматериалом, посчитанным
|
||||||
|
// заранее.
|
||||||
|
//
|
||||||
|
// Крипто выносится ИЗ транзакции сознательно. PeerSecretDigest и
|
||||||
|
// EncryptPeerSecret читают ключи из таблицы `config`, то есть ходят в ту же
|
||||||
|
// базу; делать это, удерживая открытую запись, значит без нужды держать
|
||||||
|
// блокировку на время AES по каждой из тысяч записей. Внутри транзакции должна
|
||||||
|
// остаться только работа с таблицей пиров.
|
||||||
|
type preparedPeerImport struct {
|
||||||
|
source bo.PeerExport
|
||||||
|
|
||||||
|
name string
|
||||||
|
authID string
|
||||||
|
remark string
|
||||||
|
quota int64
|
||||||
|
expires int64
|
||||||
|
maxDevices int64
|
||||||
|
disabled int64
|
||||||
|
|
||||||
|
// Задан, только если секрет пришёл в файле: у существующего пира секрет
|
||||||
|
// перезаписывается лишь в этом случае.
|
||||||
|
hasExplicitSecret bool
|
||||||
|
explicitDigest string
|
||||||
|
explicitCipher string
|
||||||
|
|
||||||
|
// Готовятся всегда: понадобятся, если запись окажется новой.
|
||||||
|
createDigest string
|
||||||
|
createCipher string
|
||||||
|
createAuthID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
|
||||||
|
prepared := make([]preparedPeerImport, 0, len(items))
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
name := strings.TrimSpace(item.Name)
|
||||||
|
authID := strings.TrimSpace(item.AuthId)
|
||||||
|
|
||||||
|
maxDevices := item.MaxDevices
|
||||||
|
if maxDevices <= 0 {
|
||||||
|
maxDevices = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := preparedPeerImport{
|
||||||
|
source: item,
|
||||||
|
name: name,
|
||||||
|
authID: authID,
|
||||||
|
remark: item.Remark,
|
||||||
|
quota: item.QuotaBytes,
|
||||||
|
expires: item.ExpiresAt,
|
||||||
|
maxDevices: maxDevices,
|
||||||
|
disabled: item.Disabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
explicitSecret := strings.TrimSpace(item.Secret)
|
||||||
|
if explicitSecret != "" {
|
||||||
|
digest, err := PeerSecretDigest(explicitSecret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cipher, err := EncryptPeerSecret(explicitSecret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entry.hasExplicitSecret = true
|
||||||
|
entry.explicitDigest = digest
|
||||||
|
entry.explicitCipher = cipher
|
||||||
|
entry.createDigest = digest
|
||||||
|
entry.createCipher = cipher
|
||||||
|
} else {
|
||||||
|
generated, err := util.RandomString(24)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
createSecret := fmt.Sprintf("%s.%s", name, generated)
|
||||||
|
digest, err := PeerSecretDigest(createSecret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cipher, err := EncryptPeerSecret(createSecret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entry.createDigest = digest
|
||||||
|
entry.createCipher = cipher
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.createAuthID = authID
|
||||||
|
if entry.createAuthID == "" {
|
||||||
|
generated, err := util.RandomString(18)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entry.createAuthID = generated
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared = append(prepared, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return prepared, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertPeerExport применяет выгрузку пиров целиком или не применяет вовсе.
|
||||||
|
//
|
||||||
|
// Три прохода, и каждый отвечает за своё:
|
||||||
|
//
|
||||||
|
// 1. ValidatePeerImportBatch — содержимое файла, без обращения к базе;
|
||||||
|
// 2. preparePeerImport — весь криптоматериал, без обращения к таблице пиров;
|
||||||
|
// 3. одна транзакция — только записи.
|
||||||
|
//
|
||||||
|
// Раньше третьего прохода не существовало: записи шли по одной, каждая своим
|
||||||
|
// оператором. Комментарий обещал «либо целиком, либо никак», но UNIQUE-конфликт
|
||||||
|
// на 37-й записи оставлял 36 применённых, и откатить это оператор уже не мог.
|
||||||
|
// Конфликт не гипотетический: пусть в базе есть A(auth_id=a, name=alice) и
|
||||||
|
// B(auth_id=b, name=bob), а файл несёт (auth_id=a, name=bob). Поиск найдёт A
|
||||||
|
// по auth_id и переименует его в bob — прямо в UNIQUE(name).
|
||||||
func UpsertPeerExport(items []bo.PeerExport) error {
|
func UpsertPeerExport(items []bo.PeerExport) error {
|
||||||
// Первый проход — только проверка. Импорт либо применяется целиком, либо
|
|
||||||
// не применяется вовсе: наполовину импортированный список пиров хуже
|
|
||||||
// отклонённого файла.
|
|
||||||
if err := ValidatePeerImportBatch(items); err != nil {
|
if err := ValidatePeerImportBatch(items); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, item := range items {
|
prepared, err := preparePeerImport(items)
|
||||||
name := strings.TrimSpace(item.Name)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dao.WithPeerTx(func(tx dao.PeerTx) error {
|
||||||
|
for _, entry := range prepared {
|
||||||
|
if err := applyPeerImportEntry(tx, entry); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error {
|
||||||
var existing entity.Peer
|
var existing entity.Peer
|
||||||
var err error
|
var err error
|
||||||
authID := strings.TrimSpace(item.AuthId)
|
|
||||||
if authID != "" {
|
if entry.authID != "" {
|
||||||
existing, err = dao.GetPeer("auth_id = ?", authID)
|
existing, err = tx.GetPeer("auth_id = ?", entry.authID)
|
||||||
}
|
}
|
||||||
if err != nil || existing.Id == nil {
|
if err != nil || existing.Id == nil {
|
||||||
existing, err = dao.GetPeer("name = ?", name)
|
existing, err = tx.GetPeer("name = ?", entry.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пир установщика не переопределяется импортом ни при каком совпадении:
|
// Пир установщика не переопределяется импортом ни при каком совпадении:
|
||||||
// его секрет живёт ещё и в /etc/hy2xs/bootstrap-admin.secret.
|
// его секрет живёт ещё и в /etc/hy2xs/bootstrap-admin.secret.
|
||||||
if err == nil && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName {
|
if err == nil && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName {
|
||||||
return fmt.Errorf("peer import: пир %q принадлежит установщику и не может быть изменён импортом", ReservedBootstrapPeerName)
|
return fmt.Errorf(
|
||||||
|
"peer import: пир %q принадлежит установщику и не может быть изменён импортом",
|
||||||
|
ReservedBootstrapPeerName,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
quota := item.QuotaBytes
|
|
||||||
expires := item.ExpiresAt
|
|
||||||
maxDevices := item.MaxDevices
|
|
||||||
if maxDevices <= 0 {
|
|
||||||
maxDevices = 3
|
|
||||||
}
|
|
||||||
disabled := item.Disabled
|
|
||||||
remark := item.Remark
|
|
||||||
|
|
||||||
if err == nil && existing.Id != nil {
|
if err == nil && existing.Id != nil {
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"name": name,
|
"name": entry.name,
|
||||||
"remark": remark,
|
"remark": entry.remark,
|
||||||
"quota_bytes": quota,
|
"quota_bytes": entry.quota,
|
||||||
"download_bytes": item.DownloadBytes,
|
"download_bytes": entry.source.DownloadBytes,
|
||||||
"upload_bytes": item.UploadBytes,
|
"upload_bytes": entry.source.UploadBytes,
|
||||||
"expires_at": expires,
|
"expires_at": entry.expires,
|
||||||
"max_devices": maxDevices,
|
"max_devices": entry.maxDevices,
|
||||||
"disabled": disabled,
|
"disabled": entry.disabled,
|
||||||
"banned_until": item.BannedUntil,
|
"banned_until": entry.source.BannedUntil,
|
||||||
"last_connection_at": item.LastConnectionAt,
|
"last_connection_at": entry.source.LastConnectionAt,
|
||||||
}
|
}
|
||||||
if authID != "" {
|
if entry.authID != "" {
|
||||||
updates["auth_id"] = authID
|
updates["auth_id"] = entry.authID
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(item.Secret) != "" {
|
if entry.hasExplicitSecret {
|
||||||
digest, derr := PeerSecretDigest(item.Secret)
|
updates["secret_digest"] = entry.explicitDigest
|
||||||
if derr != nil {
|
updates["secret_ciphertext"] = entry.explicitCipher
|
||||||
return derr
|
|
||||||
}
|
}
|
||||||
enc, eerr := EncryptPeerSecret(item.Secret)
|
return tx.UpdatePeer([]int64{*existing.Id}, updates)
|
||||||
if eerr != nil {
|
|
||||||
return eerr
|
|
||||||
}
|
|
||||||
updates["secret_digest"] = digest
|
|
||||||
updates["secret_ciphertext"] = enc
|
|
||||||
}
|
|
||||||
if uerr := dao.UpdatePeer([]int64{*existing.Id}, updates); uerr != nil {
|
|
||||||
return uerr
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createSecret := strings.TrimSpace(item.Secret)
|
name := entry.name
|
||||||
if createSecret == "" {
|
remark := entry.remark
|
||||||
generated, gerr := util.RandomString(24)
|
authID := entry.createAuthID
|
||||||
if gerr != nil {
|
digest := entry.createDigest
|
||||||
return gerr
|
cipher := entry.createCipher
|
||||||
}
|
quota := entry.quota
|
||||||
createSecret = fmt.Sprintf("%s.%s", name, generated)
|
expires := entry.expires
|
||||||
}
|
maxDevices := entry.maxDevices
|
||||||
secretDigest, derr := PeerSecretDigest(createSecret)
|
disabled := entry.disabled
|
||||||
if derr != nil {
|
download := entry.source.DownloadBytes
|
||||||
return derr
|
upload := entry.source.UploadBytes
|
||||||
}
|
bannedUntil := entry.source.BannedUntil
|
||||||
secretEncrypted, eerr := EncryptPeerSecret(createSecret)
|
lastConnection := entry.source.LastConnectionAt
|
||||||
if eerr != nil {
|
|
||||||
return eerr
|
|
||||||
}
|
|
||||||
if authID == "" {
|
|
||||||
authID, err = util.RandomString(18)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
peer := entity.Peer{
|
peer := entity.Peer{
|
||||||
Name: &name,
|
Name: &name,
|
||||||
Remark: &remark,
|
Remark: &remark,
|
||||||
AuthId: &authID,
|
AuthId: &authID,
|
||||||
SecretDigest: &secretDigest,
|
SecretDigest: &digest,
|
||||||
SecretEncrypted: &secretEncrypted,
|
SecretEncrypted: &cipher,
|
||||||
QuotaBytes: "a,
|
QuotaBytes: "a,
|
||||||
DownloadBytes: &item.DownloadBytes,
|
DownloadBytes: &download,
|
||||||
UploadBytes: &item.UploadBytes,
|
UploadBytes: &upload,
|
||||||
ExpiresAt: &expires,
|
ExpiresAt: &expires,
|
||||||
MaxDevices: &maxDevices,
|
MaxDevices: &maxDevices,
|
||||||
Disabled: &disabled,
|
Disabled: &disabled,
|
||||||
BannedUntil: &item.BannedUntil,
|
BannedUntil: &bannedUntil,
|
||||||
LastConnectionAt: &item.LastConnectionAt,
|
LastConnectionAt: &lastConnection,
|
||||||
}
|
}
|
||||||
if _, serr := dao.SavePeer(peer); serr != nil {
|
_, saveErr := tx.SavePeer(peer)
|
||||||
return serr
|
return saveErr
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExistPeerName(name string, id int64) bool {
|
func ExistPeerName(name string, id int64) bool {
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hy2xs-admin/dao"
|
||||||
|
"hy2xs-admin/model/bo"
|
||||||
|
"hy2xs-admin/model/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Тесты транзакционности импорта работают на настоящей SQLite: UNIQUE-конфликт,
|
||||||
|
// ради которого транзакция и нужна, воспроизводится только в базе. Схема
|
||||||
|
// поднимается отдельно от InitSql — bootstrap-администратор и bootstrap-пир
|
||||||
|
// здесь не нужны и только мешали бы читать ожидания.
|
||||||
|
func newTestDB(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||||
|
if err := dao.InitSqliteDBAt(dbPath); err != nil {
|
||||||
|
t.Fatalf("не удалось открыть тестовую базу: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.RunMigrations(); err != nil {
|
||||||
|
t.Fatalf("не удалось применить миграции: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = dao.CloseSqliteDB()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedPeer(t *testing.T, name string, authID string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
digest, err := PeerSecretDigest(name + "-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("digest: %v", err)
|
||||||
|
}
|
||||||
|
cipher, err := EncryptPeerSecret(name + "-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
remark := ""
|
||||||
|
quota := int64(-1)
|
||||||
|
zero := int64(0)
|
||||||
|
maxDevices := int64(3)
|
||||||
|
|
||||||
|
id, err := dao.SavePeer(entity.Peer{
|
||||||
|
Name: &name,
|
||||||
|
Remark: &remark,
|
||||||
|
AuthId: &authID,
|
||||||
|
SecretDigest: &digest,
|
||||||
|
SecretEncrypted: &cipher,
|
||||||
|
QuotaBytes: "a,
|
||||||
|
DownloadBytes: &zero,
|
||||||
|
UploadBytes: &zero,
|
||||||
|
ExpiresAt: &zero,
|
||||||
|
MaxDevices: &maxDevices,
|
||||||
|
Disabled: &zero,
|
||||||
|
BannedUntil: &zero,
|
||||||
|
LastConnectionAt: &zero,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("не удалось создать пира %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotPeers(t *testing.T) map[string]entity.Peer {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
peers, err := dao.ListPeer("1=1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("не удалось прочитать пиров: %v", err)
|
||||||
|
}
|
||||||
|
out := make(map[string]entity.Peer, len(peers))
|
||||||
|
for _, peer := range peers {
|
||||||
|
out[*peer.Name] = peer
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func importItem(name string, authID string) bo.PeerExport {
|
||||||
|
return bo.PeerExport{
|
||||||
|
Name: name,
|
||||||
|
AuthId: authID,
|
||||||
|
QuotaBytes: -1,
|
||||||
|
MaxDevices: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertPeerExportAppliesWholeBatch(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
|
||||||
|
items := []bo.PeerExport{
|
||||||
|
importItem("alpha1", ""),
|
||||||
|
importItem("bravo2", ""),
|
||||||
|
importItem("charl3", ""),
|
||||||
|
}
|
||||||
|
if err := UpsertPeerExport(items); err != nil {
|
||||||
|
t.Fatalf("валидный импорт отклонён: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
peers := snapshotPeers(t)
|
||||||
|
if len(peers) != 3 {
|
||||||
|
t.Fatalf("ожидалось 3 пира, получено %d", len(peers))
|
||||||
|
}
|
||||||
|
for _, name := range []string{"alpha1", "bravo2", "charl3"} {
|
||||||
|
if _, ok := peers[name]; !ok {
|
||||||
|
t.Errorf("пир %s не создан", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Главная регрессия: cross-conflict, описанный в комментарии к UpsertPeerExport.
|
||||||
|
//
|
||||||
|
// В базе A(auth_id=aaa, name=alice1) и B(auth_id=bbb, name=bob123).
|
||||||
|
// Импорт несёт запись (auth_id=aaa, name=bob123): поиск найдёт A по auth_id и
|
||||||
|
// попытается переименовать её в bob123 — прямо в UNIQUE(name).
|
||||||
|
//
|
||||||
|
// До транзакции всё, что шло в файле ДО конфликтной записи, оставалось
|
||||||
|
// применённым.
|
||||||
|
func TestUpsertPeerExportRollsBackCrossConflict(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
|
||||||
|
seedPeer(t, "alice1", "aaa")
|
||||||
|
seedPeer(t, "bob123", "bbb")
|
||||||
|
before := snapshotPeers(t)
|
||||||
|
|
||||||
|
items := []bo.PeerExport{
|
||||||
|
importItem("first1", ""), // применилась бы
|
||||||
|
importItem("second", ""), // применилась бы
|
||||||
|
importItem("bob123", "aaa"), // UNIQUE(name) конфликт
|
||||||
|
importItem("fourth", ""), // до неё дело не дойдёт
|
||||||
|
}
|
||||||
|
|
||||||
|
err := UpsertPeerExport(items)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("импорт с конфликтом UNIQUE должен быть отклонён")
|
||||||
|
}
|
||||||
|
// Отказ обязан прийти из базы, а не из валидации: иначе тест доказывал бы
|
||||||
|
// только то, что файл не прошёл проверку, и про откат не говорил бы ничего.
|
||||||
|
if strings.HasPrefix(err.Error(), "peer import:") {
|
||||||
|
t.Fatalf("ожидался отказ базы, получен отказ валидации: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after := snapshotPeers(t)
|
||||||
|
if len(after) != len(before) {
|
||||||
|
t.Fatalf("состояние базы изменилось: было %d пиров, стало %d", len(before), len(after))
|
||||||
|
}
|
||||||
|
for name, peer := range before {
|
||||||
|
got, ok := after[name]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("пир %s пропал после отката", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if *got.AuthId != *peer.AuthId {
|
||||||
|
t.Errorf("у пира %s изменился auth_id: %s -> %s", name, *peer.AuthId, *got.AuthId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{"first1", "second", "fourth"} {
|
||||||
|
if _, ok := after[name]; ok {
|
||||||
|
t.Errorf("частично применённый импорт: пир %s остался в базе", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тот же инвариант на дубликате auth_id: конфликт возникает на вставке, а не
|
||||||
|
// на обновлении.
|
||||||
|
func TestUpsertPeerExportRollsBackDuplicateAuthId(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
|
||||||
|
seedPeer(t, "exist1", "shared-auth-id")
|
||||||
|
before := snapshotPeers(t)
|
||||||
|
|
||||||
|
items := []bo.PeerExport{
|
||||||
|
importItem("newone", ""),
|
||||||
|
// Имя новое, поэтому запись будет вставляться, а auth_id уже занят.
|
||||||
|
importItem("newtwo", "shared-auth-id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := UpsertPeerExport(items)
|
||||||
|
if err == nil {
|
||||||
|
// Совпадение по auth_id приводит к обновлению существующего пира —
|
||||||
|
// это допустимая семантика upsert. Тогда проверяем, что первая запись
|
||||||
|
// применилась вместе со второй, а не вместо неё.
|
||||||
|
after := snapshotPeers(t)
|
||||||
|
if _, ok := after["newone"]; !ok {
|
||||||
|
t.Fatal("успешный импорт не применил первую запись")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
after := snapshotPeers(t)
|
||||||
|
if len(after) != len(before) {
|
||||||
|
t.Fatalf("частичный импорт: было %d пиров, стало %d", len(before), len(after))
|
||||||
|
}
|
||||||
|
if _, ok := after["newone"]; ok {
|
||||||
|
t.Error("частично применённый импорт: пир newone остался в базе")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отказ подготовки (валидация партии) не должен доходить до базы вовсе.
|
||||||
|
func TestUpsertPeerExportRejectsInvalidBatchBeforeAnyWrite(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
|
||||||
|
items := []bo.PeerExport{
|
||||||
|
importItem("valid1", ""),
|
||||||
|
importItem("bad", ""), // короче 6 символов
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := UpsertPeerExport(items); err == nil {
|
||||||
|
t.Fatal("партия с невалидной записью должна быть отклонена")
|
||||||
|
}
|
||||||
|
|
||||||
|
if peers := snapshotPeers(t); len(peers) != 0 {
|
||||||
|
t.Fatalf("отклонённая партия оставила записи в базе: %d", len(peers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пир установщика защищён и внутри транзакции: отказ обязан откатить всё, что
|
||||||
|
// успело примениться до него.
|
||||||
|
func TestUpsertPeerExportRollsBackOnBootstrapPeer(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
|
||||||
|
seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName)
|
||||||
|
|
||||||
|
items := []bo.PeerExport{
|
||||||
|
importItem("first1", ""),
|
||||||
|
// Имя зарезервировано, поэтому валидация не пропустит его напрямую;
|
||||||
|
// совпадение достигается через auth_id установщика.
|
||||||
|
importItem("second", ReservedBootstrapPeerName),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := UpsertPeerExport(items); err == nil {
|
||||||
|
t.Fatal("импорт, задевающий пира установщика, должен быть отклонён")
|
||||||
|
}
|
||||||
|
|
||||||
|
after := snapshotPeers(t)
|
||||||
|
if _, ok := after["first1"]; ok {
|
||||||
|
t.Error("частично применённый импорт: пир first1 остался в базе")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление существующего пира без секрета в файле не должно трогать его
|
||||||
|
// секрет: иначе перенос настроек ломал бы работающие клиентские ссылки.
|
||||||
|
func TestUpsertPeerExportKeepsSecretWhenFileHasNone(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
|
||||||
|
seedPeer(t, "keeper", "keeper-auth")
|
||||||
|
before := snapshotPeers(t)["keeper"]
|
||||||
|
|
||||||
|
item := importItem("keeper", "keeper-auth")
|
||||||
|
item.Remark = "updated remark"
|
||||||
|
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
|
||||||
|
t.Fatalf("импорт отклонён: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after := snapshotPeers(t)["keeper"]
|
||||||
|
if *after.SecretDigest != *before.SecretDigest {
|
||||||
|
t.Error("секрет пира перезаписан, хотя в файле его не было")
|
||||||
|
}
|
||||||
|
if *after.Remark != "updated remark" {
|
||||||
|
t.Errorf("remark не обновился: %q", *after.Remark)
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
-14
@@ -66,18 +66,20 @@ HY2XS admin работает как надстройка над Hysteria YAML/AP
|
|||||||
| Маршрут панели | всегда `/` |
|
| Маршрут панели | всегда `/` |
|
||||||
| TLS | терминируется снаружи (SSH-туннель или reverse proxy) |
|
| TLS | терминируется снаружи (SSH-туннель или reverse proxy) |
|
||||||
|
|
||||||
До v1 эти величины дублировались в таблице `config` под ключами
|
До v1 эти величины дублировались в таблице `config` собственными ключами
|
||||||
`H_UI_WEB_PORT`, `H_UI_WEB_CONTEXT`, `H_UI_CRT_PATH`, `H_UI_KEY_PATH` —
|
панели: оркестратор передавал порт аргументом, панель записывала его в SQLite и
|
||||||
наследие H UI, где панель публиковалась наружу самостоятельно. Получался круг:
|
тут же читала обратно, а UI показывал поля в disabled-виде. Ни одного факта база
|
||||||
оркестратор передавал порт аргументом, панель записывала его в SQLite и тут же
|
при этом не добавляла — это был второй источник истины без содержания.
|
||||||
читала обратно, а UI показывал поля в disabled-виде. Ни одного факта база при
|
|
||||||
этом не добавляла.
|
|
||||||
|
|
||||||
В v1 этих ключей нет ни в схеме, ни в seed, ни в интерфейсе. Собственного
|
В v1 таких ключей нет ни в схеме, ни в seed, ни в интерфейсе. Собственного
|
||||||
TLS-слоя у панели тоже нет: production-контракт — `HY2XS_UI_BIND_HOST=127.0.0.1`
|
TLS-слоя у панели тоже нет: production-контракт — `HY2XS_UI_BIND_HOST=127.0.0.1`
|
||||||
и `HY2XS_UI_PUBLIC_ACCESS=false`, то есть внутренний сервис. Если панели
|
и `HY2XS_UI_PUBLIC_ACCESS=false`, то есть внутренний сервис. Если панели
|
||||||
когда-нибудь понадобится публичный endpoint, TLS обязан заканчиваться на
|
когда-нибудь понадобится публичный endpoint, TLS обязан заканчиваться на
|
||||||
ingress/reverse-proxy, а не возвращаться к модели H UI.
|
ingress/reverse-proxy, а не возвращаться к модели «панель публикует себя сама».
|
||||||
|
|
||||||
|
Имена ключей предыдущего поколения намеренно не приводятся: в обычных v1-доках
|
||||||
|
их словаря нет. Всё, что нужно для распознавания и удаления старой установки, —
|
||||||
|
в [14-legacy-cleanup.md](14-legacy-cleanup.md).
|
||||||
|
|
||||||
## Пространства имён HTTP API
|
## Пространства имён HTTP API
|
||||||
|
|
||||||
@@ -90,7 +92,7 @@ ingress/reverse-proxy, а не возвращаться к модели H UI.
|
|||||||
Разделение отражает разницу в природе маршрутов. `/internal/hysteria/auth` —
|
Разделение отражает разницу в природе маршрутов. `/internal/hysteria/auth` —
|
||||||
не интерфейс для человека и не часть операторского API: это внутренний
|
не интерфейс для человека и не часть операторского API: это внутренний
|
||||||
IPC-подобный HTTP endpoint между двумя процессами на одной машине. До v1 он
|
IPC-подобный HTTP endpoint между двумя процессами на одной машине. До v1 он
|
||||||
лежал под тем же префиксом `hui`, что и JWT-защищённый админский API, хотя
|
лежал под тем же префиксом, что и JWT-защищённый админский API, хотя
|
||||||
middleware у них не пересекаются.
|
middleware у них не пересекаются.
|
||||||
|
|
||||||
Путь machine-auth — **runtime-контракт продукта**: он записывается в
|
Путь machine-auth — **runtime-контракт продукта**: он записывается в
|
||||||
@@ -99,11 +101,43 @@ middleware у них не пересекаются.
|
|||||||
`HYSTERIA_MACHINE_AUTH_PATH` в оркестраторе, — а сборка сверяет их между собой
|
`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`) | есть |
|
| Импорт пиров (`POST /api/peer-import`) | есть |
|
||||||
| Экспорт конфига Hysteria (`POST /api/config/exportHysteria2Config`) | есть, с вырезанием секретов |
|
| Экспорт конфига Hysteria (`POST /api/config/exportHysteria2Config`) | есть, с вырезанием секретов |
|
||||||
| Экспорт/импорт таблицы `config` | **удалён** |
|
| Экспорт/импорт таблицы `config` | **удалён** |
|
||||||
@@ -118,21 +152,91 @@ Hysteria YAML. В той же таблице лежат `JWT_SECRET`, `PEER_SECR
|
|||||||
Осмысленного production-сценария у этой пары не было: конфигурацией сервера
|
Осмысленного production-сценария у этой пары не было: конфигурацией сервера
|
||||||
владеет оркестратор, перенос пиров делают `peer-import`/`peer-export`.
|
владеет оркестратор, перенос пиров делают `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/`, и файл там
|
Раньше они шли через `os.Create` в `/var/lib/hy2xs-admin/export/`, и файл там
|
||||||
оставался навсегда — при `?includeSecrets=true` это означало расшифрованные
|
оставался навсегда — при `?includeSecrets=true` это означало расшифрованные
|
||||||
секреты пиров на диске, накапливающиеся с каждым нажатием кнопки. Каталога
|
секреты пиров на диске, накапливающиеся с каждым нажатием кнопки. Каталога
|
||||||
`export/` больше не существует.
|
`export/` больше не существует.
|
||||||
|
|
||||||
Импорт пиров проверяется так же строго, как обычное создание пира: те же
|
### Экспорт пиров: два режима, а не флаг
|
||||||
правила для имени, quota, `maxDevices`, `disabled`, длины секрета. Дополнительно:
|
|
||||||
|
| Кнопка | Запрос | Что внутри |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Экспорт настроек** | `POST /api/peer-export` | список пиров без секретов |
|
||||||
|
| **Резервная копия** | `POST /api/peer-export?includeSecrets=true` | то же плюс действующие секреты подключения |
|
||||||
|
|
||||||
|
Разница здесь продуктовая, а не техническая, и её нельзя оставлять неявной.
|
||||||
|
Записи с пустым секретом при импорте получают **новые** секреты. То есть
|
||||||
|
перенос обычным экспортом восстанавливает пиров, их квоты, лимиты и счётчики —
|
||||||
|
но все существующие клиентские ссылки после него перестают работать.
|
||||||
|
|
||||||
|
Раньше кнопка в панели была одна и всегда звала маршрут без `includeSecrets`,
|
||||||
|
хотя документация называла эту пару механизмом переноса пиров. Оператор
|
||||||
|
переносил пиров и обнаруживал, что все клиенты отвалились.
|
||||||
|
|
||||||
|
Резервная копия содержит фактические учётные данные доступа к VPN в открытом
|
||||||
|
виде, поэтому запускается только через явное подтверждение с описанием риска.
|
||||||
|
Такой файл следует хранить как пароль и удалять после завершения переноса.
|
||||||
|
|
||||||
|
### Импорт пиров
|
||||||
|
|
||||||
|
Импорт проверяется так же строго, как обычное создание пира: те же правила для
|
||||||
|
имени, quota, `maxDevices`, `disabled`, длины секрета. Дополнительно:
|
||||||
|
|
||||||
- неизвестные поля в JSON отклоняются, а не игнорируются молча;
|
- неизвестные поля в JSON отклоняются, а не игнорируются молча;
|
||||||
- партия проверяется целиком **до** первой записи в базу — файл применяется
|
- файл обязан содержать **ровно один** JSON-документ. `json.Decoder` читает
|
||||||
полностью или не применяется вовсе;
|
первый документ и останавливается, поэтому файл с хвостом принимался целиком,
|
||||||
|
а его вторая половина молча не применялась;
|
||||||
|
- партия проверяется целиком **до** первой записи в базу;
|
||||||
|
- применение идёт **одной транзакцией**;
|
||||||
- пир `bootstrap-admin-peer` защищён от перезаписи: его секрет продублирован
|
- пир `bootstrap-admin-peer` защищён от перезаписи: его секрет продублирован
|
||||||
в `/etc/hy2xs/bootstrap-admin.secret`.
|
в `/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
|
## Два слоя работы с конфигом Hysteria
|
||||||
|
|
||||||
Это важное архитектурное разделение.
|
Это важное архитектурное разделение.
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
Установка разделена на две фазы с жёсткой границей между ними:
|
Установка разделена на две фазы с жёсткой границей между ними:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
PHASE 0 — READ ONLY
|
PHASE 0 — READ ONLY владелец: install.sh
|
||||||
проверка прав
|
проверка прав
|
||||||
sha256sum -c metadata/checksums.txt
|
sha256sum -c metadata/checksums.txt
|
||||||
./orchestrator/hy2xs-orchestrator preflight-install --package-dir <распакованный пакет>
|
./orchestrator/hy2xs-orchestrator preflight-install --package-dir <распакованный пакет>
|
||||||
@@ -63,11 +63,11 @@ PHASE 0 — READ ONLY
|
|||||||
└── валидация конфигурации
|
└── валидация конфигурации
|
||||||
↓ ноль persistent writes
|
↓ ноль persistent writes
|
||||||
PHASE 0 PASSED
|
PHASE 0 PASSED
|
||||||
↓
|
↓ exec
|
||||||
PHASE 1 — MUTATION
|
PHASE 1 — MUTATION владелец: оркестратор
|
||||||
install -d /usr/local/lib/hy2xs
|
preflight (clean-host — последний раз за операцию)
|
||||||
раскладка оркестратора и runtime-пакета
|
bootstrapRuntime: /usr/local/lib/hy2xs, symlink, runtime-пакет
|
||||||
hy2xs-orchestrator install
|
installDeps → filesystem → UI → Hysteria → config → units → firewall → smoke
|
||||||
```
|
```
|
||||||
|
|
||||||
Ключевые свойства:
|
Ключевые свойства:
|
||||||
@@ -82,6 +82,43 @@ PHASE 1 — MUTATION
|
|||||||
`install-state.json`**. Отказ на этом этапе означает, что на сервере не
|
`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).
|
[14-legacy-cleanup.md](14-legacy-cleanup.md).
|
||||||
|
|
||||||
|
|||||||
@@ -129,9 +129,13 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
|
|||||||
попадают в список, а не только значения по умолчанию;
|
попадают в список, а не только значения по умолчанию;
|
||||||
- всё, что удаляет `purge-v0.sh`, покрыто маркерами clean-host: два списка
|
- всё, что удаляет `purge-v0.sh`, покрыто маркерами clean-host: два списка
|
||||||
описывают одну границу и не имеют права разъезжаться;
|
описывают одну границу и не имеют права разъезжаться;
|
||||||
- пути, созданные `install.sh` между фазами (`/usr/local/lib/hy2xs`,
|
- bootstrap-пути (`/usr/local/lib/hy2xs`, `/usr/local/lib/hy2xs/package`,
|
||||||
`/usr/local/lib/hy2xs/package`, `/usr/local/bin/hy2xs-orchestrator`), —
|
`/usr/local/bin/hy2xs-orchestrator`) остаются маркерами **без исключений**:
|
||||||
маркеры в PHASE 0, но не в PHASE 1;
|
их создаёт оркестратор уже после проверки чистоты хоста, поэтому «мягкой»
|
||||||
|
версии списка для PHASE 1 больше не существует;
|
||||||
|
- эти пути берутся из `config/profile.ts`, а не из копий строк: шаг, который
|
||||||
|
их создаёт, и контракт, который на них отказывает, обязаны читать одно
|
||||||
|
значение;
|
||||||
- сообщение перечисляет найденные маркеры и говорит, что хост не изменён.
|
- сообщение перечисляет найденные маркеры и говорит, что хост не изменён.
|
||||||
|
|
||||||
`orchestrator/test/install-boundary.test.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` уже делает хост изменённым;
|
`stateWritten`: записанный `install-state.json` уже делает хост изменённым;
|
||||||
- начатая (не обязательно завершённая) установка пакетов уже даёт
|
- начатая (не обязательно завершённая) установка пакетов уже даёт
|
||||||
`fatal_post_apply` — регрессия на сценарий «PHASE 0 прошла, apt-get упал,
|
`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`:
|
`orchestrator/test/install-state.test.ts`:
|
||||||
|
|
||||||
@@ -171,7 +197,26 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
|
|||||||
- секрет внутри URL-значения в env вырезается, даже если имя ключа несекретное
|
- секрет внутри URL-значения в env вырезается, даже если имя ключа несекретное
|
||||||
(`HY2_AUTH_URL`);
|
(`HY2_AUTH_URL`);
|
||||||
- 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)
|
## A8. Инвариант публичного endpoint (unit)
|
||||||
|
|
||||||
@@ -193,10 +238,23 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
|
|||||||
| нет ни одного локального публичного IPv4 | FAIL |
|
| нет ни одного локального публичного IPv4 | FAIL |
|
||||||
| `HY2XS_PUBLIC_ENDPOINT_POLICY` = strict / warn / off | fail / warn / skip |
|
| `HY2XS_PUBLIC_ENDPOINT_POLICY` = strict / warn / off | fail / warn / skip |
|
||||||
| отсутствие A-записи при любой политике | FAIL |
|
| отсутствие A-записи при любой политике | FAIL |
|
||||||
|
| отказ резолвера (SERVFAIL/таймаут/отказ) при любой политике | FAIL, отдельный текст |
|
||||||
|
|
||||||
Отдельно проверяется классификация IPv4: приватные, CGNAT, link-local,
|
Отдельно проверяется классификация IPv4. Список исключений приведён к IANA
|
||||||
multicast и reserved диапазоны не считаются публичным адресом сервера, а
|
Special-Purpose Address Registry: приватные, CGNAT, link-local, multicast,
|
||||||
`172.32.0.0` и `172.15.255.255` — считаются (границы `172.16/12`).
|
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)
|
## A9. Регистрация маршрутов (unit)
|
||||||
|
|
||||||
@@ -209,9 +267,34 @@ wildcard-маршрутом фронтенда или дублирующая р
|
|||||||
- machine-auth зарегистрирован ровно на `constant.HysteriaMachineAuthPath`;
|
- machine-auth зарегистрирован ровно на `constant.HysteriaMachineAuthPath`;
|
||||||
- операторский и auth API — под `constant.AdminAPIBase`;
|
- операторский и auth API — под `constant.AdminAPIBase`;
|
||||||
- ни один маршрут не начинается со старого пространства имён;
|
- ни один маршрут не начинается со старого пространства имён;
|
||||||
- удалённые маршруты (включая `exportConfig`/`importConfig`) не вернулись;
|
- удалённые маршруты (включая `exportConfig`/`importConfig` и `getConfig`) не
|
||||||
|
вернулись;
|
||||||
|
- пространство `/api/config` закрыто: в нём ровно четыре маршрута, и любой
|
||||||
|
новый обязан быть добавлен в тест осознанно;
|
||||||
- `/healthz` на месте.
|
- `/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)
|
## A10. Импорт пиров (unit)
|
||||||
|
|
||||||
`apps/service/peer_import_test.go`:
|
`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)
|
## A7. Контракт версий (build)
|
||||||
|
|
||||||
Шаг `verify_versions_contract` (`tools/build/lib/versions.sh`) роняет сборку до
|
Шаг `verify_versions_contract` (`tools/build/lib/versions.sh`) роняет сборку до
|
||||||
@@ -436,14 +543,39 @@ idle timeout проходил семантическую проверку. То
|
|||||||
4. в выводе **нет** `fatal_pre_apply` и нет фразы про «ничего не применялось»;
|
4. в выводе **нет** `fatal_pre_apply` и нет фразы про «ничего не применялось»;
|
||||||
5. `/var/lib/hy2xs/install-state.json` существует и честно показывает
|
5. `/var/lib/hy2xs/install-state.json` существует и честно показывает
|
||||||
`phase: failed` с текстом ошибки;
|
`phase: failed` с текстом ошибки;
|
||||||
6. diagnostics-бандл собран;
|
6. `owned_paths` в маркере содержит `/usr/local/lib/hy2xs`,
|
||||||
7. `hy2xs-orchestrator status` не заявляет установку успешной.
|
`/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, обработка
|
уже лежал на диске, но отказ классифицировался как pre-apply, обработка
|
||||||
состояния пропускалась, а следующая установка на этой машине отказывалась по
|
состояния пропускалась, а следующая установка на этой машине отказывалась по
|
||||||
clean-host контракту из-за оставшегося маркера.
|
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 провайдером
|
## D2. Устаревший DNS после смены IPv4 провайдером
|
||||||
|
|
||||||
Проверяется на рабочей установке.
|
Проверяется на рабочей установке.
|
||||||
|
|||||||
+4
-2
@@ -44,8 +44,10 @@
|
|||||||
которые она не разворачивала, не останавливаются никогда.
|
которые она не разворачивала, не останавливаются никогда.
|
||||||
7. Установка двухфазная: **PHASE 0 — read only**, **PHASE 1 — mutation**.
|
7. Установка двухфазная: **PHASE 0 — read only**, **PHASE 1 — mutation**.
|
||||||
До успешного clean-host preflight на сервере не изменяется ни один
|
До успешного clean-host preflight на сервере не изменяется ни один
|
||||||
persistent path. Очистка предыдущей установки — отдельная явная операция
|
persistent path. У мутирующей фазы ровно один владелец — оркестратор:
|
||||||
оператора, см. [14-legacy-cleanup.md](14-legacy-cleanup.md).
|
`install.sh` проверяет и передаёт управление, не изменяя ничего сам.
|
||||||
|
Очистка предыдущей установки — отдельная явная операция оператора,
|
||||||
|
см. [14-legacy-cleanup.md](14-legacy-cleanup.md).
|
||||||
8. Выдача доступа пользователям, Telegram-бот, billing, backend профилей и похожие контуры **не входят** в этот baseline.
|
8. Выдача доступа пользователям, Telegram-бот, billing, backend профилей и похожие контуры **не входят** в этот baseline.
|
||||||
|
|
||||||
## Состав документов
|
## Состав документов
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { CommonOptions } from "../types/context";
|
import type { CommonOptions } from "../types/context";
|
||||||
import { info, setOperationContext } from "../lib/log";
|
import { info, setOperationContext } from "../lib/log";
|
||||||
import { runMutating } from "../lib/process";
|
import { runMutating } from "../lib/process";
|
||||||
import { redactEnv, redactYaml } from "../lib/redaction";
|
import { redactEnv, redactLogText, redactYaml } from "../lib/redaction";
|
||||||
|
|
||||||
function shellEscapeSingleQuotes(value: string): string {
|
function shellEscapeSingleQuotes(value: string): string {
|
||||||
return value.replaceAll("'", "'\\''");
|
return value.replaceAll("'", "'\\''");
|
||||||
@@ -49,6 +49,24 @@ export async function diagnosticsCollect(_options: CommonOptions): Promise<void>
|
|||||||
// noop
|
// noop
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Журналы и вывод systemctl — такой же артефакт, покидающий сервер, как env
|
||||||
|
// и YAML. Раньше редактировались только последние два, а journal-admin.log
|
||||||
|
// копировался как есть — и уносил machine token, который админка логировала
|
||||||
|
// в составе RequestURI.
|
||||||
|
for (const logFile of [
|
||||||
|
"journal-hysteria.log",
|
||||||
|
"journal-admin.log",
|
||||||
|
"systemd-hysteria.txt",
|
||||||
|
"systemd-admin.txt"
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
const raw = await Bun.file(`${outDir}/${logFile}`).text();
|
||||||
|
await Bun.write(`${outDir}/${logFile}`, redactLogText(raw));
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await runMutating`sh -c ${`tar -czf '${shellEscapeSingleQuotes(archive)}' -C '${shellEscapeSingleQuotes(outDir)}' .`}`;
|
await runMutating`sh -c ${`tar -czf '${shellEscapeSingleQuotes(archive)}' -C '${shellEscapeSingleQuotes(outDir)}' .`}`;
|
||||||
|
|
||||||
info(`diagnostics bundle collected: ${archive}`);
|
info(`diagnostics bundle collected: ${archive}`);
|
||||||
|
|||||||
@@ -9,7 +9,14 @@ import {
|
|||||||
buildInstallStateRecord
|
buildInstallStateRecord
|
||||||
} from "../lib/installState";
|
} from "../lib/installState";
|
||||||
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
|
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
|
||||||
|
import {
|
||||||
|
ORCHESTRATOR_INSTALL_DIR,
|
||||||
|
ORCHESTRATOR_INSTALL_PATH,
|
||||||
|
ORCHESTRATOR_SYMLINK_PATH,
|
||||||
|
RUNTIME_PACKAGE_DIR
|
||||||
|
} from "../config/profile";
|
||||||
import { preflight } from "../steps/preflight";
|
import { preflight } from "../steps/preflight";
|
||||||
|
import { bootstrapRuntime } from "../steps/bootstrap";
|
||||||
import { installDeps } from "../steps/deps";
|
import { installDeps } from "../steps/deps";
|
||||||
import { prepareFilesystem } from "../steps/filesystem";
|
import { prepareFilesystem } from "../steps/filesystem";
|
||||||
import { deployUi } from "../steps/ui";
|
import { deployUi } from "../steps/ui";
|
||||||
@@ -24,6 +31,7 @@ import { diagnosticsCollect } from "./diagnostics";
|
|||||||
type InstallPhase =
|
type InstallPhase =
|
||||||
| "installing"
|
| "installing"
|
||||||
| "preflight_ok"
|
| "preflight_ok"
|
||||||
|
| "bootstrap_installed"
|
||||||
| "deps_ok"
|
| "deps_ok"
|
||||||
| "filesystem_ready"
|
| "filesystem_ready"
|
||||||
| "runtime_env_written"
|
| "runtime_env_written"
|
||||||
@@ -56,6 +64,12 @@ type InstallPhase =
|
|||||||
type OperationOwnership = {
|
type OperationOwnership = {
|
||||||
/** install-state.json уже создан: сам по себе делает хост изменённым. */
|
/** install-state.json уже создан: сам по себе делает хост изменённым. */
|
||||||
stateWritten: boolean;
|
stateWritten: boolean;
|
||||||
|
/**
|
||||||
|
* Раскладка самого оркестратора и runtime-пакета (/usr/local/lib/hy2xs,
|
||||||
|
* symlink в /usr/local/bin). Раньше эти пути создавал install.sh, и они
|
||||||
|
* оставались вне учёта владения целиком.
|
||||||
|
*/
|
||||||
|
bootstrapTouched: boolean;
|
||||||
depsTouched: boolean;
|
depsTouched: boolean;
|
||||||
filesystemTouched: boolean;
|
filesystemTouched: boolean;
|
||||||
uiTouched: boolean;
|
uiTouched: boolean;
|
||||||
@@ -79,6 +93,7 @@ type FailureKind =
|
|||||||
function newOwnership(): OperationOwnership {
|
function newOwnership(): OperationOwnership {
|
||||||
return {
|
return {
|
||||||
stateWritten: false,
|
stateWritten: false,
|
||||||
|
bootstrapTouched: false,
|
||||||
depsTouched: false,
|
depsTouched: false,
|
||||||
filesystemTouched: false,
|
filesystemTouched: false,
|
||||||
uiTouched: false,
|
uiTouched: false,
|
||||||
@@ -102,7 +117,14 @@ function installOwnedPaths(context: InstallContext): string[] {
|
|||||||
"/etc/systemd/system/hysteria-server.service",
|
"/etc/systemd/system/hysteria-server.service",
|
||||||
"/etc/nftables.conf",
|
"/etc/nftables.conf",
|
||||||
"/etc/nftables.d/hy2xs.nft",
|
"/etc/nftables.d/hy2xs.nft",
|
||||||
context.config.installDir
|
context.config.installDir,
|
||||||
|
// Bootstrap-пути принадлежат установке так же, как всё остальное. Пока их
|
||||||
|
// раскладывал install.sh, они не попадали ни в owned_paths, ни в rollback,
|
||||||
|
// и purge приходилось держать шире, чем маркер установки.
|
||||||
|
ORCHESTRATOR_INSTALL_DIR,
|
||||||
|
ORCHESTRATOR_INSTALL_PATH,
|
||||||
|
ORCHESTRATOR_SYMLINK_PATH,
|
||||||
|
RUNTIME_PACKAGE_DIR
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +189,7 @@ export function classifyFailure(ownership: OperationOwnership, phase: InstallPha
|
|||||||
ownership.uiTouched ||
|
ownership.uiTouched ||
|
||||||
ownership.filesystemTouched ||
|
ownership.filesystemTouched ||
|
||||||
ownership.depsTouched ||
|
ownership.depsTouched ||
|
||||||
|
ownership.bootstrapTouched ||
|
||||||
ownership.stateWritten
|
ownership.stateWritten
|
||||||
) {
|
) {
|
||||||
return "fatal_post_apply";
|
return "fatal_post_apply";
|
||||||
@@ -189,6 +212,11 @@ async function rollbackFailedInstall(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bootstrap-пути (/usr/local/lib/hy2xs, symlink, runtime-пакет) намеренно не
|
||||||
|
// удаляются: именно ими и работает `repair --allow-partial-state`, ради
|
||||||
|
// которого install-state сохраняется с repair_hint. Снести оркестратор при
|
||||||
|
// откате означало бы лишить оператора инструмента починки. Полная зачистка —
|
||||||
|
// это осознанное отдельное действие, tools/legacy/purge-v0.sh.
|
||||||
if (ownership.firewallTouched) {
|
if (ownership.firewallTouched) {
|
||||||
await rollbackFirewallNow(context);
|
await rollbackFirewallNow(context);
|
||||||
}
|
}
|
||||||
@@ -237,18 +265,28 @@ export async function install(options: InstallOptions): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
// PHASE 1 начинается только после успешного preflight: до него install
|
// PHASE 1 начинается только после успешного preflight: до него install
|
||||||
// не пишет ни одного persistent path, включая install-state.
|
// не пишет ни одного persistent path, включая install-state.
|
||||||
|
//
|
||||||
|
// Контракт чистого хоста проверяется здесь — последний раз за операцию.
|
||||||
|
// Всё, что дальше, уже создаёт наши собственные пути, и повторная проверка
|
||||||
|
// опознала бы их как чужую установку.
|
||||||
step("preflight");
|
step("preflight");
|
||||||
await preflight(context, { requireCapabilities: false, cleanHostPhase: "install" });
|
await preflight(context, { requireCapabilities: false, checkCleanHost: true });
|
||||||
stepDone("preflight");
|
stepDone("preflight");
|
||||||
|
|
||||||
await advanceInstallState(context, ownership, "preflight_ok");
|
await advanceInstallState(context, ownership, "preflight_ok");
|
||||||
phase = "preflight_ok";
|
phase = "preflight_ok";
|
||||||
|
step("orchestrator bootstrap");
|
||||||
|
ownership.bootstrapTouched = true;
|
||||||
|
context.options.packageDir = await bootstrapRuntime(context);
|
||||||
|
stepDone("orchestrator bootstrap");
|
||||||
|
await advanceInstallState(context, ownership, "bootstrap_installed");
|
||||||
|
phase = "bootstrap_installed";
|
||||||
step("system dependencies");
|
step("system dependencies");
|
||||||
ownership.depsTouched = true;
|
ownership.depsTouched = true;
|
||||||
await installDeps(context);
|
await installDeps(context);
|
||||||
stepDone("system dependencies");
|
stepDone("system dependencies");
|
||||||
step("preflight capabilities");
|
step("preflight capabilities");
|
||||||
await preflight(context, { requireCapabilities: true, cleanHostPhase: "install" });
|
await preflight(context, { requireCapabilities: true, checkCleanHost: false });
|
||||||
stepDone("preflight capabilities");
|
stepDone("preflight capabilities");
|
||||||
await advanceInstallState(context, ownership, "deps_ok");
|
await advanceInstallState(context, ownership, "deps_ok");
|
||||||
phase = "deps_ok";
|
phase = "deps_ok";
|
||||||
@@ -341,7 +379,22 @@ export async function install(options: InstallOptions): Promise<void> {
|
|||||||
failureKind === "smoke_readiness_timeout" ? "smoke_failed" : "failed",
|
failureKind === "smoke_readiness_timeout" ? "smoke_failed" : "failed",
|
||||||
`${failureKind}: ${message}`
|
`${failureKind}: ${message}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Диагностика — best effort, откат — обязателен.
|
||||||
|
//
|
||||||
|
// diagnosticsCollect создаёт каталог, копирует файлы и упаковывает tar:
|
||||||
|
// при заполненном диске, битой ФС или недоступном journalctl она падает
|
||||||
|
// сама. Пока она стояла перед откатом обычным await, её собственный отказ
|
||||||
|
// отменял откат целиком — то есть худший сценарий отказа установки
|
||||||
|
// гарантированно лишался единственного механизма восстановления.
|
||||||
|
try {
|
||||||
await diagnosticsCollect(options);
|
await diagnosticsCollect(options);
|
||||||
|
} catch (diagnosticsError) {
|
||||||
|
const diagnosticsMessage =
|
||||||
|
diagnosticsError instanceof Error ? diagnosticsError.message : String(diagnosticsError);
|
||||||
|
info(`diagnostics collection failed, continuing with rollback: ${diagnosticsMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
await rollbackFailedInstall(context, ownership, failureKind);
|
await rollbackFailedInstall(context, ownership, failureKind);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export async function preflightInstall(options: InstallOptions): Promise<void> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
step("bootstrap preflight (read-only)");
|
step("bootstrap preflight (read-only)");
|
||||||
await preflight(context, { requireCapabilities: false, cleanHostPhase: "bootstrap" });
|
await preflight(context, { requireCapabilities: false, checkCleanHost: true });
|
||||||
stepDone("bootstrap preflight (read-only)");
|
stepDone("bootstrap preflight (read-only)");
|
||||||
|
|
||||||
info("clean-host contract satisfied: no persistent path was modified");
|
info("clean-host contract satisfied: no persistent path was modified");
|
||||||
|
|||||||
@@ -27,10 +27,53 @@ type ReconfigurePhase =
|
|||||||
| "firewall_applied"
|
| "firewall_applied"
|
||||||
| "runtime_env_written"
|
| "runtime_env_written"
|
||||||
| "smoke_ok"
|
| "smoke_ok"
|
||||||
|
| "reconfigure_failed"
|
||||||
| "firewall_connectivity_failure"
|
| "firewall_connectivity_failure"
|
||||||
| "smoke_failed"
|
| "smoke_failed"
|
||||||
| "installed";
|
| "installed";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* К чему текущий проход reconfigure/repair уже мог прикоснуться.
|
||||||
|
*
|
||||||
|
* Та же формулировка и та же причина, что и в install: флаг взводится ПЕРЕД
|
||||||
|
* шагом, потому что шаг умеет изменить систему и упасть.
|
||||||
|
*
|
||||||
|
* Раньше записанная фаза отказа выбиралась регулярным выражением по тексту
|
||||||
|
* ошибки (`/firewall|nft|ssh port check failed/i`). Это ровно тот приём,
|
||||||
|
* который убрали из install: сообщение о недоступном DNS, в котором случайно
|
||||||
|
* встретилось слово nft, объявляло бы отказ проблемой firewall, а падение
|
||||||
|
* `nft -c` на этапе smoke — наоборот, отказом smoke. install-state — то, что
|
||||||
|
* оператор читает первым; в нём должно стоять, где на самом деле остановились.
|
||||||
|
*/
|
||||||
|
type ReconfigureOwnership = {
|
||||||
|
configTouched: boolean;
|
||||||
|
unitsTouched: boolean;
|
||||||
|
firewallTouched: boolean;
|
||||||
|
envTouched: boolean;
|
||||||
|
servicesRestarted: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function newReconfigureOwnership(): ReconfigureOwnership {
|
||||||
|
return {
|
||||||
|
configTouched: false,
|
||||||
|
unitsTouched: false,
|
||||||
|
firewallTouched: false,
|
||||||
|
envTouched: false,
|
||||||
|
servicesRestarted: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Порядок веток — от самой поздней стадии к самой ранней. */
|
||||||
|
export function classifyReconfigureFailure(ownership: ReconfigureOwnership): ReconfigurePhase {
|
||||||
|
if (ownership.servicesRestarted) {
|
||||||
|
return "smoke_failed";
|
||||||
|
}
|
||||||
|
if (ownership.firewallTouched) {
|
||||||
|
return "firewall_connectivity_failure";
|
||||||
|
}
|
||||||
|
return "reconfigure_failed";
|
||||||
|
}
|
||||||
|
|
||||||
function operationKey(context: ReconfigureContext): string {
|
function operationKey(context: ReconfigureContext): string {
|
||||||
return context.installDate;
|
return context.installDate;
|
||||||
}
|
}
|
||||||
@@ -194,21 +237,27 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
|
|||||||
await backupCurrentState();
|
await backupCurrentState();
|
||||||
stepDone("backup");
|
stepDone("backup");
|
||||||
|
|
||||||
|
const ownership = newReconfigureOwnership();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await markPhase(context, options.allowPartialState ? "repairing" : "reconfiguring");
|
await markPhase(context, options.allowPartialState ? "repairing" : "reconfiguring");
|
||||||
step("config generation");
|
step("config generation");
|
||||||
|
ownership.configTouched = true;
|
||||||
await generateConfig(context);
|
await generateConfig(context);
|
||||||
stepDone("config generation");
|
stepDone("config generation");
|
||||||
await markPhase(context, "config_generated");
|
await markPhase(context, "config_generated");
|
||||||
step("systemd units");
|
step("systemd units");
|
||||||
|
ownership.unitsTouched = true;
|
||||||
await deploySystemd(context);
|
await deploySystemd(context);
|
||||||
stepDone("systemd units");
|
stepDone("systemd units");
|
||||||
await markPhase(context, "units_deployed");
|
await markPhase(context, "units_deployed");
|
||||||
step("firewall");
|
step("firewall");
|
||||||
|
ownership.firewallTouched = true;
|
||||||
await applyFirewall(context);
|
await applyFirewall(context);
|
||||||
stepDone("firewall");
|
stepDone("firewall");
|
||||||
await markPhase(context, "firewall_applied");
|
await markPhase(context, "firewall_applied");
|
||||||
step("write env artifacts");
|
step("write env artifacts");
|
||||||
|
ownership.envTouched = true;
|
||||||
await writeTextAtomic(options.runtimeConfigPath, renderRuntimeEnv(config), {
|
await writeTextAtomic(options.runtimeConfigPath, renderRuntimeEnv(config), {
|
||||||
mode: 0o600,
|
mode: 0o600,
|
||||||
owner: "root",
|
owner: "root",
|
||||||
@@ -219,6 +268,7 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
|
|||||||
stepDone("write env artifacts");
|
stepDone("write env artifacts");
|
||||||
await markPhase(context, "runtime_env_written");
|
await markPhase(context, "runtime_env_written");
|
||||||
step("smoke checks");
|
step("smoke checks");
|
||||||
|
ownership.servicesRestarted = true;
|
||||||
await smoke(context);
|
await smoke(context);
|
||||||
stepDone("smoke checks");
|
stepDone("smoke checks");
|
||||||
await markPhase(context, "smoke_ok");
|
await markPhase(context, "smoke_ok");
|
||||||
@@ -234,12 +284,26 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
info("reconfigure failed, rollback in progress");
|
info("reconfigure failed, rollback in progress");
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
const phase: ReconfigurePhase = /firewall|nft|ssh port check failed/i.test(message)
|
await markPhase(context, classifyReconfigureFailure(ownership), message);
|
||||||
? "firewall_connectivity_failure"
|
|
||||||
: "smoke_failed";
|
// Диагностика — best effort, откат — обязателен.
|
||||||
await markPhase(context, phase, message);
|
//
|
||||||
|
// diagnosticsCollect создаёт каталог, копирует файлы и упаковывает tar:
|
||||||
|
// на заполненном диске она падает сама. Пока она стояла перед откатом
|
||||||
|
// обычным await, её отказ отменял и rollbackFirewallNow, и восстановление
|
||||||
|
// конфигов из /etc/hy2xs/backups — то есть сервер оставался с применённым
|
||||||
|
// сломанным firewall и без единственного механизма возврата.
|
||||||
|
try {
|
||||||
await diagnosticsCollect(options);
|
await diagnosticsCollect(options);
|
||||||
|
} catch (diagnosticsError) {
|
||||||
|
const diagnosticsMessage =
|
||||||
|
diagnosticsError instanceof Error ? diagnosticsError.message : String(diagnosticsError);
|
||||||
|
info(`diagnostics collection failed, continuing with rollback: ${diagnosticsMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ownership.firewallTouched) {
|
||||||
await rollbackFirewallNow(context);
|
await rollbackFirewallNow(context);
|
||||||
|
}
|
||||||
await rollbackCurrentState();
|
await rollbackCurrentState();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,36 @@ export const HY2XS_TARGET_ARCH = "amd64";
|
|||||||
export const ADMIN_API_BASE = "/api";
|
export const ADMIN_API_BASE = "/api";
|
||||||
export const HYSTERIA_MACHINE_AUTH_PATH = "/internal/hysteria/auth";
|
export const HYSTERIA_MACHINE_AUTH_PATH = "/internal/hysteria/auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Где оркестратор живёт на установленном хосте.
|
||||||
|
*
|
||||||
|
* Эти пути раскладывает шаг `bootstrapRuntime` в самом начале PHASE 1, и они же
|
||||||
|
* являются clean-host маркерами чужой установки. Объявлены здесь один раз
|
||||||
|
* именно потому, что у них два потребителя с противоположными ролями:
|
||||||
|
* steps/bootstrap.ts их создаёт, steps/cleanHost.ts на них отказывает.
|
||||||
|
* Разошедшиеся копии означали бы, что установка создаёт путь, который её
|
||||||
|
* собственный контракт чистоты не проверяет.
|
||||||
|
*/
|
||||||
|
export const ORCHESTRATOR_INSTALL_DIR = "/usr/local/lib/hy2xs";
|
||||||
|
export const ORCHESTRATOR_INSTALL_PATH = `${ORCHESTRATOR_INSTALL_DIR}/hy2xs-orchestrator`;
|
||||||
|
export const ORCHESTRATOR_SYMLINK_PATH = "/usr/local/bin/hy2xs-orchestrator";
|
||||||
|
export const RUNTIME_PACKAGE_DIR = `${ORCHESTRATOR_INSTALL_DIR}/package`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Каталоги пакета, которые обязаны пережить установку: reconfigure/repair/doctor
|
||||||
|
* работают уже от runtime-копии, а не от распакованного архива.
|
||||||
|
* `orchestrator/` сюда не входит намеренно — бинарник кладётся отдельно, в
|
||||||
|
* ORCHESTRATOR_INSTALL_PATH.
|
||||||
|
*/
|
||||||
|
export const RUNTIME_PACKAGE_CONTENTS: readonly string[] = [
|
||||||
|
"config",
|
||||||
|
"docs",
|
||||||
|
"systemd",
|
||||||
|
"templates",
|
||||||
|
"metadata",
|
||||||
|
"ui"
|
||||||
|
];
|
||||||
|
|
||||||
/** Полный machine-auth URL, который видит Hysteria. */
|
/** Полный machine-auth URL, который видит Hysteria. */
|
||||||
export function hysteriaMachineAuthUrl(uiPort: number, machineToken: string): string {
|
export function hysteriaMachineAuthUrl(uiPort: number, machineToken: string): string {
|
||||||
return `http://127.0.0.1:${uiPort}${HYSTERIA_MACHINE_AUTH_PATH}?access_token=${machineToken}`;
|
return `http://127.0.0.1:${uiPort}${HYSTERIA_MACHINE_AUTH_PATH}?access_token=${machineToken}`;
|
||||||
|
|||||||
@@ -166,6 +166,56 @@ export function redactEnv(content: string): string {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Санитайз свободного текста: журналы systemd/journald, вывод `systemctl status`.
|
||||||
|
*
|
||||||
|
* У журнала нет структуры, поэтому правил ровно два и оба консервативные:
|
||||||
|
* всё, что разбирается как URL, проходит через sanitizeUrlValue, и отдельно
|
||||||
|
* ловятся пары `имя=значение` с секретоподобным именем вне URL.
|
||||||
|
*
|
||||||
|
* Без этого прохода diagnostics-бандл оставался каналом утечки: env и YAML в
|
||||||
|
* нём редактируются, а journal-admin.log и journal-hysteria.log копировались
|
||||||
|
* как есть. Пока админка логировала RequestURI, в journal-admin.log лежал
|
||||||
|
* machine token, и бандл уносил его наружу вместе со всем остальным.
|
||||||
|
* Правило синхронизировано с SanitizeLogText в apps/service/log_sanitize.go.
|
||||||
|
*/
|
||||||
|
const LOG_URL_PATTERN = /[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s"'<>`]+/g;
|
||||||
|
const LOG_SECRET_ASSIGNMENT_PATTERN =
|
||||||
|
/([A-Za-z0-9_-]*(?:access_token|token|secret|password|passwd|passphrase|credential|api_?key|authorization|signature)[A-Za-z0-9_-]*)\s*([=:])\s*([^\s&,;"'<>`]+)/gi;
|
||||||
|
|
||||||
|
const ENCODED_REDACTED = "%3Credacted%3E";
|
||||||
|
|
||||||
|
function splitTrailingPunctuation(value: string): [string, string] {
|
||||||
|
let end = value.length;
|
||||||
|
while (end > 0 && ".,;:)]}!?".includes(value[end - 1] as string)) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
return [value.slice(0, end), value.slice(end)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactLogText(content: string): string {
|
||||||
|
if (!content) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const withSafeUrls = content.replace(LOG_URL_PATTERN, (match) => {
|
||||||
|
const [body, trailing] = splitTrailingPunctuation(match);
|
||||||
|
return `${sanitizeUrlValue(body)}${trailing}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return withSafeUrls.replace(
|
||||||
|
LOG_SECRET_ASSIGNMENT_PATTERN,
|
||||||
|
(match, key: string, separator: string, rawValue: string) => {
|
||||||
|
const [value, trailing] = splitTrailingPunctuation(rawValue);
|
||||||
|
if (value === REDACTED || value.toLowerCase() === ENCODED_REDACTED.toLowerCase()) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
const rendered = separator === ":" ? ": " : "=";
|
||||||
|
return `${key}${rendered}${REDACTED}${trailing}`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Резервный построчный проход на случай, если документ не разбирается как
|
* Резервный построчный проход на случай, если документ не разбирается как
|
||||||
* YAML. Диагностика не должна падать, но и утекать не должна, поэтому здесь
|
* YAML. Диагностика не должна падать, но и утекать не должна, поэтому здесь
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { InstallContext } from "../types/context";
|
||||||
|
import { dirExists, fileExists } from "../lib/fs";
|
||||||
|
import { info } from "../lib/log";
|
||||||
|
import { runMutatingVisible } from "../lib/process";
|
||||||
|
import {
|
||||||
|
ORCHESTRATOR_INSTALL_DIR,
|
||||||
|
ORCHESTRATOR_INSTALL_PATH,
|
||||||
|
ORCHESTRATOR_SYMLINK_PATH,
|
||||||
|
RUNTIME_PACKAGE_CONTENTS,
|
||||||
|
RUNTIME_PACKAGE_DIR
|
||||||
|
} from "../config/profile";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Первый мутирующий шаг PHASE 1: оркестратор раскладывает сам себя.
|
||||||
|
*
|
||||||
|
* Раньше это делал install.sh — между успешной PHASE 0 и запуском оркестратора.
|
||||||
|
* Владение мутацией при этом отследить было невозможно: shell уже создал
|
||||||
|
* /usr/local/lib/hy2xs, symlink и runtime-пакет, а оркестратор о них не знал и
|
||||||
|
* при отказе собственного preflight сообщал «на сервере ничего не изменено».
|
||||||
|
*
|
||||||
|
* Теперь у PHASE 1 один владелец. Шаг покрыт флагом ownership.bootstrapTouched,
|
||||||
|
* который взводится ДО вызова, поэтому частично выполненная раскладка (упал
|
||||||
|
* `cp` на середине, кончилось место) всё равно считается изменением хоста.
|
||||||
|
*
|
||||||
|
* Идемпотентности здесь не требуется и не имитируется: clean-host контракт уже
|
||||||
|
* доказал, что ни одного из этих путей на машине нет.
|
||||||
|
*
|
||||||
|
* Возвращает путь установленного runtime-пакета: дальше установка работает
|
||||||
|
* именно от него, а не от распакованного архива, — на сервере остаётся ровно
|
||||||
|
* то, из чего установка и выполнялась.
|
||||||
|
*/
|
||||||
|
export async function bootstrapRuntime(context: InstallContext): Promise<string> {
|
||||||
|
const sourcePackageDir = context.options.packageDir;
|
||||||
|
const sourceBinary = `${sourcePackageDir}/orchestrator/hy2xs-orchestrator`;
|
||||||
|
|
||||||
|
if (!(await fileExists(sourceBinary))) {
|
||||||
|
throw new Error(
|
||||||
|
`bootstrap: orchestrator artifact is missing in the package: ${sourceBinary}. ` +
|
||||||
|
"install must be started from the unpacked release package via install.sh"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await runMutatingVisible`install -d -m 0755 -o root -g root ${ORCHESTRATOR_INSTALL_DIR}`;
|
||||||
|
await runMutatingVisible`install -m 0755 -o root -g root ${sourceBinary} ${ORCHESTRATOR_INSTALL_PATH}`;
|
||||||
|
await runMutatingVisible`ln -sfn ${ORCHESTRATOR_INSTALL_PATH} ${ORCHESTRATOR_SYMLINK_PATH}`;
|
||||||
|
|
||||||
|
info(`installing runtime package assets to: ${RUNTIME_PACKAGE_DIR}`);
|
||||||
|
await runMutatingVisible`install -d -m 0755 -o root -g root ${RUNTIME_PACKAGE_DIR}`;
|
||||||
|
|
||||||
|
for (const entry of RUNTIME_PACKAGE_CONTENTS) {
|
||||||
|
const source = `${sourcePackageDir}/${entry}`;
|
||||||
|
if (!(await dirExists(source)) && !(await fileExists(source))) {
|
||||||
|
throw new Error(`bootstrap: package entry is missing: ${source}`);
|
||||||
|
}
|
||||||
|
await runMutatingVisible`cp -a ${source} ${RUNTIME_PACKAGE_DIR}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// install.sh в runtime-пакет не копируется намеренно: установщик принадлежит
|
||||||
|
// архиву релиза, а не установленной системе. Рядом с рабочей установкой он
|
||||||
|
// был бы только приглашением запустить установку поверх неё.
|
||||||
|
return RUNTIME_PACKAGE_DIR;
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { RuntimeConfig } from "../types/context";
|
import type { RuntimeConfig } from "../types/context";
|
||||||
import { dirExists, fileExists } from "../lib/fs";
|
import { dirExists, fileExists } from "../lib/fs";
|
||||||
import { runReadOnly } from "../lib/process";
|
import { runReadOnly } from "../lib/process";
|
||||||
|
import {
|
||||||
|
ORCHESTRATOR_INSTALL_DIR,
|
||||||
|
ORCHESTRATOR_SYMLINK_PATH,
|
||||||
|
RUNTIME_PACKAGE_DIR
|
||||||
|
} from "../config/profile";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Контракт чистого хоста для HY2XS v1.
|
* Контракт чистого хоста для HY2XS v1.
|
||||||
@@ -22,19 +27,8 @@ export type LegacyMarker = {
|
|||||||
target: string;
|
target: string;
|
||||||
/** Человекочитаемое объяснение на русском: попадает в текст отказа. */
|
/** Человекочитаемое объяснение на русском: попадает в текст отказа. */
|
||||||
description: string;
|
description: string;
|
||||||
/**
|
|
||||||
* Путь, который создаёт сам install.sh в PHASE 1 до запуска оркестратора.
|
|
||||||
* В PHASE 0 он остаётся признаком чужой установки, в PHASE 1 — уже наш.
|
|
||||||
*/
|
|
||||||
createdByInstaller?: true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* PHASE 0 проверяет полный список. PHASE 1 (уже внутри `install`) исключает
|
|
||||||
* пути, которые install.sh законно создал между фазами.
|
|
||||||
*/
|
|
||||||
export type CleanHostPhase = "bootstrap" | "install";
|
|
||||||
|
|
||||||
export type HostProbe = {
|
export type HostProbe = {
|
||||||
fileExists(path: string): Promise<boolean>;
|
fileExists(path: string): Promise<boolean>;
|
||||||
dirExists(path: string): Promise<boolean>;
|
dirExists(path: string): Promise<boolean>;
|
||||||
@@ -44,8 +38,15 @@ export type HostProbe = {
|
|||||||
/**
|
/**
|
||||||
* Собирает список маркеров с учётом путей из конфигурации: installDir/dataDir
|
* Собирает список маркеров с учётом путей из конфигурации: installDir/dataDir
|
||||||
* переопределяемы, и захардкоженный список пропустил бы нестандартную установку.
|
* переопределяемы, и захардкоженный список пропустил бы нестандартную установку.
|
||||||
|
*
|
||||||
|
* Деления на фазы у списка больше нет. Оно существовало, пока install.sh
|
||||||
|
* раскладывал оркестратор и runtime-пакет между PHASE 0 и запуском оркестратора:
|
||||||
|
* в PHASE 1 эти пути приходилось исключать, иначе установка отказала бы на
|
||||||
|
* своих же, только что созданных файлах. Теперь их создаёт сам оркестратор,
|
||||||
|
* уже ПОСЛЕ проверки чистоты хоста, поэтому список ровно один и означает одно:
|
||||||
|
* «на этой машине есть посторонняя установка».
|
||||||
*/
|
*/
|
||||||
export function legacyMarkersFor(config: RuntimeConfig, phase: CleanHostPhase = "bootstrap"): LegacyMarker[] {
|
export function legacyMarkersFor(config: RuntimeConfig): LegacyMarker[] {
|
||||||
const markers: LegacyMarker[] = [
|
const markers: LegacyMarker[] = [
|
||||||
{
|
{
|
||||||
kind: "file",
|
kind: "file",
|
||||||
@@ -69,21 +70,18 @@ export function legacyMarkersFor(config: RuntimeConfig, phase: CleanHostPhase =
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "dir",
|
kind: "dir",
|
||||||
target: "/usr/local/lib/hy2xs/package",
|
target: RUNTIME_PACKAGE_DIR,
|
||||||
description: "runtime-пакет предыдущей установки HY2XS",
|
description: "runtime-пакет предыдущей установки HY2XS"
|
||||||
createdByInstaller: true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "dir",
|
kind: "dir",
|
||||||
target: "/usr/local/lib/hy2xs",
|
target: ORCHESTRATOR_INSTALL_DIR,
|
||||||
description: "каталог оркестратора предыдущей установки HY2XS",
|
description: "каталог оркестратора предыдущей установки HY2XS"
|
||||||
createdByInstaller: true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "file",
|
kind: "file",
|
||||||
target: "/usr/local/bin/hy2xs-orchestrator",
|
target: ORCHESTRATOR_SYMLINK_PATH,
|
||||||
description: "symlink оркестратора предыдущей установки HY2XS",
|
description: "symlink оркестратора предыдущей установки HY2XS"
|
||||||
createdByInstaller: true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "file",
|
kind: "file",
|
||||||
@@ -149,9 +147,6 @@ export function legacyMarkersFor(config: RuntimeConfig, phase: CleanHostPhase =
|
|||||||
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return markers.filter((marker) => {
|
return markers.filter((marker) => {
|
||||||
if (phase === "install" && marker.createdByInstaller) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const key = `${marker.kind}:${marker.target}`;
|
const key = `${marker.kind}:${marker.target}`;
|
||||||
if (seen.has(key)) {
|
if (seen.has(key)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -211,15 +206,20 @@ export function renderLegacyFailure(found: readonly LegacyMarker[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only проверка. Вызывается в PHASE 0 (bootstrap preflight) и повторно
|
* Read-only проверка. Выполняется ровно дважды за установку и оба раза до
|
||||||
* внутри install перед первой записью состояния.
|
* первой мутации: в PHASE 0 (bootstrap preflight из распакованного пакета) и
|
||||||
|
* на входе в `install`, перед записью install-state.
|
||||||
|
*
|
||||||
|
* Повторять её ПОСЛЕ начала PHASE 1 нельзя: собственный
|
||||||
|
* /var/lib/hy2xs/install-state.json тут же станет маркером «посторонней
|
||||||
|
* установки». Именно это и происходило, пока clean-host был зашит внутрь
|
||||||
|
* preflight, а preflight вызывался ещё раз после installDeps.
|
||||||
*/
|
*/
|
||||||
export async function assertCleanHost(
|
export async function assertCleanHost(
|
||||||
config: RuntimeConfig,
|
config: RuntimeConfig,
|
||||||
phase: CleanHostPhase = "bootstrap",
|
|
||||||
probe: HostProbe = defaultHostProbe
|
probe: HostProbe = defaultHostProbe
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const found = await detectLegacyMarkers(legacyMarkersFor(config, phase), probe);
|
const found = await detectLegacyMarkers(legacyMarkersFor(config), probe);
|
||||||
if (found.length > 0) {
|
if (found.length > 0) {
|
||||||
throw new Error(renderLegacyFailure(found));
|
throw new Error(renderLegacyFailure(found));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,24 @@ export type EndpointProbe = {
|
|||||||
getLocalPublicIpv4s(): string[];
|
getLocalPublicIpv4s(): string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EndpointProblemKind = "resolution" | "mismatch" | "no_local_address";
|
/**
|
||||||
|
* Виды проблем публичного endpoint.
|
||||||
|
*
|
||||||
|
* `resolution` и `resolver_failure` разведены намеренно. Отсутствие A-записи —
|
||||||
|
* это ошибка конфигурации, которую чинит оператор в DNS-панели. Недоступный
|
||||||
|
* или отвечающий SERVFAIL резолвер — это проблема самой машины, и чинится она
|
||||||
|
* в /etc/resolv.conf. Раньше оба случая печатались как «has no A-record»:
|
||||||
|
* при сломанном резолвере оператор шёл править DNS-запись, которая на самом
|
||||||
|
* деле была на месте.
|
||||||
|
*
|
||||||
|
* Фатальны оба: устанавливаться с непроверенным публичным endpoint'ом продукт
|
||||||
|
* не должен ни в том, ни в другом случае.
|
||||||
|
*/
|
||||||
|
export type EndpointProblemKind =
|
||||||
|
| "resolution"
|
||||||
|
| "resolver_failure"
|
||||||
|
| "mismatch"
|
||||||
|
| "no_local_address";
|
||||||
|
|
||||||
export type EndpointProblem = {
|
export type EndpointProblem = {
|
||||||
kind: EndpointProblemKind;
|
kind: EndpointProblemKind;
|
||||||
@@ -39,10 +56,14 @@ const IPV4_PATTERN = /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){
|
|||||||
/**
|
/**
|
||||||
* Диапазоны, которые не могут быть публичным endpoint'ом HY2XS.
|
* Диапазоны, которые не могут быть публичным endpoint'ом HY2XS.
|
||||||
*
|
*
|
||||||
|
* Список приведён к IANA IPv4 Special-Purpose Address Registry, а не собран по
|
||||||
|
* памяти. Раньше в нём не было документационных диапазонов, и 203.0.113.5
|
||||||
|
* (TEST-NET-3) считался нормальным публичным адресом сервера: функция
|
||||||
|
* называлась «маршрутизируемый публичный IPv4», а отвечала на более слабый
|
||||||
|
* вопрос «не приватный ли».
|
||||||
|
*
|
||||||
* Baseline продукта — выделенный сервер с обычным публичным IPv4; NAT и
|
* Baseline продукта — выделенный сервер с обычным публичным IPv4; NAT и
|
||||||
* Hysteria Realms описаны в документации как отдельная топология вне baseline.
|
* Hysteria Realms описаны в документации как отдельная топология вне baseline.
|
||||||
* Поэтому адрес из этих диапазонов не является ответом на вопрос «какой у
|
|
||||||
* сервера публичный IP».
|
|
||||||
*/
|
*/
|
||||||
const NON_PUBLIC_IPV4_RANGES: readonly { cidr: string; description: string }[] = [
|
const NON_PUBLIC_IPV4_RANGES: readonly { cidr: string; description: string }[] = [
|
||||||
{ cidr: "0.0.0.0/8", description: "this network" },
|
{ cidr: "0.0.0.0/8", description: "this network" },
|
||||||
@@ -51,11 +72,26 @@ const NON_PUBLIC_IPV4_RANGES: readonly { cidr: string; description: string }[] =
|
|||||||
{ cidr: "127.0.0.0/8", description: "loopback" },
|
{ cidr: "127.0.0.0/8", description: "loopback" },
|
||||||
{ cidr: "169.254.0.0/16", description: "link-local" },
|
{ cidr: "169.254.0.0/16", description: "link-local" },
|
||||||
{ cidr: "172.16.0.0/12", description: "private" },
|
{ cidr: "172.16.0.0/12", description: "private" },
|
||||||
|
{ cidr: "192.0.0.0/24", description: "IETF protocol assignments" },
|
||||||
|
{ cidr: "192.0.2.0/24", description: "TEST-NET-1 (documentation)" },
|
||||||
|
{ cidr: "192.88.99.0/24", description: "6to4 relay anycast (deprecated)" },
|
||||||
{ cidr: "192.168.0.0/16", description: "private" },
|
{ cidr: "192.168.0.0/16", description: "private" },
|
||||||
|
{ cidr: "198.18.0.0/15", description: "benchmarking" },
|
||||||
|
{ cidr: "198.51.100.0/24", description: "TEST-NET-2 (documentation)" },
|
||||||
|
{ cidr: "203.0.113.0/24", description: "TEST-NET-3 (documentation)" },
|
||||||
{ cidr: "224.0.0.0/4", description: "multicast" },
|
{ cidr: "224.0.0.0/4", description: "multicast" },
|
||||||
{ cidr: "240.0.0.0/4", description: "reserved" }
|
{ cidr: "240.0.0.0/4", description: "reserved" }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Возвращает описание диапазона, если адрес в него попадает. */
|
||||||
|
export function nonPublicIpv4Range(value: string): string | null {
|
||||||
|
if (!isIpv4Literal(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const range = NON_PUBLIC_IPV4_RANGES.find((candidate) => inCidr(value, candidate.cidr));
|
||||||
|
return range ? `${range.cidr} — ${range.description}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function isIpv4Literal(value: string): boolean {
|
export function isIpv4Literal(value: string): boolean {
|
||||||
return IPV4_PATTERN.test(value.trim());
|
return IPV4_PATTERN.test(value.trim());
|
||||||
}
|
}
|
||||||
@@ -137,19 +173,46 @@ function renderMismatch(label: string, host: string, records: readonly string[],
|
|||||||
* только при непустом локальном множестве, то на сервере за NAT с
|
* только при непустом локальном множестве, то на сервере за NAT с
|
||||||
* HY2XS_PUBLIC_ENDPOINT_POLICY=off домен без A-записи прошёл бы молча.
|
* HY2XS_PUBLIC_ENDPOINT_POLICY=off домен без A-записи прошёл бы молча.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Коды node:dns, означающие «имя разрешено, но записи нужного типа нет».
|
||||||
|
* Всё остальное — отказ самого резолвера, а не ответ про имя.
|
||||||
|
*/
|
||||||
|
const NO_RECORD_DNS_CODES = new Set(["ENODATA", "ENOTFOUND", "NXDOMAIN"]);
|
||||||
|
|
||||||
|
function dnsErrorCode(error: unknown): string {
|
||||||
|
if (typeof error === "object" && error !== null && "code" in error) {
|
||||||
|
const code = (error as { code?: unknown }).code;
|
||||||
|
if (typeof code === "string") {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResolverFailure(label: string, host: string, code: string): string {
|
||||||
|
return [
|
||||||
|
`${label}: DNS resolver failed for ${host}${code ? ` (${code})` : ""}.`,
|
||||||
|
"Это отказ резолвера на самой машине, а не отсутствие DNS-записи:",
|
||||||
|
"проверьте /etc/resolv.conf и доступность указанных в нём серверов.",
|
||||||
|
"HY2XS_PUBLIC_ENDPOINT_POLICY этот случай не ослабляет — без ответа",
|
||||||
|
"резолвера проверить публичный endpoint невозможно."
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveHost(
|
async function resolveHost(
|
||||||
label: string,
|
label: string,
|
||||||
host: string,
|
host: string,
|
||||||
probe: EndpointProbe
|
probe: EndpointProbe
|
||||||
): Promise<{ records: string[]; problems: EndpointProblem[] }> {
|
): Promise<{ records: string[]; problems: EndpointProblem[] }> {
|
||||||
if (isIpv4Literal(host)) {
|
if (isIpv4Literal(host)) {
|
||||||
if (!isRoutablePublicIpv4(host)) {
|
const reservedRange = nonPublicIpv4Range(host);
|
||||||
|
if (reservedRange) {
|
||||||
return {
|
return {
|
||||||
records: [host],
|
records: [host],
|
||||||
problems: [
|
problems: [
|
||||||
{
|
{
|
||||||
kind: "mismatch",
|
kind: "mismatch",
|
||||||
message: `${label} ${host} is not a routable public IPv4 address`
|
message: `${label} ${host} is not a routable public IPv4 address (${reservedRange})`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -160,11 +223,24 @@ async function resolveHost(
|
|||||||
let records: string[] = [];
|
let records: string[] = [];
|
||||||
try {
|
try {
|
||||||
records = await probe.resolve4(host);
|
records = await probe.resolve4(host);
|
||||||
} catch {
|
} catch (error) {
|
||||||
return { records: [], problems: [{ kind: "resolution", message: `${label} has no A-record: ${host}` }] };
|
const code = dnsErrorCode(error);
|
||||||
|
if (!NO_RECORD_DNS_CODES.has(code)) {
|
||||||
|
return {
|
||||||
|
records: [],
|
||||||
|
problems: [{ kind: "resolver_failure", message: renderResolverFailure(label, host, code) }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
records: [],
|
||||||
|
problems: [{ kind: "resolution", message: `${label} has no A-record: ${host}` }]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
return { records: [], problems: [{ kind: "resolution", message: `${label} has no A-record: ${host}` }] };
|
return {
|
||||||
|
records: [],
|
||||||
|
problems: [{ kind: "resolution", message: `${label} has no A-record: ${host}` }]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { records, problems: [] };
|
return { records, problems: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,27 @@ import { fail, info } from "../lib/log";
|
|||||||
import { runReadOnly } from "../lib/process";
|
import { runReadOnly } from "../lib/process";
|
||||||
import { assertPlatform } from "../platform/assert";
|
import { assertPlatform } from "../platform/assert";
|
||||||
import { HY2XS_TARGET_ARCH, HY2XS_TARGET_DEBIAN_VERSION } from "../config/profile";
|
import { HY2XS_TARGET_ARCH, HY2XS_TARGET_DEBIAN_VERSION } from "../config/profile";
|
||||||
import { assertCleanHost, type CleanHostPhase } from "./cleanHost";
|
import { assertCleanHost } from "./cleanHost";
|
||||||
import { checkPublicEndpoint, defaultEndpointProbe, type EndpointProbe } from "./networkEndpoint";
|
import { checkPublicEndpoint, defaultEndpointProbe, type EndpointProbe } from "./networkEndpoint";
|
||||||
|
|
||||||
type PreflightOptions = {
|
export type PreflightOptions = {
|
||||||
requireCapabilities?: boolean;
|
requireCapabilities?: boolean;
|
||||||
/**
|
/**
|
||||||
* bootstrap — PHASE 0, до любой мутации (полный список маркеров).
|
* Проверять ли контракт чистого хоста.
|
||||||
* install — PHASE 1, install.sh уже разложил runtime-пакет.
|
*
|
||||||
|
* Это НЕ то же самое, что requireCapabilities, хотя раньше они ехали одним
|
||||||
|
* параметром. Чистота хоста — условие ВХОДА в операцию: она осмысленна
|
||||||
|
* ровно до первой мутации. Возможности платформы (systemd-run, nftables,
|
||||||
|
* OpenSSL 3) проверяются после installDeps, то есть уже внутри PHASE 1.
|
||||||
|
*
|
||||||
|
* Регрессия, ради которой флаг разделён: install вызывал preflight дважды и
|
||||||
|
* оба раза с включённым clean-host. Ко второму вызову на диске уже лежал
|
||||||
|
* собственный /var/lib/hy2xs/install-state.json, записанный после первого
|
||||||
|
* preflight, — и он опознавался как маркер посторонней установки. Каждая
|
||||||
|
* чистая установка падала сразу после `apt-get`, получала fatal_post_apply
|
||||||
|
* и оставляла сервер наполовину настроенным.
|
||||||
*/
|
*/
|
||||||
cleanHostPhase?: CleanHostPhase;
|
checkCleanHost?: boolean;
|
||||||
/** Подменяется в тестах, чтобы не зависеть от сети и интерфейсов машины. */
|
/** Подменяется в тестах, чтобы не зависеть от сети и интерфейсов машины. */
|
||||||
endpointProbe?: EndpointProbe;
|
endpointProbe?: EndpointProbe;
|
||||||
};
|
};
|
||||||
@@ -23,8 +34,11 @@ type PreflightOptions = {
|
|||||||
* Применяет HY2XS_PUBLIC_ENDPOINT_POLICY к найденным расхождениям.
|
* Применяет HY2XS_PUBLIC_ENDPOINT_POLICY к найденным расхождениям.
|
||||||
*
|
*
|
||||||
* Отсутствие A-записи фатально при любой политике: имя без A-записи не
|
* Отсутствие A-записи фатально при любой политике: имя без A-записи не
|
||||||
* работает ни в какой топологии, и ослаблять здесь нечего. Политика управляет
|
* работает ни в какой топологии, и ослаблять здесь нечего. Отказ резолвера
|
||||||
* только сравнением с локальными адресами.
|
* фатален по другой причине: без его ответа проверить endpoint невозможно
|
||||||
|
* вовсе, и «продолжить с предупреждением» означало бы объявить проверку
|
||||||
|
* пройденной, не выполнив её. Политика управляет только сравнением с
|
||||||
|
* локальными адресами.
|
||||||
*/
|
*/
|
||||||
export async function assertPublicEndpoint(
|
export async function assertPublicEndpoint(
|
||||||
config: RuntimeConfig,
|
config: RuntimeConfig,
|
||||||
@@ -35,7 +49,9 @@ export async function assertPublicEndpoint(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const unresolvable = problems.filter((problem) => problem.kind === "resolution");
|
const unresolvable = problems.filter(
|
||||||
|
(problem) => problem.kind === "resolution" || problem.kind === "resolver_failure"
|
||||||
|
);
|
||||||
if (unresolvable.length > 0) {
|
if (unresolvable.length > 0) {
|
||||||
fail(unresolvable.map((problem) => problem.message).join("\n\n"));
|
fail(unresolvable.map((problem) => problem.message).join("\n\n"));
|
||||||
}
|
}
|
||||||
@@ -93,6 +109,17 @@ export async function preflight(context: RuntimeContext, options?: PreflightOpti
|
|||||||
const isReconfigure = context.mode === "reconfigure";
|
const isReconfigure = context.mode === "reconfigure";
|
||||||
const requireCapabilities = options?.requireCapabilities ?? true;
|
const requireCapabilities = options?.requireCapabilities ?? true;
|
||||||
|
|
||||||
|
// Умолчания у checkCleanHost в режиме install нет намеренно. Любое умолчание
|
||||||
|
// здесь неверно: `true` ломает проход после installDeps, `false` молча
|
||||||
|
// выключает контракт чистого хоста у нового вызова. Решение обязано быть
|
||||||
|
// принято на месте вызова.
|
||||||
|
if (!isReconfigure && options?.checkCleanHost === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"preflight(install): checkCleanHost must be stated explicitly — " +
|
||||||
|
"clean-host is an entry condition of the operation, not a capability probe"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const needsFirewallCapabilities = !context.options.skipFirewall &&
|
const needsFirewallCapabilities = !context.options.skipFirewall &&
|
||||||
context.config.firewallMode !== "off" &&
|
context.config.firewallMode !== "off" &&
|
||||||
context.config.firewallMode !== "external";
|
context.config.firewallMode !== "external";
|
||||||
@@ -140,9 +167,10 @@ export async function preflight(context: RuntimeContext, options?: PreflightOpti
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Полный clean-host контракт: HY2XS v1 не устанавливается поверх другого
|
// Полный clean-host контракт: HY2XS v1 не устанавливается поверх другого
|
||||||
// поколения. Проверка read-only и выполняется до любой мутации.
|
// поколения. Проверка read-only и выполняется только до первой мутации —
|
||||||
if (!isReconfigure) {
|
// см. комментарий у PreflightOptions.checkCleanHost.
|
||||||
await assertCleanHost(context.config, options?.cleanHostPhase ?? "install");
|
if (!isReconfigure && (options?.checkCleanHost ?? false)) {
|
||||||
|
await assertCleanHost(context.config);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ports = new Set([context.config.hysteriaPort, context.config.uiPort]);
|
const ports = new Set([context.config.hysteriaPort, context.config.uiPort]);
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import {
|
|||||||
renderLegacyFailure,
|
renderLegacyFailure,
|
||||||
type HostProbe
|
type HostProbe
|
||||||
} from "../src/steps/cleanHost";
|
} from "../src/steps/cleanHost";
|
||||||
|
import {
|
||||||
|
ORCHESTRATOR_INSTALL_DIR,
|
||||||
|
ORCHESTRATOR_SYMLINK_PATH,
|
||||||
|
RUNTIME_PACKAGE_DIR
|
||||||
|
} from "../src/config/profile";
|
||||||
import { baselineConfig } from "./fixtures";
|
import { baselineConfig } from "./fixtures";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,23 +36,23 @@ const config = baselineConfig();
|
|||||||
|
|
||||||
describe("clean-host контракт", () => {
|
describe("clean-host контракт", () => {
|
||||||
test("чистый хост проходит", async () => {
|
test("чистый хост проходит", async () => {
|
||||||
await expect(assertCleanHost(config, "bootstrap", probeWith([]))).resolves.toBeUndefined();
|
await expect(assertCleanHost(config, probeWith([]))).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("каждый маркер по отдельности останавливает установку", async () => {
|
test("каждый маркер по отдельности останавливает установку", async () => {
|
||||||
const markers = legacyMarkersFor(config, "bootstrap");
|
const markers = legacyMarkersFor(config);
|
||||||
expect(markers.length).toBeGreaterThan(10);
|
expect(markers.length).toBeGreaterThan(10);
|
||||||
|
|
||||||
for (const marker of markers) {
|
for (const marker of markers) {
|
||||||
const probe = probeWith([marker.target]);
|
const probe = probeWith([marker.target]);
|
||||||
await expect(assertCleanHost(config, "bootstrap", probe)).rejects.toThrow(
|
await expect(assertCleanHost(config, probe)).rejects.toThrow(
|
||||||
/предыдущая или посторонняя установка/
|
/предыдущая или посторонняя установка/
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("список покрывает состояние, юниты, бинарник и наследие 0.x", () => {
|
test("список покрывает состояние, юниты, бинарник и наследие 0.x", () => {
|
||||||
const targets = legacyMarkersFor(config, "bootstrap").map((marker) => marker.target);
|
const targets = legacyMarkersFor(config).map((marker) => marker.target);
|
||||||
|
|
||||||
for (const expected of [
|
for (const expected of [
|
||||||
"/etc/hysteria/post-install.env",
|
"/etc/hysteria/post-install.env",
|
||||||
@@ -96,7 +101,7 @@ describe("clean-host контракт", () => {
|
|||||||
"/usr/local/bin/hysteria",
|
"/usr/local/bin/hysteria",
|
||||||
"/etc/nftables.d/hy2xs.nft"
|
"/etc/nftables.d/hy2xs.nft"
|
||||||
];
|
];
|
||||||
const targets = legacyMarkersFor(config, "bootstrap").map((marker) => marker.target);
|
const targets = legacyMarkersFor(config).map((marker) => marker.target);
|
||||||
|
|
||||||
for (const purged of purgedPaths) {
|
for (const purged of purgedPaths) {
|
||||||
const covered = targets.some((target) => target === purged || target.startsWith(`${purged}/`));
|
const covered = targets.some((target) => target === purged || target.startsWith(`${purged}/`));
|
||||||
@@ -105,50 +110,45 @@ describe("clean-host контракт", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Пути, которые install.sh законно создаёт между фазами, обязаны быть
|
* Регрессия архитектуры установки.
|
||||||
* маркером в PHASE 0 и перестать им быть в PHASE 1 — иначе установка
|
*
|
||||||
* отказала бы на собственном, только что разложенном оркестраторе.
|
* Пока PHASE 1 начиналась в install.sh, у списка маркеров была вторая,
|
||||||
|
* «мягкая» версия: пути, которые shell успевал создать между фазами,
|
||||||
|
* исключались из проверки. Это была не оптимизация, а компенсация того, что
|
||||||
|
* мутировали двое.
|
||||||
|
*
|
||||||
|
* Теперь bootstrap выполняет сам оркестратор — уже ПОСЛЕ clean-host, — и
|
||||||
|
* послаблений быть не должно: любой из этих путей на входе означает чужую
|
||||||
|
* установку, при любом вызове.
|
||||||
*/
|
*/
|
||||||
test("пути, созданные install.sh между фазами, не блокируют PHASE 1", async () => {
|
test("bootstrap-пути остаются маркерами без всяких исключений", async () => {
|
||||||
for (const target of [
|
for (const target of [ORCHESTRATOR_INSTALL_DIR, RUNTIME_PACKAGE_DIR, ORCHESTRATOR_SYMLINK_PATH]) {
|
||||||
"/usr/local/lib/hy2xs",
|
await expect(assertCleanHost(config, probeWith([target]))).rejects.toThrow(
|
||||||
"/usr/local/lib/hy2xs/package",
|
|
||||||
"/usr/local/bin/hy2xs-orchestrator"
|
|
||||||
]) {
|
|
||||||
const probe = probeWith([target]);
|
|
||||||
await expect(assertCleanHost(config, "bootstrap", probe)).rejects.toThrow(
|
|
||||||
/предыдущая или посторонняя установка/
|
/предыдущая или посторонняя установка/
|
||||||
);
|
);
|
||||||
await expect(assertCleanHost(config, "install", probe)).resolves.toBeUndefined();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("маркеры bootstrap-путей берутся из профиля, а не из копий строк", () => {
|
||||||
|
const targets = legacyMarkersFor(config).map((marker) => marker.target);
|
||||||
|
expect(targets).toContain(ORCHESTRATOR_INSTALL_DIR);
|
||||||
|
expect(targets).toContain(RUNTIME_PACKAGE_DIR);
|
||||||
|
expect(targets).toContain(ORCHESTRATOR_SYMLINK_PATH);
|
||||||
|
});
|
||||||
|
|
||||||
test("пути из конфигурации попадают в список, а не только дефолтные", () => {
|
test("пути из конфигурации попадают в список, а не только дефолтные", () => {
|
||||||
const custom = baselineConfig({
|
const custom = baselineConfig({
|
||||||
HY2XS_INSTALL_DIR: "/srv/hy2xs-app",
|
HY2XS_INSTALL_DIR: "/srv/hy2xs-app",
|
||||||
HY2XS_DATA_DIR: "/srv/hy2xs-data"
|
HY2XS_DATA_DIR: "/srv/hy2xs-data"
|
||||||
});
|
});
|
||||||
const targets = legacyMarkersFor(custom, "bootstrap").map((marker) => marker.target);
|
const targets = legacyMarkersFor(custom).map((marker) => marker.target);
|
||||||
expect(targets).toContain("/srv/hy2xs-app");
|
expect(targets).toContain("/srv/hy2xs-app");
|
||||||
expect(targets).toContain("/srv/hy2xs-data");
|
expect(targets).toContain("/srv/hy2xs-data");
|
||||||
});
|
});
|
||||||
|
|
||||||
// install.sh раскладывает runtime-пакет между фазами, поэтому в PHASE 1
|
|
||||||
// этот путь уже наш и маркером быть не может.
|
|
||||||
test("runtime-пакет — маркер в PHASE 0, но не в PHASE 1", async () => {
|
|
||||||
const probe = probeWith(["/usr/local/lib/hy2xs/package"]);
|
|
||||||
await expect(assertCleanHost(config, "bootstrap", probe)).rejects.toThrow(/usr\/local\/lib\/hy2xs\/package/);
|
|
||||||
await expect(assertCleanHost(config, "install", probe)).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("остальные маркеры продолжают работать и в PHASE 1", async () => {
|
|
||||||
const probe = probeWith(["hysteria-server.service"]);
|
|
||||||
await expect(assertCleanHost(config, "install", probe)).rejects.toThrow(/hysteria-server\.service/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("сообщение перечисляет все найденные маркеры и говорит, что хост не изменён", async () => {
|
test("сообщение перечисляет все найденные маркеры и говорит, что хост не изменён", async () => {
|
||||||
const found = await detectLegacyMarkers(
|
const found = await detectLegacyMarkers(
|
||||||
legacyMarkersFor(config, "bootstrap"),
|
legacyMarkersFor(config),
|
||||||
probeWith(["/etc/hy2xs/hy2xs.env", "hy2xs-admin.service"])
|
probeWith(["/etc/hy2xs/hy2xs.env", "hy2xs-admin.service"])
|
||||||
);
|
);
|
||||||
expect(found).toHaveLength(2);
|
expect(found).toHaveLength(2);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type Ownership = Parameters<typeof classifyFailure>[0];
|
|||||||
function ownership(overrides: Partial<Ownership> = {}): Ownership {
|
function ownership(overrides: Partial<Ownership> = {}): Ownership {
|
||||||
return {
|
return {
|
||||||
stateWritten: false,
|
stateWritten: false,
|
||||||
|
bootstrapTouched: false,
|
||||||
depsTouched: false,
|
depsTouched: false,
|
||||||
filesystemTouched: false,
|
filesystemTouched: false,
|
||||||
uiTouched: false,
|
uiTouched: false,
|
||||||
@@ -112,6 +113,19 @@ describe("классификация отказа установки", () => {
|
|||||||
expect(classifyFailure(ownership({ stateWritten: true }), "preflight_ok")).toBe("fatal_post_apply");
|
expect(classifyFailure(ownership({ stateWritten: true }), "preflight_ok")).toBe("fatal_post_apply");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Регрессия: раскладку оркестратора и runtime-пакета выполнял install.sh,
|
||||||
|
// поэтому у оркестратора не было флага, которым это можно было бы учесть.
|
||||||
|
// Отказ сразу после bootstrap выглядел как «хост не тронут», хотя на диске
|
||||||
|
// уже лежали /usr/local/lib/hy2xs и symlink в /usr/local/bin.
|
||||||
|
test("начатый bootstrap уже делает отказ post-apply", () => {
|
||||||
|
expect(classifyFailure(ownership({ bootstrapTouched: true }), "preflight_ok")).toBe(
|
||||||
|
"fatal_post_apply"
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
classifyFailure(ownership({ stateWritten: true, bootstrapTouched: true }), "bootstrap_installed")
|
||||||
|
).toBe("fatal_post_apply");
|
||||||
|
});
|
||||||
|
|
||||||
test("падение installDeps после записи состояния — post-apply", () => {
|
test("падение installDeps после записи состояния — post-apply", () => {
|
||||||
expect(
|
expect(
|
||||||
classifyFailure(ownership({ stateWritten: true, depsTouched: true }), "preflight_ok")
|
classifyFailure(ownership({ stateWritten: true, depsTouched: true }), "preflight_ok")
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { preflight } from "../src/steps/preflight";
|
||||||
|
import { classifyReconfigureFailure } from "../src/commands/reconfigure";
|
||||||
|
import { baselineConfig, testContext } from "./fixtures";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Порядок фаз установки — инвариант, который нельзя проверить ни модульно, ни
|
||||||
|
* на живом сервере дешевле, чем разбором самой последовательности.
|
||||||
|
*
|
||||||
|
* Оба дефекта, которые здесь закреплены, проявлялись ТОЛЬКО на настоящей
|
||||||
|
* Debian-машине и оба ломали установку полностью:
|
||||||
|
*
|
||||||
|
* 1. clean-host проверялся дважды, второй раз — уже после записи
|
||||||
|
* install-state.json, и опознавал собственный маркер как чужую установку;
|
||||||
|
* 2. bootstrap (раскладка оркестратора и runtime-пакета) выполнялся вне
|
||||||
|
* оркестратора, поэтому не имел владельца и не попадал в rollback.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const installSource = readFileSync(new URL("../src/commands/install.ts", import.meta.url), "utf8");
|
||||||
|
const reconfigureSource = readFileSync(
|
||||||
|
new URL("../src/commands/reconfigure.ts", import.meta.url),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const installerSource = readFileSync(new URL("../../package/install.sh", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
function occurrences(haystack: string, needle: string): number {
|
||||||
|
return haystack.split(needle).length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("preflight: clean-host — условие входа, а не проверка возможностей", () => {
|
||||||
|
const context = testContext(baselineConfig());
|
||||||
|
|
||||||
|
test("в режиме install умолчания у checkCleanHost нет", async () => {
|
||||||
|
await expect(preflight(context)).rejects.toThrow(/checkCleanHost must be stated explicitly/);
|
||||||
|
await expect(preflight(context, { requireCapabilities: true })).rejects.toThrow(
|
||||||
|
/checkCleanHost must be stated explicitly/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("отказ наступает раньше любой работы с системой", async () => {
|
||||||
|
// Проверка стоит первой в preflight, поэтому тест проходит и не под root,
|
||||||
|
// и на не-Debian машине: до assertPlatform дело не доходит.
|
||||||
|
await expect(preflight(context)).rejects.toThrow(/checkCleanHost/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("последовательность install", () => {
|
||||||
|
test("clean-host запрашивается ровно один раз за операцию", () => {
|
||||||
|
expect(occurrences(installSource, "checkCleanHost: true")).toBe(1);
|
||||||
|
expect(occurrences(installSource, "checkCleanHost: false")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Регрессия: повторный clean-host стоял ПОСЛЕ advanceInstallState и падал на
|
||||||
|
// /var/lib/hy2xs/install-state.json, записанном этой же установкой.
|
||||||
|
test("clean-host проверяется до первой записи состояния", () => {
|
||||||
|
const cleanHostAt = installSource.indexOf("checkCleanHost: true");
|
||||||
|
const firstStateWriteAt = installSource.indexOf("await advanceInstallState(");
|
||||||
|
expect(cleanHostAt).toBeGreaterThan(-1);
|
||||||
|
expect(firstStateWriteAt).toBeGreaterThan(-1);
|
||||||
|
expect(cleanHostAt).toBeLessThan(firstStateWriteAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("проход capabilities явно отказывается от clean-host", () => {
|
||||||
|
const capabilitiesAt = installSource.indexOf("requireCapabilities: true");
|
||||||
|
const optOutAt = installSource.indexOf("checkCleanHost: false");
|
||||||
|
expect(capabilitiesAt).toBeGreaterThan(-1);
|
||||||
|
// Оба флага стоят в одном вызове.
|
||||||
|
expect(Math.abs(capabilitiesAt - optOutAt)).toBeLessThan(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bootstrap — первый мутирующий шаг и он под флагом владения", () => {
|
||||||
|
const flagAt = installSource.indexOf("ownership.bootstrapTouched = true");
|
||||||
|
const callAt = installSource.indexOf("await bootstrapRuntime(");
|
||||||
|
const depsAt = installSource.indexOf("await installDeps(");
|
||||||
|
|
||||||
|
expect(flagAt).toBeGreaterThan(-1);
|
||||||
|
expect(callAt).toBeGreaterThan(-1);
|
||||||
|
expect(flagAt).toBeLessThan(callAt);
|
||||||
|
expect(callAt).toBeLessThan(depsAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("установка продолжается от установленного runtime-пакета", () => {
|
||||||
|
expect(installSource).toContain("context.options.packageDir = await bootstrapRuntime(context)");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Регрессия: diagnosticsCollect стоял перед rollback обычным await, поэтому
|
||||||
|
// её собственный отказ (нет места, недоступен journalctl) отменял откат.
|
||||||
|
test("отказ диагностики не отменяет rollback", () => {
|
||||||
|
const diagnosticsAt = installSource.indexOf("await diagnosticsCollect(options)");
|
||||||
|
const rollbackAt = installSource.indexOf("await rollbackFailedInstall(");
|
||||||
|
const tryAt = installSource.lastIndexOf("try {", diagnosticsAt);
|
||||||
|
const catchAt = installSource.indexOf("catch (diagnosticsError)");
|
||||||
|
|
||||||
|
expect(tryAt).toBeLessThan(diagnosticsAt);
|
||||||
|
expect(catchAt).toBeGreaterThan(diagnosticsAt);
|
||||||
|
expect(catchAt).toBeLessThan(rollbackAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("классификация отказа reconfigure/repair", () => {
|
||||||
|
function ownership(overrides: Partial<Parameters<typeof classifyReconfigureFailure>[0]> = {}) {
|
||||||
|
return {
|
||||||
|
configTouched: false,
|
||||||
|
unitsTouched: false,
|
||||||
|
firewallTouched: false,
|
||||||
|
envTouched: false,
|
||||||
|
servicesRestarted: false,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Регрессия: фаза выбиралась регулярным выражением по тексту ошибки. Тот же
|
||||||
|
// приём уже убрали из install — здесь он остался.
|
||||||
|
test("до firewall отказ не выдаёт себя за firewall failure", () => {
|
||||||
|
expect(classifyReconfigureFailure(ownership())).toBe("reconfigure_failed");
|
||||||
|
expect(classifyReconfigureFailure(ownership({ configTouched: true }))).toBe("reconfigure_failed");
|
||||||
|
expect(classifyReconfigureFailure(ownership({ unitsTouched: true }))).toBe("reconfigure_failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("тронутый firewall даёт firewall failure", () => {
|
||||||
|
expect(classifyReconfigureFailure(ownership({ unitsTouched: true, firewallTouched: true }))).toBe(
|
||||||
|
"firewall_connectivity_failure"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("после рестарта сервисов отказ относится к smoke", () => {
|
||||||
|
expect(
|
||||||
|
classifyReconfigureFailure(ownership({ firewallTouched: true, servicesRestarted: true }))
|
||||||
|
).toBe("smoke_failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверяется код, а не упоминание: комментарий, объясняющий, ПОЧЕМУ
|
||||||
|
// классификация по тексту ошибки убрана, должен быть разрешён.
|
||||||
|
test("текст ошибки на классификацию не влияет", () => {
|
||||||
|
expect(reconfigureSource).not.toContain(".test(message)");
|
||||||
|
expect(reconfigureSource).toContain("classifyReconfigureFailure(ownership)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("отказ диагностики не отменяет откат firewall и конфигов", () => {
|
||||||
|
const diagnosticsAt = reconfigureSource.indexOf("await diagnosticsCollect(options)");
|
||||||
|
const firewallRollbackAt = reconfigureSource.indexOf("await rollbackFirewallNow(context)");
|
||||||
|
const stateRollbackAt = reconfigureSource.indexOf("await rollbackCurrentState()");
|
||||||
|
const catchAt = reconfigureSource.indexOf("catch (diagnosticsError)");
|
||||||
|
|
||||||
|
expect(catchAt).toBeGreaterThan(diagnosticsAt);
|
||||||
|
expect(catchAt).toBeLessThan(firewallRollbackAt);
|
||||||
|
expect(firewallRollbackAt).toBeLessThan(stateRollbackAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("install.sh остаётся read-only bootstrap", () => {
|
||||||
|
test("установщик запускает preflight и передаёт мутацию через exec", () => {
|
||||||
|
expect(installerSource).toContain("preflight-install --package-dir");
|
||||||
|
expect(installerSource).toMatch(/exec "\$ORCHESTRATOR" install --package-dir/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("в установщике не осталось ни одной мутирующей команды", () => {
|
||||||
|
const mutating = /^\s*(install|ln|cp|mv|rm|mkdir|chown|chmod|systemctl|apt-get|useradd|groupadd|nft|tee)\s/;
|
||||||
|
const offenders = installerSource
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line, index) => ({ line, number: index + 1 }))
|
||||||
|
.filter((entry) => mutating.test(entry.line));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
offenders,
|
||||||
|
`install.sh мутирует хост: ${offenders.map((entry) => `${entry.number}: ${entry.line.trim()}`).join("; ")}`
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("установщик не раскладывает оркестратор сам", () => {
|
||||||
|
expect(installerSource).not.toContain("ORCHESTRATOR_INSTALL_PATH=");
|
||||||
|
expect(installerSource).not.toContain("RUNTIME_PACKAGE_DIR=");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,22 +3,30 @@ import {
|
|||||||
checkPublicEndpoint,
|
checkPublicEndpoint,
|
||||||
getLocalPublicIpv4s,
|
getLocalPublicIpv4s,
|
||||||
isRoutablePublicIpv4,
|
isRoutablePublicIpv4,
|
||||||
|
nonPublicIpv4Range,
|
||||||
type EndpointProbe
|
type EndpointProbe
|
||||||
} from "../src/steps/networkEndpoint";
|
} from "../src/steps/networkEndpoint";
|
||||||
import { assertPublicEndpoint } from "../src/steps/preflight";
|
import { assertPublicEndpoint } from "../src/steps/preflight";
|
||||||
import { baselineConfig } from "./fixtures";
|
import { baselineConfig } from "./fixtures";
|
||||||
|
|
||||||
|
/** Коды отказа резолвера, отличные от «записи нет». */
|
||||||
|
type DnsFailureCode = "ENODATA" | "ENOTFOUND" | "SERVFAIL" | "ETIMEOUT" | "ECONNREFUSED";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проба полностью подменяет и DNS, и список локальных адресов: тест не имеет
|
* Проба полностью подменяет и DNS, и список локальных адресов: тест не имеет
|
||||||
* права зависеть ни от сети, ни от интерфейсов машины разработчика.
|
* права зависеть ни от сети, ни от интерфейсов машины разработчика.
|
||||||
*/
|
*/
|
||||||
function probe(options: { local: string[]; dns?: Record<string, string[] | "ENODATA"> }): EndpointProbe {
|
function probe(options: {
|
||||||
|
local: string[];
|
||||||
|
dns?: Record<string, string[] | DnsFailureCode>;
|
||||||
|
}): EndpointProbe {
|
||||||
return {
|
return {
|
||||||
async resolve4(hostname) {
|
async resolve4(hostname) {
|
||||||
const records = options.dns?.[hostname];
|
const records = options.dns?.[hostname];
|
||||||
if (records === undefined || records === "ENODATA") {
|
if (records === undefined || typeof records === "string") {
|
||||||
const error = new Error(`queryA ENODATA ${hostname}`) as Error & { code?: string };
|
const code: DnsFailureCode = typeof records === "string" ? records : "ENODATA";
|
||||||
error.code = "ENODATA";
|
const error = new Error(`queryA ${code} ${hostname}`) as Error & { code?: string };
|
||||||
|
error.code = code;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return records;
|
return records;
|
||||||
@@ -33,7 +41,7 @@ const SECOND = "185.10.20.28";
|
|||||||
|
|
||||||
describe("классификация IPv4", () => {
|
describe("классификация IPv4", () => {
|
||||||
test("публичные адреса распознаются", () => {
|
test("публичные адреса распознаются", () => {
|
||||||
for (const ip of ["1.1.1.1", "8.8.8.8", "185.10.20.27", "203.0.113.5"]) {
|
for (const ip of ["1.1.1.1", "8.8.8.8", "185.10.20.27", "203.0.114.5"]) {
|
||||||
expect(isRoutablePublicIpv4(ip)).toBe(true);
|
expect(isRoutablePublicIpv4(ip)).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -56,6 +64,40 @@ describe("классификация IPv4", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Регрессия: функция называлась «маршрутизируемый публичный IPv4», а список
|
||||||
|
* исключений покрывал только приватные диапазоны. TEST-NET-3 (203.0.113.0/24)
|
||||||
|
* — адрес из RFC-примеров, который нельзя назначить серверу, — проходил
|
||||||
|
* проверку как обычный публичный адрес. Список приведён к IANA
|
||||||
|
* Special-Purpose Address Registry.
|
||||||
|
*/
|
||||||
|
test("документационные и служебные диапазоны IANA исключаются", () => {
|
||||||
|
for (const ip of [
|
||||||
|
"192.0.0.1", // IETF protocol assignments
|
||||||
|
"192.0.2.5", // TEST-NET-1
|
||||||
|
"192.88.99.1", // 6to4 relay anycast
|
||||||
|
"198.18.0.1", // benchmarking
|
||||||
|
"198.19.255.254", // benchmarking, верхняя граница /15
|
||||||
|
"198.51.100.5", // TEST-NET-2
|
||||||
|
"203.0.113.5" // TEST-NET-3
|
||||||
|
]) {
|
||||||
|
expect(isRoutablePublicIpv4(ip), `${ip} должен быть исключён`).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("границы специальных диапазонов не задевают соседей", () => {
|
||||||
|
for (const ip of ["192.0.1.1", "192.0.3.1", "198.17.255.255", "198.20.0.1", "203.0.112.255"]) {
|
||||||
|
expect(isRoutablePublicIpv4(ip), `${ip} не должен быть исключён`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("причина исключения называется в диагностике", () => {
|
||||||
|
expect(nonPublicIpv4Range("203.0.113.5")).toContain("TEST-NET-3");
|
||||||
|
expect(nonPublicIpv4Range("10.0.0.1")).toContain("private");
|
||||||
|
expect(nonPublicIpv4Range("1.1.1.1")).toBeNull();
|
||||||
|
expect(nonPublicIpv4Range("не адрес")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test("172.32.0.0 уже вне приватного диапазона", () => {
|
test("172.32.0.0 уже вне приватного диапазона", () => {
|
||||||
expect(isRoutablePublicIpv4("172.32.0.1")).toBe(true);
|
expect(isRoutablePublicIpv4("172.32.0.1")).toBe(true);
|
||||||
expect(isRoutablePublicIpv4("172.15.255.255")).toBe(true);
|
expect(isRoutablePublicIpv4("172.15.255.255")).toBe(true);
|
||||||
@@ -262,6 +304,61 @@ describe("HY2XS_PUBLIC_ENDPOINT_POLICY", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Регрессия диагностики: любой отказ resolve4 печатался как
|
||||||
|
* «has no A-record». При сломанном /etc/resolv.conf оператор шёл править
|
||||||
|
* DNS-запись, которая на самом деле была на месте.
|
||||||
|
*/
|
||||||
|
test("отказ резолвера отличается от отсутствия записи", async () => {
|
||||||
|
for (const code of ["SERVFAIL", "ETIMEOUT", "ECONNREFUSED"] as const) {
|
||||||
|
const problems = await checkPublicEndpoint(
|
||||||
|
baselineConfig(),
|
||||||
|
probe({ local: [CURRENT], dns: { "vpn.example.com": code } })
|
||||||
|
);
|
||||||
|
expect(problems).toHaveLength(1);
|
||||||
|
expect(problems[0].kind).toBe("resolver_failure");
|
||||||
|
expect(problems[0].message).toContain("DNS resolver failed");
|
||||||
|
expect(problems[0].message).toContain(code);
|
||||||
|
expect(problems[0].message).toContain("/etc/resolv.conf");
|
||||||
|
expect(problems[0].message).not.toContain("has no A-record");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("NXDOMAIN и ENODATA остаются отсутствием записи", async () => {
|
||||||
|
for (const code of ["ENODATA", "ENOTFOUND"] as const) {
|
||||||
|
const problems = await checkPublicEndpoint(
|
||||||
|
baselineConfig(),
|
||||||
|
probe({ local: [CURRENT], dns: { "vpn.example.com": code } })
|
||||||
|
);
|
||||||
|
expect(problems).toHaveLength(1);
|
||||||
|
expect(problems[0].kind).toBe("resolution");
|
||||||
|
expect(problems[0].message).toContain("has no A-record");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ослаблять нечего и здесь: без ответа резолвера проверка не выполнена,
|
||||||
|
// а не «выполнена с замечанием».
|
||||||
|
test("отказ резолвера фатален при любой политике", async () => {
|
||||||
|
for (const policy of ["strict", "warn", "off"]) {
|
||||||
|
const config = baselineConfig({ HY2XS_PUBLIC_ENDPOINT_POLICY: policy });
|
||||||
|
await expect(
|
||||||
|
assertPublicEndpoint(config, probe({ local: [CURRENT], dns: { "vpn.example.com": "SERVFAIL" } }))
|
||||||
|
).rejects.toThrow(/DNS resolver failed/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("IPv4-литерал из документационного диапазона отклоняется с указанием причины", async () => {
|
||||||
|
const literal = baselineConfig({
|
||||||
|
HY2XS_PUBLIC_HOST: "203.0.113.5",
|
||||||
|
HY2XS_DOMAIN: "vpn.example.com"
|
||||||
|
});
|
||||||
|
const problems = await checkPublicEndpoint(
|
||||||
|
literal,
|
||||||
|
probe({ local: [CURRENT], dns: { "vpn.example.com": [CURRENT] } })
|
||||||
|
);
|
||||||
|
expect(problems.some((problem) => problem.message.includes("TEST-NET-3"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test("неизвестное значение политики отклоняется", () => {
|
test("неизвестное значение политики отклоняется", () => {
|
||||||
expect(() => baselineConfig({ HY2XS_PUBLIC_ENDPOINT_POLICY: "maybe" })).toThrow(
|
expect(() => baselineConfig({ HY2XS_PUBLIC_ENDPOINT_POLICY: "maybe" })).toThrow(
|
||||||
/invalid HY2XS_PUBLIC_ENDPOINT_POLICY/
|
/invalid HY2XS_PUBLIC_ENDPOINT_POLICY/
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
REDACTED,
|
REDACTED,
|
||||||
isSecretKey,
|
isSecretKey,
|
||||||
redactEnv,
|
redactEnv,
|
||||||
|
redactLogText,
|
||||||
redactYaml,
|
redactYaml,
|
||||||
redactYamlFallback,
|
redactYamlFallback,
|
||||||
sanitizeUrlValue
|
sanitizeUrlValue
|
||||||
@@ -214,6 +215,65 @@ describe("санитизация URL", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Регрессия diagnostics-бандла: env и YAML в нём редактировались, а
|
||||||
|
* journal-admin.log копировался как есть. Пока админка логировала RequestURI,
|
||||||
|
* в этом файле лежал действующий machine token, и бандл уносил его наружу.
|
||||||
|
*/
|
||||||
|
describe("редакция журналов в diagnostics-бандле", () => {
|
||||||
|
const JOURNAL_LINE =
|
||||||
|
'авг 27 12:00:01 host hy2xs-admin[123]: {"level":"info","reqUri":"/internal/hysteria/auth?access_token=' +
|
||||||
|
MACHINE_TOKEN +
|
||||||
|
'"}';
|
||||||
|
|
||||||
|
test("machine token из journald-строки вырезается", () => {
|
||||||
|
const out = redactLogText(JOURNAL_LINE);
|
||||||
|
expect(out).not.toContain(MACHINE_TOKEN);
|
||||||
|
expect(out).toContain("/internal/hysteria/auth");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("адрес остаётся читаемым: host, port и path не теряются", () => {
|
||||||
|
const out = redactLogText(
|
||||||
|
`auth backend error: Post http://127.0.0.1:8080/internal/hysteria/auth?access_token=${MACHINE_TOKEN}: EOF`
|
||||||
|
);
|
||||||
|
expect(out).not.toContain(MACHINE_TOKEN);
|
||||||
|
expect(out).toContain("127.0.0.1:8080");
|
||||||
|
expect(out).toContain("/internal/hysteria/auth");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("секрет вне URL тоже вырезается", () => {
|
||||||
|
for (const line of [
|
||||||
|
`starting with access_token=${MACHINE_TOKEN}`,
|
||||||
|
`trafficStats secret=${MACHINE_TOKEN} rejected`,
|
||||||
|
`obfs password: ${OBFS_PASSWORD}`
|
||||||
|
]) {
|
||||||
|
const out = redactLogText(line);
|
||||||
|
expect(out).not.toContain(MACHINE_TOKEN);
|
||||||
|
expect(out).not.toContain(OBFS_PASSWORD);
|
||||||
|
expect(out).toContain(REDACTED);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("обычные строки журнала не меняются", () => {
|
||||||
|
const line = "server up and running, listening on 0.0.0.0:443";
|
||||||
|
expect(redactLogText(line)).toBe(line);
|
||||||
|
expect(redactLogText("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("хвостовая пунктуация сохраняется", () => {
|
||||||
|
const out = redactLogText(
|
||||||
|
`failed to call http://127.0.0.1:8080/internal/hysteria/auth?access_token=${MACHINE_TOKEN}.`
|
||||||
|
);
|
||||||
|
expect(out).not.toContain(MACHINE_TOKEN);
|
||||||
|
expect(out.endsWith(".")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("повторный проход ничего не ломает", () => {
|
||||||
|
const once = redactLogText(JOURNAL_LINE);
|
||||||
|
expect(redactLogText(once)).toBe(once);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("маркеры секретных имён", () => {
|
describe("маркеры секретных имён", () => {
|
||||||
test("покрывают типовые секретоподобные имена", () => {
|
test("покрывают типовые секретоподобные имена", () => {
|
||||||
for (const key of [
|
for (const key of [
|
||||||
|
|||||||
@@ -27,15 +27,20 @@
|
|||||||
## Установка выполняется в две фазы
|
## Установка выполняется в две фазы
|
||||||
|
|
||||||
```text
|
```text
|
||||||
PHASE 0 — READ ONLY
|
PHASE 0 — READ ONLY выполняет install.sh
|
||||||
проверка прав и checksums пакета
|
проверка прав и checksums пакета
|
||||||
clean-host preflight из распакованного архива
|
clean-host preflight из распакованного архива
|
||||||
↓ ноль изменений на сервере
|
↓ ноль изменений на сервере
|
||||||
PHASE 1 — MUTATION
|
↓ exec
|
||||||
установка orchestrator, раскладка runtime-пакета
|
PHASE 1 — MUTATION выполняет orchestrator
|
||||||
install
|
раскладка orchestrator и runtime-пакета
|
||||||
|
установка
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`install.sh` не изменяет на сервере ничего: он проверяет и передаёт управление.
|
||||||
|
Всю мутирующую фазу выполняет оркестратор, поэтому при любом отказе известно,
|
||||||
|
какие именно пути были созданы этой операцией.
|
||||||
|
|
||||||
HY2XS v1 **не устанавливается поверх** предыдущей установки и не мигрирует её
|
HY2XS v1 **не устанавливается поверх** предыдущей установки и не мигрирует её
|
||||||
состояние. Если PHASE 0 обнаружит старую установку, установщик завершится с
|
состояние. Если PHASE 0 обнаружит старую установку, установщик завершится с
|
||||||
ошибкой и **не изменит на сервере ничего**.
|
ошибкой и **не изменит на сервере ничего**.
|
||||||
|
|||||||
+25
-32
@@ -3,23 +3,31 @@ set -eu
|
|||||||
|
|
||||||
# Установка HY2XS состоит из двух строго разделённых фаз.
|
# Установка HY2XS состоит из двух строго разделённых фаз.
|
||||||
#
|
#
|
||||||
# PHASE 0 — READ ONLY
|
# PHASE 0 — READ ONLY (этот файл, целиком)
|
||||||
# проверка прав, целостности пакета и чистоты хоста.
|
# проверка прав, целостности пакета и чистоты хоста.
|
||||||
# Ни один persistent path не изменяется, включая /usr/local/lib/hy2xs.
|
# Ни один persistent path не изменяется.
|
||||||
#
|
#
|
||||||
# PHASE 1 — MUTATION
|
# PHASE 1 — MUTATION (оркестратор, `hy2xs-orchestrator install`)
|
||||||
# раскладка оркестратора и runtime-пакета, затем сама установка.
|
# раскладка самого оркестратора, runtime-пакета и вся установка.
|
||||||
#
|
#
|
||||||
# Инвариант: до успешного завершения PHASE 0 установщик не имеет права
|
# Инвариант: у мутации ровно ОДИН владелец.
|
||||||
# изменить на сервере ничего. HY2XS v1 не устанавливается поверх 0.x и не
|
#
|
||||||
# мигрирует состояние, поэтому ошибочный запуск поверх работающего сервера
|
# Раньше PHASE 1 начиналась здесь: install.sh сам создавал /usr/local/lib/hy2xs,
|
||||||
# обязан быть безвредным.
|
# ставил туда бинарник, вешал symlink и копировал runtime-пакет, и только потом
|
||||||
|
# запускал оркестратор. Между этими двумя действиями оркестратор выполнял
|
||||||
|
# собственный preflight. Если он отказывал — сменился DNS, занялся порт,
|
||||||
|
# истёк таймаут резолвера, — у оркестратора ни один ownership-флаг не был
|
||||||
|
# взведён, отказ классифицировался как `fatal_pre_apply`, и пользователь читал
|
||||||
|
# «на сервере ничего не изменено». Хост при этом уже нёс каталог оркестратора,
|
||||||
|
# symlink и runtime-пакет, а следующий запуск упирался в них как в маркеры
|
||||||
|
# чужой установки.
|
||||||
|
#
|
||||||
|
# Отследить владение мутацией нельзя, пока мутируют двое. Поэтому здесь не
|
||||||
|
# осталось ни одной изменяющей команды: shell только проверяет и передаёт
|
||||||
|
# управление через exec. Сборка проверяет это структурно (tools/build/lib/verify.sh).
|
||||||
|
|
||||||
PACKAGE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
PACKAGE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||||
ORCHESTRATOR="$PACKAGE_DIR/orchestrator/hy2xs-orchestrator"
|
ORCHESTRATOR="$PACKAGE_DIR/orchestrator/hy2xs-orchestrator"
|
||||||
ORCHESTRATOR_INSTALL_PATH="/usr/local/lib/hy2xs/hy2xs-orchestrator"
|
|
||||||
ORCHESTRATOR_SYMLINK="/usr/local/bin/hy2xs-orchestrator"
|
|
||||||
RUNTIME_PACKAGE_DIR="/usr/local/lib/hy2xs/package"
|
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
printf '[hy2xs-install] %s\n' "$*"
|
printf '[hy2xs-install] %s\n' "$*"
|
||||||
@@ -71,8 +79,6 @@ scan_config_argument() {
|
|||||||
}
|
}
|
||||||
scan_config_argument "$@"
|
scan_config_argument "$@"
|
||||||
|
|
||||||
# Bootstrap preflight запускается из РАСПАКОВАННОГО пакета: установленного
|
|
||||||
# оркестратора на этом этапе ещё не существует и появиться не должен.
|
|
||||||
log "running clean-host preflight from the unpacked package"
|
log "running clean-host preflight from the unpacked package"
|
||||||
if [ -n "$PREFLIGHT_CONFIG" ]; then
|
if [ -n "$PREFLIGHT_CONFIG" ]; then
|
||||||
"$ORCHESTRATOR" preflight-install --package-dir "$PACKAGE_DIR" --config "$PREFLIGHT_CONFIG" \
|
"$ORCHESTRATOR" preflight-install --package-dir "$PACKAGE_DIR" --config "$PREFLIGHT_CONFIG" \
|
||||||
@@ -85,24 +91,11 @@ fi
|
|||||||
log "PHASE 0 passed"
|
log "PHASE 0 passed"
|
||||||
|
|
||||||
# ---------------------------------------------------------------- PHASE 1 ---
|
# ---------------------------------------------------------------- PHASE 1 ---
|
||||||
|
#
|
||||||
log "PHASE 1: applying changes"
|
# Всё, что ниже, выполняет оркестратор: он ставит себя в /usr/local/lib/hy2xs,
|
||||||
|
# раскладывает runtime-пакет и продолжает установку — уже под собственным
|
||||||
install -d -m 0755 /usr/local/lib/hy2xs
|
# учётом владения и с собственным rollback.
|
||||||
install -m 0755 "$ORCHESTRATOR" "$ORCHESTRATOR_INSTALL_PATH"
|
|
||||||
ln -sf "$ORCHESTRATOR_INSTALL_PATH" "$ORCHESTRATOR_SYMLINK"
|
|
||||||
|
|
||||||
log "installing runtime package assets to: $RUNTIME_PACKAGE_DIR"
|
|
||||||
rm -rf "$RUNTIME_PACKAGE_DIR"
|
|
||||||
install -d -m 0755 "$RUNTIME_PACKAGE_DIR"
|
|
||||||
cp -a "$PACKAGE_DIR/config" "$RUNTIME_PACKAGE_DIR/"
|
|
||||||
cp -a "$PACKAGE_DIR/docs" "$RUNTIME_PACKAGE_DIR/"
|
|
||||||
cp -a "$PACKAGE_DIR/systemd" "$RUNTIME_PACKAGE_DIR/"
|
|
||||||
cp -a "$PACKAGE_DIR/templates" "$RUNTIME_PACKAGE_DIR/"
|
|
||||||
cp -a "$PACKAGE_DIR/metadata" "$RUNTIME_PACKAGE_DIR/"
|
|
||||||
cp -a "$PACKAGE_DIR/ui" "$RUNTIME_PACKAGE_DIR/"
|
|
||||||
cp -a "$PACKAGE_DIR/install.sh" "$RUNTIME_PACKAGE_DIR/"
|
|
||||||
|
|
||||||
log "package directory: $PACKAGE_DIR"
|
log "package directory: $PACKAGE_DIR"
|
||||||
log "starting install-only orchestrator"
|
log "handing the mutating phase over to the orchestrator"
|
||||||
exec "$ORCHESTRATOR_INSTALL_PATH" install --package-dir "$RUNTIME_PACKAGE_DIR" "$@"
|
exec "$ORCHESTRATOR" install --package-dir "$PACKAGE_DIR" "$@"
|
||||||
|
|||||||
@@ -130,7 +130,20 @@ run_fix20_acceptance_subset() {
|
|||||||
|
|
||||||
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"
|
||||||
grep -q 'hysteria2/auth?access_token=\${context.config.hysteriaTrafficStatsSecret}' orchestrator/src/steps/smoke.ts || fail "acceptance: smoke auth URL is not tokenized"
|
# Проверяется контракт, а не литерал URL.
|
||||||
|
#
|
||||||
|
# Регрессия приёмки: здесь стоял grep по строке
|
||||||
|
# `hysteria2/auth?access_token=${...}`. После переезда machine-auth на
|
||||||
|
# /internal/hysteria/auth и централизации пути в профиле такой строки в
|
||||||
|
# smoke.ts не существует — приёмка падала на корректном коде, причём в самом
|
||||||
|
# конце сборки, внутри verify_archive. Единственный источник истины у пути
|
||||||
|
# один, поэтому и проверять нужно обращение к нему.
|
||||||
|
grep -q 'HYSTERIA_MACHINE_AUTH_PATH' orchestrator/src/steps/smoke.ts \
|
||||||
|
|| fail "acceptance: smoke must take the machine-auth path from the production profile"
|
||||||
|
grep -q 'hysteriaMachineAuthUrl(' orchestrator/src/steps/smoke.ts \
|
||||||
|
|| fail "acceptance: smoke auth URL must be built by the production helper (tokenized)"
|
||||||
|
! grep -Eq 'access_token=' orchestrator/src/steps/smoke.ts \
|
||||||
|
|| fail "acceptance: smoke must not assemble the machine token into a URL by hand"
|
||||||
|
|
||||||
log_step "Acceptance: bootstrap peer can pass auth smoke"
|
log_step "Acceptance: bootstrap peer can pass auth smoke"
|
||||||
grep -q 'quota := int64(-1)' apps/dao/sqlite.go || fail "acceptance: bootstrap peer quota must be unlimited (-1), otherwise install auth smoke fails"
|
grep -q 'quota := int64(-1)' apps/dao/sqlite.go || fail "acceptance: bootstrap peer quota must be unlimited (-1), otherwise install auth smoke fails"
|
||||||
@@ -260,7 +273,9 @@ run_clean_install_acceptance() {
|
|||||||
done
|
done
|
||||||
grep -qF '/var/lib/hysteria' orchestrator/src/steps/cleanHost.ts \
|
grep -qF '/var/lib/hysteria' orchestrator/src/steps/cleanHost.ts \
|
||||||
|| fail "acceptance: clean-host must treat leftover Hysteria runtime state as a legacy marker"
|
|| 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 \
|
# Symlink оркестратора остаётся маркером, но путь объявлен в профиле: его
|
||||||
|
# создаёт steps/bootstrap.ts, и две копии строки разошлись бы.
|
||||||
|
grep -q 'ORCHESTRATOR_SYMLINK_PATH' orchestrator/src/steps/cleanHost.ts \
|
||||||
|| fail "acceptance: clean-host must treat a leftover orchestrator symlink as a legacy marker"
|
|| fail "acceptance: clean-host must treat a leftover orchestrator symlink as a legacy marker"
|
||||||
! grep -q 'keep-hysteria-binary' tools/legacy/purge-v0.sh \
|
! grep -q 'keep-hysteria-binary' tools/legacy/purge-v0.sh \
|
||||||
|| fail "acceptance: --keep-hysteria-binary contradicts the installer clean-host contract"
|
|| fail "acceptance: --keep-hysteria-binary contradicts the installer clean-host contract"
|
||||||
@@ -304,9 +319,15 @@ run_clean_install_acceptance() {
|
|||||||
log_step "Acceptance: dead updater/config-write routes stay removed"
|
log_step "Acceptance: dead updater/config-write routes stay removed"
|
||||||
# Ищется регистрация маршрута (имя в кавычках), а не любое упоминание:
|
# Ищется регистрация маршрута (имя в кавычках), а не любое упоминание:
|
||||||
# комментарий, объясняющий, почему маршрута нет, должен быть разрешён.
|
# комментарий, объясняющий, почему маршрута нет, должен быть разрешён.
|
||||||
|
#
|
||||||
|
# Тесты исключены по той же причине, и это не послабление: router_test.go
|
||||||
|
# ПЕРЕЧИСЛЯЕТ удалённые имена, потому что проверяет их отсутствие в таблице
|
||||||
|
# маршрутов. Пока `*_test.go` попадал под скан, приёмка падала на собственном
|
||||||
|
# регрессионном тесте — то есть добавление теста, закрепляющего удаление
|
||||||
|
# маршрута, ломало сборку.
|
||||||
local dead_route
|
local dead_route
|
||||||
for dead_route in hysteria2ChangeVersion listRelease updateHysteria2Config importHysteria2Config restartServer uploadCertFile hysteria2AcmePath exportConfig importConfig; do
|
for dead_route in hysteria2ChangeVersion listRelease updateHysteria2Config importHysteria2Config restartServer uploadCertFile hysteria2AcmePath exportConfig importConfig getConfig; do
|
||||||
! grep -rqF "${dead_route}\"" apps/router apps/controller \
|
! grep -rqF --include='*.go' --exclude='*_test.go' "${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 \
|
||||||
|| fail "acceptance: frontend still calls the removed route ${dead_route}"
|
|| fail "acceptance: frontend still calls the removed route ${dead_route}"
|
||||||
@@ -345,4 +366,240 @@ run_clean_install_acceptance() {
|
|||||||
[ -f docs/14-legacy-cleanup.md ] || fail "acceptance: legacy cleanup runbook is missing"
|
[ -f docs/14-legacy-cleanup.md ] || fail "acceptance: legacy cleanup runbook is missing"
|
||||||
! grep -q 'purge-v0' "$package_dir/install.sh" \
|
! grep -q 'purge-v0' "$package_dir/install.sh" \
|
||||||
|| fail "acceptance: the installer must never run destructive cleanup on its own"
|
|| fail "acceptance: the installer must never run destructive cleanup on its own"
|
||||||
|
|
||||||
|
run_single_owner_acceptance "$package_dir"
|
||||||
|
run_secret_channel_acceptance
|
||||||
|
run_atomic_import_acceptance
|
||||||
|
run_legacy_account_acceptance
|
||||||
|
}
|
||||||
|
|
||||||
|
# PHASE 1 принадлежит оркестратору целиком.
|
||||||
|
run_single_owner_acceptance() {
|
||||||
|
local package_dir="$1"
|
||||||
|
|
||||||
|
log_step "Acceptance: install.sh does not mutate the host at all"
|
||||||
|
local mutation_hits
|
||||||
|
mutation_hits="$(grep -nE '^[[:space:]]*(install|ln|cp|mv|rm|mkdir|chown|chmod|systemctl|apt-get|useradd|groupadd|nft|tee)[[:space:]]' \
|
||||||
|
"$package_dir/install.sh" || true)"
|
||||||
|
[ -z "$mutation_hits" ] \
|
||||||
|
|| fail "acceptance: install.sh must not mutate the host; PHASE 1 belongs to the orchestrator alone: $mutation_hits"
|
||||||
|
grep -q 'exec .*install --package-dir' "$package_dir/install.sh" \
|
||||||
|
|| fail "acceptance: install.sh must hand the mutating phase over via exec"
|
||||||
|
|
||||||
|
log_step "Acceptance: the orchestrator owns its own bootstrap"
|
||||||
|
[ -f orchestrator/src/steps/bootstrap.ts ] \
|
||||||
|
|| fail "acceptance: the bootstrap step module is missing"
|
||||||
|
grep -q 'ownership.bootstrapTouched' orchestrator/src/commands/install.ts \
|
||||||
|
|| fail "acceptance: bootstrap must be covered by an ownership flag"
|
||||||
|
"$BUN_BIN" -e '
|
||||||
|
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
|
||||||
|
const flagAt = source.indexOf("ownership.bootstrapTouched = true");
|
||||||
|
const callAt = source.indexOf("await bootstrapRuntime(");
|
||||||
|
const depsAt = source.indexOf("await installDeps(");
|
||||||
|
if (flagAt < 0 || callAt < 0) throw new Error("bootstrap step is not wired into install");
|
||||||
|
if (flagAt > callAt) throw new Error("bootstrapTouched is raised after bootstrapRuntime");
|
||||||
|
if (callAt > depsAt) throw new Error("bootstrap must run before installDeps");
|
||||||
|
' || fail "acceptance: bootstrap must be the first owned mutating step"
|
||||||
|
|
||||||
|
log_step "Acceptance: bootstrap paths are declared once and are clean-host markers"
|
||||||
|
grep -q 'ORCHESTRATOR_SYMLINK_PATH' orchestrator/src/steps/cleanHost.ts \
|
||||||
|
|| fail "acceptance: clean-host must take bootstrap paths from the production profile"
|
||||||
|
grep -qF '/usr/local/bin/hy2xs-orchestrator' orchestrator/src/config/profile.ts \
|
||||||
|
|| fail "acceptance: the orchestrator symlink path must be declared in the profile"
|
||||||
|
grep -qF '/var/lib/hysteria' orchestrator/src/steps/cleanHost.ts \
|
||||||
|
|| fail "acceptance: clean-host must treat leftover Hysteria runtime state as a legacy marker"
|
||||||
|
|
||||||
|
log_step "Acceptance: clean-host is checked once, before the first mutation"
|
||||||
|
# Регрессия: preflight вызывался дважды и оба раза проверял clean-host.
|
||||||
|
# Ко второму разу на диске лежал собственный install-state.json, и каждая
|
||||||
|
# чистая установка падала сразу после apt-get.
|
||||||
|
! grep -rq 'cleanHostPhase' orchestrator/src \
|
||||||
|
|| fail "acceptance: the two-tier clean-host phase hack must not come back"
|
||||||
|
grep -q 'checkCleanHost' orchestrator/src/steps/preflight.ts \
|
||||||
|
|| fail "acceptance: preflight must take clean-host as an explicit decision"
|
||||||
|
"$BUN_BIN" -e '
|
||||||
|
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
|
||||||
|
const enabled = source.split("checkCleanHost: true").length - 1;
|
||||||
|
const disabled = source.split("checkCleanHost: false").length - 1;
|
||||||
|
if (enabled !== 1) throw new Error("clean-host must be requested exactly once, got " + enabled);
|
||||||
|
if (disabled !== 1) throw new Error("the capabilities pass must opt out explicitly");
|
||||||
|
const at = source.indexOf("checkCleanHost: true");
|
||||||
|
const state = source.indexOf("await advanceInstallState(");
|
||||||
|
if (at > state) throw new Error("clean-host is checked after the first install-state write");
|
||||||
|
' || fail "acceptance: clean-host must be an entry condition, checked exactly once"
|
||||||
|
|
||||||
|
log_step "Acceptance: diagnostics never block rollback"
|
||||||
|
local command_file
|
||||||
|
for command_file in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do
|
||||||
|
grep -q 'catch (diagnosticsError)' "$command_file" \
|
||||||
|
|| fail "acceptance: diagnostics must be best effort in $command_file"
|
||||||
|
done
|
||||||
|
"$BUN_BIN" -e '
|
||||||
|
const fs = require("node:fs");
|
||||||
|
for (const file of [
|
||||||
|
["orchestrator/src/commands/install.ts", "await rollbackFailedInstall("],
|
||||||
|
["orchestrator/src/commands/reconfigure.ts", "await rollbackCurrentState()"]
|
||||||
|
]) {
|
||||||
|
const source = fs.readFileSync(file[0], "utf8");
|
||||||
|
const diagnostics = source.indexOf("await diagnosticsCollect(options)");
|
||||||
|
const guard = source.indexOf("catch (diagnosticsError)");
|
||||||
|
const rollback = source.indexOf(file[1]);
|
||||||
|
if (diagnostics < 0 || guard < 0 || rollback < 0) throw new Error("missing markers in " + file[0]);
|
||||||
|
if (!(diagnostics < guard && guard < rollback)) {
|
||||||
|
throw new Error("diagnostics is not guarded before rollback in " + file[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' || fail "acceptance: a diagnostics failure must never cancel the rollback"
|
||||||
|
|
||||||
|
log_step "Acceptance: reconfigure classifies by ownership, not by message text"
|
||||||
|
! grep -qF '.test(message)' orchestrator/src/commands/reconfigure.ts \
|
||||||
|
|| fail "acceptance: reconfigure must not classify failures by matching the error text"
|
||||||
|
grep -q 'classifyReconfigureFailure(ownership)' orchestrator/src/commands/reconfigure.ts \
|
||||||
|
|| fail "acceptance: reconfigure failure classification must be ownership-driven"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Каналы утечки секретов: Config API и журналы.
|
||||||
|
run_secret_channel_acceptance() {
|
||||||
|
log_step "Acceptance: config API is an allowlist, not a denylist"
|
||||||
|
grep -q 'IsPublicReadableConfigKey' apps/controller/config.go \
|
||||||
|
|| fail "acceptance: config reads must go through the allowlist"
|
||||||
|
grep -q 'IsPublicWritableConfigKey' apps/controller/config.go \
|
||||||
|
|| fail "acceptance: config writes must go through the allowlist"
|
||||||
|
# Ищется регистрация и вызов, а не имя: router_test.go обязан УПОМИНАТЬ
|
||||||
|
# getConfig — он проверяет, что маршрут не вернулся.
|
||||||
|
! grep -rq 'controller.GetConfig' apps/router \
|
||||||
|
|| fail "acceptance: the arbitrary-key getConfig route came back"
|
||||||
|
! grep -q 'func GetConfig(' apps/controller/config.go \
|
||||||
|
|| fail "acceptance: the arbitrary-key getConfig handler came back"
|
||||||
|
! grep -rq 'export function getConfigApi' apps/frontend/src \
|
||||||
|
|| fail "acceptance: the frontend client for getConfig came back"
|
||||||
|
! grep -rq 'getConfigApi(' apps/frontend/src \
|
||||||
|
|| fail "acceptance: something still calls the removed getConfig client"
|
||||||
|
local secret_key
|
||||||
|
for secret_key in JwtSecret PeerSecretKey PeerSecretEncryptionKey Hysteria2TrafficStatsSecret; do
|
||||||
|
grep -q "${secret_key}," apps/model/constant/config.go \
|
||||||
|
|| fail "acceptance: ${secret_key} must be declared an internal config key"
|
||||||
|
done
|
||||||
|
# Секреты не имеют права оказаться в allowlist ни на чтение, ни на запись.
|
||||||
|
"$BUN_BIN" -e '
|
||||||
|
const source = require("node:fs").readFileSync("apps/model/constant/config.go", "utf8");
|
||||||
|
const start = source.indexOf("publicReadableConfigKeys");
|
||||||
|
const end = source.indexOf("func IsPublicReadableConfigKey");
|
||||||
|
const body = source.slice(start, end);
|
||||||
|
for (const key of ["JwtSecret", "PeerSecretKey", "PeerSecretEncryptionKey", "Hysteria2TrafficStatsSecret", "Hysteria2Config"]) {
|
||||||
|
if (body.includes(key + ":")) throw new Error(key + " is in the public allowlist");
|
||||||
|
}
|
||||||
|
' || fail "acceptance: no secret key may appear in the public config allowlist"
|
||||||
|
|
||||||
|
log_step "Acceptance: request logging never carries query values"
|
||||||
|
# Проверяется КОД, а не упоминание: комментарий, объясняющий, почему логгер
|
||||||
|
# больше не пишет RequestURI, обязан быть разрешён. Поэтому строки
|
||||||
|
# комментариев отбрасываются до поиска.
|
||||||
|
local log_code server_code
|
||||||
|
log_code="$(grep -v '^[[:space:]]*//' apps/middleware/log.go || true)"
|
||||||
|
server_code="$(grep -v '^[[:space:]]*//' apps/cmd/server.go || true)"
|
||||||
|
|
||||||
|
printf '%s\n' "$log_code" | grep -q 'c.Request.URL.Path' \
|
||||||
|
|| fail "acceptance: the request logger must log the path, not RequestURI"
|
||||||
|
! printf '%s\n' "$log_code" | grep -q 'RequestURI' \
|
||||||
|
|| fail "acceptance: RequestURI carries the machine token in its query string"
|
||||||
|
! grep -rq 'ReqUri' apps/model/vo apps/frontend/src/api \
|
||||||
|
|| fail "acceptance: the reqUri log field came back"
|
||||||
|
|
||||||
|
log_step "Acceptance: the admin has exactly one HTTP log channel"
|
||||||
|
! printf '%s\n' "$server_code" | grep -q 'gin.Default()' \
|
||||||
|
|| fail "acceptance: gin.Default() logs the query string to stdout and then to journald"
|
||||||
|
printf '%s\n' "$server_code" | grep -q 'gin.New()' \
|
||||||
|
|| fail "acceptance: the admin engine must be built with gin.New()"
|
||||||
|
printf '%s\n' "$server_code" | grep -q 'gin.Recovery()' \
|
||||||
|
|| fail "acceptance: gin.New() must still install the recovery middleware"
|
||||||
|
|
||||||
|
log_step "Acceptance: exported logs are sanitized on both sides"
|
||||||
|
grep -q 'SanitizeLogText' apps/service/journal.go \
|
||||||
|
|| fail "acceptance: exported Hysteria journal must be sanitized"
|
||||||
|
grep -q 'redactLogText' orchestrator/src/commands/diagnostics.ts \
|
||||||
|
|| fail "acceptance: diagnostics bundle must sanitize collected journals"
|
||||||
|
grep -q 'journal-admin.log' orchestrator/src/commands/diagnostics.ts \
|
||||||
|
|| fail "acceptance: the admin journal must be part of the sanitized set"
|
||||||
|
|
||||||
|
log_step "Acceptance: machine token comparison is constant time"
|
||||||
|
grep -q 'subtle.ConstantTimeCompare' apps/middleware/machine_auth.go \
|
||||||
|
|| fail "acceptance: the machine token must be compared in constant time"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Импорт пиров: одна транзакция и ровно один JSON-документ.
|
||||||
|
run_atomic_import_acceptance() {
|
||||||
|
log_step "Acceptance: peer import is a single database transaction"
|
||||||
|
grep -q 'WithPeerTx' apps/service/peer.go \
|
||||||
|
|| fail "acceptance: peer import must apply the whole batch in one transaction"
|
||||||
|
grep -q 'func WithPeerTx' apps/dao/peer.go \
|
||||||
|
|| fail "acceptance: the peer transaction boundary is missing from the dao layer"
|
||||||
|
grep -q 'sqliteDB.Transaction' apps/dao/peer.go \
|
||||||
|
|| fail "acceptance: WithPeerTx must open a real database transaction"
|
||||||
|
# Применение обязано идти по tx, а не по глобальному соединению.
|
||||||
|
"$BUN_BIN" -e '
|
||||||
|
const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8");
|
||||||
|
const start = source.indexOf("func applyPeerImportEntry");
|
||||||
|
if (start < 0) throw new Error("applyPeerImportEntry is missing");
|
||||||
|
const body = source.slice(start);
|
||||||
|
for (const call of ["dao.GetPeer(", "dao.SavePeer(", "dao.UpdatePeer("]) {
|
||||||
|
if (body.includes(call)) throw new Error("peer import still writes outside the transaction: " + call);
|
||||||
|
}
|
||||||
|
' || fail "acceptance: peer import must not bypass the transaction"
|
||||||
|
|
||||||
|
log_step "Acceptance: peer import accepts exactly one JSON document"
|
||||||
|
grep -q 'io.EOF' apps/controller/peer.go \
|
||||||
|
|| fail "acceptance: peer import must verify that nothing follows the JSON document"
|
||||||
|
grep -q 'exactly one JSON document' apps/controller/peer.go \
|
||||||
|
|| fail "acceptance: the trailing-document refusal must be explicit"
|
||||||
|
|
||||||
|
log_step "Acceptance: peer export offers both modes in the UI"
|
||||||
|
grep -q 'includeSecrets' apps/frontend/src/api/peer/index.ts \
|
||||||
|
|| fail "acceptance: the UI must be able to request a secrets-bearing backup"
|
||||||
|
grep -q 'handleExportBackup' apps/frontend/src/views/peer/list/index.vue \
|
||||||
|
|| fail "acceptance: the backup export button is missing"
|
||||||
|
grep -q 'exportBackupConfirm' apps/frontend/src/views/peer/list/index.vue \
|
||||||
|
|| fail "acceptance: a secrets-bearing export must require explicit confirmation"
|
||||||
|
local locale
|
||||||
|
for locale in ru en; do
|
||||||
|
grep -q 'exportBackupConfirm' "apps/frontend/src/lang/package/${locale}.ts" \
|
||||||
|
|| fail "acceptance: the backup warning is missing from the ${locale} locale"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compatibility-слой предыдущего поколения не должен пережить 1.0.
|
||||||
|
run_legacy_account_acceptance() {
|
||||||
|
log_step "Acceptance: the legacy account layer is gone from the runtime"
|
||||||
|
[ ! -f apps/model/entity/account.go ] \
|
||||||
|
|| fail "acceptance: the LegacyAccount entity came back"
|
||||||
|
# Каталоги перечислены явно: рекурсия по apps захватила бы
|
||||||
|
# apps/frontend/node_modules, который к этому шагу сборки уже существует.
|
||||||
|
! grep -rq 'LegacyAccount' apps/dao apps/model apps/service apps/controller apps/cmd \
|
||||||
|
|| fail "acceptance: legacy account migration code came back"
|
||||||
|
# Ищется запись в списке миграций (имя в кавычках), а не упоминание:
|
||||||
|
# комментарий, объясняющий, почему миграции удалены, должен быть разрешён.
|
||||||
|
local dead_migration
|
||||||
|
for dead_migration in 002_migrate_legacy_accounts 003_archive_legacy_account; do
|
||||||
|
! grep -qF "\"${dead_migration}\"" apps/dao/sqlite.go \
|
||||||
|
|| fail "acceptance: legacy migration ${dead_migration} came back"
|
||||||
|
done
|
||||||
|
# Номера оставшихся миграций не перенумеровываются: они уже записаны в
|
||||||
|
# schema_migrations на установленных машинах.
|
||||||
|
local kept_migration
|
||||||
|
for kept_migration in 000_base_config 001_admin_peer_split 004_traffic_samples_and_aggregates 005_metric_sample; do
|
||||||
|
grep -qF "\"${kept_migration}\"" apps/dao/sqlite.go \
|
||||||
|
|| fail "acceptance: migration ${kept_migration} disappeared or was renumbered"
|
||||||
|
done
|
||||||
|
|
||||||
|
log_step "Acceptance: v1 docs carry no previous-generation vocabulary"
|
||||||
|
# docs/14 — единственное место, где эти имена обозначают реальные объекты
|
||||||
|
# для удаления. В обычных docs их быть не должно.
|
||||||
|
local doc
|
||||||
|
for doc in docs/*.md; do
|
||||||
|
case "$doc" in
|
||||||
|
docs/14-legacy-cleanup.md) continue ;;
|
||||||
|
esac
|
||||||
|
! grep -q 'H_UI_' "$doc" \
|
||||||
|
|| fail "acceptance: previous-generation config keys leaked into $doc"
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-11
@@ -62,22 +62,33 @@ verify_archive() {
|
|||||||
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/package.release_line$' || fail "archive missing product release line"
|
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/package.release_line$' || fail "archive missing product release line"
|
||||||
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/package.config_schema_version$' || fail "archive missing packaged config schema version"
|
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/package.config_schema_version$' || fail "archive missing packaged config schema version"
|
||||||
|
|
||||||
# Двухфазный контракт установки проверяется структурно: read-only preflight
|
# Контракт установки проверяется структурно: install.sh — read-only bootstrap
|
||||||
# обязан стоять раньше первой мутации, иначе ошибочный запуск поверх
|
# целиком, у PHASE 1 ровно один владелец — оркестратор.
|
||||||
# работающего сервера снова начнёт его ломать.
|
#
|
||||||
local packaged_installer preflight_line first_mutation_line
|
# Раньше здесь проверялся порядок «preflight раньше первой мутации», и сама
|
||||||
|
# проверка ТРЕБОВАЛА наличия мутирующей строки в install.sh. Пока shell сам
|
||||||
|
# раскладывал оркестратор и runtime-пакет, между двумя фазами существовало
|
||||||
|
# окно: PHASE 0 проходила, install.sh изменял хост, а отказ следующего
|
||||||
|
# preflight внутри оркестратора объявлялся «на сервере ничего не изменено».
|
||||||
|
# Владение мутацией нельзя было отследить, потому что мутировали двое.
|
||||||
|
local packaged_installer mutation_hits
|
||||||
packaged_installer="$(tar -xOzf "$archive" hy2xs-install/install.sh)"
|
packaged_installer="$(tar -xOzf "$archive" hy2xs-install/install.sh)"
|
||||||
printf '%s\n' "$packaged_installer" | grep -q 'preflight-install' \
|
printf '%s\n' "$packaged_installer" | grep -q 'preflight-install' \
|
||||||
|| fail "packaged install.sh does not run the read-only preflight"
|
|| fail "packaged install.sh does not run the read-only preflight"
|
||||||
|
printf '%s\n' "$packaged_installer" | grep -q 'preflight-install --package-dir' \
|
||||||
|
|| fail "packaged install.sh: could not locate the preflight invocation"
|
||||||
|
|
||||||
preflight_line="$(printf '%s\n' "$packaged_installer" | grep -n 'preflight-install --package-dir' | head -n1 | cut -d: -f1)"
|
# Комментарии отбрасываются: строка, ОБЪЯСНЯЮЩАЯ, почему установщик больше
|
||||||
first_mutation_line="$(printf '%s\n' "$packaged_installer" \
|
# не выполняет `install -d`, не является выполнением `install -d`.
|
||||||
| grep -nE '^[[:space:]]*(install -d|install -m|ln -sf|rm -rf|cp -a)' | head -n1 | cut -d: -f1)"
|
mutation_hits="$(printf '%s\n' "$packaged_installer" \
|
||||||
|
| grep -nE '^[[:space:]]*(install|ln|cp|mv|rm|mkdir|chown|chmod|systemctl|apt-get|useradd|groupadd|nft|tee)[[:space:]]' \
|
||||||
|
|| true)"
|
||||||
|
[ -z "$mutation_hits" ] \
|
||||||
|
|| fail "packaged install.sh must not mutate the host; PHASE 1 belongs to the orchestrator alone. Found:
|
||||||
|
$mutation_hits"
|
||||||
|
|
||||||
[ -n "$preflight_line" ] || fail "packaged install.sh: could not locate the preflight invocation"
|
printf '%s\n' "$packaged_installer" | grep -q 'exec .*install --package-dir' \
|
||||||
[ -n "$first_mutation_line" ] || fail "packaged install.sh: could not locate the mutation phase"
|
|| fail "packaged install.sh must hand the whole mutating phase to the orchestrator via exec"
|
||||||
[ "$preflight_line" -lt "$first_mutation_line" ] \
|
|
||||||
|| fail "packaged install.sh mutates the host (line $first_mutation_line) before the clean-host preflight (line $preflight_line)"
|
|
||||||
|
|
||||||
local license_text
|
local license_text
|
||||||
license_text="$(tar -xOzf "$archive" hy2xs-install/LICENSE)"
|
license_text="$(tar -xOzf "$archive" hy2xs-install/LICENSE)"
|
||||||
|
|||||||
@@ -216,10 +216,16 @@ verify_api_namespace_contract() {
|
|||||||
# Старое пространство имён не имеет права вернуться ни в один компонент.
|
# Старое пространство имён не имеет права вернуться ни в один компонент.
|
||||||
# Историческое имя допустимо только в docs/14-legacy-cleanup.md и в
|
# Историческое имя допустимо только в docs/14-legacy-cleanup.md и в
|
||||||
# legacy-маркерах clean-host: там это имя чужого артефакта, а не наше.
|
# legacy-маркерах clean-host: там это имя чужого артефакта, а не наше.
|
||||||
|
#
|
||||||
|
# Список каталогов покрывает КАЖДЫЙ слой, который может собрать URL: раньше
|
||||||
|
# в нём не было apps/middleware, apps/cmd, apps/dao и apps/util, то есть
|
||||||
|
# ровно тех мест, где живут HTTP-логгер, точка входа сервера и helpers.
|
||||||
local legacy_hits
|
local legacy_hits
|
||||||
legacy_hits="$(grep -rlF '/hui' \
|
legacy_hits="$(grep -rlF '/hui' \
|
||||||
apps/model apps/router apps/controller apps/service apps/frontend/src \
|
apps/model apps/router apps/controller apps/service apps/middleware \
|
||||||
orchestrator/src orchestrator/test package/templates tools/test 2>/dev/null || true)"
|
apps/cmd apps/dao apps/util apps/frontend/src \
|
||||||
|
orchestrator/src orchestrator/test package/templates \
|
||||||
|
tools/test tools/build 2>/dev/null || true)"
|
||||||
[ -z "$legacy_hits" ] \
|
[ -z "$legacy_hits" ] \
|
||||||
|| fail "versions contract: legacy /hui namespace came back in: $legacy_hits"
|
|| fail "versions contract: legacy /hui namespace came back in: $legacy_hits"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user