Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 079094591b |
@@ -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`), имена создаются эксклюзивно,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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/<version>` получает 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.
|
||||
@@ -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 |
|
||||
|
||||
## Разборы кода между прогонами
|
||||
|
||||
|
||||
@@ -75,11 +75,12 @@ sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env
|
||||
одной из своих стадий. Поэтому в журнале нужно читать две разные вещи:
|
||||
|
||||
| Строка в журнале | Что она означает |
|
||||
| --- | --- |
|
||||
| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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 <unit> exited with …, but the cleanup postcondition is satisfied` | юнит уже выгружен или не находится в `failed`; это допустимый идемпотентный cleanup, ручное восстановление не требуется |
|
||||
| `manual recovery data preserved at /run/hy2xs/rollback/<op>` | firewall восстановлен не полностью; прежние `nftables.conf` и `hy2xs.nft` лежат по этому пути |
|
||||
| `firewall rollback guard armed: … fires in 45s (timer accuracy 1s)` | guard взведён; с этого момента операция обязана снять его до фиксации успеха |
|
||||
| `firewall rollback guard disarmed and proven inactive` | guard снят, и это подтверждено состоянием юнитов и отсутствием маркера срабатывания |
|
||||
@@ -336,6 +337,7 @@ hy2xs-orchestrator redact-config --config /etc/hysteria/config.yaml --out /root/
|
||||
```
|
||||
|
||||
Инварианты:
|
||||
|
||||
- команда не выводит исходные секреты в stdout;
|
||||
- требуется выбрать ровно один режим: `--in-place` или `--out <path>`;
|
||||
- `--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` на сервере,
|
||||
который мы обещали не трогать.
|
||||
|
||||
|
||||
@@ -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
|
||||
- один сервер
|
||||
@@ -128,7 +132,7 @@ Guard умеет останавливать только то, что через
|
||||
универсального раннера в `lib/process.ts` нет — есть два явных набора:
|
||||
|
||||
| Набор | Guard | Назначение |
|
||||
| --- | --- | --- |
|
||||
| -------------------------------------------------------------------------- | --------------------- | --------------------------------------------------------------------- |
|
||||
| `runReadOnly`, `runReadOnlySecret` | не трогает | наблюдение за системой: `ss`, `systemctl is-active`, `curl`, `getent` |
|
||||
| `runMutating`, `runMutatingVisible`, `runMutatingHidden`, `runMutatingRaw` | спрашивает разрешение | всё, что может изменить хост |
|
||||
|
||||
@@ -278,8 +282,8 @@ preflight-ошибка со словом `nftables` приводила к отк
|
||||
Второй инвариант — **стадии отката независимы**:
|
||||
|
||||
| Команда | Стадии |
|
||||
| --- | --- |
|
||||
| `install` | firewall → stop services → disable services → reset failed services |
|
||||
| ------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `install` | firewall → stop services → disable services → reset failed `hysteria-server` → reset failed `hy2xs-admin` |
|
||||
| `reconfigure` | firewall → restore configuration |
|
||||
|
||||
Каждая стадия — это `systemctl`, `cp`, `rm -rf` или `nft`, то есть каждая умеет
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -424,7 +440,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
|
||||
Строгость управляется `HY2XS_PUBLIC_ENDPOINT_POLICY`:
|
||||
|
||||
| Значение | Поведение |
|
||||
| --- | --- |
|
||||
| ----------------------- | ------------------------------------------------ |
|
||||
| `strict` (по умолчанию) | расхождение останавливает операцию |
|
||||
| `warn` | печатается предупреждение, операция продолжается |
|
||||
| `off` | сравнение не выполняется |
|
||||
@@ -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 <path> [--config <source-env>]`
|
||||
- `install --package-dir <path> [--config <source-env>]`
|
||||
- `reconfigure --package-dir <path> --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`;
|
||||
@@ -646,7 +669,7 @@ EnvironmentFile дополнительно запрещает U+FEFF. Реали
|
||||
Поэтому smoke выполняет **настоящий вход** на `POST /api/auth/login`:
|
||||
|
||||
| Проба | Когда | Что требуется |
|
||||
| --- | --- | --- |
|
||||
| ---------------------------------------------------- | ---------------- | ----------------------------------------------------------------------- |
|
||||
| настоящий логин + СЛУЧАЙНЫЙ пароль | всегда | `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/<version>` отклоняется действующим 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`);
|
||||
|
||||
@@ -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/<version>` осознанно отклоняется scanner middleware с HTTP 403 ещё до
|
||||
разбора JSON. Значение совпадает с production smoke и не маскируется под браузер.
|
||||
|
||||
## C1. Семантический smoke конфига
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
// Флаг взводится ПЕРЕД записью, а не после неё: см. комментарий к
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// Всё, что дальше, уже создаёт наши собственные пути, и повторная проверка
|
||||
// опознала бы их как чужую установку.
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
: "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<void> {
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { info } from "./log";
|
||||
import {
|
||||
runMutatingStatus,
|
||||
runReadOnlyArgvStatus,
|
||||
type MutationResult,
|
||||
} from "./process";
|
||||
|
||||
export type ResetFailedDependencies = {
|
||||
reset: (unit: string) => Promise<MutationResult>;
|
||||
inspect: (unit: string) => Promise<MutationResult>;
|
||||
};
|
||||
|
||||
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<string, string> {
|
||||
const properties = new Map<string, string>();
|
||||
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<void> {
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+216
-64
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// не меняет диагностируемую систему» невозможно было включить 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
"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<void> {
|
||||
// синтаксически валидный — 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<void> {
|
||||
// доступа. Логин берётся настоящий — тот же, что у администратора, — чтобы
|
||||
// запрос шёл тем же путём, что и реальный вход, и доходил до проверки пароля.
|
||||
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<void> {
|
||||
// пароля с настоящим невероятно, но проверить это здесь можно точно, и тогда
|
||||
// «отвергнуто» гарантированно означает «отвергнуто», а не «не совпало».
|
||||
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/<version>`, а 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<string> {
|
||||
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<string, unknown>;
|
||||
|
||||
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<void> {
|
||||
* Выданный токен требуется отдельно: `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<Record<string, string>> {
|
||||
async function readBootstrapAdminSecret(
|
||||
context: RuntimeContext,
|
||||
): Promise<Record<string, string>> {
|
||||
return parseEnvFile(await readText(context.config.bootstrapAdminSecretPath));
|
||||
}
|
||||
|
||||
@@ -452,21 +597,26 @@ async function readBootstrapAdminSecret(context: RuntimeContext): Promise<Record
|
||||
* текст этой ошибки уезжает в журнал установки и в diagnostics-бандл, который
|
||||
* операторы пересылают в переписке. Поэтому наружу выдаётся только код ответа.
|
||||
*/
|
||||
function describeLoginFailure(body: string | undefined, error: unknown): string {
|
||||
function describeLoginFailure(
|
||||
body: string | undefined,
|
||||
error: unknown,
|
||||
): string {
|
||||
if (body === undefined) {
|
||||
return `запрос не выполнен: ${String(error)}`;
|
||||
}
|
||||
const code = body.match(/"code"\s*:\s*(\d+)/);
|
||||
if (code) {
|
||||
return `ответ с code=${code[1]} и без токена доступа`;
|
||||
const envelope = parseLoginEnvelope(body);
|
||||
if (typeof envelope?.code === "number") {
|
||||
return `ответ с code=${envelope.code} и без токена доступа`;
|
||||
}
|
||||
return "ответ не является конвертом API админки";
|
||||
}
|
||||
|
||||
/** Подтверждение выдачи токена без самого токена. */
|
||||
function describeIssuedToken(body: string): string {
|
||||
const tokenType = body.match(/"tokenType"\s*:\s*"([^"]*)"/);
|
||||
return tokenType ? `выдан токен типа ${tokenType[1]}` : "выдан токен доступа";
|
||||
const data = objectOrNull(parseLoginEnvelope(body)?.data);
|
||||
return typeof data?.tokenType === "string" && data.tokenType.length > 0
|
||||
? `выдан токен типа ${data.tokenType}`
|
||||
: "выдан токен доступа";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -474,7 +624,9 @@ function describeIssuedToken(body: string): string {
|
||||
* пакета. На reconfigure metadata может относиться к другому пакету, поэтому
|
||||
* расхождение там — предупреждение, а не отказ.
|
||||
*/
|
||||
async function assertEffectiveHysteriaVersion(context: RuntimeContext): Promise<void> {
|
||||
async function assertEffectiveHysteriaVersion(
|
||||
context: RuntimeContext,
|
||||
): Promise<void> {
|
||||
const packagedVersion = context.hysteriaVersion.trim();
|
||||
if (!packagedVersion || packagedVersion === "unknown") {
|
||||
return;
|
||||
|
||||
@@ -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(/(?<!Secret)runReadOnly`/);
|
||||
expect(body).toMatch(/runReadOnlySecret`/);
|
||||
expect(body.match(/runReadOnlySecret`/g)).toHaveLength(1);
|
||||
|
||||
// Подстановка в tagged template — это НЕ утечка: значения там проходят
|
||||
// через shellQuote и в текст ошибки не попадают. Утечкой была бы подстановка
|
||||
@@ -327,12 +398,17 @@ describe("smoke выполняет настоящий вход в панель",
|
||||
// в журнал установки и в diagnostics-бандл.
|
||||
const messages = [
|
||||
...body.matchAll(/\binfo\(([\s\S]*?)\);/g),
|
||||
...body.matchAll(/new Error\(([\s\S]*?)\);/g)
|
||||
...body.matchAll(/new Error\(([\s\S]*?)\);/g),
|
||||
].map((match) => 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='`) при этом законны и остаются: они
|
||||
|
||||
@@ -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("установленный бинарник сверяется с закреплённой версией", () => {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user