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

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

Установка

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

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

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

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

Секреты

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

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

Пиры

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

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

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

Сборка

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

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

Очистка

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

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

Проверено: bun test 233, go test 71, tsc/vue-tsc, bash -n 11 скриптов,
приёмка прогнана против дерева.
This commit is contained in:
2026-08-28 05:27:10 +05:00
parent 5574b7c89a
commit 672d455467
55 changed files with 3161 additions and 582 deletions
+145
View File
@@ -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`.
+12 -6
View File
@@ -138,12 +138,17 @@ hy2xs-install/
└── metadata/ └── metadata/
``` ```
При запуске `install.sh` пакет сначала проверяет `metadata/checksums.txt` и При запуске `install.sh` пакет проверяет `metadata/checksums.txt` и выполняет
выполняет readonly cleanhost preflight **из распакованного архива**. Только readonly cleanhost preflight **из распакованного архива**. После этого он
после этого он устанавливает orchestrator в передаёт управление installonly 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` и передаёт управление installonly orchestrator. `/usr/local/lib/hy2xs/package` и продолжает установку. Это сделано ради одного
свойства: у изменений сервера ровно один владелец, поэтому при любом отказе
известно, что именно было создано и что откатывать.
## Сетевая модель по умолчанию ## Сетевая модель по умолчанию
@@ -493,9 +498,10 @@ HY2XS_UI_PUBLIC_ACCESS=false
3. запустит cleanhost preflight **из распакованного архива**: платформа 3. запустит cleanhost 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
View File
@@ -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 внутри админки не поддерживается намеренно: панель слушает
+64 -40
View File
@@ -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`.
config, err := service.GetConfig(*configDto.Key) func isOrchestratorManagedConfigKey(key string) bool {
if err != nil { switch key {
vo.Fail(err.Error(), c) case constant.Hysteria2Enable,
return constant.Hysteria2Config,
} constant.Hysteria2TrafficStatsSecret:
configVo := vo.ConfigVo{ return true
Key: *config.Key, default:
Value: *config.Value, return false
}
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 выгружается отдельным санитизирующим маршрутом.
// Поэтому маршруты удалены, а не оставлены с фильтром: список ключей,
// требующий ручного сопровождения, снова разошёлся бы со схемой базы.
+155
View File
@@ -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)
}
}
+13
View File
@@ -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
+122
View File
@@ -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
View File
@@ -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
View File
@@ -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: &quota,
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 != "" {
+4 -9
View File
@@ -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",
+2 -3
View File
@@ -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>;
+5 -1
View File
@@ -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;
} }
+17 -1
View File
@@ -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",
}); });
} }
+7
View File
@@ -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",
+7
View File
@@ -146,6 +146,13 @@ export default {
unlimited: "Безлимит", unlimited: "Безлимит",
secretRotateConfirm: secretRotateConfirm:
"Сменить секрет пира? Текущие клиентские конфиги перестанут работать до обновления.", "Сменить секрет пира? Текущие клиентские конфиги перестанут работать до обновления.",
exportSettings: "Экспорт настроек",
exportBackup: "Резервная копия",
exportSettingsHint:
"Список пиров без секретов. Безопасно хранить и передавать, но после импорта на другой сервер пирам будут выданы новые секреты, и прежние клиентские ссылки работать не будут.",
exportBackupConfirm:
"Резервная копия содержит ДЕЙСТВУЮЩИЕ секреты подключения всех пиров в открытом виде: любой, кто получит этот файл, получит доступ к VPN. Храните его как пароль и удалите, когда перенос завершён. Только такая копия сохраняет работоспособность существующих клиентских ссылок после переноса на другой сервер.",
exportBackupConfirmButton: "Понимаю, скачать копию с секретами",
quota: "Квота", quota: "Квота",
download: "Скачано", download: "Скачано",
upload: "Отдано", upload: "Отдано",
+30 -3
View File
@@ -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
View File
@@ -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()
+105
View File
@@ -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)
}
}
+6 -1
View File
@@ -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",
+82
View File
@@ -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
}
+66
View File
@@ -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("неизвестный ключ обязан быть закрыт по умолчанию")
}
}
+3 -3
View File
@@ -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"`
-25
View File
@@ -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"
}
+8 -1
View File
@@ -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"`
} }
+6 -1
View File
@@ -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)
+35
View File
@@ -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
View File
@@ -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,
} }
} }
+78
View File
@@ -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:]
}
+80
View File
@@ -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
View File
@@ -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: &quota, QuotaBytes: &quota,
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 {
+267
View File
@@ -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: &quota,
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
View File
@@ -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
Это важное архитектурное разделение. Это важное архитектурное разделение.
+43 -6
View File
@@ -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).
+144 -12
View File
@@ -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
View File
@@ -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.
## Состав документов ## Состав документов
+19 -1
View File
@@ -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}`);
+56 -3
View File
@@ -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");
+68 -4
View File
@@ -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;
} }
+30
View File
@@ -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}`;
+50
View File
@@ -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. Диагностика не должна падать, но и утекать не должна, поэтому здесь
+62
View File
@@ -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;
}
+28 -28
View File
@@ -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));
} }
+84 -8
View File
@@ -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: [] };
} }
+39 -11
View File
@@ -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]);
+32 -32
View File
@@ -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")
+175
View File
@@ -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=");
});
});
+102 -5
View File
@@ -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/
+60
View File
@@ -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 [
+9 -4
View File
@@ -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
View File
@@ -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" "$@"
+261 -4
View File
@@ -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
View File
@@ -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)"
+8 -2
View File
@@ -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"
} }