diff --git a/CHANGELOG.md b/CHANGELOG.md index 16e928a..81c0c15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ ## [Unreleased] +По результатам runtime-проверки RC3 login smoke приведён к реальному HTTP +контуру панели: positive и negative пробы используют один helper с wire-полем +`pass`, явным `HY2XS-Installer/1.0` User-Agent и строгим разбором JSON envelope. +Тесты теперь проходят через scanner middleware и отдельно доказывают отказ +стандартного curl UA, успешный bootstrap-вход и `invalid_credentials` для +случайного пароля. Rollback cleanup проверяет `ActiveState` после +`reset-failed`: уже выгруженный юнит больше не создаёт ложное предупреждение о +ручном восстановлении, а недоступный systemd и сохранившийся `failed` остаются +ошибкой. + Diagnostics больше не пишет root-артефакты в `/var/log/hy2xs`, которым владеет `hy2xs-admin`. Staging и архив перенесены в проверяемый через `lstat` каталог `/var/lib/hy2xs/diagnostics` (`root:root 0700`), имена создаются эксклюзивно, diff --git a/apps/controller/auth_test.go b/apps/controller/auth_test.go index 23b3847..0aa736f 100644 --- a/apps/controller/auth_test.go +++ b/apps/controller/auth_test.go @@ -18,6 +18,7 @@ import ( "github.com/gin-gonic/gin" "hy2xs-admin/credential" "hy2xs-admin/dao" + "hy2xs-admin/middleware" "hy2xs-admin/model/constant" "hy2xs-admin/model/dto" "hy2xs-admin/model/entity" @@ -460,6 +461,96 @@ func postLogin(t *testing.T, body any) (int, apiResult) { return postLoginRaw(t, payload, false) } +// postLoginThroughFilter воспроизводит реальную внешнюю дверь login API: +// scanner filter выполняется раньше DTO и контроллера. Именно этой связки не +// было в тестах до RC3, поэтому backend и smoke были зелёными по отдельности, +// а настоящий installer получал 403 на стандартный curl User-Agent. +func postLoginThroughFilter(t *testing.T, body any, userAgent string) (int, apiResult) { + t.Helper() + payload, err := json.Marshal(body) + if err != nil { + t.Fatalf("не удалось собрать тело запроса: %v", err) + } + + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.Use(gin.Recovery(), middleware.FilterHandler()) + engine.POST("/api/auth/login", Login) + + request := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(payload)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("User-Agent", userAgent) + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, request) + + var result apiResult + if recorder.Body.Len() > 0 { + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { + t.Fatalf("middleware вернул не JSON: %s", recorder.Body.String()) + } + } + return recorder.Code, result +} + +// Полный wire-path RC3: стандартный UA curl действительно блокируется, а +// выделенный UA установщика проходит тот же middleware до authentication logic. +func TestLoginWirePathRespectsScannerFilterAndInstallerUserAgent(t *testing.T) { + newAuthTestDB(t, "hy2xsadmin", "bootstrap-password") + + status, blocked := postLoginThroughFilter(t, map[string]any{ + "username": "hy2xsadmin", + "pass": "bootstrap-password", + }, "curl/8.10.1") + if status != http.StatusForbidden || blocked.Code != http.StatusForbidden { + t.Fatalf("scanner-like curl не заблокирован: HTTP %d, ответ %+v", status, blocked) + } + + status, accepted := postLoginThroughFilter(t, map[string]any{ + "username": "hy2xsadmin", + "pass": "bootstrap-password", + }, "HY2XS-Installer/1.0") + if status != http.StatusOK || accepted.Code != constant.CodeSuccess { + t.Fatalf("UA установщика не дошёл до успешного входа: HTTP %d, ответ %+v", status, accepted) + } + var issued struct { + AccessToken string `json:"accessToken"` + } + if err := json.Unmarshal(accepted.Data, &issued); err != nil || issued.AccessToken == "" { + t.Fatalf("успешная wire-проба не выдала токен: %s", string(accepted.Data)) + } + + _, rejected := postLoginThroughFilter(t, map[string]any{ + "username": "hy2xsadmin", + "pass": "wrong-password", + }, "HY2XS-Installer/1.0") + var invalidCredentials bool + for _, reason := range rejected.Errors { + invalidCredentials = invalidCredentials || reason.Code == constant.ErrCodeInvalidCredentials + } + if rejected.Code != constant.CodeSysError || !invalidCredentials { + t.Fatalf("negative wire-проба не дошла до auth logic: %+v", rejected) + } +} + +// `password` не является скрытым alias: иначе orchestrator и frontend могли бы +// незаметно разойтись по двум разным HTTP-контрактам. +func TestLoginWirePathRejectsPasswordAlias(t *testing.T) { + newAuthTestDB(t, "hy2xsadmin", "bootstrap-password") + + _, result := postLoginThroughFilter(t, map[string]any{ + "username": "hy2xsadmin", + "password": "bootstrap-password", + }, "HY2XS-Installer/1.0") + + var passRequired bool + for _, reason := range result.Errors { + passRequired = passRequired || (reason.Field == "pass" && reason.Code == constant.ErrCodeRequired) + } + if result.Code != constant.CodeInvalidError || !passRequired { + t.Fatalf("alias password не отклонён как отсутствие wire-поля pass: %+v", result) + } +} + // Регрессия RC2 целиком: вход bootstrap-учёткой обязан выдать токен. func TestLoginEndpointIssuesTokenForValidCredentials(t *testing.T) { newAuthTestDB(t, "hy2xsadmin", "bootstrap-password") diff --git a/docs/acceptance/2026-09-07-v1.0.0-rc3-runtime-findings.md b/docs/acceptance/2026-09-07-v1.0.0-rc3-runtime-findings.md new file mode 100644 index 0000000..9570749 --- /dev/null +++ b/docs/acceptance/2026-09-07-v1.0.0-rc3-runtime-findings.md @@ -0,0 +1,50 @@ +# Результаты runtime-проверки v1.0.0-rc3 + +Дата проверки: 2026-09-07. + +RC3 проверялся на чистом Debian после успешной проверки целостности +опубликованного артефакта. Hysteria, ACME, firewall, диагностика и основной +authentication backend отработали штатно. Кандидат отклонён из-за несовместимости +installer smoke с HTTP middleware и ложного отчёта rollback cleanup. + +## Сводка + +| ID | Наблюдение | Итог | +| --------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| RC3-LOGIN-01 | стандартный `curl/` получает HTTP 403 от scanner middleware | исправлено для RC4 явным UA установщика | +| RC3-LOGIN-02 | запрос с полем `password` не проходит DTO: требуется `pass` | ожидаемое поведение wire-контракта, добавлена регрессия | +| RC3-LOGIN-03 | `{username, pass}` с неверным паролем доходит до auth logic и возвращает `invalid_credentials` | backend исправен | +| RC3-LOGIN-04 | positive и negative smoke имели две независимые curl-команды | исправлено общим request helper'ом | +| RC3-ROLLBACK-01 | `reset-failed` для уже выгруженного юнита создавал ложное `manual recovery may be required` | исправлено проверкой postcondition | + +## Что доказала диагностика login + +Последовательность запросов отделила middleware, DTO и authentication logic: + +| Запрос | Результат | +| ---------------------------------------- | -------------------------------------------------------- | +| стандартный curl UA + `password` | HTTP 403 до разбора тела | +| допустимый UA + `password` | validation envelope: требуется поле `pass` | +| допустимый UA + `pass` и неверный пароль | operation envelope с `errors[].code=invalid_credentials` | + +Следовательно, паника старого `validateStr` не вернулась. Дефект RC3 находился +между smoke-клиентом и scanner middleware, а не в проверке пароля. + +## Контракт RC4 + +- positive и negative login используют один helper; +- helper отправляет JSON `{username, pass}` и `User-Agent: HY2XS-Installer/1.0`; +- success требует `code=20000` и непустой `data.accessToken`; +- rejection требует `code=50000`, причину `invalid_credentials` внутри + `errors[]` и отсутствие выданного токена; +- envelope разбирается как JSON, поэтому совпадение текста внутри `message` не + считается доказательством; +- стандартный curl UA продолжает получать 403: scanner middleware в RC4 не + удаляется; +- уже выгруженный systemd-юнит считается успешным конечным состоянием cleanup + только после наблюдения `LoadState` и `ActiveState`; +- недоступный systemd или сохранившийся `ActiveState=failed` остаются настоящим + rollback failure. + +Пересмотр необходимости scanner middleware для loopback-only панели остаётся +отдельной задачей после v1.0.0 и не расширяет change surface RC4. diff --git a/docs/acceptance/README.md b/docs/acceptance/README.md index 070f0aa..06928a3 100644 --- a/docs/acceptance/README.md +++ b/docs/acceptance/README.md @@ -26,6 +26,7 @@ | --- | --- | --- | --- | --- | | 2026-09-01 | `1.0.0-rc1` | `a1f0db22` | build + host acceptance, Debian 13 | [RC ACCEPTED WITH RELEASE-REQUIRED UX FIXES](2026-09-01-v1.0.0-rc1-host-acceptance.md) | | 2026-09-04 | `1.0.0-rc2` | `82e5ca40` | clean install + runtime, Debian 13 | [RUNTIME REJECTED — вход в панель недоступен](2026-09-04-v1.0.0-rc2-runtime-findings.md) | +| 2026-09-07 | `1.0.0-rc3` | `bf10810c` | clean install + failure rollback, Debian 13 | [RUNTIME REJECTED — installer smoke блокируется middleware](2026-09-07-v1.0.0-rc3-runtime-findings.md) | ## Открытые дефекты приёмки @@ -33,6 +34,7 @@ | --- | --- | | 2026-09-01, `1.0.0-rc1` | [UX-01…UX-05 и найденное сверх отчёта](2026-09-01-v1.0.0-rc1-ux-findings.md) | | 2026-09-04, `1.0.0-rc2` | [LOGIN-01…LOGIN-08](2026-09-04-v1.0.0-rc2-runtime-findings.md) — все закрыты | +| 2026-09-07, `1.0.0-rc3` | [RC3-LOGIN-01…04, RC3-ROLLBACK-01](2026-09-07-v1.0.0-rc3-runtime-findings.md) — исправления подготовлены для RC4 | ## Разборы кода между прогонами diff --git a/docs/operations/13-production-runbook.md b/docs/operations/13-production-runbook.md index 2314b25..c5aecfb 100644 --- a/docs/operations/13-production-runbook.md +++ b/docs/operations/13-production-runbook.md @@ -74,18 +74,19 @@ sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env быть отменён: ни неудачной записью состояния в `/var/lib/hy2xs`, ни отказом одной из своих стадий. Поэтому в журнале нужно читать две разные вещи: -| Строка в журнале | Что она означает | -| --- | --- | -| `failed to persist failure state, continuing with the mandatory rollback` | маркер не обновился (обычно заполненный диск), но восстановление выполнено; после освобождения места запустить `doctor` | -| `rollback stage "<имя>" failed, continuing with the remaining stages` | конкретная половина восстановления не отработала; остальные выполнены | -| `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки | -| `rollback completed: N stage(s) succeeded` | восстановление отработало полностью | -| `manual recovery data preserved at /run/hy2xs/rollback/` | firewall восстановлен не полностью; прежние `nftables.conf` и `hy2xs.nft` лежат по этому пути | -| `firewall rollback guard armed: … fires in 45s (timer accuracy 1s)` | guard взведён; с этого момента операция обязана снять его до фиксации успеха | -| `firewall rollback guard disarmed and proven inactive` | guard снят, и это подтверждено состоянием юнитов и отсутствием маркера срабатывания | -| `automatic firewall rollback has already fired` | guard успел сработать; сервер работает на **прежнем** firewall, операция обязана завершиться отказом | -| `firewall rollback guard is still in state "…"` | остановить guard не удалось; фиксация успеха запрещена, разбирайтесь с systemd | -| `unable to verify firewall rollback guard state; systemd query failed` | состояние guard'а недоказуемо; операция не начата, чинить нужно systemd, а не ждать | +| Строка в журнале | Что она означает | +| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `failed to persist failure state, continuing with the mandatory rollback` | маркер не обновился (обычно заполненный диск), но восстановление выполнено; после освобождения места запустить `doctor` | +| `rollback stage "<имя>" failed, continuing with the remaining stages` | конкретная половина восстановления не отработала; остальные выполнены | +| `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки | +| `rollback completed: N stage(s) succeeded` | восстановление отработало полностью | +| `systemctl reset-failed exited with …, but the cleanup postcondition is satisfied` | юнит уже выгружен или не находится в `failed`; это допустимый идемпотентный cleanup, ручное восстановление не требуется | +| `manual recovery data preserved at /run/hy2xs/rollback/` | firewall восстановлен не полностью; прежние `nftables.conf` и `hy2xs.nft` лежат по этому пути | +| `firewall rollback guard armed: … fires in 45s (timer accuracy 1s)` | guard взведён; с этого момента операция обязана снять его до фиксации успеха | +| `firewall rollback guard disarmed and proven inactive` | guard снят, и это подтверждено состоянием юнитов и отсутствием маркера срабатывания | +| `automatic firewall rollback has already fired` | guard успел сработать; сервер работает на **прежнем** firewall, операция обязана завершиться отказом | +| `firewall rollback guard is still in state "…"` | остановить guard не удалось; фиксация успеха запрещена, разбирайтесь с systemd | +| `unable to verify firewall rollback guard state; systemd query failed` | состояние guard'а недоказуемо; операция не начата, чинить нужно systemd, а не ждать | Отдельно про сработавший guard. Окно 45 секунд намеренно короче худшего случая smoke и не обязано его покрывать: доказательством служит не время, а маркер @@ -336,6 +337,7 @@ hy2xs-orchestrator redact-config --config /etc/hysteria/config.yaml --out /root/ ``` Инварианты: + - команда не выводит исходные секреты в stdout; - требуется выбрать ровно один режим: `--in-place` или `--out `; - `--format auto` пытается определить формат по имени файла, при неоднозначности используйте `--format env|yaml`; @@ -357,4 +359,3 @@ hy2xs-orchestrator redact-config --config /etc/hysteria/config.yaml --out /root/ При отказе **до** начала применения изменений (`fatal_pre_apply`) бандл не собирается: его сбор сам создал бы каталог в `/var/lib/hy2xs/diagnostics` на сервере, который мы обещали не трогать. - diff --git a/docs/runtime/08-orchestrator-spec.md b/docs/runtime/08-orchestrator-spec.md index bbe27df..12b9765 100644 --- a/docs/runtime/08-orchestrator-spec.md +++ b/docs/runtime/08-orchestrator-spec.md @@ -7,11 +7,13 @@ ## Технологический стек оркестратора Оркестратор фиксируется как: + - **Bun + TypeScript** по исходникам - локальная сборка builder layer'ом - поставка на target в виде **готового install-артефакта** Это означает: + - на target нет `npm`, `pnpm`, `yarn` или `bun install` - на target нет transpile/build step - shell на target допустим только как thin wrapper entrypoint @@ -19,6 +21,7 @@ ## Главная роль оркестратора Оркестратор работает **только на target machine** и умеет: + - выполнить read-only проверку чистоты хоста (`preflight-install`) - выполнить первичную установку (`install`) - выполнить явную реконфигурацию (`reconfigure --dry-run|--apply`) @@ -42,6 +45,7 @@ ## Предусловия Оркестратор рассчитан только на: + - чистый Debian 13 - root/sudo install context - один сервер @@ -127,10 +131,10 @@ clean-host. Ко второму вызову на диске лежал собс Guard умеет останавливать только то, что через него проходит. Поэтому универсального раннера в `lib/process.ts` нет — есть два явных набора: -| Набор | Guard | Назначение | -| --- | --- | --- | -| `runReadOnly`, `runReadOnlySecret` | не трогает | наблюдение за системой: `ss`, `systemctl is-active`, `curl`, `getent` | -| `runMutating`, `runMutatingVisible`, `runMutatingHidden`, `runMutatingRaw` | спрашивает разрешение | всё, что может изменить хост | +| Набор | Guard | Назначение | +| -------------------------------------------------------------------------- | --------------------- | --------------------------------------------------------------------- | +| `runReadOnly`, `runReadOnlySecret` | не трогает | наблюдение за системой: `ss`, `systemctl is-active`, `curl`, `getent` | +| `runMutating`, `runMutatingVisible`, `runMutatingHidden`, `runMutatingRaw` | спрашивает разрешение | всё, что может изменить хост | `*Secret`-варианты не печатают команду в текст ошибки: их аргументы несут machine token или пароль пира, а сообщение уходит в логи и диагностику. @@ -277,10 +281,10 @@ preflight-ошибка со словом `nftables` приводила к отк Второй инвариант — **стадии отката независимы**: -| Команда | Стадии | -| --- | --- | -| `install` | firewall → stop services → disable services → reset failed services | -| `reconfigure` | firewall → restore configuration | +| Команда | Стадии | +| ------------- | --------------------------------------------------------------------------------------------------------- | +| `install` | firewall → stop services → disable services → reset failed `hysteria-server` → reset failed `hy2xs-admin` | +| `reconfigure` | firewall → restore configuration | Каждая стадия — это `systemctl`, `cp`, `rm -rf` или `nft`, то есть каждая умеет упасть сама. Пока они стояли цепочкой `await`, отказ первой отменял все @@ -294,6 +298,14 @@ preflight-ошибка со словом `nftables` приводила к отк ошибка операции: проблема внутри отката — это дополнительная информация о том, что осталось не восстановленным, а не замена диагноза. +`reset-failed` для каждого сервиса является отдельной стадией и завершается +проверкой `LoadState`/`ActiveState`. Ненулевой код команды допустим, если юнит +уже выгружен (`not-found` + `inactive`): failed-состояния у него больше нет, а +значит cleanup завершён. Текст `Unit … not loaded` намеренно не разбирается — он +зависит от версии и локали systemd. Ошибка чтения состояния или сохранившийся +`ActiveState=failed` остаются настоящим отказом и попадают в manual-recovery +сводку. + Команды внутри стадий **не глушат собственные ошибки**. Это правило обратно тому, что действовало раньше. Пока непрерывность держалась на `|| true` в каждой команде, стадия физически не могла сообщить, что восстановление не выполнилось: @@ -368,7 +380,11 @@ reconfigure B → создание копии упало, ошибка скры "version": 1, "opId": "2026-08-30T10-00-00.000Z", "entries": [ - { "path": "/etc/hysteria/config.yaml", "present": true, "stored": "etc_hysteria_config.yaml" }, + { + "path": "/etc/hysteria/config.yaml", + "present": true, + "stored": "etc_hysteria_config.yaml" + }, { "path": "/etc/nftables.d/hy2xs.nft", "present": false, "stored": null } ] } @@ -423,11 +439,11 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа Строгость управляется `HY2XS_PUBLIC_ENDPOINT_POLICY`: -| Значение | Поведение | -| --- | --- | -| `strict` (по умолчанию) | расхождение останавливает операцию | -| `warn` | печатается предупреждение, операция продолжается | -| `off` | сравнение не выполняется | +| Значение | Поведение | +| ----------------------- | ------------------------------------------------ | +| `strict` (по умолчанию) | расхождение останавливает операцию | +| `warn` | печатается предупреждение, операция продолжается | +| `off` | сравнение не выполняется | Ослабление предназначено для топологий вне baseline (NAT, floating IP, anycast). Отсутствие A-записи остаётся фатальным при любом значении: имя без A-записи не @@ -436,6 +452,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа ## Что приходит на target На target должен попадать уже готовый package, содержащий: + - thin install entrypoint - compiled orchestrator artifact - bundled HY2XS admin @@ -447,6 +464,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа ## Логическая модульность Даже если на target приезжает один собранный артефакт, внутри исходников оркестратор должен быть разложен по шагам: + - preflight - deps - filesystem @@ -474,17 +492,20 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа ## Модель поставки Рекомендуемая baseline-модель: + - исходники оркестратора хранятся в `orchestrator/` - builder выполняет локальную сборку через Bun - в install package кладётся готовый артефакт, который запускается thin wrapper'ом Например: + - `package/install.sh` — проверка контекста и вызов оркестратора - `package/orchestrator/hy2xs-orchestrator` — собранный артефакт ## Логирование и коды возврата Оркестратор должен: + - печатать понятные step-based сообщения - завершаться ненулевым кодом при ошибке - не скрывать первичный источник падения @@ -500,6 +521,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа ## CLI baseline Команды: + - `preflight-install --package-dir [--config ]` - `install --package-dir [--config ]` - `reconfigure --package-dir --config /etc/hy2xs/hy2xs.env --dry-run` @@ -513,6 +535,7 @@ PHASE 0 ничего не меняет. `--allow-partial-state` допустим только для `repair`. Инварианты: + - только IPv4 bind/listen; - TLS modes: `acme | file | self_signed_dev`; - `trafficStats.secret` отдельный от `JWT_SECRET`; @@ -645,10 +668,10 @@ EnvironmentFile дополнительно запрещает U+FEFF. Реали Поэтому smoke выполняет **настоящий вход** на `POST /api/auth/login`: -| Проба | Когда | Что требуется | -| --- | --- | --- | -| настоящий логин + СЛУЧАЙНЫЙ пароль | всегда | `code: 50000`, причина `invalid_credentials`, `accessToken` отсутствует | -| bootstrap-учётные данные из `bootstrap-admin.secret` | только `install` | `code: 20000` и непустой `accessToken` | +| Проба | Когда | Что требуется | +| ---------------------------------------------------- | ---------------- | ----------------------------------------------------------------------- | +| настоящий логин + СЛУЧАЙНЫЙ пароль | всегда | `code: 50000`, причина `invalid_credentials`, `accessToken` отсутствует | +| bootstrap-учётные данные из `bootstrap-admin.secret` | только `install` | `code: 20000` и непустой `accessToken` | Детали, которые здесь существенны: @@ -661,6 +684,16 @@ EnvironmentFile дополнительно запрещает U+FEFF. Реали `50000` (отказ операции, а не успех и не отказ валидации, который означал бы негодный запрос), доменная причина `invalid_credentials` и ОТСУТСТВИЕ `accessToken`; +- **конверт разбирается как JSON**, а не ищется регулярным выражением в сыром + тексте. Подстрока `invalid_credentials` внутри `message` или сломанный JSON не + имеют права превратить неизвестный ответ в успешную проверку; +- **обе пробы используют один request helper.** Wire-поле называется `pass`, а + не `password`; `Content-Type`, User-Agent и настройки curl не дублируются и не + могут разойтись между positive и negative ветками; +- **smoke отправляет явный `HY2XS-Installer/1.0` User-Agent.** Стандартный + `curl/` отклоняется действующим scanner middleware раньше DTO. UA + установщика называется своим именем, не имитирует браузер и при этом проходит + существующий фильтр; - **пароль отрицательной пробы генерируется**, а не записан литералом. Записанное в исходнике значение теоретически может оказаться настоящим паролем — и тогда проверка «неверные данные отвергаются» отчиталась бы об успешном входе. На @@ -672,10 +705,10 @@ EnvironmentFile дополнительно запрещает U+FEFF. Реали объявила бы рабочую установку сломанной; - **токен требуется отдельно.** `code: 20000` без `accessToken` означал бы панель, которая пускает и не выдаёт сессию; -- **тело собирается `JSON.stringify`**, а не интерполяцией в строку: пароль +- **тело общего helper'а собирается `JSON.stringify`**, а не интерполяцией в строку: пароль задаёт оператор, и кавычка в нём сломала бы сам запрос, а не панель — проверка объявила бы рабочую установку сломанной; -- **обе команды идут через `runReadOnlySecret`**: он не кладёт команду в текст +- **общий helper идёт через `runReadOnlySecret`**: он не кладёт команду в текст ошибки, а команда несёт пароль администратора. Наружу отдаётся только код ответа: тело успешного входа содержит токен доступа, а текст ошибки уезжает в журнал установки и в diagnostics-бандл; @@ -712,6 +745,7 @@ service-writable `HY2XS_LOG_DIR`. Родитель проверяется чер дерево. Редактируются: + - поля с секретоподобным именем (`password`, `secret`, `token`, `apiKey`, `privateKey`, `authorization`, `cookie`, `bearer`, `signature`, …); - карты, где секретны все значения (`auth.userpass`, `acme.dns.config`); diff --git a/docs/testing/11-3-target-and-runtime.md b/docs/testing/11-3-target-and-runtime.md index 37727c9..cc3c75a 100644 --- a/docs/testing/11-3-target-and-runtime.md +++ b/docs/testing/11-3-target-and-runtime.md @@ -5,6 +5,7 @@ ## B. Target install tests ### На чистом Debian 13 проверяем + 1. пакет запускается без ручной сборки на сервере 2. Hysteria2 скачивается с official upstream 3. bundled HY2XS admin раскладывается локально из пакета @@ -55,8 +56,8 @@ 35. **оператор входит в панель**: `POST /api/auth/login` с bootstrap-учётными данными из `/etc/hy2xs/bootstrap-admin.secret` отвечает `code: 20000` и непустым `accessToken`. Заведомо неверные учётные данные (со СЛУЧАЙНЫМ паролем, а не с литералом) дают HTTP 200 с конвертом отказа: `code: 50000`, причина `invalid_credentials` и отсутствие `accessToken` 36. пароль предельной длины, назначенный формой смены пароля, принимается формой входа: границы обеих форм совпадают с серверными. Границ **две** — 6-64 символа Unicode и не более 72 байт в UTF-8 (предел bcrypt): пароль из 36 кириллических букв (72 байта) принимается, из 37 (74 байта) — отвергается конвертом валидации, а не системной ошибкой 37. `HY2XS_ADMIN_INITIAL_PASSWORD` с пробелом по краям доезжает до учётной записи неизменным: значение записано в `hy2xs.env` в двойных кавычках, и вход выполняется ровно им, а не обрезанным -37a. `HY2XS_ADMIN_INITIAL_PASSWORD` со значением вне документированного домена systemd (`U+FEFF`, `U+FDD0`, `U+FFFF`) или файл с байтами `0xFF`, оборванной `0xC3`, `ED A0 80` **роняет `preflight-install`** — отказ приходит до первой мутации хоста; сервер остаётся нетронутым -37b. API входа и импорта отвергает повреждённый UTF-8 и непарные UTF-16 surrogate escape до `encoding/json`; настоящий U+FFFD работает как обычный символ + 37a. `HY2XS_ADMIN_INITIAL_PASSWORD` со значением вне документированного домена systemd (`U+FEFF`, `U+FDD0`, `U+FFFF`) или файл с байтами `0xFF`, оборванной `0xC3`, `ED A0 80` **роняет `preflight-install`** — отказ приходит до первой мутации хоста; сервер остаётся нетронутым + 37b. API входа и импорта отвергает повреждённый UTF-8 и непарные UTF-16 surrogate escape до `encoding/json`; настоящий U+FFFD работает как обычный символ 38. `last_login_at` администратора обновляется после успешного входа и не меняется после неудачной попытки ## C0. Панель обязана впускать, а не слушать порт @@ -93,6 +94,7 @@ BOOTSTRAP_PASS="$(read_bootstrap_field ADMIN_INITIAL_PASSWORD)" # Положительная проба: конверт успеха и выданный токен. curl -sS --max-time 5 -X POST \ + --user-agent 'HY2XS-Installer/1.0' \ -H 'Content-Type: application/json' \ --data "$(jq -nc --arg u "$BOOTSTRAP_USER" --arg p "$BOOTSTRAP_PASS" '{username:$u,pass:$p}')" \ http://127.0.0.1:8080/api/auth/login | jq '.code, (.data.accessToken | length)' @@ -100,6 +102,7 @@ curl -sS --max-time 5 -X POST \ # Отрицательная проба: пароль СЛУЧАЙНЫЙ, а проверяется конверт отказа целиком. # HTTP 200 сам по себе ничего не доказывает — админка отвечает 200 и на успех. curl -sS --max-time 5 -X POST \ + --user-agent 'HY2XS-Installer/1.0' \ -H 'Content-Type: application/json' \ --data "$(jq -nc --arg u "$BOOTSTRAP_USER" --arg p "$(head -c 18 /dev/urandom | base64)" '{username:$u,pass:$p}')" \ http://127.0.0.1:8080/api/auth/login \ @@ -110,7 +113,9 @@ unset BOOTSTRAP_PASS ``` Ожидается `20000` и ненулевая длина токена. Сам токен не печатается: это -действующая сессия администратора. +действующая сессия администратора. Явный User-Agent обязателен: стандартный +`curl/` осознанно отклоняется scanner middleware с HTTP 403 ещё до +разбора JSON. Значение совпадает с production smoke и не маскируется под браузер. ## C1. Семантический smoke конфига diff --git a/orchestrator/src/commands/install.ts b/orchestrator/src/commands/install.ts index 2d7209f..5d7ec4f 100644 --- a/orchestrator/src/commands/install.ts +++ b/orchestrator/src/commands/install.ts @@ -1,17 +1,22 @@ import type { InstallContext, InstallOptions } from "../types/context"; import { fileExists, readText, writeTextAtomic } from "../lib/fs"; import { runMutatingVisible } from "../lib/process"; +import { resetFailedUnit } from "../lib/systemd"; import { info, setOperationContext, step, stepDone } from "../lib/log"; import { readPackageValue } from "../lib/packageMeta"; import { REPAIR_HINT, buildInstallStateRecord } from "../lib/installState"; import { persistInstallState } from "../lib/installStateWriter"; -import { persistFailureState, runRollbackStages, type RollbackStage } from "../lib/rollback"; +import { + persistFailureState, + runRollbackStages, + type RollbackStage, +} from "../lib/rollback"; import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env"; import { ORCHESTRATOR_INSTALL_DIR, ORCHESTRATOR_INSTALL_PATH, ORCHESTRATOR_SYMLINK_PATH, - RUNTIME_PACKAGE_DIR + RUNTIME_PACKAGE_DIR, } from "../config/profile"; import { preflight } from "../steps/preflight"; import { bootstrapRuntime } from "../steps/bootstrap"; @@ -27,7 +32,7 @@ import { cleanupFirewallRollback, disarmFirewallRollback, operationKeyFor, - rollbackFirewallNow + rollbackFirewallNow, } from "../steps/firewall"; import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env"; import { smoke } from "../steps/smoke"; @@ -125,7 +130,7 @@ function newOwnership(): OperationOwnership { firewallTouched: false, postInstallTouched: false, bootstrapSecretTouched: false, - servicesStarted: false + servicesStarted: false, }; } @@ -146,14 +151,14 @@ function installOwnedPaths(context: InstallContext): string[] { ORCHESTRATOR_INSTALL_DIR, ORCHESTRATOR_INSTALL_PATH, ORCHESTRATOR_SYMLINK_PATH, - RUNTIME_PACKAGE_DIR + RUNTIME_PACKAGE_DIR, ]; } async function writeInstallState( context: InstallContext, phase: InstallPhase, - lastError: string + lastError: string, ): Promise { const record = buildInstallStateRecord({ productVersion: context.packageVersion, @@ -168,7 +173,7 @@ async function writeInstallState( installed: phase === "installed", ownedPaths: installOwnedPaths(context), lastError, - repairHint: phase === "installed" ? undefined : REPAIR_HINT + repairHint: phase === "installed" ? undefined : REPAIR_HINT, }); await persistInstallState(record); @@ -178,7 +183,7 @@ async function advanceInstallState( context: InstallContext, ownership: OperationOwnership, phase: InstallPhase, - lastError = "" + lastError = "", ): Promise { // Флаг взводится ПЕРЕД записью, а не после неё: см. комментарий к // stateTouched. Частично выполненная запись маркера — это уже изменение @@ -211,7 +216,7 @@ async function advanceInstallState( export function classifyFailure( ownership: OperationOwnership, phase: InstallPhase, - error?: unknown + error?: unknown, ): FailureKind { if (error instanceof FirewallGuardFiredError) { return "firewall_guard_fired"; @@ -256,10 +261,12 @@ export function classifyFailure( async function rollbackFailedInstall( context: InstallContext, ownership: OperationOwnership, - failureKind: FailureKind + failureKind: FailureKind, ): Promise { if (failureKind === "fatal_pre_apply") { - info("pre-apply failure: nothing was applied, system rollback is not required"); + info( + "pre-apply failure: nothing was applied, system rollback is not required", + ); return; } @@ -275,7 +282,7 @@ async function rollbackFailedInstall( name: "firewall", run: async () => { await rollbackFirewallNow(context); - } + }, }); } @@ -295,23 +302,31 @@ async function rollbackFailedInstall( name: "stop services", run: async () => { await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin`; - } + }, }, { name: "disable services", run: async () => { await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin`; - } + }, }, { - name: "reset failed services", + name: "reset failed hysteria-server", run: async () => { - await runMutatingVisible`systemctl reset-failed hysteria-server hy2xs-admin`; - } - } + await resetFailedUnit("hysteria-server"); + }, + }, + { + name: "reset failed hy2xs-admin", + run: async () => { + await resetFailedUnit("hy2xs-admin"); + }, + }, ); } else { - info("rollback: systemd units were not deployed by this operation, leaving services untouched"); + info( + "rollback: systemd units were not deployed by this operation, leaving services untouched", + ); } await runRollbackStages(stages); @@ -319,11 +334,15 @@ async function rollbackFailedInstall( export async function install(options: InstallOptions): Promise { setOperationContext(`install-${Date.now().toString(36)}`); - const hasSourceConfig = options.sourceConfigPath ? await fileExists(options.sourceConfigPath) : false; + const hasSourceConfig = options.sourceConfigPath + ? await fileExists(options.sourceConfigPath) + : false; if (options.sourceConfigPath && !hasSourceConfig) { throw new Error(`config source not found: ${options.sourceConfigPath}`); } - const sourceConfigPath = hasSourceConfig ? options.sourceConfigPath : `${options.packageDir}/config/hy2xs.env`; + const sourceConfigPath = hasSourceConfig + ? options.sourceConfigPath + : `${options.packageDir}/config/hy2xs.env`; const sourceConfigRaw = await readText(sourceConfigPath); const config = parseRuntimeEnv(sourceConfigRaw); @@ -331,18 +350,48 @@ export async function install(options: InstallOptions): Promise { mode: "install", options, config, - packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"), - packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"), + packageVersion: await readPackageValue( + options.packageDir, + "package.version", + "unknown", + ), + packageBuildId: await readPackageValue( + options.packageDir, + "package.build_id", + "unknown", + ), installDate: new Date().toISOString(), hysteriaVersion: "unknown", - hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown"), - hysteriaTargetVersion: await readPackageValue(options.packageDir, "hysteria.version", ""), - hysteriaArtifactUrl: await readPackageValue(options.packageDir, "hysteria.url", ""), - hysteriaArtifactSha256: await readPackageValue(options.packageDir, "hysteria.sha256", "") + hysteriaResolution: await readPackageValue( + options.packageDir, + "hysteria.resolution", + "unknown", + ), + hysteriaTargetVersion: await readPackageValue( + options.packageDir, + "hysteria.version", + "", + ), + hysteriaArtifactUrl: await readPackageValue( + options.packageDir, + "hysteria.url", + "", + ), + hysteriaArtifactSha256: await readPackageValue( + options.packageDir, + "hysteria.sha256", + "", + ), }; - if (!context.hysteriaTargetVersion || !context.hysteriaArtifactUrl || !context.hysteriaArtifactSha256) { - throw new Error("missing Hysteria lock metadata in package: hysteria.version/hysteria.url/hysteria.sha256"); + if ( + !context.hysteriaTargetVersion || + !context.hysteriaArtifactUrl || + !context.hysteriaArtifactSha256 + ) { + throw new Error( + "missing Hysteria lock metadata in package: hysteria.version/hysteria.url/hysteria.sha256", + ); } const ownership = newOwnership(); @@ -356,7 +405,10 @@ export async function install(options: InstallOptions): Promise { // Всё, что дальше, уже создаёт наши собственные пути, и повторная проверка // опознала бы их как чужую установку. step("preflight"); - await preflight(context, { requireCapabilities: false, checkCleanHost: true }); + await preflight(context, { + requireCapabilities: false, + checkCleanHost: true, + }); stepDone("preflight"); await advanceInstallState(context, ownership, "preflight_ok"); @@ -372,7 +424,10 @@ export async function install(options: InstallOptions): Promise { await installDeps(context); stepDone("system dependencies"); step("preflight capabilities"); - await preflight(context, { requireCapabilities: true, checkCleanHost: false }); + await preflight(context, { + requireCapabilities: true, + checkCleanHost: false, + }); stepDone("preflight capabilities"); await advanceInstallState(context, ownership, "deps_ok"); phase = "deps_ok"; @@ -387,7 +442,7 @@ export async function install(options: InstallOptions): Promise { await writeTextAtomic(options.runtimeConfigPath, renderRuntimeEnv(config), { mode: 0o600, owner: "root", - group: "root" + group: "root", }); stepDone("write runtime env"); await advanceInstallState(context, ownership, "runtime_env_written"); @@ -468,8 +523,13 @@ export async function install(options: InstallOptions): Promise { try { await cleanupFirewallRollback(context); } catch (cleanupError) { - const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); - info(`firewall rollback data cleanup failed after a successful install: ${cleanupMessage}`); + const cleanupMessage = + cleanupError instanceof Error + ? cleanupError.message + : String(cleanupError); + info( + `firewall rollback data cleanup failed after a successful install: ${cleanupMessage}`, + ); } } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -498,7 +558,12 @@ export async function install(options: InstallOptions): Promise { : "failed"; await persistFailureState(() => - advanceInstallState(context, ownership, failurePhase, `${failureKind}: ${message}`) + advanceInstallState( + context, + ownership, + failurePhase, + `${failureKind}: ${message}`, + ), ); // Диагностика — best effort, откат — обязателен. @@ -512,8 +577,12 @@ export async function install(options: InstallOptions): Promise { await diagnosticsCollect(options); } catch (diagnosticsError) { const diagnosticsMessage = - diagnosticsError instanceof Error ? diagnosticsError.message : String(diagnosticsError); - info(`diagnostics collection failed, continuing with rollback: ${diagnosticsMessage}`); + diagnosticsError instanceof Error + ? diagnosticsError.message + : String(diagnosticsError); + info( + `diagnostics collection failed, continuing with rollback: ${diagnosticsMessage}`, + ); } await rollbackFailedInstall(context, ownership, failureKind); diff --git a/orchestrator/src/lib/systemd.ts b/orchestrator/src/lib/systemd.ts new file mode 100644 index 0000000..f33354a --- /dev/null +++ b/orchestrator/src/lib/systemd.ts @@ -0,0 +1,89 @@ +import { info } from "./log"; +import { + runMutatingStatus, + runReadOnlyArgvStatus, + type MutationResult, +} from "./process"; + +export type ResetFailedDependencies = { + reset: (unit: string) => Promise; + inspect: (unit: string) => Promise; +}; + +const productionDependencies: ResetFailedDependencies = { + reset: async (unit) => runMutatingStatus`systemctl reset-failed ${unit}`, + inspect: async (unit) => + runReadOnlyArgvStatus([ + "systemctl", + "show", + unit, + "--property=LoadState", + "--property=ActiveState", + "--no-pager", + ]), +}; + +function propertiesOf(output: string): Map { + const properties = new Map(); + for (const line of output.split(/\r?\n/)) { + const separator = line.indexOf("="); + if (separator <= 0) { + continue; + } + properties.set(line.slice(0, separator), line.slice(separator + 1)); + } + return properties; +} + +function commandFailure(result: MutationResult): string { + return ( + result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}` + ); +} + +/** + * Сбрасывает failed-состояние юнита и доказывает postcondition наблюдением. + * + * `systemctl reset-failed` возвращает ненулевой код и для уже выгруженного + * юнита. Это не отказ уборки: у такого юнита физически нет failed-состояния, + * которое нужно было бы сбрасывать. Разбирать английское `Unit ... not loaded` + * нельзя — текст зависит от версии и локали systemd. Поэтому код команды + * сохраняется для диагностики, а решение принимается по ActiveState. + * + * Ошибка чтения состояния не маскируется. Если systemd недоступен либо юнит всё + * ещё `failed`, rollback обязан оставить оператору настоящее предупреждение. + */ +export async function resetFailedUnit( + unit: string, + dependencies: ResetFailedDependencies = productionDependencies, +): Promise { + const reset = await dependencies.reset(unit); + const observed = await dependencies.inspect(unit); + if (observed.exitCode !== 0) { + throw new Error( + `cannot verify systemd state for ${unit} after reset-failed: ${commandFailure(observed)}`, + ); + } + + const properties = propertiesOf(observed.stdout); + const loadState = properties.get("LoadState"); + const activeState = properties.get("ActiveState"); + if (!loadState || !activeState) { + throw new Error( + `systemctl show ${unit} did not return LoadState and ActiveState after reset-failed`, + ); + } + if (activeState === "failed") { + throw new Error( + `systemd unit ${unit} remains failed after reset-failed (load state: ${loadState}; ` + + `command: ${commandFailure(reset)})`, + ); + } + + if (reset.exitCode !== 0) { + info( + `systemctl reset-failed ${unit} exited with ${reset.exitCode}, but the cleanup ` + + `postcondition is satisfied: LoadState=${loadState}, ActiveState=${activeState}`, + ); + } +} diff --git a/orchestrator/src/steps/smoke.ts b/orchestrator/src/steps/smoke.ts index c29e251..c57e401 100644 --- a/orchestrator/src/steps/smoke.ts +++ b/orchestrator/src/steps/smoke.ts @@ -3,8 +3,16 @@ import type { RuntimeContext } from "../types/context"; import { info } from "../lib/log"; import { readText } from "../lib/fs"; import { parseEnvFile } from "../lib/envFile"; -import { runReadOnly, runReadOnlySecret, runMutatingVisible } from "../lib/process"; -import { ADMIN_LOGIN_PATH, HYSTERIA_MACHINE_AUTH_PATH, hysteriaMachineAuthUrl } from "../config/profile"; +import { + runReadOnly, + runReadOnlySecret, + runMutatingVisible, +} from "../lib/process"; +import { + ADMIN_LOGIN_PATH, + HYSTERIA_MACHINE_AUTH_PATH, + hysteriaMachineAuthUrl, +} from "../config/profile"; import { assertHysteriaConfigMatchesProfile } from "./configAssertions"; import { assertEffectiveFirewallIsOurs } from "./firewall"; @@ -82,7 +90,8 @@ export async function smoke(context: RuntimeContext): Promise { 1000, async () => runReadOnlySecret`systemctl is-active hysteria-server || true`, (state) => state.trim() === "active", - (state, error) => new Error(`hysteria-server is not active: ${state ?? String(error)}`), + (state, error) => + new Error(`hysteria-server is not active: ${state ?? String(error)}`), ); await retry( "systemd hy2xs-admin active", @@ -90,7 +99,8 @@ export async function smoke(context: RuntimeContext): Promise { 1000, async () => runReadOnlySecret`systemctl is-active hy2xs-admin || true`, (state) => state.trim() === "active", - (state, error) => new Error(`hy2xs-admin is not active: ${state ?? String(error)}`), + (state, error) => + new Error(`hy2xs-admin is not active: ${state ?? String(error)}`), ); if (context.options.skipSmoke) { @@ -103,24 +113,38 @@ export async function smoke(context: RuntimeContext): Promise { 15, 1000, async () => runReadOnlySecret`ss -H -ltn`, - (lines) => hasTcpListener(lines, context.config.uiBindHost, context.config.uiPort), - (lines, error) => new Error(`ui listener not ready on ${context.config.uiBindHost}:${context.config.uiPort}: ${lines ?? String(error)}`), + (lines) => + hasTcpListener(lines, context.config.uiBindHost, context.config.uiPort), + (lines, error) => + new Error( + `ui listener not ready on ${context.config.uiBindHost}:${context.config.uiPort}: ${lines ?? String(error)}`, + ), ); await retry( "hysteria udp listener readiness", 15, 1000, async () => runReadOnlySecret`ss -H -lun`, - (lines) => hasUdpListener(lines, context.config.hysteriaBindHost, context.config.hysteriaPort), - (lines, error) => new Error(`hysteria udp listener not ready on 0.0.0.0:${context.config.hysteriaPort}: ${lines ?? String(error)}`), + (lines) => + hasUdpListener( + lines, + context.config.hysteriaBindHost, + context.config.hysteriaPort, + ), + (lines, error) => + new Error( + `hysteria udp listener not ready on 0.0.0.0:${context.config.hysteriaPort}: ${lines ?? String(error)}`, + ), ); await retry( "admin healthz readiness", 15, 1000, - async () => runReadOnlySecret`curl -sS --max-time 5 http://127.0.0.1:${context.config.uiPort}/healthz`, + async () => + runReadOnlySecret`curl -sS --max-time 5 http://127.0.0.1:${context.config.uiPort}/healthz`, (response) => /"ok"\s*:\s*true/.test(response), - (response, error) => new Error(`admin healthz is not ready: ${response ?? String(error)}`), + (response, error) => + new Error(`admin healthz is not ready: ${response ?? String(error)}`), ); // Всё, что ниже, — НАБЛЮДЕНИЕ, и оно выполняется read-only раннерами. @@ -131,7 +155,9 @@ export async function smoke(context: RuntimeContext): Promise { // не меняет диагностируемую систему» невозможно было включить guard'ом — он // отказал бы на первой же читающей команде. Классификация здесь — часть // контракта, а не стиль. - info(`installed Hysteria: ${await runReadOnly`/usr/local/bin/hysteria version`}`); + info( + `installed Hysteria: ${await runReadOnly`/usr/local/bin/hysteria version`}`, + ); await runReadOnly`test -s /etc/hysteria/config.yaml`; await runReadOnly`test -s /etc/hy2xs/hy2xs.env`; await runReadOnly`test -s /etc/hysteria/post-install.env`; @@ -156,7 +182,9 @@ export async function smoke(context: RuntimeContext): Promise { if (context.config.uiBindHost === "127.0.0.1") { const tcp = await runReadOnlySecret`ss -H -ltn`; if (hasTcpListener(tcp, "0.0.0.0", context.config.uiPort)) { - throw new Error(`ui listener must not be public on 0.0.0.0:${context.config.uiPort}`); + throw new Error( + `ui listener must not be public on 0.0.0.0:${context.config.uiPort}`, + ); } } await runReadOnly`! ss -H -ltn | grep -q '\[::\]:${context.config.uiPort} '`; @@ -167,47 +195,67 @@ export async function smoke(context: RuntimeContext): Promise { const machineAuthUrlNoToken = `http://127.0.0.1:${context.config.uiPort}${HYSTERIA_MACHINE_AUTH_PATH}`; const machineAuthUrl = hysteriaMachineAuthUrl( context.config.uiPort, - context.config.hysteriaTrafficStatsSecret + context.config.hysteriaTrafficStatsSecret, ); - const missingTokenAuthCode = await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrlNoToken}`; + const missingTokenAuthCode = + await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrlNoToken}`; if (missingTokenAuthCode.trim() !== "403") { - throw new Error(`unexpected auth status without machine token: ${missingTokenAuthCode}`); + throw new Error( + `unexpected auth status without machine token: ${missingTokenAuthCode}`, + ); } const invalidAuthResponse = await retry( "auth invalid credentials", 5, 1000, - async () => runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrl}`, + async () => + runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrl}`, (response) => /"ok"\s*:\s*false/.test(response), - (response, error) => new Error(`unexpected auth response for invalid credentials: ${response ?? String(error)}`), + (response, error) => + new Error( + `unexpected auth response for invalid credentials: ${response ?? String(error)}`, + ), ); for (let i = 0; i < 10; i += 1) { - const response = await runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrl}`; + const response = + await runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrl}`; if (!/"ok"\s*:\s*false/.test(response)) { - throw new Error(`unexpected auth response during rate-limit smoke: ${response}`); + throw new Error( + `unexpected auth response during rate-limit smoke: ${response}`, + ); } } - const invalidTypeAuthCode = await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":"0"}' ${machineAuthUrl}`; + const invalidTypeAuthCode = + await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":"0"}' ${machineAuthUrl}`; if (invalidTypeAuthCode.trim() !== "400") { - throw new Error(`unexpected auth status for tx as string: ${invalidTypeAuthCode}`); + throw new Error( + `unexpected auth status for tx as string: ${invalidTypeAuthCode}`, + ); } if (context.mode === "install") { - const adminConPass = (await readBootstrapAdminSecret(context)).ADMIN_CON_PASS ?? ""; + const adminConPass = + (await readBootstrapAdminSecret(context)).ADMIN_CON_PASS ?? ""; if (!adminConPass) { - throw new Error("admin connection password is empty in bootstrap secret file"); + throw new Error( + "admin connection password is empty in bootstrap secret file", + ); } await retry( "auth valid credentials", 10, 1000, - async () => runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"${adminConPass}","tx":0}' ${machineAuthUrl}`, + async () => + runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"${adminConPass}","tx":0}' ${machineAuthUrl}`, (response) => /"ok"\s*:\s*true/.test(response), - (response, error) => new Error(`unexpected auth response for valid credentials: ${response ?? String(error)}`), + (response, error) => + new Error( + `unexpected auth response for valid credentials: ${response ?? String(error)}`, + ), ); } @@ -217,13 +265,20 @@ export async function smoke(context: RuntimeContext): Promise { "trafficStats valid secret", 10, 1000, - async () => runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: ${context.config.hysteriaTrafficStatsSecret}' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`, + async () => + runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: ${context.config.hysteriaTrafficStatsSecret}' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`, (code) => /^2\d\d$/.test(code.trim()), - (code, error) => new Error(`unexpected trafficStats status for valid secret: ${code ?? String(error)}`), + (code, error) => + new Error( + `unexpected trafficStats status for valid secret: ${code ?? String(error)}`, + ), ); - const deniedCode = await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: invalid-hy2xs-secret' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`; + const deniedCode = + await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: invalid-hy2xs-secret' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`; if (!/(401|403)/.test(deniedCode)) { - throw new Error(`unexpected trafficStats status for invalid secret: ${deniedCode}`); + throw new Error( + `unexpected trafficStats status for invalid secret: ${deniedCode}`, + ); } // `nft -c` только разбирает файл и правил не применяет — это проверка @@ -237,13 +292,18 @@ export async function smoke(context: RuntimeContext): Promise { // синтаксически валидный — ruleset, проверка проходила зелёной, и операция // объявляла успешной установку, работающую на firewall, который она же // только что заменила. - info("verifying that the effective firewall is the one generated for this configuration"); + info( + "verifying that the effective firewall is the one generated for this configuration", + ); await assertEffectiveFirewallIsOurs(context); // Семантическая проверка установленного конфига: разбираем YAML и сверяем // с production-профилем, а не ищем подстроки. info("verifying effective Hysteria config against HY2XS production profile"); - assertHysteriaConfigMatchesProfile(await readText("/etc/hysteria/config.yaml"), context.config); + assertHysteriaConfigMatchesProfile( + await readText("/etc/hysteria/config.yaml"), + context.config, + ); await assertEffectiveHysteriaVersion(context); } @@ -296,25 +356,23 @@ async function assertAdminLoginWorks(context: RuntimeContext): Promise { // доступа. Логин берётся настоящий — тот же, что у администратора, — чтобы // запрос шёл тем же путём, что и реальный вход, и доходил до проверки пароля. const rejectedPassword = randomBytes(24).toString("base64url"); - const rejectedPayload = JSON.stringify({ - username: context.config.adminUser, - pass: rejectedPassword - }); const rejectedBody = await retry( "admin login rejects wrong credentials", 10, 1000, async () => - runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data ${rejectedPayload} ${loginUrl}`, + requestAdminLogin(loginUrl, context.config.adminUser, rejectedPassword), (body) => isRejectedLogin(body), (body, error) => new Error( `admin login did not reject invalid credentials: ${describeRejectionFailure(body, error)}\n` + `Панель обязана отвечать конвертом отказа с причиной ${INVALID_CREDENTIALS_CODE}. ` + - `Отсутствие такого конверта означает, что запрос не доживает до проверки учётных данных.` - ) + `Отсутствие такого конверта означает, что запрос не доживает до проверки учётных данных.`, + ), + ); + info( + `admin login rejects wrong credentials: ${describeRejection(rejectedBody)}`, ); - info(`admin login rejects wrong credentials: ${describeRejection(rejectedBody)}`); if (context.mode !== "install") { return; @@ -341,27 +399,107 @@ async function assertAdminLoginWorks(context: RuntimeContext): Promise { // пароля с настоящим невероятно, но проверить это здесь можно точно, и тогда // «отвергнуто» гарантированно означает «отвергнуто», а не «не совпало». if (rejectedPassword === adminPassword) { - throw new Error("negative login probe accidentally used the real admin password"); + throw new Error( + "negative login probe accidentally used the real admin password", + ); } - const payload = JSON.stringify({ username: adminUser, pass: adminPassword }); const response = await retry( "admin login with bootstrap credentials", 10, 1000, - async () => - runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data ${payload} ${loginUrl}`, + async () => requestAdminLogin(loginUrl, adminUser, adminPassword), (body) => isSuccessfulLogin(body), (body, error) => new Error( `admin panel refused the bootstrap login it created itself: ${describeLoginFailure(body, error)}\n` + - `Порт открыт и /healthz отвечает, но войти в панель нельзя — установка не считается выполненной.` - ) + `Порт открыт и /healthz отвечает, но войти в панель нельзя — установка не считается выполненной.`, + ), ); info(`admin login accepted: ${describeIssuedToken(response)}`); } +/** + * UA login-smoke называется своим именем и не маскируется под браузер. + * + * curl по умолчанию отправляет `curl/`, а production middleware + * осознанно отклоняет scanner-like UA до разбора DTO. Без явного значения + * установщик проверял не вход, а собственную несовместимость с middleware. + */ +export const ADMIN_LOGIN_SMOKE_USER_AGENT = "HY2XS-Installer/1.0"; + +export type AdminLoginRequest = { + contentType: "application/json"; + userAgent: string; + body: string; +}; + +/** Единственный wire-контракт обеих login-проб. */ +export function buildAdminLoginRequest( + username: string, + pass: string, +): AdminLoginRequest { + return { + contentType: "application/json", + userAgent: ADMIN_LOGIN_SMOKE_USER_AGENT, + body: JSON.stringify({ username, pass }), + }; +} + +/** + * Отправляет login-пробу без утечки тела в текст ошибки. + * + * Positive и negative smoke намеренно проходят через одну функцию: заголовки, + * имя wire-поля и настройки транспорта не могут разойтись между пробами. + */ +async function requestAdminLogin( + loginUrl: string, + username: string, + pass: string, +): Promise { + const request = buildAdminLoginRequest(username, pass); + return runReadOnlySecret`curl -sS --max-time 5 --request POST --header ${`Content-Type: ${request.contentType}`} --user-agent ${request.userAgent} --data ${request.body} ${loginUrl}`; +} + +type JsonObject = Record; + +function objectOrNull(value: unknown): JsonObject | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as JsonObject) + : null; +} + +function parseLoginEnvelope(body: string): JsonObject | null { + try { + return objectOrNull(JSON.parse(body)); + } catch { + return null; + } +} + +function accessTokenOf(envelope: JsonObject | null): string | null { + const data = objectOrNull(envelope?.data); + return typeof data?.accessToken === "string" && data.accessToken.length > 0 + ? data.accessToken + : null; +} + +function carriesAccessToken(envelope: JsonObject | null): boolean { + const data = objectOrNull(envelope?.data); + return data !== null && Object.hasOwn(data, "accessToken") && data.accessToken !== null; +} + +function rejectionCodesOf(envelope: JsonObject | null): string[] { + if (!Array.isArray(envelope?.errors)) { + return []; + } + return envelope.errors.flatMap((error) => { + const item = objectOrNull(error); + return typeof item?.code === "string" ? [item.code] : []; + }); +} + /** * Успех определяется по КОНВЕРТУ, а не по коду HTTP. * @@ -372,8 +510,9 @@ async function assertAdminLoginWorks(context: RuntimeContext): Promise { * Выданный токен требуется отдельно: `code: 20000` без `accessToken` означал бы * панель, которая пускает и не выдаёт сессию. */ -function isSuccessfulLogin(body: string): boolean { - return /"code"\s*:\s*20000/.test(body) && /"accessToken"\s*:\s*"[^"]+"/.test(body); +export function isSuccessfulLogin(body: string): boolean { + const envelope = parseLoginEnvelope(body); + return envelope?.code === 20000 && accessTokenOf(envelope) !== null; } /** @@ -398,11 +537,12 @@ const INVALID_CREDENTIALS_CODE = "invalid_credentials"; * скажем, по недоступной базе; * отсутствие accessToken — панель не выдала сессию. */ -function isRejectedLogin(body: string): boolean { +export function isRejectedLogin(body: string): boolean { + const envelope = parseLoginEnvelope(body); return ( - /"code"\s*:\s*50000/.test(body) && - new RegExp(`"code"\\s*:\\s*"${INVALID_CREDENTIALS_CODE}"`).test(body) && - !/"accessToken"\s*:\s*"[^"]+"/.test(body) + envelope?.code === 50000 && + rejectionCodesOf(envelope).includes(INVALID_CREDENTIALS_CODE) && + !carriesAccessToken(envelope) ); } @@ -414,11 +554,14 @@ function isRejectedLogin(body: string): boolean { * если панель по недоразумению впустила, в ответе лежит токен доступа, а этот * текст уезжает в журнал установки и в diagnostics-бандл. */ -function describeRejectionFailure(body: string | undefined, error: unknown): string { +function describeRejectionFailure( + body: string | undefined, + error: unknown, +): string { if (body === undefined) { return `запрос не выполнен: ${String(error)}`; } - if (/"accessToken"\s*:\s*"[^"]+"/.test(body)) { + if (carriesAccessToken(parseLoginEnvelope(body))) { return "панель ВЫДАЛА токен доступа на заведомо неверные учётные данные"; } return describeRejection(body); @@ -426,9 +569,9 @@ function describeRejectionFailure(body: string | undefined, error: unknown): str /** Как выглядит отказ: код конверта и код причины, без тела. */ function describeRejection(body: string): string { - const envelope = body.match(/"code"\s*:\s*(\d+)/); - const reason = body.match(/"code"\s*:\s*"([a-z_]+)"/); - return `code=${envelope ? envelope[1] : "нет"}, причина=${reason ? reason[1] : "нет"}, токен не выдан`; + const envelope = parseLoginEnvelope(body); + const reason = rejectionCodesOf(envelope)[0]; + return `code=${typeof envelope?.code === "number" ? envelope.code : "нет"}, причина=${reason ?? "нет"}, токен не выдан`; } // Читает /etc/hy2xs/bootstrap-admin.secret тем же парсером, которым он написан. @@ -441,7 +584,9 @@ function describeRejection(body: string): string { // Комментарий записан строчными `//`, а не блоком: скан релизных гейтов // отбрасывает только их, и объяснение, называющее убранную конструкцию по // имени, иначе роняет проверку «этой конструкции здесь больше нет». -async function readBootstrapAdminSecret(context: RuntimeContext): Promise> { +async function readBootstrapAdminSecret( + context: RuntimeContext, +): Promise> { return parseEnvFile(await readText(context.config.bootstrapAdminSecretPath)); } @@ -452,21 +597,26 @@ async function readBootstrapAdminSecret(context: RuntimeContext): Promise 0 + ? `выдан токен типа ${data.tokenType}` + : "выдан токен доступа"; } /** @@ -474,7 +624,9 @@ function describeIssuedToken(body: string): string { * пакета. На reconfigure metadata может относиться к другому пакету, поэтому * расхождение там — предупреждение, а не отказ. */ -async function assertEffectiveHysteriaVersion(context: RuntimeContext): Promise { +async function assertEffectiveHysteriaVersion( + context: RuntimeContext, +): Promise { const packagedVersion = context.hysteriaVersion.trim(); if (!packagedVersion || packagedVersion === "unknown") { return; diff --git a/orchestrator/test/admin-credentials.test.ts b/orchestrator/test/admin-credentials.test.ts index f364998..c5dbd63 100644 --- a/orchestrator/test/admin-credentials.test.ts +++ b/orchestrator/test/admin-credentials.test.ts @@ -12,8 +12,14 @@ import { ADMIN_USERNAME_MAX_LENGTH, ADMIN_USERNAME_MIN_LENGTH, ADMIN_USERNAME_PATTERN, - isValidAdminPassword + isValidAdminPassword, } from "../src/config/profile"; +import { + ADMIN_LOGIN_SMOKE_USER_AGENT, + buildAdminLoginRequest, + isRejectedLogin, + isSuccessfulLogin, +} from "../src/steps/smoke"; import { baselineConfig, envText } from "./fixtures"; /** @@ -31,7 +37,7 @@ const REPO_ROOT = path.resolve(import.meta.dir, "..", ".."); function goContract(): string { return fs.readFileSync( path.join(REPO_ROOT, "apps", "credential", "admin.go"), - "utf8" + "utf8", ); } @@ -41,7 +47,9 @@ function goConst(name: string): string { if (numeric) { return numeric[1]; } - const backticked = source.match(new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``)); + const backticked = source.match( + new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``), + ); if (backticked) { return backticked[1]; } @@ -68,14 +76,24 @@ describe("контракт учётных данных совпадает с а // Go-бинарника и импортировать его константы не может. Проверяется поэтому // не отсутствие копии, а её совпадение с оригиналом — сверкой по исходнику. test("границы совпадают с credential.Admin*", () => { - expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(goConst("AdminUsernameMinLength")); - expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(goConst("AdminUsernameMaxLength")); - expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(goConst("AdminPasswordMinLength")); - expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(goConst("AdminPasswordMaxLength")); + expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe( + goConst("AdminUsernameMinLength"), + ); + expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe( + goConst("AdminUsernameMaxLength"), + ); + expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe( + goConst("AdminPasswordMinLength"), + ); + expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe( + goConst("AdminPasswordMaxLength"), + ); // Предел bcrypt — ОТДЕЛЬНАЯ константа и отдельная сверка: пока его тут не // было, оркестратор пропускал в hy2xs.env пароль, который админка не могла // захешировать, и установка заканчивалась сервером без администратора. - expect(String(ADMIN_PASSWORD_MAX_BYTES)).toBe(goConst("AdminPasswordMaxBytes")); + expect(String(ADMIN_PASSWORD_MAX_BYTES)).toBe( + goConst("AdminPasswordMaxBytes"), + ); }); test("набор символов логина совпадает с классом из Go", () => { @@ -83,8 +101,11 @@ describe("контракт учётных данных совпадает с а // raw-строке, в TypeScript — в обычной, где обратный слеш удваивается. const declared = goConst("adminUsernameCharacterClass"); const mirrored = ADMIN_USERNAME_PATTERN.source.replace( - new RegExp(`^\\^\\[|\\]\\{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}\\}\\$$`, "g"), - "" + new RegExp( + `^\\^\\[|\\]\\{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}\\}\\$$`, + "g", + ), + "", ); expect(mirrored).toBe(declared); }); @@ -105,7 +126,10 @@ describe("контракт учётных данных совпадает с а test("путь формы входа собран из базового пространства имён API", () => { expect(ADMIN_LOGIN_PATH).toBe("/api/auth/login"); - const router = fs.readFileSync(path.join(REPO_ROOT, "apps", "router", "auth.go"), "utf8"); + const router = fs.readFileSync( + path.join(REPO_ROOT, "apps", "router", "auth.go"), + "utf8", + ); expect(router).toContain('auth.POST("/login"'); expect(router).toContain('authApi.Group("/auth")'); }); @@ -122,66 +146,81 @@ describe("HY2XS_ADMIN_USER проверяется при разборе окру // шесть. Установка проходила целиком, а панель не впускала никого. test("значение по умолчанию не короче минимума", () => { const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_USER: null })); - expect(config.adminUser.length).toBeGreaterThanOrEqual(ADMIN_USERNAME_MIN_LENGTH); + expect(config.adminUser.length).toBeGreaterThanOrEqual( + ADMIN_USERNAME_MIN_LENGTH, + ); }); // Умолчание обязано совпадать с запасным значением админки: иначе установка // без явного HY2XS_ADMIN_USER создаёт одну учётную запись, а админка при // повреждённом окружении ищет другую. test("умолчание совпадает с запасным значением админки и с packaged env", () => { - const dao = fs.readFileSync(path.join(REPO_ROOT, "apps", "dao", "sqlite.go"), "utf8"); + const dao = fs.readFileSync( + path.join(REPO_ROOT, "apps", "dao", "sqlite.go"), + "utf8", + ); expect(dao).toContain('adminUser = "hy2xsadmin"'); const packaged = fs.readFileSync( path.join(REPO_ROOT, "package", "config", "hy2xs.env"), - "utf8" + "utf8", ); expect(packaged).toContain("HY2XS_ADMIN_USER=hy2xsadmin"); }); test("слишком короткий логин роняет установку", () => { expect(() => baselineConfig({ HY2XS_ADMIN_USER: "admin" })).toThrow( - /invalid HY2XS_ADMIN_USER/ + /invalid HY2XS_ADMIN_USER/, ); }); test("слишком длинный логин роняет установку", () => { expect(() => - baselineConfig({ HY2XS_ADMIN_USER: "a".repeat(ADMIN_USERNAME_MAX_LENGTH + 1) }) + baselineConfig({ + HY2XS_ADMIN_USER: "a".repeat(ADMIN_USERNAME_MAX_LENGTH + 1), + }), ).toThrow(/invalid HY2XS_ADMIN_USER/); }); test("логин с пробелом, кириллицей или переводом строки роняет установку", () => { for (const rejected of ["hy2xs admin", "админ12", "hy2xs\tadmin"]) { expect(() => baselineConfig({ HY2XS_ADMIN_USER: rejected })).toThrow( - /invalid HY2XS_ADMIN_USER/ + /invalid HY2XS_ADMIN_USER/, ); } }); test("отказ называет и границы, и набор символов", () => { expect(() => baselineConfig({ HY2XS_ADMIN_USER: "admin" })).toThrow( - new RegExp(`${ADMIN_USERNAME_MIN_LENGTH}.*${ADMIN_USERNAME_MAX_LENGTH}`) + new RegExp(`${ADMIN_USERNAME_MIN_LENGTH}.*${ADMIN_USERNAME_MAX_LENGTH}`), + ); + expect(() => baselineConfig({ HY2XS_ADMIN_USER: "admin" })).toThrow( + /a-z A-Z 0-9/, ); - expect(() => baselineConfig({ HY2XS_ADMIN_USER: "admin" })).toThrow(/a-z A-Z 0-9/); }); // Значение уезжает в hy2xs.env и оттуда в базу. Пробел по краям превращался // бы в часть имени учётной записи, и вход отказывал бы «неверным логином» — // отказом, который невозможно связать с причиной. test("окружающие пробелы снимаются, а не уезжают в имя учётной записи", () => { - expect(baselineConfig({ HY2XS_ADMIN_USER: " hy2xsadmin " }).adminUser).toBe("hy2xsadmin"); + expect( + baselineConfig({ HY2XS_ADMIN_USER: " hy2xsadmin " }).adminUser, + ).toBe("hy2xsadmin"); }); test("точка в логине принимается: набор не сужается задним числом", () => { - expect(baselineConfig({ HY2XS_ADMIN_USER: "admin.ops" }).adminUser).toBe("admin.ops"); + expect(baselineConfig({ HY2XS_ADMIN_USER: "admin.ops" }).adminUser).toBe( + "admin.ops", + ); }); }); describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разборе окружения", () => { test("сгенерированный пароль проходит контракт панели", () => { for (const raw of ["", "__GENERATE__"]) { - const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_INITIAL_PASSWORD: raw })); + const config = parseRuntimeEnv( + envText({ HY2XS_ADMIN_INITIAL_PASSWORD: raw }), + ); const length = [...config.adminInitialPassword].length; expect(length).toBeGreaterThanOrEqual(ADMIN_PASSWORD_MIN_LENGTH); expect(length).toBeLessThanOrEqual(ADMIN_PASSWORD_MAX_LENGTH); @@ -189,26 +228,31 @@ describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разбо }); test("слишком короткий пароль роняет установку", () => { - expect(() => baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: "12345" })).toThrow( - /invalid HY2XS_ADMIN_INITIAL_PASSWORD/ - ); + expect(() => + baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: "12345" }), + ).toThrow(/invalid HY2XS_ADMIN_INITIAL_PASSWORD/); }); test("слишком длинный пароль роняет установку", () => { expect(() => baselineConfig({ - HY2XS_ADMIN_INITIAL_PASSWORD: "a".repeat(ADMIN_PASSWORD_MAX_LENGTH + 1) - }) + HY2XS_ADMIN_INITIAL_PASSWORD: "a".repeat(ADMIN_PASSWORD_MAX_LENGTH + 1), + }), ).toThrow(/invalid HY2XS_ADMIN_INITIAL_PASSWORD/); }); // Набор символов пароля сервером не ограничивается, и оркестратор не имеет // права быть строже: отвергнутый здесь пароль сервер принял бы. test("пароль из произвольных символов принимается", () => { - for (const password of ["p@ssw0rd?!", "пароль-администратора", "a b c d e f"]) { - expect(baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: password }).adminInitialPassword).toBe( - password - ); + for (const password of [ + "p@ssw0rd?!", + "пароль-администратора", + "a b c d e f", + ]) { + expect( + baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: password }) + .adminInitialPassword, + ).toBe(password); } }); @@ -226,8 +270,12 @@ describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разбо test("границы пароля проверяются и в символах, и в байтах", () => { // Проверка самой таблицы: если «я» перестанет быть двухбайтовой, случаи // ниже проверяли бы не то, что написано. - expect(Buffer.byteLength("я".repeat(36), "utf8")).toBe(ADMIN_PASSWORD_MAX_BYTES); - expect(Buffer.byteLength("😀".repeat(18), "utf8")).toBe(ADMIN_PASSWORD_MAX_BYTES); + expect(Buffer.byteLength("я".repeat(36), "utf8")).toBe( + ADMIN_PASSWORD_MAX_BYTES, + ); + expect(Buffer.byteLength("😀".repeat(18), "utf8")).toBe( + ADMIN_PASSWORD_MAX_BYTES, + ); const accepted = [ "a".repeat(ADMIN_PASSWORD_MIN_LENGTH), @@ -235,13 +283,14 @@ describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разбо "я".repeat(36), "😀".repeat(18), "abcde ", - " ".repeat(ADMIN_PASSWORD_MIN_LENGTH) + " ".repeat(ADMIN_PASSWORD_MIN_LENGTH), ]; for (const password of accepted) { expect(isValidAdminPassword(password)).toBe(true); - expect(baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: `"${password}"` }).adminInitialPassword).toBe( - password - ); + expect( + baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: `"${password}"` }) + .adminInitialPassword, + ).toBe(password); } const rejected = [ @@ -249,13 +298,13 @@ describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разбо "a".repeat(ADMIN_PASSWORD_MAX_LENGTH + 1), "я".repeat(37), "😀".repeat(19), - "я".repeat(ADMIN_PASSWORD_MAX_LENGTH) + "я".repeat(ADMIN_PASSWORD_MAX_LENGTH), ]; for (const password of rejected) { expect(isValidAdminPassword(password)).toBe(false); - expect(() => baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: `"${password}"` })).toThrow( - /invalid HY2XS_ADMIN_INITIAL_PASSWORD/ - ); + expect(() => + baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: `"${password}"` }), + ).toThrow(/invalid HY2XS_ADMIN_INITIAL_PASSWORD/); } }); @@ -264,11 +313,13 @@ describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разбо // отправило бы оператора сокращать пароль, отвергнутый не за это. test("отказ называет и символы, и байты", () => { const tooManyBytes = `"${"я".repeat(ADMIN_PASSWORD_MAX_LENGTH)}"`; - expect(() => baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: tooManyBytes })).toThrow( - new RegExp(`${ADMIN_PASSWORD_MAX_BYTES} байт`) - ); - expect(() => baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: tooManyBytes })).toThrow( - new RegExp(`${ADMIN_PASSWORD_MIN_LENGTH}.*${ADMIN_PASSWORD_MAX_LENGTH}`) + expect(() => + baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: tooManyBytes }), + ).toThrow(new RegExp(`${ADMIN_PASSWORD_MAX_BYTES} байт`)); + expect(() => + baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: tooManyBytes }), + ).toThrow( + new RegExp(`${ADMIN_PASSWORD_MIN_LENGTH}.*${ADMIN_PASSWORD_MAX_LENGTH}`), ); }); @@ -282,7 +333,9 @@ describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разбо // генератор — такой же источник значения, как и оператор. test("сгенерированный пароль проходит контракт целиком", () => { for (let i = 0; i < 50; i += 1) { - const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_INITIAL_PASSWORD: "__GENERATE__" })); + const config = parseRuntimeEnv( + envText({ HY2XS_ADMIN_INITIAL_PASSWORD: "__GENERATE__" }), + ); expect(isValidAdminPassword(config.adminInitialPassword)).toBe(true); } }); @@ -290,7 +343,10 @@ describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разбо describe("smoke выполняет настоящий вход в панель", () => { const smoke = () => - fs.readFileSync(path.join(REPO_ROOT, "orchestrator", "src", "steps", "smoke.ts"), "utf8"); + fs.readFileSync( + path.join(REPO_ROOT, "orchestrator", "src", "steps", "smoke.ts"), + "utf8", + ); // Главный барьер этого релиза. Пока установка отвечала на вопрос «работает ли // панель» только фактом LISTEN и ответом /healthz, RC2 объявлял успешной @@ -303,23 +359,38 @@ describe("smoke выполняет настоящий вход в панель", }); test("успех определяется конвертом ответа и выданным токеном", () => { - // Админка отвечает HTTP 200 и на отказ: проверка по коду HTTP приняла бы за - // успешный вход любой отказ. - const source = smoke(); - expect(source).toMatch(/"code"\\s\*:\\s\*20000/); - expect(source).toMatch(/accessToken/); + expect( + isSuccessfulLogin( + JSON.stringify({ + code: 20000, + type: "ok", + data: { accessToken: "jwt", tokenType: "Bearer" }, + }), + ), + ).toBe(true); + expect( + isSuccessfulLogin( + JSON.stringify({ code: 50000, data: { accessToken: "jwt" } }), + ), + ).toBe(false); + expect( + isSuccessfulLogin( + JSON.stringify({ code: 20000, data: { accessToken: "" } }), + ), + ).toBe(false); + expect(isSuccessfulLogin("not json")).toBe(false); }); test("учётные данные не попадают ни в команду ошибки, ни в сообщение", () => { const source = smoke(); const start = source.indexOf("async function assertAdminLoginWorks"); expect(start).toBeGreaterThan(-1); - const body = source.slice(start, source.indexOf("\nfunction isSuccessfulLogin")); + const body = source.slice(start, source.indexOf("\ntype JsonObject")); // runReadOnly (без Secret) кладёт команду в текст ошибки, а команда несёт // пароль администратора. expect(body).not.toMatch(/(? match[1]); expect(messages.length).toBeGreaterThan(0); for (const message of messages) { - for (const secret of ["adminPassword", "payload", "response", "body}"]) { + for (const secret of [ + "adminPassword", + "request.body", + "response", + "body}", + ]) { expect(message).not.toContain(`\${${secret}`); } } @@ -343,11 +419,42 @@ describe("smoke выполняет настоящий вход в панель", expect(body).toContain("describeIssuedToken(response)"); }); - test("тело запроса собирается JSON.stringify, а не интерполяцией", () => { - // Пароль задаёт оператор: кавычка или обратный слеш в нём сломали бы сам - // запрос, и проверка объявила бы рабочую установку сломанной. - const body = smoke(); - expect(body).toContain("JSON.stringify({ username: adminUser, pass: adminPassword })"); + test("обе пробы используют один wire-helper", () => { + const source = codeOf(smoke()); + expect(source.match(/requestAdminLogin\(/g)).toHaveLength(3); // два вызова и определение + const helperStart = source.indexOf("async function requestAdminLogin"); + const helper = source.slice( + helperStart, + source.indexOf("type JsonObject", helperStart), + ); + expect(helperStart).toBeGreaterThan(-1); + expect(helper.match(/runReadOnlySecret`curl/g)).toHaveLength(1); + }); + + test("wire-тело содержит pass, а не несовместимый alias password", () => { + const request = buildAdminLoginRequest('admin"name', "pass\\word "); + expect(JSON.parse(request.body)).toEqual({ + username: 'admin"name', + pass: "pass\\word ", + }); + expect(request.body).not.toContain('"password"'); + expect(request.contentType).toBe("application/json"); + }); + + test("UA smoke проходит действующее scanner-правило", () => { + const filter = fs.readFileSync( + path.join(REPO_ROOT, "apps", "middleware", "filter.go"), + "utf8", + ); + const declared = filter.match(/regexp\.MatchString\(`\(\?i\)([^`]+)`/); + expect(declared).not.toBeNull(); + const scannerPattern = new RegExp(declared![1], "i"); + + expect(scannerPattern.test("curl/8.10.1")).toBe(true); + expect(scannerPattern.test(ADMIN_LOGIN_SMOKE_USER_AGENT)).toBe(false); + expect(buildAdminLoginRequest("hy2xsadmin", "secret1").userAgent).toBe( + ADMIN_LOGIN_SMOKE_USER_AGENT, + ); }); test("отрицательная проба не зависит от режима операции", () => { @@ -357,7 +464,10 @@ describe("smoke выполняет настоящий вход в панель", // дефект RC2. const source = smoke(); const start = source.indexOf("async function assertAdminLoginWorks"); - const body = source.slice(start, source.indexOf("\nfunction isSuccessfulLogin")); + const body = source.slice( + start, + source.indexOf("\nfunction isSuccessfulLogin"), + ); const rejected = body.indexOf("admin login rejects wrong credentials"); const modeGate = body.indexOf('context.mode !== "install"'); @@ -371,26 +481,53 @@ describe("smoke выполняет настоящий вход в панель", // закончился пятисоткой. Админка отвечает HTTP 200 и на успешный вход тоже, // поэтому «неверные учётные данные отвергнуты» этой проверкой не следовало. test("отрицательная проба сверяет конверт отказа, а не код HTTP", () => { - const source = smoke(); - - expect(source).toContain("isRejectedLogin"); - // Код конверта отказа, доменная причина и ОТСУТСТВИЕ токена — три признака, - // и каждый закрывает свой способ ошибиться. - expect(source).toMatch(/"code"\\s\*:\\s\*50000/); - expect(source).toContain('INVALID_CREDENTIALS_CODE = "invalid_credentials"'); - - const start = source.indexOf("function isRejectedLogin"); - const body = source.slice(start, source.indexOf("\n/**", start)); - expect(body).toContain("!/\"accessToken\""); + const rejected = { + code: 50000, + type: "no", + errors: [{ code: "invalid_credentials", message: "wrong password" }], + data: null, + }; + expect(isRejectedLogin(JSON.stringify(rejected))).toBe(true); + expect(isRejectedLogin(JSON.stringify({ ...rejected, code: 50001 }))).toBe( + false, + ); + expect( + isRejectedLogin( + JSON.stringify({ ...rejected, errors: [{ code: "required" }] }), + ), + ).toBe(false); + expect( + isRejectedLogin( + JSON.stringify({ + ...rejected, + data: { accessToken: "unexpected-token" }, + }), + ), + ).toBe(false); + expect(isRejectedLogin(JSON.stringify({ ...rejected, data: { accessToken: "" } }))).toBe( + false, + ); + expect( + isRejectedLogin( + JSON.stringify({ + code: 50000, + message: 'fake {"code":"invalid_credentials"}', + data: null, + }), + ), + ).toBe(false); + expect(isRejectedLogin("not json")).toBe(false); }); // Доменный код обязан совпадать с тем, который объявляет админка. test("код причины отказа совпадает с constant.ErrCodeInvalidCredentials", () => { const goErrors = fs.readFileSync( path.join(REPO_ROOT, "apps", "model", "constant", "error.go"), - "utf8" + "utf8", + ); + const declared = goErrors.match( + /ErrCodeInvalidCredentials\s+string\s*=\s*"([^"]+)"/, ); - const declared = goErrors.match(/ErrCodeInvalidCredentials\s+string\s*=\s*"([^"]+)"/); expect(declared).not.toBeNull(); expect(smoke()).toContain(`INVALID_CREDENTIALS_CODE = "${declared![1]}"`); }); @@ -406,7 +543,9 @@ describe("smoke выполняет настоящий вход в панель", // acceptance.sh и в codeOf во frontend-contract.test.ts. expect(codeOf(source)).not.toContain("definitely-not-the-admin-password"); expect(source).toContain('randomBytes(24).toString("base64url")'); - expect(source).toContain("negative login probe accidentally used the real admin password"); + expect(source).toContain( + "negative login probe accidentally used the real admin password", + ); }); // Bootstrap-секрет читается ПАРСЕРОМ того же формата, которым он написан. @@ -419,7 +558,9 @@ describe("smoke выполняет настоящий вход в панель", const source = smoke(); expect(source).toContain("readBootstrapAdminSecret"); - expect(source).toContain("parseEnvFile(await readText(context.config.bootstrapAdminSecretPath))"); + expect(source).toContain( + "parseEnvFile(await readText(context.config.bootstrapAdminSecretPath))", + ); // Извлечения значения шеллом не остаётся ни одного. Проверки НАЛИЧИЯ // ключей (`grep -q '^ADMIN_USER='`) при этом законны и остаются: они diff --git a/orchestrator/test/release-rollover.test.ts b/orchestrator/test/release-rollover.test.ts index 46518f0..8caa824 100644 --- a/orchestrator/test/release-rollover.test.ts +++ b/orchestrator/test/release-rollover.test.ts @@ -5,7 +5,7 @@ import { HYSTERIA_HASHES_ASSET, HYSTERIA_LINUX_AMD64_ASSET, selectLatestStableRelease, - type GithubRelease + type GithubRelease, } from "../src/build/hysteriaRelease"; function assetUrl(tag: string, name: string): string { @@ -21,13 +21,13 @@ function release(tag: string): GithubRelease { assets: [ { name: HYSTERIA_LINUX_AMD64_ASSET, - browser_download_url: assetUrl(tag, HYSTERIA_LINUX_AMD64_ASSET) + browser_download_url: assetUrl(tag, HYSTERIA_LINUX_AMD64_ASSET), }, { name: HYSTERIA_HASHES_ASSET, - browser_download_url: assetUrl(tag, HYSTERIA_HASHES_ASSET) - } - ] + browser_download_url: assetUrl(tag, HYSTERIA_HASHES_ASSET), + }, + ], }; } @@ -63,7 +63,7 @@ describe("install-time никогда не резолвит latest", () => { ["commands", "install.ts"], ["steps", "hysteria.ts"], ["lib", "packageMeta.ts"], - ["commands", "reconfigure.ts"] + ["commands", "reconfigure.ts"], ]; test("install-time код не импортирует build-time резолвер", () => { @@ -83,13 +83,19 @@ describe("install-time никогда не резолвит latest", () => { test("установка берёт версию, url и sha256 только из metadata пакета", () => { const install = srcFile("commands", "install.ts"); - expect(install).toContain('readPackageValue(options.packageDir, "hysteria.version"'); - expect(install).toContain('readPackageValue(options.packageDir, "hysteria.url"'); - expect(install).toContain('readPackageValue(options.packageDir, "hysteria.sha256"'); + for (const key of ["hysteria.version", "hysteria.url", "hysteria.sha256"]) { + expect(install).toMatch( + new RegExp( + `readPackageValue\\(\\s*options\\.packageDir,\\s*"${key.replace(".", "\\.")}"`, + ), + ); + } }); test("отсутствие любой части lock-метаданных прерывает установку", () => { - expect(srcFile("commands", "install.ts")).toContain("missing Hysteria lock metadata in package"); + expect(srcFile("commands", "install.ts")).toContain( + "missing Hysteria lock metadata in package", + ); }); test("установленный бинарник сверяется с закреплённой версией", () => { diff --git a/orchestrator/test/rollback-mandatory.test.ts b/orchestrator/test/rollback-mandatory.test.ts index 19cb9d4..7267f22 100644 --- a/orchestrator/test/rollback-mandatory.test.ts +++ b/orchestrator/test/rollback-mandatory.test.ts @@ -25,7 +25,10 @@ import { persistFailureState, runRollbackStages } from "../src/lib/rollback"; // Пути считаются от файла теста, а не от cwd: `bun test` запускается и из корня // репозитория (сборка), и из orchestrator/ (разработчик). function source(relativeToSrc: string): string { - return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8"); + return readFileSync( + join(import.meta.dir, "..", "src", relativeToSrc), + "utf8", + ); } describe("стадии отката независимы", () => { @@ -33,7 +36,7 @@ describe("стадии отката независимы", () => { const executed: string[] = []; const failures = await runRollbackStages([ { name: "firewall", run: async () => void executed.push("firewall") }, - { name: "services", run: async () => void executed.push("services") } + { name: "services", run: async () => void executed.push("services") }, ]); expect(executed).toEqual(["firewall", "services"]); @@ -49,10 +52,13 @@ describe("стадии отката независимы", () => { run: async () => { executed.push("firewall"); throw new Error("nft: command failed"); - } + }, }, - { name: "restore configuration", run: async () => void executed.push("restore") }, - { name: "stop services", run: async () => void executed.push("stop") } + { + name: "restore configuration", + run: async () => void executed.push("restore"), + }, + { name: "stop services", run: async () => void executed.push("stop") }, ]); expect(executed).toEqual(["firewall", "restore", "stop"]); @@ -67,15 +73,15 @@ describe("стадии отката независимы", () => { name: "firewall", run: async () => { throw new Error("first"); - } + }, }, { name: "healthy", run: async () => undefined }, { name: "restore configuration", run: async () => { throw new Error("second"); - } - } + }, + }, ]); expect(failures).toHaveLength(2); @@ -91,14 +97,14 @@ describe("стадии отката независимы", () => { name: "a", run: async () => { throw new Error("boom"); - } + }, }, { name: "b", run: async () => { throw new Error("boom"); - } - } + }, + }, ]); expect(failures).toHaveLength(2); }); @@ -109,8 +115,8 @@ describe("стадии отката независимы", () => { name: "weird", run: async () => { throw "строковая ошибка"; - } - } + }, + }, ]); expect(failures[0]).toContain("строковая ошибка"); }); @@ -135,7 +141,7 @@ describe("состояние отказа пишется best effort", () => { await expect( persistFailureState(async () => { throw new Error("ENOSPC: no space left on device"); - }) + }), ).resolves.toBeUndefined(); }); }); @@ -173,7 +179,10 @@ describe("install: откат обязателен после операцион // Прямых await-вызовов отката в теле rollbackFailedInstall быть не должно: // именно они и образовывали отменяемую цепочку. const start = installSource.indexOf("async function rollbackFailedInstall"); - const body = installSource.slice(start, installSource.indexOf("export async function install")); + const body = installSource.slice( + start, + installSource.indexOf("export async function install"), + ); expect(body).toContain("await rollbackFirewallNow(context)"); // Вызов существует только внутри стадии. const firewallAt = body.indexOf("await rollbackFirewallNow(context)"); @@ -182,14 +191,19 @@ describe("install: откат обязателен после операцион }); test("остановка сервисов остаётся отдельными стадиями", () => { - for (const stage of ["stop services", "disable services", "reset failed services"]) { + for (const stage of [ + "stop services", + "disable services", + "reset failed hysteria-server", + "reset failed hy2xs-admin", + ]) { expect(installSource).toContain(`name: "${stage}"`); } }); test("чужие сервисы по-прежнему не трогаются", () => { expect(installSource).toContain( - "systemd units were not deployed by this operation, leaving services untouched" + "systemd units were not deployed by this operation, leaving services untouched", ); }); }); @@ -202,7 +216,7 @@ describe("reconfigure: откат обязателен после операци // выполняться ВНУТРИ persistFailureState, в какую бы строку его ни перенёс // редактор. const wrapped = reconfigureSource.match( - /await persistFailureState\(\(\) =>\s*markPhase\(context,\s*classifyReconfigureFailure\(ownership, error\), message\)\s*\)/ + /await persistFailureState\(\(\) =>\s*markPhase\(context,\s*classifyReconfigureFailure\(ownership, error\), message\)\s*\)/, ); expect(wrapped).not.toBeNull(); }); @@ -222,8 +236,12 @@ describe("reconfigure: откат обязателен после операци // Регрессия: отказ rollbackFirewallNow отменял rollbackCurrentState целиком. test("порядок сохранён: сначала firewall, затем конфиги", () => { - const firewall = reconfigureSource.indexOf("await rollbackFirewallNow(context)"); - const restore = reconfigureSource.indexOf("await rollbackCurrentState(context)"); + const firewall = reconfigureSource.indexOf( + "await rollbackFirewallNow(context)", + ); + const restore = reconfigureSource.indexOf( + "await rollbackCurrentState(context)", + ); const stages = reconfigureSource.indexOf("await runRollbackStages(stages)"); expect(firewall).toBeGreaterThan(-1); expect(firewall).toBeLessThan(restore); @@ -242,14 +260,19 @@ describe("reconfigure: откат обязателен после операци const start = reconfigureSource.indexOf("function restoreStages("); const body = reconfigureSource.slice( start, - reconfigureSource.indexOf("async function rollbackCurrentState") + reconfigureSource.indexOf("async function rollbackCurrentState"), ); const muted = body .split(/\r?\n/) .filter((line) => line.includes("runMutatingVisible`")) - .filter((line) => line.includes("|| true") || line.includes("2>/dev/null")); + .filter( + (line) => line.includes("|| true") || line.includes("2>/dev/null"), + ); - expect(muted, `восстановление снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]); + expect( + muted, + `восстановление снова скрывает ошибки: ${muted.join("; ")}`, + ).toEqual([]); }); test("восстановление разбито на независимые стадии", () => { @@ -260,7 +283,7 @@ describe("reconfigure: откат обязателен после операци "restore nftables files", "apply restored ruleset", "reload systemd units", - "restart services" + "restart services", ]) { expect(reconfigureSource).toContain(`"${stage}"`); } @@ -269,7 +292,9 @@ describe("reconfigure: откат обязателен после операци // Неполное восстановление обязано доходить до итоговой сводки как отказ. test("частичное восстановление не выдаётся за успешное", () => { expect(reconfigureSource).toContain("restoreFailures.length > 0"); - expect(reconfigureSource).toContain("восстановление состояния выполнено не полностью"); + expect(reconfigureSource).toContain( + "восстановление состояния выполнено не полностью", + ); }); }); @@ -282,12 +307,18 @@ describe("install: команды отката не глушат собстве // них не выполнялось никогда. test("стадии остановки сервисов не подавляют код возврата", () => { const start = installSource.indexOf("async function rollbackFailedInstall"); - const body = installSource.slice(start, installSource.indexOf("export async function install")); + const body = installSource.slice( + start, + installSource.indexOf("export async function install"), + ); const muted = body .split(/\r?\n/) .filter((line) => line.includes("runMutatingVisible`")) .filter((line) => line.includes("|| true")); - expect(muted, `стадии отката снова скрывают ошибки: ${muted.join("; ")}`).toEqual([]); + expect( + muted, + `стадии отката снова скрывают ошибки: ${muted.join("; ")}`, + ).toEqual([]); }); }); diff --git a/orchestrator/test/systemd-cleanup.test.ts b/orchestrator/test/systemd-cleanup.test.ts new file mode 100644 index 0000000..291de2c --- /dev/null +++ b/orchestrator/test/systemd-cleanup.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test"; +import { + resetFailedUnit, + type ResetFailedDependencies, +} from "../src/lib/systemd"; +import type { MutationResult } from "../src/lib/process"; +import { runRollbackStages } from "../src/lib/rollback"; + +function result(exitCode: number, stdout = "", stderr = ""): MutationResult { + return { exitCode, stdout, stderr }; +} + +function dependencies( + reset: MutationResult, + inspect: MutationResult, + calls: string[] = [], +): ResetFailedDependencies { + return { + reset: async (unit) => { + calls.push(`reset:${unit}`); + return reset; + }, + inspect: async (unit) => { + calls.push(`inspect:${unit}`); + return inspect; + }, + }; +} + +describe("systemd rollback cleanup", () => { + test("успешный reset-failed подтверждается наблюдением", async () => { + const calls: string[] = []; + await expect( + resetFailedUnit( + "hy2xs-admin", + dependencies( + result(0), + result(0, "LoadState=loaded\nActiveState=inactive\n"), + calls, + ), + ), + ).resolves.toBeUndefined(); + expect(calls).toEqual(["reset:hy2xs-admin", "inspect:hy2xs-admin"]); + }); + + test("уже выгруженный юнит является успешным конечным состоянием", async () => { + await expect( + resetFailedUnit( + "hy2xs-admin", + dependencies( + result(5, "", "Unit hy2xs-admin.service not loaded."), + result(0, "LoadState=not-found\nActiveState=inactive\n"), + ), + ), + ).resolves.toBeUndefined(); + }); + + test("уже выгруженный юнит не создаёт manual-recovery failure", async () => { + const failures = await runRollbackStages([ + { + name: "reset failed hy2xs-admin", + run: async () => + resetFailedUnit( + "hy2xs-admin", + dependencies( + result(5, "", "Unit hy2xs-admin.service not loaded."), + result(0, "LoadState=not-found\nActiveState=inactive\n"), + ), + ), + }, + ]); + expect(failures).toEqual([]); + }); + + test("решение не зависит от английского текста stderr", async () => { + await expect( + resetFailedUnit( + "hysteria-server", + dependencies( + result(5, "", "модуль не загружен"), + result(0, "LoadState=not-found\nActiveState=inactive\n"), + ), + ), + ).resolves.toBeUndefined(); + }); + + test("сохранившееся failed-состояние остаётся настоящим отказом", async () => { + await expect( + resetFailedUnit( + "hysteria-server", + dependencies( + result(1, "", "access denied"), + result(0, "LoadState=loaded\nActiveState=failed\n"), + ), + ), + ).rejects.toThrow("remains failed"); + }); + + test("ошибка наблюдения не выдаётся за успешную уборку", async () => { + await expect( + resetFailedUnit( + "hysteria-server", + dependencies(result(1), result(1, "", "Failed to connect to bus")), + ), + ).rejects.toThrow("cannot verify systemd state"); + }); + + test("неполный ответ systemctl show отклоняется fail-closed", async () => { + await expect( + resetFailedUnit( + "hysteria-server", + dependencies(result(0), result(0, "LoadState=loaded\n")), + ), + ).rejects.toThrow("did not return LoadState and ActiveState"); + }); +}); diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index 3b4eda7..0a5d6aa 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -1059,24 +1059,48 @@ EOF || fail "acceptance: smoke больше не выполняет настоящий вход в панель" code_has orchestrator/src/steps/smoke.ts -F -- 'ADMIN_LOGIN_PATH' \ || fail "acceptance: адрес формы входа перестал браться из профиля" - # Успех определяется КОНВЕРТОМ: админка отвечает 200 OK и на отказ тоже. - code_has orchestrator/src/steps/smoke.ts -F -- '"code"\s*:\s*20000' \ + # Успех определяется разобранным КОНВЕРТОМ: админка отвечает 200 OK и на + # отказ тоже, а regex по сырому тексту способен принять подстроку в message. + code_has orchestrator/src/steps/smoke.ts -F -- 'envelope?.code === 20000' \ || fail "acceptance: успех входа снова определяется кодом HTTP, а не конвертом ответа" - # Команда несёт пароль администратора и не имеет права попасть в текст ошибки: - # runReadOnly (без Secret) кладёт её туда, а текст уезжает в журнал установки - # и в diagnostics-бандл. Проверяется ТЕЛО функции, а не файл: остальной smoke - # законно пользуется обоими раннерами. - local login_body + # Positive и negative probe обязаны пользоваться одной транспортной функцией: + # две независимые curl-команды и разошлись в RC3 с production middleware. + local login_body login_request_body login_body="$(sed -n '/^async function assertAdminLoginWorks/,/^}/p' orchestrator/src/steps/smoke.ts)" \ || fail "acceptance: не удалось прочитать проверку входа в панель" [ -n "$login_body" ] \ || fail "acceptance: функция проверки входа в панель не найдена" - grep -q 'runReadOnlySecret`curl' <<<"$login_body" \ + [ "$(grep -c 'requestAdminLogin(' <<<"$login_body")" -eq 2 ] \ + || fail "acceptance: positive и negative login probe не используют один request helper" + ! grep -q 'curl ' <<<"$login_body" \ + || fail "acceptance: assertAdminLoginWorks снова содержит отдельную curl-команду" + + login_request_body="$(sed -n '/^async function requestAdminLogin/,/^}/p' orchestrator/src/steps/smoke.ts)" \ + || fail "acceptance: не удалось прочитать transport helper входа" + [ -n "$login_request_body" ] \ + || fail "acceptance: общий transport helper входа не найден" + grep -q 'runReadOnlySecret`curl' <<<"$login_request_body" \ || fail "acceptance: запрос входа не выполняется секретным раннером" # `runReadOnly`curl` совпадает только с ГОЛОЙ формой: у секретной после # `runReadOnly` идёт `Secret`, а не обратная кавычка. - ! grep -q 'runReadOnly`curl' <<<"$login_body" \ + ! grep -q 'runReadOnly`curl' <<<"$login_request_body" \ || fail "acceptance: запрос входа выполняется раннером, кладущим команду в текст ошибки" + grep -q -- '--user-agent' <<<"$login_request_body" \ + || fail "acceptance: login smoke снова наследует блокируемый curl User-Agent" + + # Wire-contract проверяется значением, а не поиском форматирования в исходнике. + "$BUN_BIN" -e ' + const smoke = await import("./orchestrator/src/steps/smoke.ts"); + const request = smoke.buildAdminLoginRequest("hy2xsadmin", "quote\" slash\\ tail "); + const body = JSON.parse(request.body); + if (body.username !== "hy2xsadmin" || body.pass !== "quote\" slash\\ tail ") { + throw new Error("login helper искажает username/pass"); + } + if (Object.hasOwn(body, "password")) throw new Error("login helper отправляет alias password"); + if (!request.userAgent || /fofa|shodan|curl|wget/i.test(request.userAgent)) { + throw new Error("login helper отправляет scanner-like User-Agent"); + } + ' || fail "acceptance: общий login helper нарушает wire-контракт или scanner middleware" log_step "Acceptance: the negative login probe proves a rejection" # Проверка `code.trim() === "200"` доказывала ровно одно: что запрос не @@ -1084,6 +1108,8 @@ EOF # поэтому «неверные учётные данные отвергнуты» из неё не следовало. code_has orchestrator/src/steps/smoke.ts -F -- 'isRejectedLogin' \ || fail "acceptance: отрицательная проба снова определяет отказ по коду HTTP" + code_has orchestrator/src/steps/smoke.ts -F -- 'JSON.parse(body)' \ + || fail "acceptance: login envelope снова проверяется regex по сырому тексту" code_has orchestrator/src/steps/smoke.ts -F -- 'INVALID_CREDENTIALS_CODE = "invalid_credentials"' \ || fail "acceptance: отрицательная проба не сверяет доменный код отказа" # Литерал в исходнике теоретически может оказаться настоящим паролем — тогда @@ -1579,6 +1605,17 @@ run_single_owner_acceptance() { grep -q 'persistFailureState(' "$rollback_command" \ || fail "acceptance: запись состояния отказа в $rollback_command не помечена как best effort" done + # reset-failed — cleanup с допустимым исходом «юнит уже выгружен». Ненулевой + # код нельзя ни превращать в ложный manual-recovery warning, ни глушить: + # решение принимается после наблюдения ActiveState каждого юнита. + grep -q 'resetFailedUnit("hysteria-server")' orchestrator/src/commands/install.ts \ + || fail "acceptance: rollback install не проверяет postcondition reset-failed для hysteria-server" + grep -q 'resetFailedUnit("hy2xs-admin")' orchestrator/src/commands/install.ts \ + || fail "acceptance: rollback install не проверяет postcondition reset-failed для hy2xs-admin" + code_has orchestrator/src/lib/systemd.ts -F -- 'runMutatingStatus`systemctl reset-failed' \ + || fail "acceptance: reset-failed снова бросает до проверки допустимого not-loaded состояния" + code_has orchestrator/src/lib/systemd.ts -F -- 'properties.get("ActiveState")' \ + || fail "acceptance: успех reset-failed не доказывается наблюдаемым ActiveState" "$BUN_BIN" -e ' const fs = require("node:fs"); for (const [file, write] of [