diff --git a/CHANGELOG.md b/CHANGELOG.md index 710e222..6fa6798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` пишется сразу после успешного preflight, до установки пакетов, но классификация отказа его не учитывала. Падение `apt-get update` или @@ -207,6 +304,35 @@ Hardening-проход перед релизом `1.0.0`. Основная те 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-схема — `apps/docs/sql/schema.sql` вместо `h_ui_db.sql`. Совместимость сохранять не требуется: v1 ставится только с нуля. Историческое имя `h_ui.db` остаётся в @@ -262,6 +388,25 @@ Hardening-проход перед релизом `1.0.0`. Основная те - Мёртвые строки i18n, оставшиеся от H UI: `noHttpsTip`, `defaultPassTip`, `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 Первый релиз линейки `v1`. diff --git a/README.md b/README.md index 800ac46..4cec421 100644 --- a/README.md +++ b/README.md @@ -138,12 +138,17 @@ hy2xs-install/ └── metadata/ ``` -При запуске `install.sh` пакет сначала проверяет `metadata/checksums.txt` и -выполняет read‑only clean‑host preflight **из распакованного архива**. Только -после этого он устанавливает orchestrator в +При запуске `install.sh` пакет проверяет `metadata/checksums.txt` и выполняет +read‑only clean‑host preflight **из распакованного архива**. После этого он +передаёт управление install‑only orchestrator через `exec` — и больше не делает +ничего: сам `install.sh` не изменяет на сервере ни одного файла. + +Всю раскладку выполняет уже оркестратор: ставит себя в `/usr/local/lib/hy2xs/hy2xs-orchestrator`, создаёт symlink `/usr/local/bin/hy2xs-orchestrator`, копирует package assets в -`/usr/local/lib/hy2xs/package` и передаёт управление install‑only orchestrator. +`/usr/local/lib/hy2xs/package` и продолжает установку. Это сделано ради одного +свойства: у изменений сервера ровно один владелец, поэтому при любом отказе +известно, что именно было создано и что откатывать. ## Сетевая модель по умолчанию @@ -493,9 +498,10 @@ HY2XS_UI_PUBLIC_ACCESS=false 3. запустит clean‑host preflight **из распакованного архива**: платформа 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; 6. запишет `/etc/hy2xs/hy2xs.env`; 7. разложит bundled HY2XS admin; diff --git a/apps/cmd/server.go b/apps/cmd/server.go index 6750153..20c68da 100644 --- a/apps/cmd/server.go +++ b/apps/cmd/server.go @@ -76,7 +76,21 @@ func runServer(port string) error { 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) // TLS внутри админки не поддерживается намеренно: панель слушает diff --git a/apps/controller/config.go b/apps/controller/config.go index 7ecb93e..739ef66 100644 --- a/apps/controller/config.go +++ b/apps/controller/config.go @@ -2,29 +2,41 @@ package controller import ( "fmt" + "sort" + "strings" + "time" + "github.com/gin-gonic/gin" "hy2xs-admin/model/constant" "hy2xs-admin/model/dto" "hy2xs-admin/model/vo" "hy2xs-admin/service" - "time" ) -// Ключи, которыми владеет install-оркестратор: панель обязана отказать в -// записи, а не молча создать второй источник истины. +// Доступ операторского API к таблице `config` — строго по allowlist. // -// Сетевых настроек самой панели в этом списке больше нет, потому что их нет -// и в базе: порт и bind приходят из контракта запуска, TLS терминируется -// снаружи. См. model/constant/config.go. -func isOrchestratorManagedConfigKey(key string) bool { - switch key { - case constant.Hysteria2Enable, - constant.Hysteria2Config, - constant.Hysteria2TrafficStatsSecret: - return true - default: - return false - } +// Что было. Проверка работала 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 позволял подменить JWT_SECRET и оба peer-ключа. +// +// То есть опасность, ради которой удаляли generic export/import таблицы +// `config`, никуда не делась — она осталась в точечном API. +// +// Список ключей ведётся в 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) { @@ -39,11 +51,18 @@ func UpdateConfigs(c *gin.Context) { key := *item.Key value := *item.Value + // Отдельное сообщение для ключей, которыми владеет оркестратор: их + // отказ — это не «нет такого ключа», а указание на владельца. if isOrchestratorManagedConfigKey(key) { vo.Fail(fmt.Sprintf("%s managed by orchestrator: use hy2xs-orchestrator reconfigure", key), c) return } + if !constant.IsPublicWritableConfigKey(key) { + denyUnknownConfigKey(key, constant.PublicWritableConfigKeys(), "write", c) + return + } + if key == constant.ResetTrafficCron { resetTrafficCron, err := service.GetConfig(constant.ResetTrafficCron) if err != nil { @@ -70,38 +89,45 @@ func UpdateConfigs(c *gin.Context) { vo.Success(nil, c) } -func GetConfig(c *gin.Context) { - configDto, err := validateField(c, dto.ConfigDto{}) - if err != nil { - return +// isOrchestratorManagedConfigKey — ключи, которыми владеет install-оркестратор. +// +// Формально они и так не входят в allowlist, но отказ по ним обязан объяснять +// ПОЧЕМУ: «этим значением владеет оркестратор» — это другой ответ, чем «такого +// ключа в API нет», и он ведёт оператора к `hy2xs-orchestrator reconfigure`. +func isOrchestratorManagedConfigKey(key string) bool { + switch key { + case constant.Hysteria2Enable, + constant.Hysteria2Config, + constant.Hysteria2TrafficStatsSecret: + return true + default: + return false } - config, err := service.GetConfig(*configDto.Key) - if err != nil { - vo.Fail(err.Error(), c) - return - } - configVo := vo.ConfigVo{ - Key: *config.Key, - Value: *config.Value, - } - - running := service.Hysteria2IsRunning() - if *config.Key == constant.Hysteria2Enable { - if running { - configVo.Value = "1" - } else { - configVo.Value = "0" - } - } - - vo.Success(configVo, c) } +// Маршрута GET /config/getConfig здесь больше нет. +// +// Он принимал произвольный ключ и был вторым, менее заметным входом в ту же +// таблицу секретов, что и удалённый generic export. При этом ни одного +// потребителя у него не было: панель читает настройки только через listConfig. +// Маршрут не оставлен с фильтром, а удалён — точка входа, которой никто не +// пользуется, не должна существовать. + func ListConfig(c *gin.Context) { configsDto, err := validateField(c, dto.ConfigsDto{}) if err != nil { return } + + // Проверка идёт до обращения к базе: отказ не должен зависеть от того, + // существует ли строка с таким ключом. + for _, key := range configsDto.Keys { + if !constant.IsPublicReadableConfigKey(key) { + denyUnknownConfigKey(key, constant.PublicReadableConfigKeys(), "read", c) + return + } + } + configs, err := service.ListConfig(configsDto.Keys) if err != nil { vo.Fail(err.Error(), c) @@ -159,5 +185,3 @@ func ExportHysteria2Config(c *gin.Context) { // Осмысленного production-сценария у этой пары не было: конфигурацией сервера // владеет install-оркестратор, перенос пиров делают ImportPeer/ExportPeer, а // серверный конфиг Hysteria выгружается отдельным санитизирующим маршрутом. -// Поэтому маршруты удалены, а не оставлены с фильтром: список ключей, -// требующий ручного сопровождения, снова разошёлся бы со схемой базы. diff --git a/apps/controller/config_test.go b/apps/controller/config_test.go new file mode 100644 index 0000000..d3e23ce --- /dev/null +++ b/apps/controller/config_test.go @@ -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) + } +} diff --git a/apps/controller/peer.go b/apps/controller/peer.go index c0f363e..d8f39cd 100644 --- a/apps/controller/peer.go +++ b/apps/controller/peer.go @@ -178,6 +178,19 @@ func ImportPeer(c *gin.Context) { 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 { vo.Fail(err.Error(), c) return diff --git a/apps/controller/peer_test.go b/apps/controller/peer_test.go new file mode 100644 index 0000000..445ee0d --- /dev/null +++ b/apps/controller/peer_test.go @@ -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)) + } +} diff --git a/apps/dao/peer.go b/apps/dao/peer.go index a1a8fe3..d6b0622 100644 --- a/apps/dao/peer.go +++ b/apps/dao/peer.go @@ -11,14 +11,91 @@ import ( "time" ) -func SavePeer(peer entity.Peer) (int64, error) { - if tx := sqliteDB.Save(&peer); tx.Error != nil { +// PeerTx — операции над пирами в границах одной единицы работы. +// +// Интерфейс существует, чтобы транзакция была доступна слою 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) return 0, errors.New(constant.SysError) } 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 { if tx := sqliteDB.Where("id in ?", ids).Delete(&entity.Peer{}); tx.Error != nil { logrus.Errorf("%v", tx.Error) @@ -27,30 +104,6 @@ func DeletePeer(ids []int64) error { 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) { var peers []entity.Peer if tx := sqliteDB.Model(&entity.Peer{}).Where(query, args...).Order("create_time desc").Find(&peers); tx.Error != nil { diff --git a/apps/dao/sqlite.go b/apps/dao/sqlite.go index 2f7b7e6..f1cbfe5 100644 --- a/apps/dao/sqlite.go +++ b/apps/dao/sqlite.go @@ -3,7 +3,6 @@ package dao import ( "encoding/base64" "errors" - "fmt" "github.com/glebarez/sqlite" "github.com/sirupsen/logrus" "gorm.io/gorm" @@ -14,9 +13,6 @@ import ( "hy2xs-admin/util" "log" "os" - "path/filepath" - "sort" - "strconv" "strings" "time" ) @@ -24,8 +20,17 @@ import ( var sqliteDB *gorm.DB func InitSqliteDB() error { + return InitSqliteDBAt(constant.SqliteDBPath) +} + +// InitSqliteDBAt открывает базу по указанному пути. +// +// Путь параметризован, а не зашит: без этого единственным способом проверить +// поведение слоя данных (транзакционность импорта пиров, в первую очередь) +// было бы обращение к реальной /var/lib/hy2xs-admin/hy2xs-admin.db. +func InitSqliteDBAt(path string) 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, Logger: logger.New( log.New(os.Stdout, "\r\n", log.LstdFlags), @@ -76,15 +81,6 @@ func InitSql() error { 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 { raw := strings.TrimSpace(strings.ToLower(os.Getenv(name))) if raw == "" { @@ -264,6 +260,15 @@ func getOrCreateConfigKey(key string, size int) (string, error) { return value, nil } +// RunMigrations приводит схему базы к текущему поколению. +// +// Экспортирована, чтобы схему можно было поднять отдельно от InitSql: тестам +// слоя данных нужна таблица, но не нужны bootstrap-администратор, bootstrap-пир +// и генерация JWT-секрета. +func RunMigrations() error { + return runMigrations() +} + func runMigrations() error { if tx := sqliteDB.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, @@ -278,11 +283,21 @@ func runMigrations() 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{ {version: "000_base_config", apply: migrateBaseConfig}, {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: "005_metric_sample", apply: migrateMetricSampleTable}, } @@ -403,125 +418,6 @@ func migrateAdminPeerSplit() error { return nil } -func migrateLegacyAccounts() error { - if !tableExists("account") { - return nil - } - var accounts []entity.LegacyAccount - if tx := sqliteDB.Model(&entity.LegacyAccount{}).Order("id asc").Find(&accounts); tx.Error != nil { - logrus.Errorf("sqlite legacy account query err: %v", tx.Error) - return errors.New("sqlite legacy account query err") - } - nowMs := time.Now().UnixMilli() - for _, acc := range accounts { - if acc.Role != nil && *acc.Role == "admin" { - if acc.Username == nil || acc.Pass == nil { - continue - } - _, getErr := GetAdminUser("username = ?", *acc.Username) - if getErr == nil { - continue - } - status := int64(1) - if acc.Deleted != nil && *acc.Deleted == 1 { - status = 0 - } - lastLogin := int64(0) - if acc.LoginAt != nil { - lastLogin = *acc.LoginAt - } - passwordChangedAt := nowMs - admin := entity.AdminUser{ - Username: acc.Username, - PasswordHash: acc.Pass, - Status: &status, - ForcePasswordChange: acc.ForcePasswordChange, - LastLoginAt: &lastLogin, - PasswordChangedAt: &passwordChangedAt, - TokenVersion: func() *int64 { v := int64(1); return &v }(), - } - if _, saveErr := SaveAdminUser(admin); saveErr != nil { - return saveErr - } - continue - } - - if acc.Username == nil || acc.ConPass == nil { - continue - } - _, getPeerErr := GetPeer("name = ?", *acc.Username) - if getPeerErr == nil { - continue - } - - authId, authErr := util.RandomString(18) - if authErr != nil { - return authErr - } - peerSecretKey, keyErr := getOrCreatePeerSecretDigestKey() - if keyErr != nil { - return keyErr - } - secretDigest := util.HmacSHA256Hex(*acc.ConPass, peerSecretKey) - secretEncrypted := *acc.ConPass - quota := int64(0) - if acc.Quota != nil { - quota = *acc.Quota - } - download := int64(0) - if acc.Download != nil { - download = *acc.Download - } - upload := int64(0) - if acc.Upload != nil { - upload = *acc.Upload - } - expires := int64(0) - if acc.ExpireTime != nil { - expires = *acc.ExpireTime - } - maxDevices := int64(3) - if acc.DeviceNo != nil { - maxDevices = *acc.DeviceNo - } - disabled := int64(0) - if acc.Deleted != nil { - disabled = *acc.Deleted - } - bannedUntil := int64(0) - if acc.KickUtilTime != nil { - bannedUntil = *acc.KickUtilTime - } - lastConnection := int64(0) - if acc.ConAt != nil { - lastConnection = *acc.ConAt - } - remark := "" - if acc.Remark != nil { - remark = *acc.Remark - } - peer := entity.Peer{ - Name: acc.Username, - Remark: &remark, - AuthId: &authId, - SecretDigest: &secretDigest, - SecretEncrypted: &secretEncrypted, - QuotaBytes: "a, - DownloadBytes: &download, - UploadBytes: &upload, - ExpiresAt: &expires, - MaxDevices: &maxDevices, - Disabled: &disabled, - BannedUntil: &bannedUntil, - LastConnectionAt: &lastConnection, - } - if _, saveErr := SavePeer(peer); saveErr != nil { - return saveErr - } - } - return nil -} - func getOrCreateJwtSecret() (string, error) { if existing, err := GetConfig("key = ?", constant.JwtSecret); err == nil { if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" { @@ -562,18 +458,6 @@ func getOrCreatePeerSecretDigestKey() (string, error) { 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 { stmts := []string{ `CREATE TABLE IF NOT EXISTS traffic_sample ( @@ -658,26 +542,6 @@ func tableExists(tableName string) bool { } 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 { envSecret := strings.TrimSpace(os.Getenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET")) if envSecret != "" { diff --git a/apps/frontend/src/api/config/index.ts b/apps/frontend/src/api/config/index.ts index 5200394..d2f6646 100644 --- a/apps/frontend/src/api/config/index.ts +++ b/apps/frontend/src/api/config/index.ts @@ -1,7 +1,6 @@ import { AxiosPromise } from "axios"; import request from "@/utils/request"; import { - ConfigDto, ConfigsDto, ConfigUpdateDto, ConfigVo, @@ -14,6 +13,10 @@ import { // // Generic-выгрузки таблицы `config` здесь тоже нет: она отдавала JWT- и // peer-ключи в открытом виде. Перенос пиров делают ImportPeer/ExportPeer. +// +// getConfigApi удалён вместе с маршрутом: он принимал произвольный ключ той же +// таблицы и не вызывался ни из одного экрана. Чтение настроек идёт через +// listConfigApi, на стороне сервера — по allowlist. export function getHysteria2ConfigApi(): AxiosPromise { return request({ @@ -22,14 +25,6 @@ export function getHysteria2ConfigApi(): AxiosPromise { }); } -export function getConfigApi(data: ConfigDto): AxiosPromise { - return request({ - url: "/config/getConfig", - method: "get", - params: data, - }); -} - export function listConfigApi(data: ConfigsDto): AxiosPromise> { return request({ url: "/config/listConfig", diff --git a/apps/frontend/src/api/config/types.ts b/apps/frontend/src/api/config/types.ts index eabd3b7..3772705 100644 --- a/apps/frontend/src/api/config/types.ts +++ b/apps/frontend/src/api/config/types.ts @@ -1,6 +1,5 @@ -export interface ConfigDto { - key: string; -} +// ConfigDto удалён вместе с маршрутом getConfig: чтение произвольного ключа +// таблицы `config` было входом к JWT- и peer-ключам. export interface ConfigsDto { keys: Array; diff --git a/apps/frontend/src/api/log/types.ts b/apps/frontend/src/api/log/types.ts index cdb230b..398a3a6 100644 --- a/apps/frontend/src/api/log/types.ts +++ b/apps/frontend/src/api/log/types.ts @@ -12,7 +12,11 @@ export interface LogSystemVo { level: string; msg: string; reqMethod: string; - reqUri: string; + // Путь без query string: в query по машинному endpoint'у приезжает + // действующий machine token, и в журнале его быть не должно. + reqPath: string; + // Только имена переданных query-параметров, без значений. + reqQueryKeys: string; statusCode: string; time: string; } diff --git a/apps/frontend/src/api/peer/index.ts b/apps/frontend/src/api/peer/index.ts index b70e7ff..cdc282d 100644 --- a/apps/frontend/src/api/peer/index.ts +++ b/apps/frontend/src/api/peer/index.ts @@ -89,10 +89,26 @@ export function importPeerApi(data: FormData): AxiosPromise { }); } -export function exportPeerApi(): AxiosPromise { +/** + * Экспорт пиров в двух режимах, и это продуктовое различие, а не флаг. + * + * Без секретов — список настроек пиров. Файл безопасно хранить, но при импорте + * на другой сервер записям с пустым секретом выдаются НОВЫЕ секреты, то есть + * прежние клиентские ссылки перестают работать. + * + * С секретами — резервная копия, сохраняющая работоспособность существующих + * ссылок. Файл содержит действующие учётные данные доступа, поэтому запускается + * только через явное подтверждение оператора. + * + * Раньше кнопка была одна и всегда звала маршрут без includeSecrets, а + * документация называла эту пару механизмом переноса пиров. Оператор + * переносил пиров и обнаруживал, что все клиенты отвалились. + */ +export function exportPeerApi(includeSecrets = false): AxiosPromise { return request({ url: "/peer-export", method: "post", + params: includeSecrets ? { includeSecrets: "true" } : undefined, responseType: "blob", }); } diff --git a/apps/frontend/src/lang/package/en.ts b/apps/frontend/src/lang/package/en.ts index 97824d1..d5845e3 100644 --- a/apps/frontend/src/lang/package/en.ts +++ b/apps/frontend/src/lang/package/en.ts @@ -150,6 +150,13 @@ export default { unlimited: "Unlimited", secretRotateConfirm: "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", download: "Download", upload: "Upload", diff --git a/apps/frontend/src/lang/package/ru.ts b/apps/frontend/src/lang/package/ru.ts index 3bbb655..02fb78f 100644 --- a/apps/frontend/src/lang/package/ru.ts +++ b/apps/frontend/src/lang/package/ru.ts @@ -146,6 +146,13 @@ export default { unlimited: "Безлимит", secretRotateConfirm: "Сменить секрет пира? Текущие клиентские конфиги перестанут работать до обновления.", + exportSettings: "Экспорт настроек", + exportBackup: "Резервная копия", + exportSettingsHint: + "Список пиров без секретов. Безопасно хранить и передавать, но после импорта на другой сервер пирам будут выданы новые секреты, и прежние клиентские ссылки работать не будут.", + exportBackupConfirm: + "Резервная копия содержит ДЕЙСТВУЮЩИЕ секреты подключения всех пиров в открытом виде: любой, кто получит этот файл, получит доступ к VPN. Храните его как пароль и удалите, когда перенос завершён. Только такая копия сохраняет работоспособность существующих клиентских ссылок после переноса на другой сервер.", + exportBackupConfirmButton: "Понимаю, скачать копию с секретами", quota: "Квота", download: "Скачано", upload: "Отдано", diff --git a/apps/frontend/src/views/peer/list/index.vue b/apps/frontend/src/views/peer/list/index.vue index 6ab939b..8385152 100644 --- a/apps/frontend/src/views/peer/list/index.vue +++ b/apps/frontend/src/views/peer/list/index.vue @@ -30,9 +30,14 @@ > {{ $t("common.import") }} - {{ - $t("common.export") - }} + + {{ + $t("peer.exportSettings") + }} + + + {{ $t("peer.exportBackup") }} + @@ -558,9 +563,9 @@ function beforeImport(file: UploadRawFile) { return true; } -async function handleExport() { +async function downloadExport(includeSecrets: boolean) { try { - const response = await exportPeerApi(); + const response = await exportPeerApi(includeSecrets); const blob = new Blob([response.data], { 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); diff --git a/apps/middleware/log.go b/apps/middleware/log.go index e485de6..0f84108 100644 --- a/apps/middleware/log.go +++ b/apps/middleware/log.go @@ -1,11 +1,14 @@ package middleware import ( + "sort" + "strings" + "time" + "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" "gopkg.in/natefinch/lumberjack.v2" "hy2xs-admin/model/constant" - "time" ) func InitLog() { @@ -21,6 +24,40 @@ func InitLog() { 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= — то есть при каждом +// подключении пира. Пока логировался RequestURI, действующий machine token +// оседал открытым текстом в /var/log/hy2xs/hy2xs-admin.log, а этот файл +// отдаётся оператору через ExportLog и попадает в diagnostics-бандл. +// Получался постоянный канал утечки секрета в обычной штатной работе, мимо +// всей структурной редакции, сделанной для конфигов и env. +// +// Значения query-параметров не логируются вовсе: список того, «что можно», при +// таком подходе пришлось бы поддерживать вручную и он неизбежно разошёлся бы с +// набором маршрутов. Имена параметров сохранены — их для диагностики хватает. func LogHandler() gin.HandlerFunc { return func(c *gin.Context) { startTime := time.Now() @@ -30,13 +67,18 @@ func LogHandler() gin.HandlerFunc { statusCode := c.Writer.Status() latencyTime := time.Since(startTime) - entry := logrus.WithFields(logrus.Fields{ + fields := logrus.Fields{ "statusCode": statusCode, "latencyTime": latencyTime.Milliseconds(), "clientIP": c.ClientIP(), "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 { entry.Error() diff --git a/apps/middleware/log_test.go b/apps/middleware/log_test.go new file mode 100644 index 0000000..0ef0c15 --- /dev/null +++ b/apps/middleware/log_test.go @@ -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) + } +} diff --git a/apps/middleware/machine_auth.go b/apps/middleware/machine_auth.go index fd6317b..72c4200 100644 --- a/apps/middleware/machine_auth.go +++ b/apps/middleware/machine_auth.go @@ -1,6 +1,7 @@ package middleware import ( + "crypto/subtle" "net/http" "strings" @@ -38,7 +39,11 @@ func MachineAuthHandler() gin.HandlerFunc { 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{ "code": http.StatusForbidden, "type": "no", diff --git a/apps/model/constant/config.go b/apps/model/constant/config.go index c39a49f..35e5ef2 100644 --- a/apps/model/constant/config.go +++ b/apps/model/constant/config.go @@ -19,3 +19,85 @@ const ( Hysteria2ConfigRemark = "HYSTERIA2_CONFIG_REMARK" 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 +} diff --git a/apps/model/constant/config_test.go b/apps/model/constant/config_test.go new file mode 100644 index 0000000..f576364 --- /dev/null +++ b/apps/model/constant/config_test.go @@ -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("неизвестный ключ обязан быть закрыт по умолчанию") + } +} diff --git a/apps/model/dto/config.go b/apps/model/dto/config.go index 0646770..2fb525f 100644 --- a/apps/model/dto/config.go +++ b/apps/model/dto/config.go @@ -1,8 +1,8 @@ package dto -type ConfigDto struct { - Key *string `json:"key" form:"key" validate:"required,min=1,max=128"` -} +// ConfigDto удалён вместе с маршрутом GET /config/getConfig: чтение +// произвольного ключа таблицы `config` было точечным входом к JWT- и +// peer-ключам. Чтение идёт через ConfigsDto и allowlist. type ConfigsDto struct { Keys []string `json:"keys" form:"keys" validate:"required"` diff --git a/apps/model/entity/account.go b/apps/model/entity/account.go deleted file mode 100644 index 2a1a204..0000000 --- a/apps/model/entity/account.go +++ /dev/null @@ -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" -} diff --git a/apps/model/vo/log.go b/apps/model/vo/log.go index f4fc456..c9e0792 100644 --- a/apps/model/vo/log.go +++ b/apps/model/vo/log.go @@ -5,15 +5,22 @@ type LogSystemPage[T LogSystemVo | LogHysteria2Vo] struct { Total int64 `json:"total"` } +// LogSystemVo — строка журнала админки в том виде, в каком её пишет +// middleware.LogHandler. +// +// Поля reqUri здесь нет намеренно: RequestURI несёт query string, а по +// машинному endpoint'у в ней приезжает действующий machine token. Логируется +// путь и, отдельно, имена переданных query-параметров без значений. type LogSystemVo struct { - ClientIP string `json:"clientIp"` - LatencyTime int64 `json:"latencyTime"` - Level string `json:"level"` - Msg string `json:"msg"` - ReqMethod string `json:"reqMethod"` - ReqUri string `json:"reqUri"` - StatusCode int64 `json:"statusCode"` - Time string `json:"time"` + ClientIP string `json:"clientIp"` + LatencyTime int64 `json:"latencyTime"` + Level string `json:"level"` + Msg string `json:"msg"` + ReqMethod string `json:"reqMethod"` + ReqPath string `json:"reqPath"` + ReqQueryKeys string `json:"reqQueryKeys"` + StatusCode int64 `json:"statusCode"` + Time string `json:"time"` } type LogHysteria2Vo struct { diff --git a/apps/router/config.go b/apps/router/config.go index 196b171..a68558d 100644 --- a/apps/router/config.go +++ b/apps/router/config.go @@ -14,11 +14,16 @@ import ( // По той же причине здесь нет generic exportConfig/importConfig: они отдавали // и принимали таблицу `config` целиком, вместе с JWT- и peer-ключами. // См. комментарий в controller/config.go. +// +// Маршрута getConfig тоже нет. Он принимал произвольный ключ и был вторым +// входом в ту же таблицу секретов, только точечным: `?key=JWT_SECRET` +// возвращал подписывающий ключ, `?key=PEER_SECRET_ENCRYPTION_KEY` — master-key +// шифрования секретов пиров. Потребителей у маршрута не было ни одного: +// панель читает настройки через listConfig, у которого теперь allowlist. func initConfigRouter(configApi *gin.RouterGroup) { config := configApi.Group("/config") { config.POST("/updateConfigs", controller.UpdateConfigs) - config.GET("/getConfig", controller.GetConfig) config.POST("/listConfig", controller.ListConfig) config.GET("/getHysteria2Config", controller.GetHysteria2Config) config.POST("/exportHysteria2Config", controller.ExportHysteria2Config) diff --git a/apps/router/router_test.go b/apps/router/router_test.go index 02993b6..8e478d8 100644 --- a/apps/router/router_test.go +++ b/apps/router/router_test.go @@ -84,6 +84,10 @@ func TestRouterHasNoRemovedRoutes(t *testing.T) { "restartServer", "uploadCertFile", "hysteria2AcmePath", + // getConfig принимал произвольный ключ таблицы `config` и был точечным + // входом к JWT_SECRET, PEER_SECRET_KEY и PEER_SECRET_ENCRYPTION_KEY. + // Потребителей у него не было ни одного. + "getConfig", } for _, route := range buildRoutes(t) { 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) { found := false for _, route := range buildRoutes(t) { diff --git a/apps/service/journal.go b/apps/service/journal.go index d61b0cc..4fefcc1 100644 --- a/apps/service/journal.go +++ b/apps/service/journal.go @@ -58,10 +58,19 @@ func ReadHysteriaJournalLogs(numLine int) ([]vo.LogHysteria2Vo, int, error) { return result, len(result), nil } +// ExportHysteriaJournalLogs отдаёт журнал Hysteria оператору. +// +// Файл покидает сервер, поэтому проходит тот же санитайз, что и остальные +// выгружаемые артефакты: HY2_AUTH_URL несёт access_token, и upstream волен +// упомянуть его в сообщении об ошибке обращения к auth-backend. func ExportHysteriaJournalLogs(numLine int) (string, error) { 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 { @@ -87,12 +96,13 @@ func parseHysteriaJournalRecord(item journalctlLogLine) vo.LogHysteria2Vo { if parsed.Msg == "" { parsed.Msg = item.Message } + parsed.Msg = SanitizeLogText(parsed.Msg) return parsed } return vo.LogHysteria2Vo{ Level: fallbackLevel, - Msg: item.Message, + Msg: SanitizeLogText(item.Message), Time: fallbackTime, } } diff --git a/apps/service/log_sanitize.go b/apps/service/log_sanitize.go new file mode 100644 index 0000000..8862309 --- /dev/null +++ b/apps/service/log_sanitize.go @@ -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:] +} diff --git a/apps/service/log_sanitize_test.go b/apps/service/log_sanitize_test.go new file mode 100644 index 0000000..29a8649 --- /dev/null +++ b/apps/service/log_sanitize_test.go @@ -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("пустой вход должен оставаться пустым") + } +} diff --git a/apps/service/peer.go b/apps/service/peer.go index 12f2abf..a7c561b 100644 --- a/apps/service/peer.go +++ b/apps/service/peer.go @@ -204,119 +204,216 @@ func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) { return out, nil } -func UpsertPeerExport(items []bo.PeerExport) error { - // Первый проход — только проверка. Импорт либо применяется целиком, либо - // не применяется вовсе: наполовину импортированный список пиров хуже - // отклонённого файла. - if err := ValidatePeerImportBatch(items); err != nil { - return err - } +// 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) - - var existing entity.Peer - var err error authID := strings.TrimSpace(item.AuthId) - if authID != "" { - existing, err = dao.GetPeer("auth_id = ?", authID) - } - if err != nil || existing.Id == nil { - existing, err = dao.GetPeer("name = ?", name) - } - // Пир установщика не переопределяется импортом ни при каком совпадении: - // его секрет живёт ещё и в /etc/hy2xs/bootstrap-admin.secret. - if err == nil && existing.Name != nil && *existing.Name == 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 { - updates := map[string]interface{}{ - "name": name, - "remark": remark, - "quota_bytes": quota, - "download_bytes": item.DownloadBytes, - "upload_bytes": item.UploadBytes, - "expires_at": expires, - "max_devices": maxDevices, - "disabled": disabled, - "banned_until": item.BannedUntil, - "last_connection_at": item.LastConnectionAt, - } - if authID != "" { - updates["auth_id"] = authID - } - if strings.TrimSpace(item.Secret) != "" { - digest, derr := PeerSecretDigest(item.Secret) - if derr != nil { - return derr - } - enc, eerr := EncryptPeerSecret(item.Secret) - 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 + entry := preparedPeerImport{ + source: item, + name: name, + authID: authID, + remark: item.Remark, + quota: item.QuotaBytes, + expires: item.ExpiresAt, + maxDevices: maxDevices, + disabled: item.Disabled, } - createSecret := strings.TrimSpace(item.Secret) - if createSecret == "" { - generated, gerr := util.RandomString(24) - if gerr != nil { - return gerr - } - createSecret = fmt.Sprintf("%s.%s", name, generated) - } - secretDigest, derr := PeerSecretDigest(createSecret) - if derr != nil { - return derr - } - secretEncrypted, eerr := EncryptPeerSecret(createSecret) - if eerr != nil { - return eerr - } - if authID == "" { - authID, err = util.RandomString(18) + 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 { + if err := ValidatePeerImportBatch(items); err != nil { + return err + } + + prepared, err := preparePeerImport(items) + 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 + }) +} - peer := entity.Peer{ - Name: &name, - Remark: &remark, - AuthId: &authID, - SecretDigest: &secretDigest, - SecretEncrypted: &secretEncrypted, - QuotaBytes: "a, - DownloadBytes: &item.DownloadBytes, - UploadBytes: &item.UploadBytes, - ExpiresAt: &expires, - MaxDevices: &maxDevices, - Disabled: &disabled, - BannedUntil: &item.BannedUntil, - LastConnectionAt: &item.LastConnectionAt, - } - if _, serr := dao.SavePeer(peer); serr != nil { - return serr - } +func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error { + var existing entity.Peer + var err error + + if entry.authID != "" { + existing, err = tx.GetPeer("auth_id = ?", entry.authID) } - return nil + if err != nil || existing.Id == nil { + existing, err = tx.GetPeer("name = ?", entry.name) + } + + // Пир установщика не переопределяется импортом ни при каком совпадении: + // его секрет живёт ещё и в /etc/hy2xs/bootstrap-admin.secret. + if err == nil && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName { + return fmt.Errorf( + "peer import: пир %q принадлежит установщику и не может быть изменён импортом", + ReservedBootstrapPeerName, + ) + } + + if err == nil && existing.Id != nil { + updates := map[string]interface{}{ + "name": entry.name, + "remark": entry.remark, + "quota_bytes": entry.quota, + "download_bytes": entry.source.DownloadBytes, + "upload_bytes": entry.source.UploadBytes, + "expires_at": entry.expires, + "max_devices": entry.maxDevices, + "disabled": entry.disabled, + "banned_until": entry.source.BannedUntil, + "last_connection_at": entry.source.LastConnectionAt, + } + if entry.authID != "" { + updates["auth_id"] = entry.authID + } + if entry.hasExplicitSecret { + updates["secret_digest"] = entry.explicitDigest + updates["secret_ciphertext"] = entry.explicitCipher + } + return tx.UpdatePeer([]int64{*existing.Id}, updates) + } + + name := entry.name + remark := entry.remark + authID := entry.createAuthID + digest := entry.createDigest + cipher := entry.createCipher + quota := entry.quota + expires := entry.expires + maxDevices := entry.maxDevices + disabled := entry.disabled + download := entry.source.DownloadBytes + upload := entry.source.UploadBytes + bannedUntil := entry.source.BannedUntil + lastConnection := entry.source.LastConnectionAt + + peer := entity.Peer{ + Name: &name, + Remark: &remark, + AuthId: &authID, + SecretDigest: &digest, + SecretEncrypted: &cipher, + QuotaBytes: "a, + DownloadBytes: &download, + UploadBytes: &upload, + ExpiresAt: &expires, + MaxDevices: &maxDevices, + Disabled: &disabled, + BannedUntil: &bannedUntil, + LastConnectionAt: &lastConnection, + } + _, saveErr := tx.SavePeer(peer) + return saveErr } func ExistPeerName(name string, id int64) bool { diff --git a/apps/service/peer_import_tx_test.go b/apps/service/peer_import_tx_test.go new file mode 100644 index 0000000..8addc8c --- /dev/null +++ b/apps/service/peer_import_tx_test.go @@ -0,0 +1,267 @@ +package service + +import ( + "path/filepath" + "strings" + "testing" + + "hy2xs-admin/dao" + "hy2xs-admin/model/bo" + "hy2xs-admin/model/entity" +) + +// Тесты транзакционности импорта работают на настоящей SQLite: UNIQUE-конфликт, +// ради которого транзакция и нужна, воспроизводится только в базе. Схема +// поднимается отдельно от InitSql — bootstrap-администратор и bootstrap-пир +// здесь не нужны и только мешали бы читать ожидания. +func newTestDB(t *testing.T) { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db") + if err := dao.InitSqliteDBAt(dbPath); err != nil { + t.Fatalf("не удалось открыть тестовую базу: %v", err) + } + if err := dao.RunMigrations(); err != nil { + t.Fatalf("не удалось применить миграции: %v", err) + } + t.Cleanup(func() { + _ = dao.CloseSqliteDB() + }) +} + +func seedPeer(t *testing.T, name string, authID string) int64 { + t.Helper() + + digest, err := PeerSecretDigest(name + "-secret") + if err != nil { + t.Fatalf("digest: %v", err) + } + cipher, err := EncryptPeerSecret(name + "-secret") + if err != nil { + t.Fatalf("encrypt: %v", err) + } + + remark := "" + quota := int64(-1) + zero := int64(0) + maxDevices := int64(3) + + id, err := dao.SavePeer(entity.Peer{ + Name: &name, + Remark: &remark, + AuthId: &authID, + SecretDigest: &digest, + SecretEncrypted: &cipher, + QuotaBytes: "a, + DownloadBytes: &zero, + UploadBytes: &zero, + ExpiresAt: &zero, + MaxDevices: &maxDevices, + Disabled: &zero, + BannedUntil: &zero, + LastConnectionAt: &zero, + }) + if err != nil { + t.Fatalf("не удалось создать пира %s: %v", name, err) + } + return id +} + +func snapshotPeers(t *testing.T) map[string]entity.Peer { + t.Helper() + + peers, err := dao.ListPeer("1=1") + if err != nil { + t.Fatalf("не удалось прочитать пиров: %v", err) + } + out := make(map[string]entity.Peer, len(peers)) + for _, peer := range peers { + out[*peer.Name] = peer + } + return out +} + +func importItem(name string, authID string) bo.PeerExport { + return bo.PeerExport{ + Name: name, + AuthId: authID, + QuotaBytes: -1, + MaxDevices: 3, + } +} + +func TestUpsertPeerExportAppliesWholeBatch(t *testing.T) { + newTestDB(t) + + items := []bo.PeerExport{ + importItem("alpha1", ""), + importItem("bravo2", ""), + importItem("charl3", ""), + } + if err := UpsertPeerExport(items); err != nil { + t.Fatalf("валидный импорт отклонён: %v", err) + } + + peers := snapshotPeers(t) + if len(peers) != 3 { + t.Fatalf("ожидалось 3 пира, получено %d", len(peers)) + } + for _, name := range []string{"alpha1", "bravo2", "charl3"} { + if _, ok := peers[name]; !ok { + t.Errorf("пир %s не создан", name) + } + } +} + +// Главная регрессия: cross-conflict, описанный в комментарии к UpsertPeerExport. +// +// В базе A(auth_id=aaa, name=alice1) и B(auth_id=bbb, name=bob123). +// Импорт несёт запись (auth_id=aaa, name=bob123): поиск найдёт A по auth_id и +// попытается переименовать её в bob123 — прямо в UNIQUE(name). +// +// До транзакции всё, что шло в файле ДО конфликтной записи, оставалось +// применённым. +func TestUpsertPeerExportRollsBackCrossConflict(t *testing.T) { + newTestDB(t) + + seedPeer(t, "alice1", "aaa") + seedPeer(t, "bob123", "bbb") + before := snapshotPeers(t) + + items := []bo.PeerExport{ + importItem("first1", ""), // применилась бы + importItem("second", ""), // применилась бы + importItem("bob123", "aaa"), // UNIQUE(name) конфликт + importItem("fourth", ""), // до неё дело не дойдёт + } + + err := UpsertPeerExport(items) + if err == nil { + t.Fatal("импорт с конфликтом UNIQUE должен быть отклонён") + } + // Отказ обязан прийти из базы, а не из валидации: иначе тест доказывал бы + // только то, что файл не прошёл проверку, и про откат не говорил бы ничего. + if strings.HasPrefix(err.Error(), "peer import:") { + t.Fatalf("ожидался отказ базы, получен отказ валидации: %v", err) + } + + after := snapshotPeers(t) + if len(after) != len(before) { + t.Fatalf("состояние базы изменилось: было %d пиров, стало %d", len(before), len(after)) + } + for name, peer := range before { + got, ok := after[name] + if !ok { + t.Errorf("пир %s пропал после отката", name) + continue + } + if *got.AuthId != *peer.AuthId { + t.Errorf("у пира %s изменился auth_id: %s -> %s", name, *peer.AuthId, *got.AuthId) + } + } + for _, name := range []string{"first1", "second", "fourth"} { + if _, ok := after[name]; ok { + t.Errorf("частично применённый импорт: пир %s остался в базе", name) + } + } +} + +// Тот же инвариант на дубликате auth_id: конфликт возникает на вставке, а не +// на обновлении. +func TestUpsertPeerExportRollsBackDuplicateAuthId(t *testing.T) { + newTestDB(t) + + seedPeer(t, "exist1", "shared-auth-id") + before := snapshotPeers(t) + + items := []bo.PeerExport{ + importItem("newone", ""), + // Имя новое, поэтому запись будет вставляться, а auth_id уже занят. + importItem("newtwo", "shared-auth-id"), + } + + err := UpsertPeerExport(items) + if err == nil { + // Совпадение по auth_id приводит к обновлению существующего пира — + // это допустимая семантика upsert. Тогда проверяем, что первая запись + // применилась вместе со второй, а не вместо неё. + after := snapshotPeers(t) + if _, ok := after["newone"]; !ok { + t.Fatal("успешный импорт не применил первую запись") + } + return + } + + after := snapshotPeers(t) + if len(after) != len(before) { + t.Fatalf("частичный импорт: было %d пиров, стало %d", len(before), len(after)) + } + if _, ok := after["newone"]; ok { + t.Error("частично применённый импорт: пир newone остался в базе") + } +} + +// Отказ подготовки (валидация партии) не должен доходить до базы вовсе. +func TestUpsertPeerExportRejectsInvalidBatchBeforeAnyWrite(t *testing.T) { + newTestDB(t) + + items := []bo.PeerExport{ + importItem("valid1", ""), + importItem("bad", ""), // короче 6 символов + } + + if err := UpsertPeerExport(items); err == nil { + t.Fatal("партия с невалидной записью должна быть отклонена") + } + + if peers := snapshotPeers(t); len(peers) != 0 { + t.Fatalf("отклонённая партия оставила записи в базе: %d", len(peers)) + } +} + +// Пир установщика защищён и внутри транзакции: отказ обязан откатить всё, что +// успело примениться до него. +func TestUpsertPeerExportRollsBackOnBootstrapPeer(t *testing.T) { + newTestDB(t) + + seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName) + + items := []bo.PeerExport{ + importItem("first1", ""), + // Имя зарезервировано, поэтому валидация не пропустит его напрямую; + // совпадение достигается через auth_id установщика. + importItem("second", ReservedBootstrapPeerName), + } + + if err := UpsertPeerExport(items); err == nil { + t.Fatal("импорт, задевающий пира установщика, должен быть отклонён") + } + + after := snapshotPeers(t) + if _, ok := after["first1"]; ok { + t.Error("частично применённый импорт: пир first1 остался в базе") + } +} + +// Обновление существующего пира без секрета в файле не должно трогать его +// секрет: иначе перенос настроек ломал бы работающие клиентские ссылки. +func TestUpsertPeerExportKeepsSecretWhenFileHasNone(t *testing.T) { + newTestDB(t) + + seedPeer(t, "keeper", "keeper-auth") + before := snapshotPeers(t)["keeper"] + + item := importItem("keeper", "keeper-auth") + item.Remark = "updated remark" + if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil { + t.Fatalf("импорт отклонён: %v", err) + } + + after := snapshotPeers(t)["keeper"] + if *after.SecretDigest != *before.SecretDigest { + t.Error("секрет пира перезаписан, хотя в файле его не было") + } + if *after.Remark != "updated remark" { + t.Errorf("remark не обновился: %q", *after.Remark) + } +} diff --git a/docs/04-admin-panel.md b/docs/04-admin-panel.md index 0951659..8449cdc 100644 --- a/docs/04-admin-panel.md +++ b/docs/04-admin-panel.md @@ -66,18 +66,20 @@ HY2XS admin работает как надстройка над Hysteria YAML/AP | Маршрут панели | всегда `/` | | TLS | терминируется снаружи (SSH-туннель или reverse proxy) | -До v1 эти величины дублировались в таблице `config` под ключами -`H_UI_WEB_PORT`, `H_UI_WEB_CONTEXT`, `H_UI_CRT_PATH`, `H_UI_KEY_PATH` — -наследие H UI, где панель публиковалась наружу самостоятельно. Получался круг: -оркестратор передавал порт аргументом, панель записывала его в SQLite и тут же -читала обратно, а UI показывал поля в disabled-виде. Ни одного факта база при -этом не добавляла. +До v1 эти величины дублировались в таблице `config` собственными ключами +панели: оркестратор передавал порт аргументом, панель записывала его в SQLite и +тут же читала обратно, а UI показывал поля в disabled-виде. Ни одного факта база +при этом не добавляла — это был второй источник истины без содержания. -В v1 этих ключей нет ни в схеме, ни в seed, ни в интерфейсе. Собственного +В v1 таких ключей нет ни в схеме, ни в seed, ни в интерфейсе. Собственного TLS-слоя у панели тоже нет: production-контракт — `HY2XS_UI_BIND_HOST=127.0.0.1` и `HY2XS_UI_PUBLIC_ACCESS=false`, то есть внутренний сервис. Если панели когда-нибудь понадобится публичный endpoint, TLS обязан заканчиваться на -ingress/reverse-proxy, а не возвращаться к модели H UI. +ingress/reverse-proxy, а не возвращаться к модели «панель публикует себя сама». + +Имена ключей предыдущего поколения намеренно не приводятся: в обычных v1-доках +их словаря нет. Всё, что нужно для распознавания и удаления старой установки, — +в [14-legacy-cleanup.md](14-legacy-cleanup.md). ## Пространства имён HTTP API @@ -90,7 +92,7 @@ ingress/reverse-proxy, а не возвращаться к модели H UI. Разделение отражает разницу в природе маршрутов. `/internal/hysteria/auth` — не интерфейс для человека и не часть операторского API: это внутренний IPC-подобный HTTP endpoint между двумя процессами на одной машине. До v1 он -лежал под тем же префиксом `hui`, что и JWT-защищённый админский API, хотя +лежал под тем же префиксом, что и JWT-защищённый админский API, хотя middleware у них не пересекаются. Путь machine-auth — **runtime-контракт продукта**: он записывается в @@ -99,11 +101,43 @@ middleware у них не пересекаются. `HYSTERIA_MACHINE_AUTH_PATH` в оркестраторе, — а сборка сверяет их между собой и с шаблонами. +## Журнал запросов не содержит значений query-параметров + +Hysteria обращается к машинному endpoint'у как +`/internal/hysteria/auth?access_token=` — при каждом подключении +пира. Поэтому в журнале админки пишется **путь**, а не `RequestURI`: + +```json +{ "reqMethod": "POST", "reqPath": "/internal/hysteria/auth", "reqQueryKeys": "access_token" } +``` + +Пока логировался `RequestURI`, действующий machine token оседал открытым +текстом в `/var/log/hy2xs/hy2xs-admin.log`. Этот файл отдаётся оператору через +`ExportLog` и попадает в diagnostics-бандл, то есть секрет утекал наружу в +штатном режиме работы — мимо всей структурной редакции, сделанной для конфигов +и env. + +Значения query-параметров не логируются вовсе: список «что можно» пришлось бы +вести вручную, и он неизбежно разошёлся бы с набором маршрутов. Имена +параметров сохранены — для диагностики их достаточно. + +Каналов журналирования у панели ровно один. Админка запускается через +`gin.New()` + `gin.Recovery()`, а не `gin.Default()`: штатный `gin.Logger()` +печатает путь **вместе с query string** в stdout, откуда он уходит в journald, а +оттуда — в diagnostics-бандл. Это был второй, независимый канал той же утечки, и +починка собственного логгера его бы не закрыла. + +Журнал Hysteria (`ExportLog`, вкладка логов) проходит через санитайз +`service.SanitizeLogText`: `HY2_AUTH_URL` несёт `access_token`, и upstream волен +упомянуть его в сообщении об ошибке обращения к auth-backend. Санитайз +сохраняет host, port и path — диагностика от него не страдает. Тот же проход +применяется к `journal-*.log` внутри diagnostics-бандла оркестратора. + ## Импорт и экспорт | Операция | Статус | | --- | --- | -| Экспорт пиров (`POST /api/peer-export`) | есть | +| Экспорт пиров (`POST /api/peer-export`) | есть, в двух режимах | | Импорт пиров (`POST /api/peer-import`) | есть | | Экспорт конфига Hysteria (`POST /api/config/exportHysteria2Config`) | есть, с вырезанием секретов | | Экспорт/импорт таблицы `config` | **удалён** | @@ -118,21 +152,91 @@ Hysteria YAML. В той же таблице лежат `JWT_SECRET`, `PEER_SECR Осмысленного production-сценария у этой пары не было: конфигурацией сервера владеет оркестратор, перенос пиров делают `peer-import`/`peer-export`. +### Точечный доступ к таблице `config` — по allowlist + +Удаления generic-пары оказалось недостаточно. Опасность осталась в точечном +API: `getConfig` и `listConfig` принимали произвольный ключ, а проверка записи +работала denylist'ом из трёх ключей оркестратора. То есть авторизованный запрос +`?key=PEER_SECRET_ENCRYPTION_KEY` отдавал master-key шифрования секретов пиров, +а `updateConfigs` позволял подменить `JWT_SECRET` и оба peer-ключа. Отверстие +сменило размер, но не исчезло. + +В v1: + +| Ключ | Чтение | Запись | +| --- | --- | --- | +| `HYSTERIA2_TRAFFIC_TIME` | да | да | +| `RESET_TRAFFIC_CRON` | да | да | +| `HYSTERIA2_CONFIG_REMARK` | да | нет | +| `HYSTERIA2_ENABLE`, `HYSTERIA2_CONFIG`, `HYSTERIA2_TRAFFIC_STATS_SECRET` | нет | нет, владелец — оркестратор | +| `JWT_SECRET`, `PEER_SECRET_KEY`, `PEER_SECRET_ENCRYPTION_KEY` | нет | нет | +| любой другой | нет | нет | + +Список — **allowlist**, и это структурное решение, а не стилистическое. +Denylist требует, чтобы автор каждого нового ключа вспомнил про этот файл: +забытый ключ при denylist сразу публичен, при allowlist — сразу закрыт. Отказ +по умолчанию не зависит от внимательности. + +Маршрут `GET /api/config/getConfig` **удалён целиком**: потребителей у него не +было ни одного, а фильтр на неиспользуемой двери — это по-прежнему дверь. Право +записи `HYSTERIA2_CONFIG_REMARK` тоже убрано: панель его только отображает. + +Ключи оркестратора отклоняются отдельным сообщением, называющим владельца, — +«этим значением владеет оркестратор» это другой ответ, чем «такого ключа нет», +и он ведёт оператора к `hy2xs-orchestrator reconfigure`. + Оба оставшихся экспорта формируются **в памяти** и отдаются прямо в ответ. Раньше они шли через `os.Create` в `/var/lib/hy2xs-admin/export/`, и файл там оставался навсегда — при `?includeSecrets=true` это означало расшифрованные секреты пиров на диске, накапливающиеся с каждым нажатием кнопки. Каталога `export/` больше не существует. -Импорт пиров проверяется так же строго, как обычное создание пира: те же -правила для имени, quota, `maxDevices`, `disabled`, длины секрета. Дополнительно: +### Экспорт пиров: два режима, а не флаг + +| Кнопка | Запрос | Что внутри | +| --- | --- | --- | +| **Экспорт настроек** | `POST /api/peer-export` | список пиров без секретов | +| **Резервная копия** | `POST /api/peer-export?includeSecrets=true` | то же плюс действующие секреты подключения | + +Разница здесь продуктовая, а не техническая, и её нельзя оставлять неявной. +Записи с пустым секретом при импорте получают **новые** секреты. То есть +перенос обычным экспортом восстанавливает пиров, их квоты, лимиты и счётчики — +но все существующие клиентские ссылки после него перестают работать. + +Раньше кнопка в панели была одна и всегда звала маршрут без `includeSecrets`, +хотя документация называла эту пару механизмом переноса пиров. Оператор +переносил пиров и обнаруживал, что все клиенты отвалились. + +Резервная копия содержит фактические учётные данные доступа к VPN в открытом +виде, поэтому запускается только через явное подтверждение с описанием риска. +Такой файл следует хранить как пароль и удалять после завершения переноса. + +### Импорт пиров + +Импорт проверяется так же строго, как обычное создание пира: те же правила для +имени, quota, `maxDevices`, `disabled`, длины секрета. Дополнительно: - неизвестные поля в JSON отклоняются, а не игнорируются молча; -- партия проверяется целиком **до** первой записи в базу — файл применяется - полностью или не применяется вовсе; +- файл обязан содержать **ровно один** JSON-документ. `json.Decoder` читает + первый документ и останавливается, поэтому файл с хвостом принимался целиком, + а его вторая половина молча не применялась; +- партия проверяется целиком **до** первой записи в базу; +- применение идёт **одной транзакцией**; - пир `bootstrap-admin-peer` защищён от перезаписи: его секрет продублирован в `/etc/hy2xs/bootstrap-admin.secret`. +Транзакция — не дублирование проверки, а закрытие другого класса отказов. +Валидация проверяет содержимое файла и ничего не знает о том, что уже лежит в +базе. Пусть существуют `A(auth_id=aaa, name=alice1)` и +`B(auth_id=bbb, name=bob123)`, а файл несёт `(auth_id=aaa, name=bob123)`: поиск +найдёт A по `auth_id` и попытается переименовать её в `bob123` — прямо в +`UNIQUE(name)`. Пока записи применялись по одной, всё, что шло в файле до +конфликтной строки, оставалось применённым, и откатить это оператор уже не мог. + +Криптоматериал (digest и шифртекст секретов) считается **до** открытия +транзакции: эти операции читают ключи из той же таблицы `config`, и держать на +ней открытую запись во время AES по каждой из тысяч записей незачем. + ## Два слоя работы с конфигом Hysteria Это важное архитектурное разделение. diff --git a/docs/08-orchestrator-spec.md b/docs/08-orchestrator-spec.md index 96217ac..c097469 100644 --- a/docs/08-orchestrator-spec.md +++ b/docs/08-orchestrator-spec.md @@ -54,7 +54,7 @@ Установка разделена на две фазы с жёсткой границей между ними: ```text -PHASE 0 — READ ONLY +PHASE 0 — READ ONLY владелец: install.sh проверка прав sha256sum -c metadata/checksums.txt ./orchestrator/hy2xs-orchestrator preflight-install --package-dir <распакованный пакет> @@ -63,11 +63,11 @@ PHASE 0 — READ ONLY └── валидация конфигурации ↓ ноль persistent writes PHASE 0 PASSED - ↓ -PHASE 1 — MUTATION - install -d /usr/local/lib/hy2xs - раскладка оркестратора и runtime-пакета - hy2xs-orchestrator install + ↓ exec +PHASE 1 — MUTATION владелец: оркестратор + preflight (clean-host — последний раз за операцию) + bootstrapRuntime: /usr/local/lib/hy2xs, symlink, runtime-пакет + installDeps → filesystem → UI → Hysteria → config → units → firewall → smoke ``` Ключевые свойства: @@ -82,6 +82,43 @@ PHASE 1 — MUTATION `install-state.json`**. Отказ на этом этапе означает, что на сервере не изменено ничего. +### У мутации ровно один владелец + +`install.sh` не изменяет на сервере ничего. Он проверяет и делает `exec`. + +Раньше PHASE 1 начиналась в shell: установщик сам создавал +`/usr/local/lib/hy2xs`, ставил туда бинарник, вешал symlink и копировал +runtime-пакет, и только после этого запускал оркестратор, который выполнял +собственный preflight. Между двумя фазами возникало окно: если второй preflight +отказывал — сменился DNS, занялся порт, не ответил резолвер, — у оркестратора не +был взведён ни один флаг владения, отказ классифицировался как +`fatal_pre_apply`, и оператор читал «на сервере ничего не изменено». Хост при +этом уже нёс каталог оркестратора, symlink и runtime-пакет, а следующий запуск +упирался в них как в маркеры чужой установки. + +Владение мутацией невозможно отследить, пока мутируют двое. Поэтому раскладку +выполняет шаг `steps/bootstrap.ts` под флагом `ownership.bootstrapTouched`, и +эти пути попадают в `owned_paths` install-state наравне со всеми остальными. +Сборка проверяет структурно, что в `install.sh` не осталось ни одной мутирующей +команды. + +### clean-host проверяется до первой мутации и только там + +`preflight()` принимает `checkCleanHost` явно, без значения по умолчанию. + +Причина в том, что clean-host — условие **входа** в операцию, а проверка +возможностей платформы (`systemd-run`, `nftables`, OpenSSL 3) выполняется уже +после `installDeps`, то есть внутри PHASE 1. Пока обе проверки ехали одним +параметром, `install` вызывал preflight дважды и оба раза с включённым +clean-host. Ко второму вызову на диске лежал собственный +`/var/lib/hy2xs/install-state.json`, записанный после первого preflight, — и он +опознавался как маркер посторонней установки. Каждая чистая установка падала +сразу после `apt-get`, получала `fatal_post_apply` и оставляла сервер +наполовину настроенным. + +По той же причине у списка маркеров больше нет «мягкой» версии для PHASE 1: +пути, которые раньше приходилось исключать, теперь создаются после проверки. + Полный список маркеров чужой установки и порядок очистки — [14-legacy-cleanup.md](14-legacy-cleanup.md). diff --git a/docs/11-testing-and-acceptance.md b/docs/11-testing-and-acceptance.md index f5a1738..70eca76 100644 --- a/docs/11-testing-and-acceptance.md +++ b/docs/11-testing-and-acceptance.md @@ -129,9 +129,13 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh попадают в список, а не только значения по умолчанию; - всё, что удаляет `purge-v0.sh`, покрыто маркерами clean-host: два списка описывают одну границу и не имеют права разъезжаться; -- пути, созданные `install.sh` между фазами (`/usr/local/lib/hy2xs`, - `/usr/local/lib/hy2xs/package`, `/usr/local/bin/hy2xs-orchestrator`), — - маркеры в PHASE 0, но не в PHASE 1; +- bootstrap-пути (`/usr/local/lib/hy2xs`, `/usr/local/lib/hy2xs/package`, + `/usr/local/bin/hy2xs-orchestrator`) остаются маркерами **без исключений**: + их создаёт оркестратор уже после проверки чистоты хоста, поэтому «мягкой» + версии списка для PHASE 1 больше не существует; +- эти пути берутся из `config/profile.ts`, а не из копий строк: шаг, который + их создаёт, и контракт, который на них отказывает, обязаны читать одно + значение; - сообщение перечисляет найденные маркеры и говорит, что хост не изменён. `orchestrator/test/install-boundary.test.ts`: @@ -146,7 +150,29 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh `stateWritten`: записанный `install-state.json` уже делает хост изменённым; - начатая (не обязательно завершённая) установка пакетов уже даёт `fatal_post_apply` — регрессия на сценарий «PHASE 0 прошла, apt-get упал, - установщик заявил, что ничего не тронул». + установщик заявил, что ничего не тронул»; +- начатый bootstrap (`bootstrapTouched`) тоже даёт `fatal_post_apply`: раскладку + выполняет оркестратор, и она учитывается наравне с остальными шагами. + +`orchestrator/test/install-sequence.test.ts` — порядок фаз, который иначе +проверяется только на живом сервере: + +- `preflight()` в режиме install **отказывается работать без явного + `checkCleanHost`**, и отказ наступает до любой работы с системой; +- clean-host запрашивается ровно один раз за операцию и **до** первой записи + install-state — регрессия на сценарий, где повторный preflight после + `installDeps` опознавал собственный `install-state.json` как маркер чужой + установки и валил каждую чистую установку; +- проход capabilities явно отказывается от clean-host; +- `bootstrapTouched` взводится **перед** `bootstrapRuntime`, а сам bootstrap + идёт до `installDeps`; +- дальнейшая установка работает от установленного runtime-пакета; +- `diagnosticsCollect` обёрнута в `try/catch`, и `catch` стоит **до** отката: + диагностика — best effort, откат — обязателен; +- в `package/install.sh` не осталось ни одной мутирующей команды, и он + передаёт управление оркестратору через `exec`; +- классификация отказа `reconfigure`/`repair` идёт по ownership-флагам, а не по + регулярному выражению над текстом ошибки. `orchestrator/test/install-state.test.ts`: @@ -171,7 +197,26 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh - секрет внутри URL-значения в env вырезается, даже если имя ключа несекретное (`HY2_AUTH_URL`); - URL под **произвольным** именем ключа теряет встроенные учётные данные и - секретные query-параметры, но сохраняет адрес; то же для URL внутри списка. + секретные query-параметры, но сохраняет адрес; то же для URL внутри списка; +- `redactLogText` вырезает machine token из строки journald, сохраняя host, + port и path; ловит секрет и вне URL; не трогает обычные строки; сохраняет + хвостовую пунктуацию; идемпотентен — регрессия на diagnostics-бандл, где + редактировались env и YAML, а `journal-admin.log` копировался как есть. + +## A7. Machine token в журналах (unit) + +`apps/middleware/log_test.go` — запрос +`/internal/hysteria/auth?access_token=SUPER_SECRET_SENTINEL`: + +- sentinel **не появляется** в журнале ни в каком виде; +- в журнале есть `reqPath`, поля `reqUri` нет; +- имя query-параметра сохраняется (`reqQueryKeys`), значение — нет; +- пустой список параметров в журнал не пишется; +- то же правило действует на операторских маршрутах, а не только на машинном. + +`apps/service/log_sanitize_test.go` — тот же санитайз на стороне админки: журнал +Hysteria покидает сервер через `ExportLog`, а `HY2_AUTH_URL` несёт +`access_token`, который upstream волен упомянуть в сообщении об ошибке. ## A8. Инвариант публичного endpoint (unit) @@ -193,10 +238,23 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh | нет ни одного локального публичного IPv4 | FAIL | | `HY2XS_PUBLIC_ENDPOINT_POLICY` = strict / warn / off | fail / warn / skip | | отсутствие A-записи при любой политике | FAIL | +| отказ резолвера (SERVFAIL/таймаут/отказ) при любой политике | FAIL, отдельный текст | -Отдельно проверяется классификация IPv4: приватные, CGNAT, link-local, -multicast и reserved диапазоны не считаются публичным адресом сервера, а -`172.32.0.0` и `172.15.255.255` — считаются (границы `172.16/12`). +Отдельно проверяется классификация IPv4. Список исключений приведён к IANA +Special-Purpose Address Registry: приватные, CGNAT, link-local, multicast, +reserved, benchmarking (`198.18/15`), 6to4-anycast и **документационные** +диапазоны (`192.0.2/24`, `198.51.100/24`, `203.0.113/24`) не считаются +публичным адресом сервера. Регрессия: `203.0.113.5` из RFC-примеров раньше +проходил проверку как обычный публичный адрес. Границы проверяются с обеих +сторон — `172.32.0.0`, `192.0.1.1`, `198.20.0.1` и `203.0.112.255` считаются +публичными. + +Отказ резолвера отделён от отсутствия записи: `ENODATA`/`ENOTFOUND`/`NXDOMAIN` +— это «нет A-записи» и чинится в DNS-панели, всё остальное — «резолвер не +ответил» и чинится в `/etc/resolv.conf`. Раньше оба случая печатались как +«has no A-record», и при сломанном резолвере оператор шёл править запись, +которая была на месте. Фатальны оба: без ответа резолвера проверка не выполнена, +а не «выполнена с замечанием». ## A9. Регистрация маршрутов (unit) @@ -209,9 +267,34 @@ wildcard-маршрутом фронтенда или дублирующая р - machine-auth зарегистрирован ровно на `constant.HysteriaMachineAuthPath`; - операторский и auth API — под `constant.AdminAPIBase`; - ни один маршрут не начинается со старого пространства имён; -- удалённые маршруты (включая `exportConfig`/`importConfig`) не вернулись; +- удалённые маршруты (включая `exportConfig`/`importConfig` и `getConfig`) не + вернулись; +- пространство `/api/config` закрыто: в нём ровно четыре маршрута, и любой + новый обязан быть добавлен в тест осознанно; - `/healthz` на месте. +## A9a. Доступ к таблице `config` (unit) + +`apps/model/constant/config_test.go` — allowlist как структура, а не как +соглашение: + +- ни один внутренний ключ не читается и не записывается через API; +- `JWT_SECRET`, `PEER_SECRET_KEY`, `PEER_SECRET_ENCRYPTION_KEY` и + `HYSTERIA2_TRAFFIC_STATS_SECRET` поимённо объявлены внутренними; +- пользовательские настройки остаются доступными; +- множество записываемых ключей — подмножество читаемых; +- неизвестный ключ закрыт **по умолчанию**: забытый при denylist ключ был бы + сразу публичным. + +`apps/controller/config_test.go` — то же на уровне HTTP: + +- чтение и запись каждого секрета отклоняются; +- секрет, спрятанный среди разрешённых ключей, отклоняет весь запрос; +- отказ наступает **до** обращения к базе (тест работает без SQLite — сам факт, + что обработчик не падает, это и доказывает); +- ключи оркестратора отклоняются с указанием владельца, а не общим «нет такого + ключа»: оператор должен быть отправлен к `hy2xs-orchestrator reconfigure`. + ## A10. Импорт пиров (unit) `apps/service/peer_import_test.go`: @@ -229,6 +312,30 @@ wildcard-маршрутом фронтенда или дублирующая р - невалидная **последняя** запись отклоняет весь файл: импорт применяется целиком или не применяется вовсе. +`apps/service/peer_import_tx_test.go` — та же гарантия уже на уровне базы, на +настоящей SQLite. Валидация не даёт применить испорченный файл, но она ничего +не говорит о конфликте с тем, что УЖЕ лежит в базе: + +- валидная партия применяется целиком; +- **cross-conflict откатывается полностью**: пусть в базе есть + `A(auth_id=aaa, name=alice1)` и `B(auth_id=bbb, name=bob123)`, а файл несёт + `(auth_id=aaa, name=bob123)` — поиск найдёт A по `auth_id` и попытается + переименовать её в `bob123`, прямо в `UNIQUE(name)`. Записи, шедшие в файле + до конфликтной, не должны остаться применёнными. Тест дополнительно + убеждается, что отказ пришёл **из базы**, а не из валидации; +- дубликат `auth_id` на вставке ведёт себя так же; +- отказ валидации не доходит до базы вовсе; +- пир установщика защищён и внутри транзакции; +- обновление без секрета в файле не перезаписывает существующий секрет. + +`apps/controller/peer_test.go` — разбор загруженного файла: + +- файл с **хвостовым** JSON-документом отклоняется: `json.Decoder` читает первый + документ и останавливается, поэтому раньше оператор видел «импорт выполнен», + а вторая половина файла молча не применялась; +- неизвестные поля и файл не с расширением `.json` отклоняются; +- корректный одиночный документ доходит до базы и создаёт пира. + ## A7. Контракт версий (build) Шаг `verify_versions_contract` (`tools/build/lib/versions.sh`) роняет сборку до @@ -436,14 +543,39 @@ idle timeout проходил семантическую проверку. То 4. в выводе **нет** `fatal_pre_apply` и нет фразы про «ничего не применялось»; 5. `/var/lib/hy2xs/install-state.json` существует и честно показывает `phase: failed` с текстом ошибки; -6. diagnostics-бандл собран; -7. `hy2xs-orchestrator status` не заявляет установку успешной. +6. `owned_paths` в маркере содержит `/usr/local/lib/hy2xs`, + `/usr/local/bin/hy2xs-orchestrator` и `/usr/local/lib/hy2xs/package` — + всё, что операция действительно создала; +7. diagnostics-бандл собран; +8. `hy2xs-orchestrator status` не заявляет установку успешной. -До исправления шаги 4–6 давали противоположный результат: `install-state.json` +До исправления шаги 4–7 давали противоположный результат: `install-state.json` уже лежал на диске, но отказ классифицировался как pre-apply, обработка состояния пропускалась, а следующая установка на этой машине отказывалась по clean-host контракту из-за оставшегося маркера. +Пункт 6 закрывает вторую половину той же щели. Пока раскладку оркестратора и +runtime-пакета выполнял `install.sh`, эти пути не принадлежали никому: они не +попадали в `owned_paths`, а отказ **второго** preflight (сменился DNS, занялся +порт, не ответил резолвер) объявлялся `fatal_pre_apply` — «на сервере ничего не +изменено» — при уже созданном каталоге оркестратора. + +## D1a. Проход установки не спотыкается о собственный маркер + +Проверяется на чистом хосте, обычной успешной установкой. + +1. `install.sh` доходит до `preflight capabilities` **после** `apt-get`; +2. установка на этом шаге **не** падает с текстом «обнаружена предыдущая или + посторонняя установка»; +3. установка доходит до `installed`. + +Это сценарий, который не воспроизводится ни на одном dry-run: clean-host внутри +`install` проверялся дважды, и ко второму разу на диске уже лежал собственный +`/var/lib/hy2xs/install-state.json`, записанный после первого preflight. Каждая +чистая установка падала сразу после `apt-get`, получала `fatal_post_apply` и +оставляла сервер наполовину настроенным. Структурно закреплено в +`orchestrator/test/install-sequence.test.ts`. + ## D2. Устаревший DNS после смены IPv4 провайдером Проверяется на рабочей установке. diff --git a/docs/README.md b/docs/README.md index c204438..14dae99 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,8 +44,10 @@ которые она не разворачивала, не останавливаются никогда. 7. Установка двухфазная: **PHASE 0 — read only**, **PHASE 1 — mutation**. До успешного clean-host preflight на сервере не изменяется ни один - persistent path. Очистка предыдущей установки — отдельная явная операция - оператора, см. [14-legacy-cleanup.md](14-legacy-cleanup.md). + persistent path. У мутирующей фазы ровно один владелец — оркестратор: + `install.sh` проверяет и передаёт управление, не изменяя ничего сам. + Очистка предыдущей установки — отдельная явная операция оператора, + см. [14-legacy-cleanup.md](14-legacy-cleanup.md). 8. Выдача доступа пользователям, Telegram-бот, billing, backend профилей и похожие контуры **не входят** в этот baseline. ## Состав документов diff --git a/orchestrator/src/commands/diagnostics.ts b/orchestrator/src/commands/diagnostics.ts index af8a318..ba97d46 100644 --- a/orchestrator/src/commands/diagnostics.ts +++ b/orchestrator/src/commands/diagnostics.ts @@ -1,7 +1,7 @@ import type { CommonOptions } from "../types/context"; import { info, setOperationContext } from "../lib/log"; import { runMutating } from "../lib/process"; -import { redactEnv, redactYaml } from "../lib/redaction"; +import { redactEnv, redactLogText, redactYaml } from "../lib/redaction"; function shellEscapeSingleQuotes(value: string): string { return value.replaceAll("'", "'\\''"); @@ -49,6 +49,24 @@ export async function diagnosticsCollect(_options: CommonOptions): Promise // 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)}' .`}`; info(`diagnostics bundle collected: ${archive}`); diff --git a/orchestrator/src/commands/install.ts b/orchestrator/src/commands/install.ts index 5b162bb..a915ffb 100644 --- a/orchestrator/src/commands/install.ts +++ b/orchestrator/src/commands/install.ts @@ -9,7 +9,14 @@ import { buildInstallStateRecord } from "../lib/installState"; 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 { bootstrapRuntime } from "../steps/bootstrap"; import { installDeps } from "../steps/deps"; import { prepareFilesystem } from "../steps/filesystem"; import { deployUi } from "../steps/ui"; @@ -24,6 +31,7 @@ import { diagnosticsCollect } from "./diagnostics"; type InstallPhase = | "installing" | "preflight_ok" + | "bootstrap_installed" | "deps_ok" | "filesystem_ready" | "runtime_env_written" @@ -56,6 +64,12 @@ type InstallPhase = type OperationOwnership = { /** install-state.json уже создан: сам по себе делает хост изменённым. */ stateWritten: boolean; + /** + * Раскладка самого оркестратора и runtime-пакета (/usr/local/lib/hy2xs, + * symlink в /usr/local/bin). Раньше эти пути создавал install.sh, и они + * оставались вне учёта владения целиком. + */ + bootstrapTouched: boolean; depsTouched: boolean; filesystemTouched: boolean; uiTouched: boolean; @@ -79,6 +93,7 @@ type FailureKind = function newOwnership(): OperationOwnership { return { stateWritten: false, + bootstrapTouched: false, depsTouched: false, filesystemTouched: false, uiTouched: false, @@ -102,7 +117,14 @@ function installOwnedPaths(context: InstallContext): string[] { "/etc/systemd/system/hysteria-server.service", "/etc/nftables.conf", "/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.filesystemTouched || ownership.depsTouched || + ownership.bootstrapTouched || ownership.stateWritten ) { return "fatal_post_apply"; @@ -189,6 +212,11 @@ async function rollbackFailedInstall( return; } + // Bootstrap-пути (/usr/local/lib/hy2xs, symlink, runtime-пакет) намеренно не + // удаляются: именно ими и работает `repair --allow-partial-state`, ради + // которого install-state сохраняется с repair_hint. Снести оркестратор при + // откате означало бы лишить оператора инструмента починки. Полная зачистка — + // это осознанное отдельное действие, tools/legacy/purge-v0.sh. if (ownership.firewallTouched) { await rollbackFirewallNow(context); } @@ -237,18 +265,28 @@ export async function install(options: InstallOptions): Promise { try { // PHASE 1 начинается только после успешного preflight: до него install // не пишет ни одного persistent path, включая install-state. + // + // Контракт чистого хоста проверяется здесь — последний раз за операцию. + // Всё, что дальше, уже создаёт наши собственные пути, и повторная проверка + // опознала бы их как чужую установку. step("preflight"); - await preflight(context, { requireCapabilities: false, cleanHostPhase: "install" }); + await preflight(context, { requireCapabilities: false, checkCleanHost: true }); stepDone("preflight"); await advanceInstallState(context, ownership, "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"); ownership.depsTouched = true; await installDeps(context); stepDone("system dependencies"); step("preflight capabilities"); - await preflight(context, { requireCapabilities: true, cleanHostPhase: "install" }); + await preflight(context, { requireCapabilities: true, checkCleanHost: false }); stepDone("preflight capabilities"); await advanceInstallState(context, ownership, "deps_ok"); phase = "deps_ok"; @@ -341,7 +379,22 @@ export async function install(options: InstallOptions): Promise { failureKind === "smoke_readiness_timeout" ? "smoke_failed" : "failed", `${failureKind}: ${message}` ); - await diagnosticsCollect(options); + + // Диагностика — best effort, откат — обязателен. + // + // diagnosticsCollect создаёт каталог, копирует файлы и упаковывает tar: + // при заполненном диске, битой ФС или недоступном journalctl она падает + // сама. Пока она стояла перед откатом обычным await, её собственный отказ + // отменял откат целиком — то есть худший сценарий отказа установки + // гарантированно лишался единственного механизма восстановления. + try { + 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); throw error; } diff --git a/orchestrator/src/commands/preflight-install.ts b/orchestrator/src/commands/preflight-install.ts index 0a9d267..8b9ac01 100644 --- a/orchestrator/src/commands/preflight-install.ts +++ b/orchestrator/src/commands/preflight-install.ts @@ -36,7 +36,7 @@ export async function preflightInstall(options: InstallOptions): Promise { }; step("bootstrap preflight (read-only)"); - await preflight(context, { requireCapabilities: false, cleanHostPhase: "bootstrap" }); + await preflight(context, { requireCapabilities: false, checkCleanHost: true }); stepDone("bootstrap preflight (read-only)"); info("clean-host contract satisfied: no persistent path was modified"); diff --git a/orchestrator/src/commands/reconfigure.ts b/orchestrator/src/commands/reconfigure.ts index 357880a..1b53b4d 100644 --- a/orchestrator/src/commands/reconfigure.ts +++ b/orchestrator/src/commands/reconfigure.ts @@ -27,10 +27,53 @@ type ReconfigurePhase = | "firewall_applied" | "runtime_env_written" | "smoke_ok" + | "reconfigure_failed" | "firewall_connectivity_failure" | "smoke_failed" | "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 { return context.installDate; } @@ -194,21 +237,27 @@ export async function reconfigure(options: ReconfigureOptions): Promise { await backupCurrentState(); stepDone("backup"); + const ownership = newReconfigureOwnership(); + try { await markPhase(context, options.allowPartialState ? "repairing" : "reconfiguring"); step("config generation"); + ownership.configTouched = true; await generateConfig(context); stepDone("config generation"); await markPhase(context, "config_generated"); step("systemd units"); + ownership.unitsTouched = true; await deploySystemd(context); stepDone("systemd units"); await markPhase(context, "units_deployed"); step("firewall"); + ownership.firewallTouched = true; await applyFirewall(context); stepDone("firewall"); await markPhase(context, "firewall_applied"); step("write env artifacts"); + ownership.envTouched = true; await writeTextAtomic(options.runtimeConfigPath, renderRuntimeEnv(config), { mode: 0o600, owner: "root", @@ -219,6 +268,7 @@ export async function reconfigure(options: ReconfigureOptions): Promise { stepDone("write env artifacts"); await markPhase(context, "runtime_env_written"); step("smoke checks"); + ownership.servicesRestarted = true; await smoke(context); stepDone("smoke checks"); await markPhase(context, "smoke_ok"); @@ -234,12 +284,26 @@ export async function reconfigure(options: ReconfigureOptions): Promise { } catch (error) { info("reconfigure failed, rollback in progress"); const message = error instanceof Error ? error.message : String(error); - const phase: ReconfigurePhase = /firewall|nft|ssh port check failed/i.test(message) - ? "firewall_connectivity_failure" - : "smoke_failed"; - await markPhase(context, phase, message); - await diagnosticsCollect(options); - await rollbackFirewallNow(context); + await markPhase(context, classifyReconfigureFailure(ownership), message); + + // Диагностика — best effort, откат — обязателен. + // + // diagnosticsCollect создаёт каталог, копирует файлы и упаковывает tar: + // на заполненном диске она падает сама. Пока она стояла перед откатом + // обычным await, её отказ отменял и rollbackFirewallNow, и восстановление + // конфигов из /etc/hy2xs/backups — то есть сервер оставался с применённым + // сломанным firewall и без единственного механизма возврата. + try { + 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 rollbackCurrentState(); throw error; } diff --git a/orchestrator/src/config/profile.ts b/orchestrator/src/config/profile.ts index ec78b54..cc1de71 100644 --- a/orchestrator/src/config/profile.ts +++ b/orchestrator/src/config/profile.ts @@ -41,6 +41,36 @@ export const HY2XS_TARGET_ARCH = "amd64"; export const ADMIN_API_BASE = "/api"; 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. */ export function hysteriaMachineAuthUrl(uiPort: number, machineToken: string): string { return `http://127.0.0.1:${uiPort}${HYSTERIA_MACHINE_AUTH_PATH}?access_token=${machineToken}`; diff --git a/orchestrator/src/lib/redaction.ts b/orchestrator/src/lib/redaction.ts index aace3a0..3339a90 100644 --- a/orchestrator/src/lib/redaction.ts +++ b/orchestrator/src/lib/redaction.ts @@ -166,6 +166,56 @@ export function redactEnv(content: string): string { .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. Диагностика не должна падать, но и утекать не должна, поэтому здесь diff --git a/orchestrator/src/steps/bootstrap.ts b/orchestrator/src/steps/bootstrap.ts new file mode 100644 index 0000000..7e03775 --- /dev/null +++ b/orchestrator/src/steps/bootstrap.ts @@ -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 { + 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; +} diff --git a/orchestrator/src/steps/cleanHost.ts b/orchestrator/src/steps/cleanHost.ts index 5af5b77..01d371f 100644 --- a/orchestrator/src/steps/cleanHost.ts +++ b/orchestrator/src/steps/cleanHost.ts @@ -1,6 +1,11 @@ import type { RuntimeConfig } from "../types/context"; import { dirExists, fileExists } from "../lib/fs"; import { runReadOnly } from "../lib/process"; +import { + ORCHESTRATOR_INSTALL_DIR, + ORCHESTRATOR_SYMLINK_PATH, + RUNTIME_PACKAGE_DIR +} from "../config/profile"; /** * Контракт чистого хоста для HY2XS v1. @@ -22,19 +27,8 @@ export type LegacyMarker = { target: 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 = { fileExists(path: string): Promise; dirExists(path: string): Promise; @@ -44,8 +38,15 @@ export type HostProbe = { /** * Собирает список маркеров с учётом путей из конфигурации: 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[] = [ { kind: "file", @@ -69,21 +70,18 @@ export function legacyMarkersFor(config: RuntimeConfig, phase: CleanHostPhase = }, { kind: "dir", - target: "/usr/local/lib/hy2xs/package", - description: "runtime-пакет предыдущей установки HY2XS", - createdByInstaller: true + target: RUNTIME_PACKAGE_DIR, + description: "runtime-пакет предыдущей установки HY2XS" }, { kind: "dir", - target: "/usr/local/lib/hy2xs", - description: "каталог оркестратора предыдущей установки HY2XS", - createdByInstaller: true + target: ORCHESTRATOR_INSTALL_DIR, + description: "каталог оркестратора предыдущей установки HY2XS" }, { kind: "file", - target: "/usr/local/bin/hy2xs-orchestrator", - description: "symlink оркестратора предыдущей установки HY2XS", - createdByInstaller: true + target: ORCHESTRATOR_SYMLINK_PATH, + description: "symlink оркестратора предыдущей установки HY2XS" }, { kind: "file", @@ -149,9 +147,6 @@ export function legacyMarkersFor(config: RuntimeConfig, phase: CleanHostPhase = const seen = new Set(); return markers.filter((marker) => { - if (phase === "install" && marker.createdByInstaller) { - return false; - } const key = `${marker.kind}:${marker.target}`; if (seen.has(key)) { return false; @@ -211,15 +206,20 @@ export function renderLegacyFailure(found: readonly LegacyMarker[]): string { } /** - * Read-only проверка. Вызывается в PHASE 0 (bootstrap preflight) и повторно - * внутри install перед первой записью состояния. + * Read-only проверка. Выполняется ровно дважды за установку и оба раза до + * первой мутации: в PHASE 0 (bootstrap preflight из распакованного пакета) и + * на входе в `install`, перед записью install-state. + * + * Повторять её ПОСЛЕ начала PHASE 1 нельзя: собственный + * /var/lib/hy2xs/install-state.json тут же станет маркером «посторонней + * установки». Именно это и происходило, пока clean-host был зашит внутрь + * preflight, а preflight вызывался ещё раз после installDeps. */ export async function assertCleanHost( config: RuntimeConfig, - phase: CleanHostPhase = "bootstrap", probe: HostProbe = defaultHostProbe ): Promise { - const found = await detectLegacyMarkers(legacyMarkersFor(config, phase), probe); + const found = await detectLegacyMarkers(legacyMarkersFor(config), probe); if (found.length > 0) { throw new Error(renderLegacyFailure(found)); } diff --git a/orchestrator/src/steps/networkEndpoint.ts b/orchestrator/src/steps/networkEndpoint.ts index 594deef..5a31501 100644 --- a/orchestrator/src/steps/networkEndpoint.ts +++ b/orchestrator/src/steps/networkEndpoint.ts @@ -27,7 +27,24 @@ export type EndpointProbe = { 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 = { 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. * + * Список приведён к IANA IPv4 Special-Purpose Address Registry, а не собран по + * памяти. Раньше в нём не было документационных диапазонов, и 203.0.113.5 + * (TEST-NET-3) считался нормальным публичным адресом сервера: функция + * называлась «маршрутизируемый публичный IPv4», а отвечала на более слабый + * вопрос «не приватный ли». + * * Baseline продукта — выделенный сервер с обычным публичным IPv4; NAT и * Hysteria Realms описаны в документации как отдельная топология вне baseline. - * Поэтому адрес из этих диапазонов не является ответом на вопрос «какой у - * сервера публичный IP». */ const NON_PUBLIC_IPV4_RANGES: readonly { cidr: string; description: string }[] = [ { 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: "169.254.0.0/16", description: "link-local" }, { 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: "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: "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 { return IPV4_PATTERN.test(value.trim()); } @@ -137,19 +173,46 @@ function renderMismatch(label: string, host: string, records: readonly string[], * только при непустом локальном множестве, то на сервере за NAT с * 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( label: string, host: string, probe: EndpointProbe ): Promise<{ records: string[]; problems: EndpointProblem[] }> { if (isIpv4Literal(host)) { - if (!isRoutablePublicIpv4(host)) { + const reservedRange = nonPublicIpv4Range(host); + if (reservedRange) { return { records: [host], problems: [ { 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[] = []; try { records = await probe.resolve4(host); - } catch { - return { records: [], problems: [{ kind: "resolution", message: `${label} has no A-record: ${host}` }] }; + } catch (error) { + 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) { - 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: [] }; } diff --git a/orchestrator/src/steps/preflight.ts b/orchestrator/src/steps/preflight.ts index 74a32dc..3fec561 100644 --- a/orchestrator/src/steps/preflight.ts +++ b/orchestrator/src/steps/preflight.ts @@ -5,16 +5,27 @@ import { fail, info } from "../lib/log"; import { runReadOnly } from "../lib/process"; import { assertPlatform } from "../platform/assert"; 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"; -type PreflightOptions = { +export type PreflightOptions = { 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; }; @@ -23,8 +34,11 @@ type PreflightOptions = { * Применяет HY2XS_PUBLIC_ENDPOINT_POLICY к найденным расхождениям. * * Отсутствие A-записи фатально при любой политике: имя без A-записи не - * работает ни в какой топологии, и ослаблять здесь нечего. Политика управляет - * только сравнением с локальными адресами. + * работает ни в какой топологии, и ослаблять здесь нечего. Отказ резолвера + * фатален по другой причине: без его ответа проверить endpoint невозможно + * вовсе, и «продолжить с предупреждением» означало бы объявить проверку + * пройденной, не выполнив её. Политика управляет только сравнением с + * локальными адресами. */ export async function assertPublicEndpoint( config: RuntimeConfig, @@ -35,7 +49,9 @@ export async function assertPublicEndpoint( 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) { 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 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 && context.config.firewallMode !== "off" && context.config.firewallMode !== "external"; @@ -140,9 +167,10 @@ export async function preflight(context: RuntimeContext, options?: PreflightOpti } // Полный clean-host контракт: HY2XS v1 не устанавливается поверх другого - // поколения. Проверка read-only и выполняется до любой мутации. - if (!isReconfigure) { - await assertCleanHost(context.config, options?.cleanHostPhase ?? "install"); + // поколения. Проверка read-only и выполняется только до первой мутации — + // см. комментарий у PreflightOptions.checkCleanHost. + if (!isReconfigure && (options?.checkCleanHost ?? false)) { + await assertCleanHost(context.config); } const ports = new Set([context.config.hysteriaPort, context.config.uiPort]); diff --git a/orchestrator/test/clean-host.test.ts b/orchestrator/test/clean-host.test.ts index f4fed9b..6166cda 100644 --- a/orchestrator/test/clean-host.test.ts +++ b/orchestrator/test/clean-host.test.ts @@ -6,6 +6,11 @@ import { renderLegacyFailure, type HostProbe } from "../src/steps/cleanHost"; +import { + ORCHESTRATOR_INSTALL_DIR, + ORCHESTRATOR_SYMLINK_PATH, + RUNTIME_PACKAGE_DIR +} from "../src/config/profile"; import { baselineConfig } from "./fixtures"; /** @@ -31,23 +36,23 @@ const config = baselineConfig(); describe("clean-host контракт", () => { test("чистый хост проходит", async () => { - await expect(assertCleanHost(config, "bootstrap", probeWith([]))).resolves.toBeUndefined(); + await expect(assertCleanHost(config, probeWith([]))).resolves.toBeUndefined(); }); test("каждый маркер по отдельности останавливает установку", async () => { - const markers = legacyMarkersFor(config, "bootstrap"); + const markers = legacyMarkersFor(config); expect(markers.length).toBeGreaterThan(10); for (const marker of markers) { const probe = probeWith([marker.target]); - await expect(assertCleanHost(config, "bootstrap", probe)).rejects.toThrow( + await expect(assertCleanHost(config, probe)).rejects.toThrow( /предыдущая или посторонняя установка/ ); } }); test("список покрывает состояние, юниты, бинарник и наследие 0.x", () => { - const targets = legacyMarkersFor(config, "bootstrap").map((marker) => marker.target); + const targets = legacyMarkersFor(config).map((marker) => marker.target); for (const expected of [ "/etc/hysteria/post-install.env", @@ -96,7 +101,7 @@ describe("clean-host контракт", () => { "/usr/local/bin/hysteria", "/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) { 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 () => { - for (const target of [ - "/usr/local/lib/hy2xs", - "/usr/local/lib/hy2xs/package", - "/usr/local/bin/hy2xs-orchestrator" - ]) { - const probe = probeWith([target]); - await expect(assertCleanHost(config, "bootstrap", probe)).rejects.toThrow( + test("bootstrap-пути остаются маркерами без всяких исключений", async () => { + for (const target of [ORCHESTRATOR_INSTALL_DIR, RUNTIME_PACKAGE_DIR, ORCHESTRATOR_SYMLINK_PATH]) { + await expect(assertCleanHost(config, probeWith([target]))).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("пути из конфигурации попадают в список, а не только дефолтные", () => { const custom = baselineConfig({ HY2XS_INSTALL_DIR: "/srv/hy2xs-app", 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-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 () => { const found = await detectLegacyMarkers( - legacyMarkersFor(config, "bootstrap"), + legacyMarkersFor(config), probeWith(["/etc/hy2xs/hy2xs.env", "hy2xs-admin.service"]) ); expect(found).toHaveLength(2); diff --git a/orchestrator/test/install-boundary.test.ts b/orchestrator/test/install-boundary.test.ts index 10b961f..f591daa 100644 --- a/orchestrator/test/install-boundary.test.ts +++ b/orchestrator/test/install-boundary.test.ts @@ -16,6 +16,7 @@ type Ownership = Parameters[0]; function ownership(overrides: Partial = {}): Ownership { return { stateWritten: false, + bootstrapTouched: false, depsTouched: false, filesystemTouched: false, uiTouched: false, @@ -112,6 +113,19 @@ describe("классификация отказа установки", () => { 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", () => { expect( classifyFailure(ownership({ stateWritten: true, depsTouched: true }), "preflight_ok") diff --git a/orchestrator/test/install-sequence.test.ts b/orchestrator/test/install-sequence.test.ts new file mode 100644 index 0000000..d8044db --- /dev/null +++ b/orchestrator/test/install-sequence.test.ts @@ -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[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="); + }); +}); diff --git a/orchestrator/test/network-endpoint.test.ts b/orchestrator/test/network-endpoint.test.ts index 93da77e..0bc9c28 100644 --- a/orchestrator/test/network-endpoint.test.ts +++ b/orchestrator/test/network-endpoint.test.ts @@ -3,22 +3,30 @@ import { checkPublicEndpoint, getLocalPublicIpv4s, isRoutablePublicIpv4, + nonPublicIpv4Range, type EndpointProbe } from "../src/steps/networkEndpoint"; import { assertPublicEndpoint } from "../src/steps/preflight"; import { baselineConfig } from "./fixtures"; +/** Коды отказа резолвера, отличные от «записи нет». */ +type DnsFailureCode = "ENODATA" | "ENOTFOUND" | "SERVFAIL" | "ETIMEOUT" | "ECONNREFUSED"; + /** * Проба полностью подменяет и DNS, и список локальных адресов: тест не имеет * права зависеть ни от сети, ни от интерфейсов машины разработчика. */ -function probe(options: { local: string[]; dns?: Record }): EndpointProbe { +function probe(options: { + local: string[]; + dns?: Record; +}): EndpointProbe { return { async resolve4(hostname) { const records = options.dns?.[hostname]; - if (records === undefined || records === "ENODATA") { - const error = new Error(`queryA ENODATA ${hostname}`) as Error & { code?: string }; - error.code = "ENODATA"; + if (records === undefined || typeof records === "string") { + const code: DnsFailureCode = typeof records === "string" ? records : "ENODATA"; + const error = new Error(`queryA ${code} ${hostname}`) as Error & { code?: string }; + error.code = code; throw error; } return records; @@ -33,7 +41,7 @@ const SECOND = "185.10.20.28"; describe("классификация IPv4", () => { 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); } }); @@ -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 уже вне приватного диапазона", () => { expect(isRoutablePublicIpv4("172.32.0.1")).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("неизвестное значение политики отклоняется", () => { expect(() => baselineConfig({ HY2XS_PUBLIC_ENDPOINT_POLICY: "maybe" })).toThrow( /invalid HY2XS_PUBLIC_ENDPOINT_POLICY/ diff --git a/orchestrator/test/redaction.test.ts b/orchestrator/test/redaction.test.ts index a952168..c988fd0 100644 --- a/orchestrator/test/redaction.test.ts +++ b/orchestrator/test/redaction.test.ts @@ -3,6 +3,7 @@ import { REDACTED, isSecretKey, redactEnv, + redactLogText, redactYaml, redactYamlFallback, 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("маркеры секретных имён", () => { test("покрывают типовые секретоподобные имена", () => { for (const key of [ diff --git a/package/docs/README.md b/package/docs/README.md index 60db7c5..8b1667a 100644 --- a/package/docs/README.md +++ b/package/docs/README.md @@ -27,15 +27,20 @@ ## Установка выполняется в две фазы ```text -PHASE 0 — READ ONLY +PHASE 0 — READ ONLY выполняет install.sh проверка прав и checksums пакета clean-host preflight из распакованного архива ↓ ноль изменений на сервере -PHASE 1 — MUTATION - установка orchestrator, раскладка runtime-пакета - install + ↓ exec +PHASE 1 — MUTATION выполняет orchestrator + раскладка orchestrator и runtime-пакета + установка ``` +`install.sh` не изменяет на сервере ничего: он проверяет и передаёт управление. +Всю мутирующую фазу выполняет оркестратор, поэтому при любом отказе известно, +какие именно пути были созданы этой операцией. + HY2XS v1 **не устанавливается поверх** предыдущей установки и не мигрирует её состояние. Если PHASE 0 обнаружит старую установку, установщик завершится с ошибкой и **не изменит на сервере ничего**. diff --git a/package/install.sh b/package/install.sh index 77fae39..9285ad1 100755 --- a/package/install.sh +++ b/package/install.sh @@ -3,23 +3,31 @@ set -eu # Установка HY2XS состоит из двух строго разделённых фаз. # -# PHASE 0 — READ ONLY +# PHASE 0 — READ ONLY (этот файл, целиком) # проверка прав, целостности пакета и чистоты хоста. -# Ни один persistent path не изменяется, включая /usr/local/lib/hy2xs. +# Ни один persistent path не изменяется. # -# PHASE 1 — MUTATION -# раскладка оркестратора и runtime-пакета, затем сама установка. +# PHASE 1 — MUTATION (оркестратор, `hy2xs-orchestrator install`) +# раскладка самого оркестратора, 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)" 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() { printf '[hy2xs-install] %s\n' "$*" @@ -71,8 +79,6 @@ scan_config_argument() { } scan_config_argument "$@" -# Bootstrap preflight запускается из РАСПАКОВАННОГО пакета: установленного -# оркестратора на этом этапе ещё не существует и появиться не должен. log "running clean-host preflight from the unpacked package" if [ -n "$PREFLIGHT_CONFIG" ]; then "$ORCHESTRATOR" preflight-install --package-dir "$PACKAGE_DIR" --config "$PREFLIGHT_CONFIG" \ @@ -85,24 +91,11 @@ fi log "PHASE 0 passed" # ---------------------------------------------------------------- PHASE 1 --- - -log "PHASE 1: applying changes" - -install -d -m 0755 /usr/local/lib/hy2xs -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/" +# +# Всё, что ниже, выполняет оркестратор: он ставит себя в /usr/local/lib/hy2xs, +# раскладывает runtime-пакет и продолжает установку — уже под собственным +# учётом владения и с собственным rollback. log "package directory: $PACKAGE_DIR" -log "starting install-only orchestrator" -exec "$ORCHESTRATOR_INSTALL_PATH" install --package-dir "$RUNTIME_PACKAGE_DIR" "$@" +log "handing the mutating phase over to the orchestrator" +exec "$ORCHESTRATOR" install --package-dir "$PACKAGE_DIR" "$@" diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index 07843a0..bdf5c1c 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -130,7 +130,20 @@ run_fix20_acceptance_subset() { 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 '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" 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 grep -qF '/var/lib/hysteria' orchestrator/src/steps/cleanHost.ts \ || 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" ! grep -q 'keep-hysteria-binary' tools/legacy/purge-v0.sh \ || 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" # Ищется регистрация маршрута (имя в кавычках), а не любое упоминание: # комментарий, объясняющий, почему маршрута нет, должен быть разрешён. + # + # Тесты исключены по той же причине, и это не послабление: router_test.go + # ПЕРЕЧИСЛЯЕТ удалённые имена, потому что проверяет их отсутствие в таблице + # маршрутов. Пока `*_test.go` попадал под скан, приёмка падала на собственном + # регрессионном тесте — то есть добавление теста, закрепляющего удаление + # маршрута, ломало сборку. local dead_route - for dead_route in hysteria2ChangeVersion listRelease updateHysteria2Config importHysteria2Config restartServer uploadCertFile hysteria2AcmePath exportConfig importConfig; do - ! grep -rqF "${dead_route}\"" apps/router apps/controller \ + for dead_route in hysteria2ChangeVersion listRelease updateHysteria2Config importHysteria2Config restartServer uploadCertFile hysteria2AcmePath exportConfig importConfig getConfig; do + ! grep -rqF --include='*.go' --exclude='*_test.go' "${dead_route}\"" apps/router apps/controller \ || fail "acceptance: removed route ${dead_route} came back" ! grep -rqF "${dead_route}\"" apps/frontend/src/api \ || 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" ! grep -q 'purge-v0' "$package_dir/install.sh" \ || 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 } diff --git a/tools/build/lib/verify.sh b/tools/build/lib/verify.sh index 62a9541..897c09e 100644 --- a/tools/build/lib/verify.sh +++ b/tools/build/lib/verify.sh @@ -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.config_schema_version$' || fail "archive missing packaged config schema version" - # Двухфазный контракт установки проверяется структурно: read-only preflight - # обязан стоять раньше первой мутации, иначе ошибочный запуск поверх - # работающего сервера снова начнёт его ломать. - local packaged_installer preflight_line first_mutation_line + # Контракт установки проверяется структурно: install.sh — read-only bootstrap + # целиком, у PHASE 1 ровно один владелец — оркестратор. + # + # Раньше здесь проверялся порядок «preflight раньше первой мутации», и сама + # проверка ТРЕБОВАЛА наличия мутирующей строки в install.sh. Пока shell сам + # раскладывал оркестратор и runtime-пакет, между двумя фазами существовало + # окно: PHASE 0 проходила, install.sh изменял хост, а отказ следующего + # preflight внутри оркестратора объявлялся «на сервере ничего не изменено». + # Владение мутацией нельзя было отследить, потому что мутировали двое. + local packaged_installer mutation_hits packaged_installer="$(tar -xOzf "$archive" hy2xs-install/install.sh)" printf '%s\n' "$packaged_installer" | grep -q 'preflight-install' \ || 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" \ - | grep -nE '^[[:space:]]*(install -d|install -m|ln -sf|rm -rf|cp -a)' | head -n1 | cut -d: -f1)" + # Комментарии отбрасываются: строка, ОБЪЯСНЯЮЩАЯ, почему установщик больше + # не выполняет `install -d`, не является выполнением `install -d`. + 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" - [ -n "$first_mutation_line" ] || fail "packaged install.sh: could not locate the mutation phase" - [ "$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)" + printf '%s\n' "$packaged_installer" | grep -q 'exec .*install --package-dir' \ + || fail "packaged install.sh must hand the whole mutating phase to the orchestrator via exec" local license_text license_text="$(tar -xOzf "$archive" hy2xs-install/LICENSE)" diff --git a/tools/build/lib/versions.sh b/tools/build/lib/versions.sh index 61202a1..aa03a8d 100644 --- a/tools/build/lib/versions.sh +++ b/tools/build/lib/versions.sh @@ -216,10 +216,16 @@ verify_api_namespace_contract() { # Старое пространство имён не имеет права вернуться ни в один компонент. # Историческое имя допустимо только в docs/14-legacy-cleanup.md и в # legacy-маркерах clean-host: там это имя чужого артефакта, а не наше. + # + # Список каталогов покрывает КАЖДЫЙ слой, который может собрать URL: раньше + # в нём не было apps/middleware, apps/cmd, apps/dao и apps/util, то есть + # ровно тех мест, где живут HTTP-логгер, точка входа сервера и helpers. local legacy_hits legacy_hits="$(grep -rlF '/hui' \ - apps/model apps/router apps/controller apps/service apps/frontend/src \ - orchestrator/src orchestrator/test package/templates tools/test 2>/dev/null || true)" + apps/model apps/router apps/controller apps/service apps/middleware \ + 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" ] \ || fail "versions contract: legacy /hui namespace came back in: $legacy_hits" }