Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95f81c2858 | |||
| 041f88b6a2 | |||
| 5c1dd24111 | |||
| b7e478f014 | |||
| 079094591b |
+57
-2
@@ -8,6 +8,61 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Документация — итоговая приёмка v1.0.0
|
||||||
|
|
||||||
|
- Зафиксирован финальный production-прогон `1.0.0-rc5`: build, повторная
|
||||||
|
проверка опубликованного артефакта, чистая установка на Debian 13, reboot,
|
||||||
|
Admin/UI, управление пирами, diagnostics и внешний E2E официальным клиентом
|
||||||
|
Hysteria 2.12.2 дали `PASS`.
|
||||||
|
- Отдельно записан downstream gateway/TUN-прогон через `vpn-egressctl 0.2.0`
|
||||||
|
и sing-box 1.14.0, включая реального клиента за `br_vpn`, idempotent sync и
|
||||||
|
fail-closed / anti-leak проверку.
|
||||||
|
- Release identity привязана к source commit `b7e478f0143846633d1261ca0bcd220736d28f44`,
|
||||||
|
Build ID `rc5-final-fi-20260908T003829Z` и SHA-256 артефакта
|
||||||
|
`d35cf469d8e1e3b5c1e79cb94329c1b19d1afbd7ade771498d2fb8caf5cd746a`.
|
||||||
|
Stable `v1.0.0` опубликован из этих же байтов без пересборки и по решению
|
||||||
|
release operator остаётся привязан к тегу `1.0.0-rc5`; docs-only коммиты не
|
||||||
|
меняют идентичность проверенного кандидата.
|
||||||
|
- Полный протокол опубликован в
|
||||||
|
[итоговом отчёте о приёмке RC5](docs/acceptance/2026-09-15-v1.0.0-rc5-final-acceptance.md).
|
||||||
|
- Отдельная
|
||||||
|
[фиксация stable promotion](docs/acceptance/2026-09-15-v1.0.0-stable-promotion.md)
|
||||||
|
записывает фактическую связь release/tag и basename-only checksum-sidecar
|
||||||
|
размером 93 байта.
|
||||||
|
|
||||||
|
Runtime-разбор RC4 закрыл три дефекта панели и один связанный риск
|
||||||
|
сопровождения. Набор знаков имени пира больше не хранится как сырой `@` внутри
|
||||||
|
message format: общий frontend-контракт передаёт его в RU/EN-подсказку через
|
||||||
|
named interpolation и сверяется с Go-константами. Builder после frozen install
|
||||||
|
компилирует реальным `vue-i18n` каждую строку обоих словарей, перехватывая как
|
||||||
|
исключения, так и compiler diagnostics в `console.error`; удалить этот gate и
|
||||||
|
при этом получить `tests_gate=true` нельзя. Сам `vue-i18n` переведён с
|
||||||
|
неподдерживаемой ветки 9.14.5 на поддерживаемую 11.4.10.
|
||||||
|
|
||||||
|
Попутный аудит package manager устранил ложную декларацию security overrides:
|
||||||
|
pnpm предупреждал, что больше не читает настройки из поля `pnpm` в
|
||||||
|
`package.json`. Правила перенесены в `apps/frontend/pnpm-workspace.yaml`, то
|
||||||
|
есть теперь не только сохранены в старом lock-файле, но и действительно
|
||||||
|
участвуют в следующем разрешении графа.
|
||||||
|
|
||||||
|
Dashboard использует `value` у `el-radio-button` вместо deprecated-семантики
|
||||||
|
«`label` как значение», а общий frontend-контракт запрещает вернуть старый API
|
||||||
|
в любом Vue-компоненте. ENV-redaction теперь имеет одно точное исключение для
|
||||||
|
диагностического boolean `HY2XS_FORCE_PASSWORD_CHANGE`: корректное значение
|
||||||
|
`true`/`false` видно в bundle, но другое значение либо совпадение по
|
||||||
|
префиксу/суффиксу исключения не даёт, и пароли, токены, секреты Hysteria и
|
||||||
|
credentials продолжают заменяться на `<redacted>`.
|
||||||
|
|
||||||
|
По результатам 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`, которым владеет
|
Diagnostics больше не пишет root-артефакты в `/var/log/hy2xs`, которым владеет
|
||||||
`hy2xs-admin`. Staging и архив перенесены в проверяемый через `lstat` каталог
|
`hy2xs-admin`. Staging и архив перенесены в проверяемый через `lstat` каталог
|
||||||
`/var/lib/hy2xs/diagnostics` (`root:root 0700`), имена создаются эксклюзивно,
|
`/var/lib/hy2xs/diagnostics` (`root:root 0700`), имена создаются эксклюзивно,
|
||||||
@@ -2279,5 +2334,5 @@ v257.13, то есть той самой линии, что стоит на Debi
|
|||||||
Клиентские ссылки `0.x` в любом случае перестанут работать: смена
|
Клиентские ссылки `0.x` в любом случае перестанут работать: смена
|
||||||
обфускации — это изменение wire-совместимости.
|
обфускации — это изменение wire-совместимости.
|
||||||
|
|
||||||
[Unreleased]: https://git.flamy.studio/prod/HY2XS_flamy/compare/v1.0.0...HEAD
|
[Unreleased]: https://git.flamy.studio/prod/HY2XS_flamy/compare/1.0.0-rc5...main
|
||||||
[1.0.0]: https://git.flamy.studio/prod/HY2XS_flamy/releases/tag/v1.0.0
|
[1.0.0]: https://git.flamy.studio/prod/HY2XS_flamy/releases/tag/1.0.0-rc5
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#что-это">Что это</a> ·
|
<a href="#что-это">Что это</a> ·
|
||||||
|
<a href="#статус-приёмки-v100">Приёмка v1.0.0</a> ·
|
||||||
<a href="#возможности">Возможности</a> ·
|
<a href="#возможности">Возможности</a> ·
|
||||||
<a href="#быстрый-старт-для-нового-сервера">Быстрый старт</a> ·
|
<a href="#быстрый-старт-для-нового-сервера">Быстрый старт</a> ·
|
||||||
<a href="#конфигурация-hy2xsenv">Конфигурация</a> ·
|
<a href="#конфигурация-hy2xsenv">Конфигурация</a> ·
|
||||||
@@ -37,6 +38,22 @@ HY2XS подходит для сценария, где нужен один produ
|
|||||||
|
|
||||||
Ключевая идея: на target‑сервере выполняется только установка готового пакета. Вся тяжёлая сборочная часть остаётся на build‑машине.
|
Ключевая идея: на target‑сервере выполняется только установка готового пакета. Вся тяжёлая сборочная часть остаётся на build‑машине.
|
||||||
|
|
||||||
|
## Статус приёмки v1.0.0
|
||||||
|
|
||||||
|
Stable `v1.0.0` выпущен из принятого artifact `1.0.0-rc5` без пересборки.
|
||||||
|
Проверенные source commit, tarball и SHA-256 не изменялись; release сознательно
|
||||||
|
остаётся привязан к тегу `1.0.0-rc5`.
|
||||||
|
|
||||||
|
Финальная production-приёмка на чистом Debian 13 охватила build, установку,
|
||||||
|
reboot persistence, Admin/UI, управление пирами, diagnostics, реальный E2E
|
||||||
|
официальным клиентом Hysteria 2.12.2 и дополнительный gateway/TUN anti-leak
|
||||||
|
прогон. Вердикт: **`PASS / PRODUCTION E2E VALIDATED`**.
|
||||||
|
|
||||||
|
Подробности:
|
||||||
|
|
||||||
|
- [итоговый отчёт о приёмке RC5](docs/acceptance/2026-09-15-v1.0.0-rc5-final-acceptance.md);
|
||||||
|
- [фиксация stable promotion](docs/acceptance/2026-09-15-v1.0.0-stable-promotion.md).
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
HY2XS release‑пакет разворачивает и настраивает:
|
HY2XS release‑пакет разворачивает и настраивает:
|
||||||
@@ -771,6 +788,11 @@ hy2xs-orchestrator diagnostics collect \
|
|||||||
декодируется с заменой и не попадает в архив; вместо него записывается
|
декодируется с заменой и не попадает в архив; вместо него записывается
|
||||||
безопасная причина пропуска.
|
безопасная причина пропуска.
|
||||||
|
|
||||||
|
`HY2XS_FORCE_PASSWORD_CHANGE` — диагностический boolean, поэтому его значение
|
||||||
|
`true`/`false` сохраняется. Исключение точное и не распространяется на другие
|
||||||
|
ключи с `PASSWORD`: начальный пароль администратора, `ADMIN_CON_PASS`, пароль
|
||||||
|
obfs и остальные секреты по-прежнему заменяются на `<redacted>`.
|
||||||
|
|
||||||
Архив создаётся в `/var/lib/hy2xs/diagnostics` с режимом `0600`. Этот каталог
|
Архив создаётся в `/var/lib/hy2xs/diagnostics` с режимом `0600`. Этот каталог
|
||||||
принадлежит `root:root`, имеет режим `0700` и отделён от
|
принадлежит `root:root`, имеет режим `0700` и отделён от
|
||||||
`HY2XS_LOG_DIR`, которым владеет сервисный пользователь `hy2xs-admin`.
|
`HY2XS_LOG_DIR`, которым владеет сервисный пользователь `hy2xs-admin`.
|
||||||
@@ -1008,17 +1030,18 @@ export GITHUB_TOKEN=<token>
|
|||||||
|
|
||||||
1. проверяет контракт `versions.env` (`verify_versions_contract`);
|
1. проверяет контракт `versions.env` (`verify_versions_contract`);
|
||||||
2. прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`);
|
2. прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`);
|
||||||
3. прогоняет контрактные тесты панели (спрайт иконок, словари локализации, коды ошибок, атрибуция);
|
3. прогоняет dependency-free контракты панели (спрайт иконок, совпадение словарей, коды ошибок, атрибуция и frontend/Go-контракты);
|
||||||
4. определяет последнюю стабильную версию Hysteria, берёт ожидаемый SHA‑256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт;
|
4. определяет последнюю стабильную версию Hysteria, берёт ожидаемый SHA‑256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт;
|
||||||
5. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander;
|
5. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander;
|
||||||
6. собирает orchestrator, frontend и backend, проставляя версию админки из контракта;
|
6. собирает standalone-бинарник orchestrator;
|
||||||
7. прогоняет `go vet` и `go test` для HY2XS admin;
|
7. устанавливает frontend lock-граф, runtime-компилирует каждое сообщение RU/EN реальным `vue-i18n`, затем проверяет типы и собирает frontend и backend, проставляя версию админки из контракта;
|
||||||
8. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу);
|
8. прогоняет `go vet` и `go test` для HY2XS admin;
|
||||||
9. формирует архив и прогоняет acceptance‑проверки.
|
9. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу);
|
||||||
|
10. формирует архив и прогоняет acceptance‑проверки.
|
||||||
|
|
||||||
Любой сбой на шагах 1–8 останавливает сборку до создания пакета.
|
Любой сбой на шагах 1–9 останавливает сборку до создания пакета.
|
||||||
|
|
||||||
Тесты и типы (шаги 2, 3 и 7) — такой же обязательный гейт, как проверка
|
Тесты и типы (шаги 2, 3, 7 и 8) — такой же обязательный гейт, как проверка
|
||||||
зависимостей: переменной, которая их отключает, не существует. Готовый пакет
|
зависимостей: переменной, которая их отключает, не существует. Готовый пакет
|
||||||
объявляет об этом полем `tests_gate=true` в `metadata/package.env`, и это
|
объявляет об этом полем `tests_gate=true` в `metadata/package.env`, и это
|
||||||
утверждение опирается на фактический прогон, а не на намерение.
|
утверждение опирается на фактический прогон, а не на намерение.
|
||||||
@@ -1028,6 +1051,14 @@ export GITHUB_TOKEN=<token>
|
|||||||
`bun test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts`
|
`bun test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts`
|
||||||
запускаются напрямую и tarball не создают.
|
запускаются напрямую и tarball не создают.
|
||||||
|
|
||||||
|
Runtime-проверка словарей требует установленного frontend lock-графа:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/frontend
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
bun test test/i18n-runtime.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
Переменные, управляющие выбором версии Hysteria:
|
Переменные, управляющие выбором версии Hysteria:
|
||||||
|
|
||||||
| Переменная | По умолчанию | Назначение |
|
| Переменная | По умолчанию | Назначение |
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"hy2xs-admin/credential"
|
"hy2xs-admin/credential"
|
||||||
"hy2xs-admin/dao"
|
"hy2xs-admin/dao"
|
||||||
|
"hy2xs-admin/middleware"
|
||||||
"hy2xs-admin/model/constant"
|
"hy2xs-admin/model/constant"
|
||||||
"hy2xs-admin/model/dto"
|
"hy2xs-admin/model/dto"
|
||||||
"hy2xs-admin/model/entity"
|
"hy2xs-admin/model/entity"
|
||||||
@@ -460,6 +461,96 @@ func postLogin(t *testing.T, body any) (int, apiResult) {
|
|||||||
return postLoginRaw(t, payload, false)
|
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-учёткой обязан выдать токен.
|
// Регрессия RC2 целиком: вход bootstrap-учёткой обязан выдать токен.
|
||||||
func TestLoginEndpointIssuesTokenForValidCredentials(t *testing.T) {
|
func TestLoginEndpointIssuesTokenForValidCredentials(t *testing.T) {
|
||||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||||
|
|||||||
@@ -19,5 +19,11 @@ Frontend собирается production builder'ом из [`tools/build`](../..
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm run build:prod
|
bun test test/i18n-runtime.test.ts
|
||||||
|
pnpm run verify
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Runtime-тест обходит все строковые сообщения русского и английского словарей
|
||||||
|
через установленный `vue-i18n`. Он проверяет не только исключения, но и
|
||||||
|
compiler diagnostics в `console.error`: некоторые версии компилятора сообщают
|
||||||
|
ошибку формата без исключения из `t()`.
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"qrcode.vue": "3.4.1",
|
"qrcode.vue": "3.4.1",
|
||||||
"vue": "^3.5.42",
|
"vue": "^3.5.42",
|
||||||
"vue-echarts": "^8.1.0",
|
"vue-echarts": "^8.1.0",
|
||||||
"vue-i18n": "^9.14.5",
|
"vue-i18n": "^11.4.10",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -65,17 +65,6 @@
|
|||||||
"vue-eslint-parser": "10.4.1",
|
"vue-eslint-parser": "10.4.1",
|
||||||
"vue-tsc": "^3.3.11"
|
"vue-tsc": "^3.3.11"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
|
||||||
"overrides": {
|
|
||||||
"lodash": ">=4.18.1",
|
|
||||||
"lodash-es": ">=4.18.1",
|
|
||||||
"brace-expansion": ">=1.1.18",
|
|
||||||
"picomatch": ">=2.3.2",
|
|
||||||
"js-yaml": ">=4.3.1",
|
|
||||||
"table>ajv": ">=8.18.0",
|
|
||||||
"browserslist": "4.28.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+45
-38
@@ -4,15 +4,6 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
overrides:
|
|
||||||
lodash: '>=4.18.1'
|
|
||||||
lodash-es: '>=4.18.1'
|
|
||||||
brace-expansion: '>=1.1.18'
|
|
||||||
picomatch: '>=2.3.2'
|
|
||||||
js-yaml: '>=4.3.1'
|
|
||||||
table>ajv: '>=8.18.0'
|
|
||||||
browserslist: 4.28.7
|
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -54,8 +45,8 @@ importers:
|
|||||||
specifier: ^8.1.0
|
specifier: ^8.1.0
|
||||||
version: 8.1.0(echarts@6.1.0)(vue@3.5.42(typescript@5.9.3))
|
version: 8.1.0(echarts@6.1.0)(vue@3.5.42(typescript@5.9.3))
|
||||||
vue-i18n:
|
vue-i18n:
|
||||||
specifier: ^9.14.5
|
specifier: ^11.4.10
|
||||||
version: 9.14.5(vue@3.5.42(typescript@5.9.3))
|
version: 11.4.10(vue@3.5.42(typescript@5.9.3))
|
||||||
vue-router:
|
vue-router:
|
||||||
specifier: ^4.6.4
|
specifier: ^4.6.4
|
||||||
version: 4.6.4(vue@3.5.42(typescript@5.9.3))
|
version: 4.6.4(vue@3.5.42(typescript@5.9.3))
|
||||||
@@ -492,17 +483,21 @@ packages:
|
|||||||
'@iconify/utils@3.1.4':
|
'@iconify/utils@3.1.4':
|
||||||
resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==}
|
resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==}
|
||||||
|
|
||||||
'@intlify/core-base@9.14.5':
|
'@intlify/core-base@11.4.10':
|
||||||
resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==}
|
resolution: {integrity: sha512-+yJ74JRWVJokdgG9zYNMyTSzeNV3O9T4vVxk8PvLFHmI+R/BYA//cITh7vhRK37hWLZ4/kTcKcUz1dlWOpypIg==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 22'}
|
||||||
|
|
||||||
'@intlify/message-compiler@9.14.5':
|
'@intlify/devtools-types@11.4.10':
|
||||||
resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==}
|
resolution: {integrity: sha512-xZxzZsAuu6/0zoLRVQWdpXWe5Kjl0LnWpjlQA3r9u9FbLYMhapqt7IwkgQyn0Tm2GUNAqhj9eZiUmYOrB024BQ==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 22'}
|
||||||
|
|
||||||
'@intlify/shared@9.14.5':
|
'@intlify/message-compiler@11.4.10':
|
||||||
resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==}
|
resolution: {integrity: sha512-oUB/scz2EJENXDiUJ7JjZffOrH8UIZ1BuZeHvonbi5fWLavLt04aivuk2OIByOZA0tsci1bkeeQRmwhb5M8Imw==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 22'}
|
||||||
|
|
||||||
|
'@intlify/shared@11.4.10':
|
||||||
|
resolution: {integrity: sha512-FeImVdPeoSHTm3NBFFZHv0eRP9gQ3F4lj2puDBX5Kw7iiM1uJW6JTf39ian0K/17pbXCI3ef5i9RVsRrALqI6Q==}
|
||||||
|
engines: {node: '>= 22'}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
@@ -1525,7 +1520,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
picomatch: '>=2.3.2'
|
picomatch: ^3 || ^4
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
@@ -1789,8 +1784,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
|
resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/lodash-es': '*'
|
'@types/lodash-es': '*'
|
||||||
lodash: '>=4.18.1'
|
lodash: '*'
|
||||||
lodash-es: '>=4.18.1'
|
lodash-es: '*'
|
||||||
|
|
||||||
lodash.truncate@4.4.2:
|
lodash.truncate@4.4.2:
|
||||||
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
|
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
|
||||||
@@ -1957,6 +1952,10 @@ packages:
|
|||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
|
picomatch@2.3.2:
|
||||||
|
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
|
||||||
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
picomatch@4.0.7:
|
picomatch@4.0.7:
|
||||||
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2406,7 +2405,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==}
|
resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
browserslist: 4.28.7
|
browserslist: '>= 4.21.0'
|
||||||
|
|
||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||||
@@ -2483,10 +2482,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||||
|
|
||||||
vue-i18n@9.14.5:
|
vue-i18n@11.4.10:
|
||||||
resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==}
|
resolution: {integrity: sha512-Lp+BjOxqzOY87DS6Z8KrQrpiTr9IN/Lt4kZEilwyXG2Wrx+AcU6IVsAW92HNXtVcn1HFFPV6ty41p9e/qDpyvg==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 22'}
|
||||||
deprecated: v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html
|
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.0.0
|
vue: ^3.0.0
|
||||||
|
|
||||||
@@ -2782,17 +2780,23 @@ snapshots:
|
|||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
import-meta-resolve: 4.2.0
|
import-meta-resolve: 4.2.0
|
||||||
|
|
||||||
'@intlify/core-base@9.14.5':
|
'@intlify/core-base@11.4.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@intlify/message-compiler': 9.14.5
|
'@intlify/devtools-types': 11.4.10
|
||||||
'@intlify/shared': 9.14.5
|
'@intlify/message-compiler': 11.4.10
|
||||||
|
'@intlify/shared': 11.4.10
|
||||||
|
|
||||||
'@intlify/message-compiler@9.14.5':
|
'@intlify/devtools-types@11.4.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@intlify/shared': 9.14.5
|
'@intlify/core-base': 11.4.10
|
||||||
|
'@intlify/shared': 11.4.10
|
||||||
|
|
||||||
|
'@intlify/message-compiler@11.4.10':
|
||||||
|
dependencies:
|
||||||
|
'@intlify/shared': 11.4.10
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
'@intlify/shared@9.14.5': {}
|
'@intlify/shared@11.4.10': {}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4113,7 +4117,7 @@ snapshots:
|
|||||||
micromatch@4.0.8:
|
micromatch@4.0.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
braces: 3.0.3
|
braces: 3.0.3
|
||||||
picomatch: 4.0.7
|
picomatch: 2.3.2
|
||||||
|
|
||||||
mime-db@1.52.0: {}
|
mime-db@1.52.0: {}
|
||||||
|
|
||||||
@@ -4245,6 +4249,8 @@ snapshots:
|
|||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
|
picomatch@2.3.2: {}
|
||||||
|
|
||||||
picomatch@4.0.7: {}
|
picomatch@4.0.7: {}
|
||||||
|
|
||||||
pinia@2.3.1(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)):
|
pinia@2.3.1(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)):
|
||||||
@@ -4798,10 +4804,11 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
vue-i18n@9.14.5(vue@3.5.42(typescript@5.9.3)):
|
vue-i18n@11.4.10(vue@3.5.42(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@intlify/core-base': 9.14.5
|
'@intlify/core-base': 11.4.10
|
||||||
'@intlify/shared': 9.14.5
|
'@intlify/devtools-types': 11.4.10
|
||||||
|
'@intlify/shared': 11.4.10
|
||||||
'@vue/devtools-api': 6.6.4
|
'@vue/devtools-api': 6.6.4
|
||||||
vue: 3.5.42(typescript@5.9.3)
|
vue: 3.5.42(typescript@5.9.3)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Настройки resolution принадлежат pnpm, а не npm package manifest.
|
||||||
|
# Единственный workspace-проект — сам frontend.
|
||||||
|
packages:
|
||||||
|
- "."
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
lodash: ">=4.18.1"
|
||||||
|
lodash-es: ">=4.18.1"
|
||||||
|
brace-expansion: ">=1.1.18"
|
||||||
|
picomatch: ">=2.3.2"
|
||||||
|
js-yaml: ">=4.3.1"
|
||||||
|
"table>ajv": ">=8.18.0"
|
||||||
|
browserslist: "4.28.7"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Контракт имени пира на стороне панели.
|
||||||
|
*
|
||||||
|
* Сервер остаётся конечным авторитетом: эти значения зеркалят константы из
|
||||||
|
* apps/service/peer_import.go и сверяются с ними release-тестом. Общий модуль
|
||||||
|
* не даёт форме, сообщению об ошибке и подсказке завести разные копии правила.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PEER_NAME_MIN_LENGTH = 6;
|
||||||
|
export const PEER_NAME_MAX_LENGTH = 32;
|
||||||
|
|
||||||
|
/** Набор знаков без букв и цифр — передаётся в i18n как значение. */
|
||||||
|
export const PEER_NAME_PUNCTUATION = "!@#$%^&*()_+-=";
|
||||||
|
|
||||||
|
/** Полный набор в том виде, в каком его показывают оператору. */
|
||||||
|
export const PEER_NAME_CHARSET = `a-z A-Z 0-9 ${PEER_NAME_PUNCTUATION}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Набор символов в записи регулярного выражения.
|
||||||
|
*
|
||||||
|
* Дефис экранирован намеренно: неэкранированное `+-=` становится диапазоном и
|
||||||
|
* принимает лишние знаки. Числовые границы берутся из тех же констант, что и
|
||||||
|
* тексты формы.
|
||||||
|
*/
|
||||||
|
const PEER_NAME_CHARACTER_CLASS = "a-zA-Z0-9!@#$%^&*()_+\\-=";
|
||||||
|
|
||||||
|
export const PEER_NAME_PATTERN = new RegExp(
|
||||||
|
`^[${PEER_NAME_CHARACTER_CLASS}]{${PEER_NAME_MIN_LENGTH},${PEER_NAME_MAX_LENGTH}}$`
|
||||||
|
);
|
||||||
@@ -60,7 +60,8 @@ export default {
|
|||||||
trafficApiUnreachable: "Unreachable",
|
trafficApiUnreachable: "Unreachable",
|
||||||
security: {
|
security: {
|
||||||
hysteriaStopped: "Hysteria2 is stopped",
|
hysteriaStopped: "Hysteria2 is stopped",
|
||||||
hysteriaStateUnknown: "Hysteria2 service state is unknown: systemd did not answer",
|
hysteriaStateUnknown:
|
||||||
|
"Hysteria2 service state is unknown: systemd did not answer",
|
||||||
trafficApiUnavailable: "Traffic API is unavailable",
|
trafficApiUnavailable: "Traffic API is unavailable",
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
@@ -217,7 +218,7 @@ export default {
|
|||||||
// The character set is stated as service.IsValidPeerName accepts it. The
|
// The character set is stated as service.IsValidPeerName accepts it. The
|
||||||
// previous wording described the rule that was in force before EX-03.
|
// previous wording described the rule that was in force before EX-03.
|
||||||
nameHint:
|
nameHint:
|
||||||
"Peer identifier: 6 to 32 characters — latin letters, digits and !@#$%^&*()_+-=. The name becomes part of the auto-generated secret and is shown to the client as the profile name.",
|
"Peer identifier: {min} to {max} characters — latin letters, digits and {punctuation}. The name becomes part of the auto-generated secret and is shown to the client as the profile name.",
|
||||||
remarkPlaceholder: "laptop",
|
remarkPlaceholder: "laptop",
|
||||||
remarkHint: "Optional operator note. It is never shown to the client.",
|
remarkHint: "Optional operator note. It is never shown to the client.",
|
||||||
secretPlaceholder: "leave empty to generate one",
|
secretPlaceholder: "leave empty to generate one",
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export default {
|
|||||||
// действовавшего до EX-03: подсказка обещала более узкий набор, чем
|
// действовавшего до EX-03: подсказка обещала более узкий набор, чем
|
||||||
// сервер принимает, и оператор не имел причин пробовать остальные.
|
// сервер принимает, и оператор не имел причин пробовать остальные.
|
||||||
nameHint:
|
nameHint:
|
||||||
"Идентификатор пира: от 6 до 32 символов — латиница, цифры и знаки !@#$%^&*()_+-=. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
|
"Идентификатор пира: от {min} до {max} символов — латиница, цифры и знаки {punctuation}. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
|
||||||
remarkPlaceholder: "ноутбук",
|
remarkPlaceholder: "ноутбук",
|
||||||
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
|
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
|
||||||
secretPlaceholder: "оставьте пустым — сгенерируем",
|
secretPlaceholder: "оставьте пустым — сгенерируем",
|
||||||
|
|||||||
@@ -98,7 +98,9 @@
|
|||||||
<el-col :xs="24" :sm="12" :lg="6"
|
<el-col :xs="24" :sm="12" :lg="6"
|
||||||
><el-card shadow="never"
|
><el-card shadow="never"
|
||||||
>{{ $t("dashboard.onlinePeers") }}:
|
>{{ $t("dashboard.onlinePeers") }}:
|
||||||
{{ summary.hysteria.apiReachable ? summary.peers.onlinePeers : "?" }}</el-card
|
{{
|
||||||
|
summary.hysteria.apiReachable ? summary.peers.onlinePeers : "?"
|
||||||
|
}}</el-card
|
||||||
></el-col
|
></el-col
|
||||||
>
|
>
|
||||||
<el-col :xs="24" :sm="12" :lg="6"
|
<el-col :xs="24" :sm="12" :lg="6"
|
||||||
@@ -128,9 +130,9 @@
|
|||||||
<div class="chart-header">
|
<div class="chart-header">
|
||||||
<span>{{ $t("dashboard.trafficChart") }}</span>
|
<span>{{ $t("dashboard.trafficChart") }}</span>
|
||||||
<el-radio-group v-model="range" size="small" @change="loadDashboard">
|
<el-radio-group v-model="range" size="small" @change="loadDashboard">
|
||||||
<el-radio-button label="1h">1h</el-radio-button>
|
<el-radio-button value="1h">1h</el-radio-button>
|
||||||
<el-radio-button label="24h">24h</el-radio-button>
|
<el-radio-button value="24h">24h</el-radio-button>
|
||||||
<el-radio-button label="7d">7d</el-radio-button>
|
<el-radio-button value="7d">7d</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -247,7 +247,15 @@
|
|||||||
v-model="dataForm.name"
|
v-model="dataForm.name"
|
||||||
:placeholder="$t('peer.form.namePlaceholder')"
|
:placeholder="$t('peer.form.namePlaceholder')"
|
||||||
/>
|
/>
|
||||||
<div class="form-hint">{{ $t("peer.form.nameHint") }}</div>
|
<div class="form-hint">
|
||||||
|
{{
|
||||||
|
$t("peer.form.nameHint", {
|
||||||
|
min: PEER_NAME_MIN_LENGTH,
|
||||||
|
max: PEER_NAME_MAX_LENGTH,
|
||||||
|
punctuation: PEER_NAME_PUNCTUATION,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="$t('peer.remark')" :error="serverErrors.remark">
|
<el-form-item :label="$t('peer.remark')" :error="serverErrors.remark">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -445,6 +453,13 @@ import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus";
|
|||||||
import { ERR_CODE, isApiError } from "@/utils/api-error";
|
import { ERR_CODE, isApiError } from "@/utils/api-error";
|
||||||
import { describeApiError, fieldErrorMap } from "@/utils/api-message";
|
import { describeApiError, fieldErrorMap } from "@/utils/api-message";
|
||||||
import { saveResponseAsFile } from "@/utils/download";
|
import { saveResponseAsFile } from "@/utils/download";
|
||||||
|
import {
|
||||||
|
PEER_NAME_CHARSET,
|
||||||
|
PEER_NAME_MAX_LENGTH,
|
||||||
|
PEER_NAME_MIN_LENGTH,
|
||||||
|
PEER_NAME_PATTERN,
|
||||||
|
PEER_NAME_PUNCTUATION,
|
||||||
|
} from "@/constants/peer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ограничение ширины всплывающих подсказок.
|
* Ограничение ширины всплывающих подсказок.
|
||||||
@@ -582,10 +597,6 @@ watch(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Зеркало серверного контракта, а не второй его экземпляр: границы и набор
|
|
||||||
// символов заданы в service.IsValidPeerName и dto.PeerSaveDto, и расхождение
|
|
||||||
// здесь приводит лишь к лишнему запросу, а не к принятому некорректному пиру.
|
|
||||||
const PEER_NAME_PATTERN = /^[a-zA-Z0-9!@#$%^&*()_+\-=]{6,32}$/;
|
|
||||||
const SECRET_MIN_LENGTH = 6;
|
const SECRET_MIN_LENGTH = 6;
|
||||||
const SECRET_MAX_LENGTH = 128;
|
const SECRET_MAX_LENGTH = 128;
|
||||||
|
|
||||||
@@ -600,9 +611,9 @@ const rules = {
|
|||||||
pattern: PEER_NAME_PATTERN,
|
pattern: PEER_NAME_PATTERN,
|
||||||
message: t("error.code.peer_name", {
|
message: t("error.code.peer_name", {
|
||||||
field: t("error.field.name"),
|
field: t("error.field.name"),
|
||||||
min: 6,
|
min: PEER_NAME_MIN_LENGTH,
|
||||||
max: 32,
|
max: PEER_NAME_MAX_LENGTH,
|
||||||
charset: "a-z A-Z 0-9 !@#$%^&*()_+-=",
|
charset: PEER_NAME_CHARSET,
|
||||||
}),
|
}),
|
||||||
trigger: ["change", "blur"],
|
trigger: ["change", "blur"],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { createI18n } from "vue-i18n";
|
||||||
|
|
||||||
|
import {
|
||||||
|
PEER_NAME_MAX_LENGTH,
|
||||||
|
PEER_NAME_MIN_LENGTH,
|
||||||
|
PEER_NAME_PUNCTUATION,
|
||||||
|
} from "../src/constants/peer";
|
||||||
|
import en from "../src/lang/package/en";
|
||||||
|
import ru from "../src/lang/package/ru";
|
||||||
|
|
||||||
|
type Dictionary = Record<string, unknown>;
|
||||||
|
|
||||||
|
function leafMessages(
|
||||||
|
value: unknown,
|
||||||
|
prefix = ""
|
||||||
|
): Array<[key: string, message: string]> {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return [[prefix, value]];
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.entries(value as Dictionary).flatMap(([key, child]) =>
|
||||||
|
leafMessages(child, prefix ? `${prefix}.${key}` : key)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolationValues(message: string): Record<string, string | number> {
|
||||||
|
const values: Record<string, string | number> = {
|
||||||
|
count: 2,
|
||||||
|
n: 2,
|
||||||
|
min: PEER_NAME_MIN_LENGTH,
|
||||||
|
max: PEER_NAME_MAX_LENGTH,
|
||||||
|
punctuation: PEER_NAME_PUNCTUATION,
|
||||||
|
};
|
||||||
|
for (const match of message.matchAll(/\{([A-Za-z_][A-Za-z0-9_$-]*)\}/g)) {
|
||||||
|
values[match[1]] ??= match[1];
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [locale, dictionary] of Object.entries({ ru, en }) as Array<
|
||||||
|
["ru" | "en", Dictionary]
|
||||||
|
>) {
|
||||||
|
describe(`runtime-компиляция локализации ${locale}`, () => {
|
||||||
|
test("каждое строковое сообщение компилируется без diagnostics", () => {
|
||||||
|
const compilerDiagnostics: string[] = [];
|
||||||
|
const thrown: string[] = [];
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
|
||||||
|
console.error = (...args: unknown[]) => {
|
||||||
|
compilerDiagnostics.push(args.map(String).join(" "));
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale,
|
||||||
|
fallbackLocale: false,
|
||||||
|
missingWarn: false,
|
||||||
|
fallbackWarn: false,
|
||||||
|
messages: { [locale]: dictionary },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [key, message] of leafMessages(dictionary)) {
|
||||||
|
try {
|
||||||
|
const translated = i18n.global.t(key, interpolationValues(message));
|
||||||
|
if (typeof translated !== "string") {
|
||||||
|
thrown.push(`${key}: результат не является строкой`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
thrown.push(
|
||||||
|
`${key}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(thrown).toEqual([]);
|
||||||
|
expect(compilerDiagnostics).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("набор знаков имени пира проходит интерполяцию как данные", () => {
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale,
|
||||||
|
fallbackLocale: false,
|
||||||
|
messages: { [locale]: dictionary },
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = (dictionary as any).peer.form.nameHint as string;
|
||||||
|
expect(
|
||||||
|
i18n.global.t("peer.form.nameHint", {
|
||||||
|
min: PEER_NAME_MIN_LENGTH,
|
||||||
|
max: PEER_NAME_MAX_LENGTH,
|
||||||
|
punctuation: PEER_NAME_PUNCTUATION,
|
||||||
|
})
|
||||||
|
).toBe(
|
||||||
|
template
|
||||||
|
.replace("{min}", String(PEER_NAME_MIN_LENGTH))
|
||||||
|
.replace("{max}", String(PEER_NAME_MAX_LENGTH))
|
||||||
|
.replace("{punctuation}", PEER_NAME_PUNCTUATION)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
# HY2XS 1.0.0-rc5 — итоговый отчёт о приёмке
|
||||||
|
|
||||||
|
**Дата прогона:** 2026-09-15
|
||||||
|
|
||||||
|
**Вердикт:** `PASS / PRODUCTION E2E VALIDATED`
|
||||||
|
|
||||||
|
**Статус выпуска:** `READY FOR 1.0.0`
|
||||||
|
|
||||||
|
Этот отчёт фиксирует фактическую финальную приёмку RC5: release build,
|
||||||
|
повторную проверку опубликованного артефакта, установку на чистый Debian 13,
|
||||||
|
проверки после перезагрузки, Admin/UI, диагностику и реальный внешний dataplane
|
||||||
|
официальным клиентом Hysteria 2.12.2.
|
||||||
|
|
||||||
|
Проверенный кандидат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Product: HY2XS
|
||||||
|
Version: 1.0.0-rc5
|
||||||
|
Source commit: b7e478f0143846633d1261ca0bcd220736d28f44
|
||||||
|
Build ID: rc5-final-fi-20260908T003829Z
|
||||||
|
Artifact: hy2xs-install-1.0.0.tar.gz
|
||||||
|
Artifact size: 50 785 975 bytes
|
||||||
|
SHA-256: d35cf469d8e1e3b5c1e79cb94329c1b19d1afbd7ade771498d2fb8caf5cd746a
|
||||||
|
Target: Debian 13 / amd64
|
||||||
|
Hysteria: v2.12.2
|
||||||
|
```
|
||||||
|
|
||||||
|
> Публичный IPv4 тестового хоста в отчёте заменён на `198.51.100.20`
|
||||||
|
> (RFC 5737, документационный диапазон). Домен и порты оставлены: без них
|
||||||
|
> конфигурация прогона была бы неоднозначной. Секреты, credentials и клиентские
|
||||||
|
> URI в отчёт не перенесены.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Build acceptance
|
||||||
|
|
||||||
|
Production builder успешно завершил полный release pipeline из чистого
|
||||||
|
рабочего дерева.
|
||||||
|
|
||||||
|
Подтверждено:
|
||||||
|
|
||||||
|
- тесты оркестратора — `PASS`;
|
||||||
|
- frontend contract tests — `PASS`;
|
||||||
|
- runtime-тесты RU/EN словарей `vue-i18n` — `PASS`;
|
||||||
|
- frontend typecheck — `PASS`;
|
||||||
|
- production build frontend — `PASS`;
|
||||||
|
- Go tests — `PASS`;
|
||||||
|
- Go race checks — `PASS`;
|
||||||
|
- `govulncheck` — `PASS`;
|
||||||
|
- `pnpm audit` с порогом high+ — `PASS`;
|
||||||
|
- upstream SHA-256 Hysteria — `PASS`;
|
||||||
|
- совместимость Gecko — `PASS`;
|
||||||
|
- совместимость Salamander — `PASS`;
|
||||||
|
- полные release acceptance gates — `PASS`;
|
||||||
|
- внутренние контрольные суммы пакета — `PASS`;
|
||||||
|
- рабочее дерево источника после сборки осталось чистым.
|
||||||
|
|
||||||
|
## 2. Приёмка опубликованного артефакта
|
||||||
|
|
||||||
|
Для clean-host прогона использован опубликованный release artifact, повторно
|
||||||
|
скачанный из Gitea. На target он не пересобирался.
|
||||||
|
|
||||||
|
Повторно проверены:
|
||||||
|
|
||||||
|
- имя и размер архива;
|
||||||
|
- внешний SHA-256 архива;
|
||||||
|
- package provenance;
|
||||||
|
- Build ID;
|
||||||
|
- source commit;
|
||||||
|
- внутренние контрольные суммы;
|
||||||
|
- clean-host preflight.
|
||||||
|
|
||||||
|
Все проверки идентичности сошлись с release identity в начале отчёта.
|
||||||
|
|
||||||
|
## 3. Чистый хост и установка
|
||||||
|
|
||||||
|
RC5 установлен штатным `install.sh` на чистый Debian 13 amd64. До установки
|
||||||
|
подтверждены актуальное состояние ОС и kernel, отсутствие ожидающих package
|
||||||
|
upgrades, SSH на TCP/2323 с key-only root authentication и отключённой password
|
||||||
|
authentication. Публичный IPv4 хоста и DNS endpoint совпадали.
|
||||||
|
|
||||||
|
Admin forwarding был разрешён только к `127.0.0.1:8080`.
|
||||||
|
|
||||||
|
Результат установки:
|
||||||
|
|
||||||
|
```text
|
||||||
|
install.sh exit code: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
В ходе штатной установки успешно проверены:
|
||||||
|
|
||||||
|
- установка Hysteria 2.12.2;
|
||||||
|
- upstream SHA-256 Hysteria;
|
||||||
|
- генерация production-конфигурации;
|
||||||
|
- systemd units;
|
||||||
|
- nftables takeover;
|
||||||
|
- staged firewall apply;
|
||||||
|
- firewall rollback guard;
|
||||||
|
- runtime Hysteria;
|
||||||
|
- runtime HY2XS admin;
|
||||||
|
- отрицательный Admin login: `invalid_credentials`, токен отсутствует;
|
||||||
|
- положительный Admin login: Bearer token получен;
|
||||||
|
- effective firewall соответствует generated configuration;
|
||||||
|
- effective Hysteria configuration соответствует production profile.
|
||||||
|
|
||||||
|
После успешного smoke rollback guard снят, install state переведён в durable
|
||||||
|
состояние `installed`.
|
||||||
|
|
||||||
|
## 4. Admin/UI и управление пирами
|
||||||
|
|
||||||
|
Admin UI был доступен только через SSH tunnel на `127.0.0.1:8080`.
|
||||||
|
|
||||||
|
Фактически подтверждены:
|
||||||
|
|
||||||
|
- вход через реальный браузер;
|
||||||
|
- работа dashboard;
|
||||||
|
- состояние Hysteria2 отображается как active;
|
||||||
|
- доступность Traffic Stats API;
|
||||||
|
- отображение системных журналов;
|
||||||
|
- отображение журналов Hysteria;
|
||||||
|
- создание пира;
|
||||||
|
- изменение состояния пира;
|
||||||
|
- получение client URI/config.
|
||||||
|
|
||||||
|
В runtime отдельно проверены исправления RC5:
|
||||||
|
|
||||||
|
- diagnostic `vue-i18n Invalid linked format` отсутствует;
|
||||||
|
- форма пира корректно показывает допустимую пунктуацию;
|
||||||
|
- Element Plus Radio больше не использует deprecated `label` as value;
|
||||||
|
- выбор диапазона dashboard работает штатно.
|
||||||
|
|
||||||
|
## 5. Состояние после перезагрузки
|
||||||
|
|
||||||
|
После reboot подтверждено:
|
||||||
|
|
||||||
|
```text
|
||||||
|
hysteria-server.service enabled / active
|
||||||
|
hy2xs-admin.service enabled / active
|
||||||
|
nftables.service enabled / active
|
||||||
|
|
||||||
|
runtime_state running
|
||||||
|
install state installed
|
||||||
|
firewall state valid
|
||||||
|
rollback guard quiescent
|
||||||
|
operation in progress none
|
||||||
|
doctor PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
В журналах не обнаружены runtime-сигналы `panic`, `fatal`, `segfault` или HTTP
|
||||||
|
500.
|
||||||
|
|
||||||
|
Runtime listeners:
|
||||||
|
|
||||||
|
| Назначение | Адрес |
|
||||||
|
| --- | --- |
|
||||||
|
| Hysteria | публичный UDP endpoint |
|
||||||
|
| SSH | TCP/2323 |
|
||||||
|
| HY2XS admin | `127.0.0.1:8080` |
|
||||||
|
| Traffic Stats API | `127.0.0.1:36712` |
|
||||||
|
|
||||||
|
Admin и Traffic Stats API остались loopback-only.
|
||||||
|
|
||||||
|
## 6. Diagnostics acceptance
|
||||||
|
|
||||||
|
Diagnostics bundle успешно сформирован в trusted storage.
|
||||||
|
|
||||||
|
Подтверждено:
|
||||||
|
|
||||||
|
- diagnostics directory принадлежит root;
|
||||||
|
- режим архива — `0600`;
|
||||||
|
- владелец архива — `root:root`;
|
||||||
|
- временный workspace удаляется;
|
||||||
|
- реальные secret values в bundle отсутствуют;
|
||||||
|
- Admin credentials заменены на `<redacted>`;
|
||||||
|
- Hysteria Traffic Stats secret заменён на `<redacted>`;
|
||||||
|
- Gecko/obfs password заменён на `<redacted>`;
|
||||||
|
- `HY2XS_FORCE_PASSWORD_CHANGE=false` остаётся видимым как non-secret flag.
|
||||||
|
|
||||||
|
Итог diagnostics contract: `PASS`.
|
||||||
|
|
||||||
|
## 7. Production E2E официальным клиентом
|
||||||
|
|
||||||
|
Финальный независимый dataplane E2E выполнен официальным клиентом Hysteria
|
||||||
|
2.12.2.
|
||||||
|
|
||||||
|
Production deployment:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Host: se.api.withen.pro
|
||||||
|
IPv4: 198.51.100.20 (обезличен)
|
||||||
|
OS: Debian 13 / amd64
|
||||||
|
Hysteria: v2.12.2
|
||||||
|
HY2XS: 1.0.0-rc5
|
||||||
|
Public Hysteria endpoint: UDP/2053
|
||||||
|
SSH: TCP/2323
|
||||||
|
ACME HTTP-01: TCP/80
|
||||||
|
Admin: 127.0.0.1:8080
|
||||||
|
Traffic Stats API: 127.0.0.1:36712
|
||||||
|
Firewall: takeover / policy drop
|
||||||
|
Obfuscation: Gecko
|
||||||
|
```
|
||||||
|
|
||||||
|
Созданный в HY2XS пир успешно использован официальным клиентом Hysteria.
|
||||||
|
|
||||||
|
Подтверждено:
|
||||||
|
|
||||||
|
- соединение с `se.api.withen.pro:2053` — `PASS`;
|
||||||
|
- QUIC/Hysteria handshake — `PASS`;
|
||||||
|
- `udpEnabled=true`;
|
||||||
|
- SOCKS5 tunnel — `PASS`;
|
||||||
|
- HTTPS через tunnel — `PASS`;
|
||||||
|
- внешний IPv4 через tunnel совпал с публичным IPv4 HY2XS-хоста;
|
||||||
|
- packet capture: 136 packets;
|
||||||
|
- dropped packets: 0;
|
||||||
|
- client → server UDP packets: 70;
|
||||||
|
- server → client UDP packets: 66.
|
||||||
|
|
||||||
|
Тем самым подтверждена полная цепочка:
|
||||||
|
|
||||||
|
```text
|
||||||
|
client
|
||||||
|
→ DNS/TLS/SNI
|
||||||
|
→ UDP endpoint
|
||||||
|
→ Gecko
|
||||||
|
→ Hysteria2
|
||||||
|
→ HY2XS peer authentication
|
||||||
|
→ Internet egress
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Дополнительная downstream-проверка
|
||||||
|
|
||||||
|
Отдельный production-прогон через `vpn-egressctl 0.2.0` и sing-box 1.14.0 не
|
||||||
|
является приёмкой этих внешних компонентов в репозитории HY2XS. Он фиксируется
|
||||||
|
здесь как дополнительное доказательство совместимости HY2XS endpoint с
|
||||||
|
gateway/TUN-сценарием и реальным клиентом за gateway.
|
||||||
|
|
||||||
|
Проверенная конфигурация:
|
||||||
|
|
||||||
|
```text
|
||||||
|
vpn-egressctl: 0.2.0
|
||||||
|
sing-box: 1.14.0
|
||||||
|
Server Hysteria: 2.12.2
|
||||||
|
Obfuscation: Gecko
|
||||||
|
Endpoint: se.api.withen.pro:2053
|
||||||
|
Gateway TUN: tun-sb0
|
||||||
|
TUN address: 172.19.0.1/30
|
||||||
|
Config SHA-256: 0d888e7793bd979d212b613542070ce9349a625ddace2a0727776072b66c96ae
|
||||||
|
```
|
||||||
|
|
||||||
|
На чистом first apply успешно прошли `check`, генерация конфигурации, первый
|
||||||
|
`sync` и активация runtime. `sing-box.service` и
|
||||||
|
`vpn-egress-guard.service` были active, `tun-sb0` поднят, DNS и HTTPS через
|
||||||
|
Hysteria outbound работали. Повторный `sync` завершился no-op с
|
||||||
|
`changed=false`, без config/source drift и без изменения SHA-256.
|
||||||
|
|
||||||
|
Независимый клиент `10.30.0.20/24` за `br_vpn` получил DNS и HTTPS через
|
||||||
|
следующую цепочку:
|
||||||
|
|
||||||
|
```text
|
||||||
|
10.30.0.20
|
||||||
|
→ br_vpn
|
||||||
|
→ vpn-egress-gw / eth1
|
||||||
|
→ tun-sb0
|
||||||
|
→ sing-box 1.14.0
|
||||||
|
→ Hysteria2 / Gecko
|
||||||
|
→ se.api.withen.pro:2053
|
||||||
|
→ Internet
|
||||||
|
```
|
||||||
|
|
||||||
|
Внешний IPv4 gateway и клиента совпал с публичным IPv4 HY2XS-хоста.
|
||||||
|
|
||||||
|
Fail-closed / anti-leak проверка также прошла: после остановки sing-box сервис
|
||||||
|
стал inactive, guard остался active, прямой Internet access клиента отсутствовал
|
||||||
|
(`curl` завершился с `rc=7`), обход через обычный WAN/`eth0` не появился. После
|
||||||
|
возврата sing-box доступ восстановился через тот же HY2XS endpoint.
|
||||||
|
|
||||||
|
Состояния `last_good_available=false` и `last_good_valid=false` после чистого
|
||||||
|
first apply ожидаемы: предыдущей managed-good generation для rollback ещё не
|
||||||
|
существовало, а последующий idempotent sync был no-op.
|
||||||
|
|
||||||
|
После проверки временные namespace/veth, диагностический бинарник Hysteria,
|
||||||
|
временные configs, logs и pcap-файлы удалены. Production URI, config и runtime
|
||||||
|
state сохранены.
|
||||||
|
|
||||||
|
## 9. Граница вывода по предыдущим endpoint failures
|
||||||
|
|
||||||
|
Ранее неуспешные E2E-прогоны через UFO endpoint не доказали дефект
|
||||||
|
`vpn-egressctl 0.2.0`, renderer или sing-box 1.14.0. Контрольная приёмка на
|
||||||
|
текущем endpoint прошла без изменения основного client/gateway stack:
|
||||||
|
официальный Hysteria client E2E, first apply, gateway HTTPS, idempotent sync,
|
||||||
|
клиент за `br_vpn` и fail-closed / anti-leak дали `PASS`.
|
||||||
|
|
||||||
|
Это позволяет относить прежнее наблюдение к конкретному endpoint/environment
|
||||||
|
path либо к фактору вне доказанного runtime-контракта. Точная первопричина UFO
|
||||||
|
endpoint в рамках этой приёмки не установлена; приписывать эпизод как
|
||||||
|
подтверждённый дефект HY2XS, `vpn-egressctl`, renderer или sing-box нельзя.
|
||||||
|
|
||||||
|
## 10. Итог
|
||||||
|
|
||||||
|
```text
|
||||||
|
BUILD ACCEPTED
|
||||||
|
PUBLISHED ARTIFACT ACCEPTED
|
||||||
|
CLEAN INSTALL ACCEPTED
|
||||||
|
POST-REBOOT ACCEPTED
|
||||||
|
ADMIN/UI ACCEPTED
|
||||||
|
PEER MANAGEMENT ACCEPTED
|
||||||
|
DIAGNOSTICS ACCEPTED
|
||||||
|
OFFICIAL CLIENT E2E ACCEPTED
|
||||||
|
DOWNSTREAM GATEWAY E2E ACCEPTED
|
||||||
|
ANTI-LEAK ACCEPTED
|
||||||
|
|
||||||
|
RUNTIME STATUS ACCEPTED
|
||||||
|
RELEASE STATUS READY FOR 1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
HY2XS 1.0.0-rc5 прошёл production build acceptance, проверку неизменности
|
||||||
|
артефакта, чистую установку, install smoke, runtime Admin/UI, управление пирами,
|
||||||
|
firewall acceptance, reboot persistence, diagnostics security и реальный E2E
|
||||||
|
официальным клиентом Hysteria.
|
||||||
|
|
||||||
|
**Финальный статус: `PASS / PRODUCTION E2E VALIDATED`.**
|
||||||
|
|
||||||
|
## 11. Требование к stable promotion
|
||||||
|
|
||||||
|
Stable `1.0.0` должен продвигать без пересборки ровно тот immutable artifact,
|
||||||
|
который прошёл этот прогон:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Tag: 1.0.0
|
||||||
|
Source commit: b7e478f0143846633d1261ca0bcd220736d28f44
|
||||||
|
Artifact: hy2xs-install-1.0.0.tar.gz
|
||||||
|
SHA-256: d35cf469d8e1e3b5c1e79cb94329c1b19d1afbd7ade771498d2fb8caf5cd746a
|
||||||
|
Size: 50 785 975 bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
Документационный коммит, добавляющий настоящий отчёт в `main`, намеренно идёт
|
||||||
|
после source commit кандидата и не меняет release identity. Тег `1.0.0` должен
|
||||||
|
указывать на source commit выше, а не на последующий docs-only commit.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# HY2XS v1.0.0 — фиксация stable promotion
|
||||||
|
|
||||||
|
**Дата фиксации:** 2026-09-15
|
||||||
|
|
||||||
|
**Статус:** `PUBLISHED / EXACT RC5 BYTES`
|
||||||
|
|
||||||
|
Этот документ дополняет, но не изменяет опубликованный
|
||||||
|
[итоговый отчёт о приёмке RC5](2026-09-15-v1.0.0-rc5-final-acceptance.md).
|
||||||
|
Он фиксирует release plumbing после публикации stable: какой тег связан с
|
||||||
|
release, из какого commit получен артефакт и какие именно байты опубликованы.
|
||||||
|
|
||||||
|
Раздел 11 итогового RC5 acceptance-report фиксировал первоначально
|
||||||
|
запланированную схему promotion с отдельным тегом `1.0.0`. После завершения
|
||||||
|
acceptance release operator принял и реализовал другую схему: stable release
|
||||||
|
`v1.0.0` остаётся связан с проверенным тегом `1.0.0-rc5`. Настоящий документ
|
||||||
|
является фактической записью выполненного promotion и в части release plumbing
|
||||||
|
заменяет первоначальный план из раздела 11, не изменяя результаты и
|
||||||
|
доказательства самого RC5 acceptance.
|
||||||
|
|
||||||
|
## 1. Структура публикации
|
||||||
|
|
||||||
|
По решению release operator отдельный stable-тег `1.0.0` не создавался. Stable
|
||||||
|
release использует принятый тег RC5:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Release title: v1.0.0
|
||||||
|
Release tag: 1.0.0-rc5
|
||||||
|
Tag commit: b7e478f0143846633d1261ca0bcd220736d28f44
|
||||||
|
Prerelease: false
|
||||||
|
Draft: false
|
||||||
|
```
|
||||||
|
|
||||||
|
Это намеренная фактическая схема публикации, а не утверждение о наличии
|
||||||
|
отдельного тега `1.0.0`.
|
||||||
|
|
||||||
|
## 2. Идентичность артефакта
|
||||||
|
|
||||||
|
В stable release опубликован тот же immutable tarball, который прошёл финальную
|
||||||
|
приёмку RC5. Повторная сборка и изменение архива не выполнялись.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Build ID: rc5-final-fi-20260908T003829Z
|
||||||
|
Artifact: hy2xs-install-1.0.0.tar.gz
|
||||||
|
Artifact size: 50 785 975 bytes
|
||||||
|
SHA-256: d35cf469d8e1e3b5c1e79cb94329c1b19d1afbd7ade771498d2fb8caf5cd746a
|
||||||
|
Gitea asset ID: 20
|
||||||
|
```
|
||||||
|
|
||||||
|
Release source, Build ID, размер и SHA-256 совпадают с принятым RC5 artifact.
|
||||||
|
|
||||||
|
## 3. Checksum sidecar
|
||||||
|
|
||||||
|
Stable sidecar опубликован в basename-only формате:
|
||||||
|
|
||||||
|
```text
|
||||||
|
d35cf469d8e1e3b5c1e79cb94329c1b19d1afbd7ade771498d2fb8caf5cd746a hy2xs-install-1.0.0.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
За строкой находится один LF. Итоговый размер файла — ровно 93 байта.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Sidecar: hy2xs-install-1.0.0.tar.gz.sha256
|
||||||
|
Sidecar size: 93 bytes
|
||||||
|
Gitea asset ID: 25
|
||||||
|
```
|
||||||
|
|
||||||
|
Такой sidecar проверяется из каталога с обоими файлами обычной командой:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sha256sum -c hy2xs-install-1.0.0.tar.gz.sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
Путь сборочной машины `dist/` в опубликованном sidecar отсутствует.
|
||||||
|
|
||||||
|
## 4. Граница source и документации
|
||||||
|
|
||||||
|
Release tag `1.0.0-rc5` указывает на проверенный source commit
|
||||||
|
`b7e478f0143846633d1261ca0bcd220736d28f44`. Последующие docs-only commits в
|
||||||
|
`main` не являются source commit артефакта и не меняют release identity.
|
||||||
|
|
||||||
|
Финальный RC5 acceptance-report не переписывался после публикации. Эта запись
|
||||||
|
существует отдельно именно для фиксации последующего stable promotion.
|
||||||
|
|
||||||
|
## 5. Итог
|
||||||
|
|
||||||
|
```text
|
||||||
|
STABLE RELEASE PUBLISHED
|
||||||
|
RELEASE TITLE v1.0.0
|
||||||
|
RELEASE TAG 1.0.0-rc5
|
||||||
|
SOURCE COMMIT b7e478f0143846633d1261ca0bcd220736d28f44
|
||||||
|
ARTIFACT REBUILT NO
|
||||||
|
ARTIFACT SHA-256 VERIFIED
|
||||||
|
SIDECAR BASENAME-ONLY VERIFIED
|
||||||
|
SIDECAR SIZE 93 BYTES
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stable v1.0.0 опубликован из exact RC5 bytes без пересборки.**
|
||||||
@@ -26,6 +26,14 @@
|
|||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| 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-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-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) |
|
||||||
|
| 2026-09-15 | `1.0.0-rc5` | `b7e478f0` | build + clean install + production E2E, Debian 13 | [PASS / PRODUCTION E2E VALIDATED — READY FOR 1.0.0](2026-09-15-v1.0.0-rc5-final-acceptance.md) |
|
||||||
|
|
||||||
|
## Публикация stable
|
||||||
|
|
||||||
|
| Дата фиксации | Release | Тег | Коммит источника | Запись |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 2026-09-15 | `v1.0.0` | `1.0.0-rc5` | `b7e478f0` | [PUBLISHED / EXACT RC5 BYTES](2026-09-15-v1.0.0-stable-promotion.md) |
|
||||||
|
|
||||||
## Открытые дефекты приёмки
|
## Открытые дефекты приёмки
|
||||||
|
|
||||||
@@ -33,6 +41,10 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 2026-09-01, `1.0.0-rc1` | [UX-01…UX-05 и найденное сверх отчёта](2026-09-01-v1.0.0-rc1-ux-findings.md) |
|
| 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-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 |
|
||||||
|
|
||||||
|
Итоговая приёмка RC5 не выявила открытых release blockers; исправления
|
||||||
|
предыдущих кандидатов подтверждены фактическим runtime-прогоном и внешним E2E.
|
||||||
|
|
||||||
## Разборы кода между прогонами
|
## Разборы кода между прогонами
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,12 @@
|
|||||||
сборку — vue-i18n молча отдаёт сам ключ, и оператор видит `error.code.min_length`
|
сборку — vue-i18n молча отдаёт сам ключ, и оператор видит `error.code.min_length`
|
||||||
вместо фразы.
|
вместо фразы.
|
||||||
|
|
||||||
|
Совпадения ключей недостаточно: строки компилируются как Vue I18n message
|
||||||
|
format только при переводе. Специальные символы (`@`, `$`, `{}`, `|`) нельзя
|
||||||
|
вставлять в текст как произвольные данные. Динамический набор знаков передаётся
|
||||||
|
через named interpolation, а builder переводит каждую leaf-строку RU/EN и
|
||||||
|
считает ошибкой как исключение, так и compiler diagnostics в `console.error`.
|
||||||
|
|
||||||
**Состояние сессии** сообщается кодами `unauthorized`, `session_expired`,
|
**Состояние сессии** сообщается кодами `unauthorized`, `session_expired`,
|
||||||
`token_invalid`, `account_disabled` при `code = 50401`. Панель по ним
|
`token_invalid`, `account_disabled` при `code = 50401`. Панель по ним
|
||||||
показывает диалог и возвращает на форму входа.
|
показывает диалог и возвращает на форму входа.
|
||||||
@@ -132,6 +138,11 @@
|
|||||||
продукт: в таблицу пиров ведут две двери, и они не имеют права требовать
|
продукт: в таблицу пиров ведут две двери, и они не имеют права требовать
|
||||||
разного.
|
разного.
|
||||||
|
|
||||||
|
Frontend-зеркало границ, regex и человекочитаемого набора находится в одном
|
||||||
|
модуле `constants/peer.ts` и сверяется с Go-контрактом тестом. Пунктуация
|
||||||
|
`!@#$%^&*()_+-=` попадает в подсказку как значение `{punctuation}`, а не как
|
||||||
|
часть синтаксиса message format.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Таблицы журнала
|
## 4. Таблицы журнала
|
||||||
|
|||||||
+15
-8
@@ -136,7 +136,7 @@ Bun обновляется отдельно от остальных: оркес
|
|||||||
полного прохода `bun test → tsc → compile → приёмка на Debian`, а не строки в
|
полного прохода `bun test → tsc → compile → приёмка на Debian`, а не строки в
|
||||||
общем патче.
|
общем патче.
|
||||||
|
|
||||||
### Проверка типов frontend — обязательный шаг релиза
|
### Runtime-компиляция локализации и типы frontend — обязательные шаги релиза
|
||||||
|
|
||||||
```text
|
```text
|
||||||
pnpm run typecheck → vue-tsc --noEmit → ОБЯЗАН пройти
|
pnpm run typecheck → vue-tsc --noEmit → ОБЯЗАН пройти
|
||||||
@@ -144,9 +144,13 @@ pnpm run build:prod → vite build
|
|||||||
pnpm run verify → typecheck, затем build
|
pnpm run verify → typecheck, затем build
|
||||||
```
|
```
|
||||||
|
|
||||||
`bundle_ui()` запускает `typecheck` **до** сборки bundle: собирать production
|
После `pnpm install --frozen-lockfile` функция `bundle_ui()` сначала запускает
|
||||||
bundle из кода, который не проходит проверку типов, незачем. Порядок и сам факт
|
`bun test test/i18n-runtime.test.ts`, затем `typecheck` и только потом сборку
|
||||||
наличия шага проверяются приёмкой.
|
bundle. Словари заданы TypeScript-объектами, поэтому Vite и `vue-tsc` не
|
||||||
|
компилируют message format и не ловят специальные символы `@`, `$`, `{}`, `|`.
|
||||||
|
Runtime-gate использует реальный `vue-i18n` из lock-графа, обходит все строки
|
||||||
|
RU/EN и перехватывает также `console.error`: один лишь `try/catch` недостаточен,
|
||||||
|
поскольку компилятор может сообщить об ошибке без исключения из `t()`.
|
||||||
|
|
||||||
До v1 этой гарантии не было. `build:prod` означал `vite build && vue-tsc
|
До v1 этой гарантии не было. `build:prod` означал `vite build && vue-tsc
|
||||||
--noEmit`, но `vue-tsc` был версии `0.35.0` (2022 год) и шаблоны Vue
|
--noEmit`, но `vue-tsc` был версии `0.35.0` (2022 год) и шаблоны Vue
|
||||||
@@ -222,7 +226,9 @@ bundle из кода, который не проходит проверку ти
|
|||||||
|
|
||||||
Уязвимость обычно приходит не из прямой зависимости, а из чужого поддерева, и
|
Уязвимость обычно приходит не из прямой зависимости, а из чужого поддерева, и
|
||||||
поднимать ради неё весь frontend-граф не нужно. Такие случаи закрываются
|
поднимать ради неё весь frontend-граф не нужно. Такие случаи закрываются
|
||||||
списком `pnpm.overrides` в `apps/frontend/package.json`:
|
списком `overrides` в `apps/frontend/pnpm-workspace.yaml`. Настройки pnpm не
|
||||||
|
хранятся в поле `pnpm` package manifest: современные версии его игнорируют, и
|
||||||
|
правило осталось бы только случайно замороженным в старом lock-файле.
|
||||||
|
|
||||||
| override | зачем |
|
| override | зачем |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -253,14 +259,15 @@ bundle из кода, который не проходит проверку ти
|
|||||||
| Шаг сборки | Что запускается |
|
| Шаг сборки | Что запускается |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `run_orchestrator_tests` | `bun x tsc --noEmit`, `bun test` |
|
| `run_orchestrator_tests` | `bun x tsc --noEmit`, `bun test` |
|
||||||
| `bundle_ui` | `pnpm run typecheck` (`vue-tsc --noEmit`) до сборки bundle |
|
| `run_frontend_tests` | dependency-free контракты панели |
|
||||||
|
| `bundle_ui` | frozen install → runtime-компиляция всех RU/EN-сообщений → `pnpm run typecheck` → production bundle |
|
||||||
| `run_admin_tests` | `go vet ./...`, `go test ./...` |
|
| `run_admin_tests` | `go vet ./...`, `go test ./...` |
|
||||||
|
|
||||||
Готовый пакет объявляет об этом полем `tests_gate=true` в
|
Готовый пакет объявляет об этом полем `tests_gate=true` в
|
||||||
`metadata/package.env` — так же, как `dependency_security_gate` и
|
`metadata/package.env` — так же, как `dependency_security_gate` и
|
||||||
`hysteria_compat_gate`. Значение у поля ровно одно, потому что не бывает
|
`hysteria_compat_gate`. Значение у поля ровно одно, потому что не бывает
|
||||||
пакета, собранного с пропущенными тестами: обе функции прогона выставляют свой
|
пакета, собранного с пропущенными тестами: функции прогона выставляют свои
|
||||||
флаг **после** успешного завершения, а `write_metadata` отказывается писать
|
флаги **после** успешного завершения, а `write_metadata` отказывается писать
|
||||||
метаданные, если хотя бы один из них не выставлен. То есть поле остаётся
|
метаданные, если хотя бы один из них не выставлен. То есть поле остаётся
|
||||||
утверждением о результате, а не переключателем.
|
утверждением о результате, а не переключателем.
|
||||||
|
|
||||||
|
|||||||
@@ -74,18 +74,19 @@ sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env
|
|||||||
быть отменён: ни неудачной записью состояния в `/var/lib/hy2xs`, ни отказом
|
быть отменён: ни неудачной записью состояния в `/var/lib/hy2xs`, ни отказом
|
||||||
одной из своих стадий. Поэтому в журнале нужно читать две разные вещи:
|
одной из своих стадий. Поэтому в журнале нужно читать две разные вещи:
|
||||||
|
|
||||||
| Строка в журнале | Что она означает |
|
| Строка в журнале | Что она означает |
|
||||||
| --- | --- |
|
| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `failed to persist failure state, continuing with the mandatory rollback` | маркер не обновился (обычно заполненный диск), но восстановление выполнено; после освобождения места запустить `doctor` |
|
| `failed to persist failure state, continuing with the mandatory rollback` | маркер не обновился (обычно заполненный диск), но восстановление выполнено; после освобождения места запустить `doctor` |
|
||||||
| `rollback stage "<имя>" failed, continuing with the remaining stages` | конкретная половина восстановления не отработала; остальные выполнены |
|
| `rollback stage "<имя>" failed, continuing with the remaining stages` | конкретная половина восстановления не отработала; остальные выполнены |
|
||||||
| `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки |
|
| `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки |
|
||||||
| `rollback completed: N stage(s) succeeded` | восстановление отработало полностью |
|
| `rollback completed: N stage(s) succeeded` | восстановление отработало полностью |
|
||||||
| `manual recovery data preserved at /run/hy2xs/rollback/<op>` | firewall восстановлен не полностью; прежние `nftables.conf` и `hy2xs.nft` лежат по этому пути |
|
| `systemctl reset-failed <unit> exited with …, but the cleanup postcondition is satisfied` | юнит уже выгружен или не находится в `failed`; это допустимый идемпотентный cleanup, ручное восстановление не требуется |
|
||||||
| `firewall rollback guard armed: … fires in 45s (timer accuracy 1s)` | guard взведён; с этого момента операция обязана снять его до фиксации успеха |
|
| `manual recovery data preserved at /run/hy2xs/rollback/<op>` | firewall восстановлен не полностью; прежние `nftables.conf` и `hy2xs.nft` лежат по этому пути |
|
||||||
| `firewall rollback guard disarmed and proven inactive` | guard снят, и это подтверждено состоянием юнитов и отсутствием маркера срабатывания |
|
| `firewall rollback guard armed: … fires in 45s (timer accuracy 1s)` | guard взведён; с этого момента операция обязана снять его до фиксации успеха |
|
||||||
| `automatic firewall rollback has already fired` | guard успел сработать; сервер работает на **прежнем** firewall, операция обязана завершиться отказом |
|
| `firewall rollback guard disarmed and proven inactive` | guard снят, и это подтверждено состоянием юнитов и отсутствием маркера срабатывания |
|
||||||
| `firewall rollback guard <unit> is still in state "…"` | остановить guard не удалось; фиксация успеха запрещена, разбирайтесь с systemd |
|
| `automatic firewall rollback has already fired` | guard успел сработать; сервер работает на **прежнем** firewall, операция обязана завершиться отказом |
|
||||||
| `unable to verify firewall rollback guard state; systemd query failed` | состояние guard'а недоказуемо; операция не начата, чинить нужно systemd, а не ждать |
|
| `firewall rollback guard <unit> is still in state "…"` | остановить guard не удалось; фиксация успеха запрещена, разбирайтесь с systemd |
|
||||||
|
| `unable to verify firewall rollback guard state; systemd query failed` | состояние guard'а недоказуемо; операция не начата, чинить нужно systemd, а не ждать |
|
||||||
|
|
||||||
Отдельно про сработавший guard. Окно 45 секунд намеренно короче худшего случая
|
Отдельно про сработавший guard. Окно 45 секунд намеренно короче худшего случая
|
||||||
smoke и не обязано его покрывать: доказательством служит не время, а маркер
|
smoke и не обязано его покрывать: доказательством служит не время, а маркер
|
||||||
@@ -336,6 +337,7 @@ hy2xs-orchestrator redact-config --config /etc/hysteria/config.yaml --out /root/
|
|||||||
```
|
```
|
||||||
|
|
||||||
Инварианты:
|
Инварианты:
|
||||||
|
|
||||||
- команда не выводит исходные секреты в stdout;
|
- команда не выводит исходные секреты в stdout;
|
||||||
- требуется выбрать ровно один режим: `--in-place` или `--out <path>`;
|
- требуется выбрать ровно один режим: `--in-place` или `--out <path>`;
|
||||||
- `--format auto` пытается определить формат по имени файла, при неоднозначности используйте `--format env|yaml`;
|
- `--format auto` пытается определить формат по имени файла, при неоднозначности используйте `--format env|yaml`;
|
||||||
@@ -344,6 +346,8 @@ hy2xs-orchestrator redact-config --config /etc/hysteria/config.yaml --out /root/
|
|||||||
редакцию, а результат остаётся валидным YAML;
|
редакцию, а результат остаётся валидным YAML;
|
||||||
- в env-файлах секрет вырезается и из URL-значения, даже если имя ключа
|
- в env-файлах секрет вырезается и из URL-значения, даже если имя ключа
|
||||||
несекретное — например, `HY2_AUTH_URL` в `post-install.env`.
|
несекретное — например, `HY2_AUTH_URL` в `post-install.env`.
|
||||||
|
- `HY2XS_FORCE_PASSWORD_CHANGE=true|false` сохраняется как диагностический
|
||||||
|
boolean; это точное исключение, остальные ключи с `PASSWORD` редактируются.
|
||||||
|
|
||||||
Та же редакция применяется к diagnostics-бандлу
|
Та же редакция применяется к diagnostics-бандлу
|
||||||
(`hy2xs-orchestrator diagnostics collect`), который собирается автоматически при
|
(`hy2xs-orchestrator diagnostics collect`), который собирается автоматически при
|
||||||
@@ -357,4 +361,3 @@ hy2xs-orchestrator redact-config --config /etc/hysteria/config.yaml --out /root/
|
|||||||
При отказе **до** начала применения изменений (`fatal_pre_apply`) бандл не
|
При отказе **до** начала применения изменений (`fatal_pre_apply`) бандл не
|
||||||
собирается: его сбор сам создал бы каталог в `/var/lib/hy2xs/diagnostics` на сервере,
|
собирается: его сбор сам создал бы каталог в `/var/lib/hy2xs/diagnostics` на сервере,
|
||||||
который мы обещали не трогать.
|
который мы обещали не трогать.
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
## Технологический стек оркестратора
|
## Технологический стек оркестратора
|
||||||
|
|
||||||
Оркестратор фиксируется как:
|
Оркестратор фиксируется как:
|
||||||
|
|
||||||
- **Bun + TypeScript** по исходникам
|
- **Bun + TypeScript** по исходникам
|
||||||
- локальная сборка builder layer'ом
|
- локальная сборка builder layer'ом
|
||||||
- поставка на target в виде **готового install-артефакта**
|
- поставка на target в виде **готового install-артефакта**
|
||||||
|
|
||||||
Это означает:
|
Это означает:
|
||||||
|
|
||||||
- на target нет `npm`, `pnpm`, `yarn` или `bun install`
|
- на target нет `npm`, `pnpm`, `yarn` или `bun install`
|
||||||
- на target нет transpile/build step
|
- на target нет transpile/build step
|
||||||
- shell на target допустим только как thin wrapper entrypoint
|
- shell на target допустим только как thin wrapper entrypoint
|
||||||
@@ -19,6 +21,7 @@
|
|||||||
## Главная роль оркестратора
|
## Главная роль оркестратора
|
||||||
|
|
||||||
Оркестратор работает **только на target machine** и умеет:
|
Оркестратор работает **только на target machine** и умеет:
|
||||||
|
|
||||||
- выполнить read-only проверку чистоты хоста (`preflight-install`)
|
- выполнить read-only проверку чистоты хоста (`preflight-install`)
|
||||||
- выполнить первичную установку (`install`)
|
- выполнить первичную установку (`install`)
|
||||||
- выполнить явную реконфигурацию (`reconfigure --dry-run|--apply`)
|
- выполнить явную реконфигурацию (`reconfigure --dry-run|--apply`)
|
||||||
@@ -42,6 +45,7 @@
|
|||||||
## Предусловия
|
## Предусловия
|
||||||
|
|
||||||
Оркестратор рассчитан только на:
|
Оркестратор рассчитан только на:
|
||||||
|
|
||||||
- чистый Debian 13
|
- чистый Debian 13
|
||||||
- root/sudo install context
|
- root/sudo install context
|
||||||
- один сервер
|
- один сервер
|
||||||
@@ -127,10 +131,10 @@ clean-host. Ко второму вызову на диске лежал собс
|
|||||||
Guard умеет останавливать только то, что через него проходит. Поэтому
|
Guard умеет останавливать только то, что через него проходит. Поэтому
|
||||||
универсального раннера в `lib/process.ts` нет — есть два явных набора:
|
универсального раннера в `lib/process.ts` нет — есть два явных набора:
|
||||||
|
|
||||||
| Набор | Guard | Назначение |
|
| Набор | Guard | Назначение |
|
||||||
| --- | --- | --- |
|
| -------------------------------------------------------------------------- | --------------------- | --------------------------------------------------------------------- |
|
||||||
| `runReadOnly`, `runReadOnlySecret` | не трогает | наблюдение за системой: `ss`, `systemctl is-active`, `curl`, `getent` |
|
| `runReadOnly`, `runReadOnlySecret` | не трогает | наблюдение за системой: `ss`, `systemctl is-active`, `curl`, `getent` |
|
||||||
| `runMutating`, `runMutatingVisible`, `runMutatingHidden`, `runMutatingRaw` | спрашивает разрешение | всё, что может изменить хост |
|
| `runMutating`, `runMutatingVisible`, `runMutatingHidden`, `runMutatingRaw` | спрашивает разрешение | всё, что может изменить хост |
|
||||||
|
|
||||||
`*Secret`-варианты не печатают команду в текст ошибки: их аргументы несут
|
`*Secret`-варианты не печатают команду в текст ошибки: их аргументы несут
|
||||||
machine token или пароль пира, а сообщение уходит в логи и диагностику.
|
machine token или пароль пира, а сообщение уходит в логи и диагностику.
|
||||||
@@ -277,10 +281,10 @@ 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 |
|
| `reconfigure` | firewall → restore configuration |
|
||||||
|
|
||||||
Каждая стадия — это `systemctl`, `cp`, `rm -rf` или `nft`, то есть каждая умеет
|
Каждая стадия — это `systemctl`, `cp`, `rm -rf` или `nft`, то есть каждая умеет
|
||||||
упасть сама. Пока они стояли цепочкой `await`, отказ первой отменял все
|
упасть сама. Пока они стояли цепочкой `await`, отказ первой отменял все
|
||||||
@@ -294,6 +298,14 @@ preflight-ошибка со словом `nftables` приводила к отк
|
|||||||
ошибка операции: проблема внутри отката — это дополнительная информация о том,
|
ошибка операции: проблема внутри отката — это дополнительная информация о том,
|
||||||
что осталось не восстановленным, а не замена диагноза.
|
что осталось не восстановленным, а не замена диагноза.
|
||||||
|
|
||||||
|
`reset-failed` для каждого сервиса является отдельной стадией и завершается
|
||||||
|
проверкой `LoadState`/`ActiveState`. Ненулевой код команды допустим, если юнит
|
||||||
|
уже выгружен (`not-found` + `inactive`): failed-состояния у него больше нет, а
|
||||||
|
значит cleanup завершён. Текст `Unit … not loaded` намеренно не разбирается — он
|
||||||
|
зависит от версии и локали systemd. Ошибка чтения состояния или сохранившийся
|
||||||
|
`ActiveState=failed` остаются настоящим отказом и попадают в manual-recovery
|
||||||
|
сводку.
|
||||||
|
|
||||||
Команды внутри стадий **не глушат собственные ошибки**. Это правило обратно
|
Команды внутри стадий **не глушат собственные ошибки**. Это правило обратно
|
||||||
тому, что действовало раньше. Пока непрерывность держалась на `|| true` в каждой
|
тому, что действовало раньше. Пока непрерывность держалась на `|| true` в каждой
|
||||||
команде, стадия физически не могла сообщить, что восстановление не выполнилось:
|
команде, стадия физически не могла сообщить, что восстановление не выполнилось:
|
||||||
@@ -368,7 +380,11 @@ reconfigure B → создание копии упало, ошибка скры
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"opId": "2026-08-30T10-00-00.000Z",
|
"opId": "2026-08-30T10-00-00.000Z",
|
||||||
"entries": [
|
"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 }
|
{ "path": "/etc/nftables.d/hy2xs.nft", "present": false, "stored": null }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -423,11 +439,11 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
|
|||||||
|
|
||||||
Строгость управляется `HY2XS_PUBLIC_ENDPOINT_POLICY`:
|
Строгость управляется `HY2XS_PUBLIC_ENDPOINT_POLICY`:
|
||||||
|
|
||||||
| Значение | Поведение |
|
| Значение | Поведение |
|
||||||
| --- | --- |
|
| ----------------------- | ------------------------------------------------ |
|
||||||
| `strict` (по умолчанию) | расхождение останавливает операцию |
|
| `strict` (по умолчанию) | расхождение останавливает операцию |
|
||||||
| `warn` | печатается предупреждение, операция продолжается |
|
| `warn` | печатается предупреждение, операция продолжается |
|
||||||
| `off` | сравнение не выполняется |
|
| `off` | сравнение не выполняется |
|
||||||
|
|
||||||
Ослабление предназначено для топологий вне baseline (NAT, floating IP, anycast).
|
Ослабление предназначено для топологий вне baseline (NAT, floating IP, anycast).
|
||||||
Отсутствие A-записи остаётся фатальным при любом значении: имя без A-записи не
|
Отсутствие A-записи остаётся фатальным при любом значении: имя без A-записи не
|
||||||
@@ -436,6 +452,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
|
|||||||
## Что приходит на target
|
## Что приходит на target
|
||||||
|
|
||||||
На target должен попадать уже готовый package, содержащий:
|
На target должен попадать уже готовый package, содержащий:
|
||||||
|
|
||||||
- thin install entrypoint
|
- thin install entrypoint
|
||||||
- compiled orchestrator artifact
|
- compiled orchestrator artifact
|
||||||
- bundled HY2XS admin
|
- bundled HY2XS admin
|
||||||
@@ -447,6 +464,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
|
|||||||
## Логическая модульность
|
## Логическая модульность
|
||||||
|
|
||||||
Даже если на target приезжает один собранный артефакт, внутри исходников оркестратор должен быть разложен по шагам:
|
Даже если на target приезжает один собранный артефакт, внутри исходников оркестратор должен быть разложен по шагам:
|
||||||
|
|
||||||
- preflight
|
- preflight
|
||||||
- deps
|
- deps
|
||||||
- filesystem
|
- filesystem
|
||||||
@@ -474,17 +492,20 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
|
|||||||
## Модель поставки
|
## Модель поставки
|
||||||
|
|
||||||
Рекомендуемая baseline-модель:
|
Рекомендуемая baseline-модель:
|
||||||
|
|
||||||
- исходники оркестратора хранятся в `orchestrator/`
|
- исходники оркестратора хранятся в `orchestrator/`
|
||||||
- builder выполняет локальную сборку через Bun
|
- builder выполняет локальную сборку через Bun
|
||||||
- в install package кладётся готовый артефакт, который запускается thin wrapper'ом
|
- в install package кладётся готовый артефакт, который запускается thin wrapper'ом
|
||||||
|
|
||||||
Например:
|
Например:
|
||||||
|
|
||||||
- `package/install.sh` — проверка контекста и вызов оркестратора
|
- `package/install.sh` — проверка контекста и вызов оркестратора
|
||||||
- `package/orchestrator/hy2xs-orchestrator` — собранный артефакт
|
- `package/orchestrator/hy2xs-orchestrator` — собранный артефакт
|
||||||
|
|
||||||
## Логирование и коды возврата
|
## Логирование и коды возврата
|
||||||
|
|
||||||
Оркестратор должен:
|
Оркестратор должен:
|
||||||
|
|
||||||
- печатать понятные step-based сообщения
|
- печатать понятные step-based сообщения
|
||||||
- завершаться ненулевым кодом при ошибке
|
- завершаться ненулевым кодом при ошибке
|
||||||
- не скрывать первичный источник падения
|
- не скрывать первичный источник падения
|
||||||
@@ -500,6 +521,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
|
|||||||
## CLI baseline
|
## CLI baseline
|
||||||
|
|
||||||
Команды:
|
Команды:
|
||||||
|
|
||||||
- `preflight-install --package-dir <path> [--config <source-env>]`
|
- `preflight-install --package-dir <path> [--config <source-env>]`
|
||||||
- `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`
|
- `reconfigure --package-dir <path> --config /etc/hy2xs/hy2xs.env --dry-run`
|
||||||
@@ -513,6 +535,7 @@ PHASE 0 ничего не меняет.
|
|||||||
`--allow-partial-state` допустим только для `repair`.
|
`--allow-partial-state` допустим только для `repair`.
|
||||||
|
|
||||||
Инварианты:
|
Инварианты:
|
||||||
|
|
||||||
- только IPv4 bind/listen;
|
- только IPv4 bind/listen;
|
||||||
- TLS modes: `acme | file | self_signed_dev`;
|
- TLS modes: `acme | file | self_signed_dev`;
|
||||||
- `trafficStats.secret` отдельный от `JWT_SECRET`;
|
- `trafficStats.secret` отдельный от `JWT_SECRET`;
|
||||||
@@ -645,10 +668,10 @@ EnvironmentFile дополнительно запрещает U+FEFF. Реали
|
|||||||
|
|
||||||
Поэтому smoke выполняет **настоящий вход** на `POST /api/auth/login`:
|
Поэтому smoke выполняет **настоящий вход** на `POST /api/auth/login`:
|
||||||
|
|
||||||
| Проба | Когда | Что требуется |
|
| Проба | Когда | Что требуется |
|
||||||
| --- | --- | --- |
|
| ---------------------------------------------------- | ---------------- | ----------------------------------------------------------------------- |
|
||||||
| настоящий логин + СЛУЧАЙНЫЙ пароль | всегда | `code: 50000`, причина `invalid_credentials`, `accessToken` отсутствует |
|
| настоящий логин + СЛУЧАЙНЫЙ пароль | всегда | `code: 50000`, причина `invalid_credentials`, `accessToken` отсутствует |
|
||||||
| bootstrap-учётные данные из `bootstrap-admin.secret` | только `install` | `code: 20000` и непустой `accessToken` |
|
| bootstrap-учётные данные из `bootstrap-admin.secret` | только `install` | `code: 20000` и непустой `accessToken` |
|
||||||
|
|
||||||
Детали, которые здесь существенны:
|
Детали, которые здесь существенны:
|
||||||
|
|
||||||
@@ -661,6 +684,16 @@ EnvironmentFile дополнительно запрещает U+FEFF. Реали
|
|||||||
`50000` (отказ операции, а не успех и не отказ валидации, который означал бы
|
`50000` (отказ операции, а не успех и не отказ валидации, который означал бы
|
||||||
негодный запрос), доменная причина `invalid_credentials` и ОТСУТСТВИЕ
|
негодный запрос), доменная причина `invalid_credentials` и ОТСУТСТВИЕ
|
||||||
`accessToken`;
|
`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` означал бы
|
- **токен требуется отдельно.** `code: 20000` без `accessToken` означал бы
|
||||||
панель, которая пускает и не выдаёт сессию;
|
панель, которая пускает и не выдаёт сессию;
|
||||||
- **тело собирается `JSON.stringify`**, а не интерполяцией в строку: пароль
|
- **тело общего helper'а собирается `JSON.stringify`**, а не интерполяцией в строку: пароль
|
||||||
задаёт оператор, и кавычка в нём сломала бы сам запрос, а не панель — проверка
|
задаёт оператор, и кавычка в нём сломала бы сам запрос, а не панель — проверка
|
||||||
объявила бы рабочую установку сломанной;
|
объявила бы рабочую установку сломанной;
|
||||||
- **обе команды идут через `runReadOnlySecret`**: он не кладёт команду в текст
|
- **общий helper идёт через `runReadOnlySecret`**: он не кладёт команду в текст
|
||||||
ошибки, а команда несёт пароль администратора. Наружу отдаётся только код
|
ошибки, а команда несёт пароль администратора. Наружу отдаётся только код
|
||||||
ответа: тело успешного входа содержит токен доступа, а текст ошибки уезжает в
|
ответа: тело успешного входа содержит токен доступа, а текст ошибки уезжает в
|
||||||
журнал установки и в diagnostics-бандл;
|
журнал установки и в diagnostics-бандл;
|
||||||
@@ -712,6 +745,7 @@ service-writable `HY2XS_LOG_DIR`. Родитель проверяется чер
|
|||||||
дерево.
|
дерево.
|
||||||
|
|
||||||
Редактируются:
|
Редактируются:
|
||||||
|
|
||||||
- поля с секретоподобным именем (`password`, `secret`, `token`, `apiKey`,
|
- поля с секретоподобным именем (`password`, `secret`, `token`, `apiKey`,
|
||||||
`privateKey`, `authorization`, `cookie`, `bearer`, `signature`, …);
|
`privateKey`, `authorization`, `cookie`, `bearer`, `signature`, …);
|
||||||
- карты, где секретны все значения (`auth.userpass`, `acme.dns.config`);
|
- карты, где секретны все значения (`auth.userpass`, `acme.dns.config`);
|
||||||
|
|||||||
@@ -144,6 +144,13 @@ anycast. Отсутствие A-записи фатально при любом
|
|||||||
- `HY2XS_FORCE_PASSWORD_CHANGE` в production baseline установлен в `false` (forced UX-flow пока не реализован);
|
- `HY2XS_FORCE_PASSWORD_CHANGE` в production baseline установлен в `false` (forced UX-flow пока не реализован);
|
||||||
- после первичного seed перезапуски `hy2xs-admin` не должны переопределять пароль admin и `con_pass`.
|
- после первичного seed перезапуски `hy2xs-admin` не должны переопределять пароль admin и `con_pass`.
|
||||||
|
|
||||||
|
В санитизированной диагностике корректное значение `true`/`false` у
|
||||||
|
`HY2XS_FORCE_PASSWORD_CHANGE` остаётся видимым: это boolean состояния, а не
|
||||||
|
пароль. Исключение применяется только к точному имени ключа и допустимому
|
||||||
|
boolean; неизвестное значение скрывается fail-closed.
|
||||||
|
`HY2XS_ADMIN_INITIAL_PASSWORD`, `HY2XS_ADMIN_CON_PASS`, пароль obfs,
|
||||||
|
traffic-stats secret и похожие ключи продолжают редактироваться.
|
||||||
|
|
||||||
### Учётные данные администратора проверяются при разборе окружения
|
### Учётные данные администратора проверяются при разборе окружения
|
||||||
|
|
||||||
`HY2XS_ADMIN_USER` и `HY2XS_ADMIN_INITIAL_PASSWORD` — это значения, которые
|
`HY2XS_ADMIN_USER` и `HY2XS_ADMIN_INITIAL_PASSWORD` — это значения, которые
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ cd apps && go vet ./... && go test ./...
|
|||||||
# Проверка типов и сборка frontend
|
# Проверка типов и сборка frontend
|
||||||
cd apps/frontend && pnpm install --frozen-lockfile && pnpm run verify
|
cd apps/frontend && pnpm install --frozen-lockfile && pnpm run verify
|
||||||
|
|
||||||
# Контракты панели: спрайт иконок, словари локализации, коды ошибок, атрибуция
|
# Dependency-free контракты панели
|
||||||
bun test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts
|
bun test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts
|
||||||
|
|
||||||
|
# Runtime-компиляция всех RU/EN-сообщений реальным vue-i18n
|
||||||
|
cd apps/frontend && bun test test/i18n-runtime.test.ts
|
||||||
|
|
||||||
# Сверка среды разработки с versions.env (ничего не меняет)
|
# Сверка среды разработки с versions.env (ничего не меняет)
|
||||||
./tools/dev/doctor.sh
|
./tools/dev/doctor.sh
|
||||||
|
|
||||||
@@ -36,9 +39,12 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
|
|||||||
|
|
||||||
## Почему контракты панели проверяет Bun, а не vitest
|
## Почему контракты панели проверяет Bun, а не vitest
|
||||||
|
|
||||||
Проверяемые модули (`SvgIcon/symbol.ts`, `constants/branding.ts`, словари
|
Dependency-free модули (`SvgIcon/symbol.ts`, frontend constants и словари
|
||||||
локализации) намеренно чистые: ни Vite, ни DOM в них нет, поэтому их можно
|
локализации) намеренно чистые: ни Vite, ни DOM в них нет. Отдельный
|
||||||
выполнить вне браузера уже закреплённым в `versions.env` Bun.
|
`i18n-runtime.test.ts` запускается после `pnpm install`, потому что обязан
|
||||||
|
использовать именно `vue-i18n` из frozen lock-графа. Он обходит каждую строку
|
||||||
|
обоих словарей и считает отказом и исключение, и compiler diagnostics в
|
||||||
|
`console.error`.
|
||||||
|
|
||||||
Vitest с jsdom не вычисляет `currentColor` и визуальной корректности всё равно
|
Vitest с jsdom не вычисляет `currentColor` и визуальной корректности всё равно
|
||||||
не доказал бы, зато привёл бы в граф `pnpm audit` — а его порог считается по
|
не доказал бы, зато привёл бы в граф `pnpm audit` — а его порог считается по
|
||||||
|
|||||||
@@ -918,7 +918,8 @@ build tooling исполняется на build-машине и порождае
|
|||||||
| Шаг сборки | Что запускается |
|
| Шаг сборки | Что запускается |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `run_orchestrator_tests` | `bun x tsc --noEmit`, `bun test` |
|
| `run_orchestrator_tests` | `bun x tsc --noEmit`, `bun test` |
|
||||||
| `bundle_ui` | `pnpm run typecheck` до сборки bundle |
|
| `run_frontend_tests` | dependency-free контракты панели |
|
||||||
|
| `bundle_ui` | после frozen install: `bun test test/i18n-runtime.test.ts`, затем `pnpm run typecheck` и bundle |
|
||||||
| `run_admin_tests` | `go vet ./...`, `go test ./...` |
|
| `run_admin_tests` | `go vet ./...`, `go test ./...` |
|
||||||
|
|
||||||
Приёмка проверяет:
|
Приёмка проверяет:
|
||||||
@@ -926,9 +927,13 @@ build tooling исполняется на build-машине и порождае
|
|||||||
- отключающей тесты переменной нет ни в одном модуле сборки, ни в README/docs
|
- отключающей тесты переменной нет ни в одном модуле сборки, ни в README/docs
|
||||||
(место для истории — `CHANGELOG.md`);
|
(место для истории — `CHANGELOG.md`);
|
||||||
- `metadata/package.env` содержит `tests_gate=true`;
|
- `metadata/package.env` содержит `tests_gate=true`;
|
||||||
|
- общий frontend-флаг выставляется только после ранних контрактов и
|
||||||
|
runtime-компиляции всех сообщений RU/EN;
|
||||||
|
- runtime-gate стоит между frozen install и typecheck/build, использует
|
||||||
|
`vue-i18n` из lock-графа и считает ошибкой compiler diagnostics;
|
||||||
- утверждение о прогоне выставляется **после** самого прогона, а не до него;
|
- утверждение о прогоне выставляется **после** самого прогона, а не до него;
|
||||||
- `write_metadata` отказывается писать метаданные, если хотя бы один из двух
|
- `write_metadata` отказывается писать метаданные, если хотя бы одна из
|
||||||
прогонов не подтверждён.
|
обязательных групп проверок не подтверждена.
|
||||||
|
|
||||||
## A12. Приёмка проверяет код, а не упоминания
|
## A12. Приёмка проверяет код, а не упоминания
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
## B. Target install tests
|
## B. Target install tests
|
||||||
|
|
||||||
### На чистом Debian 13 проверяем
|
### На чистом Debian 13 проверяем
|
||||||
|
|
||||||
1. пакет запускается без ручной сборки на сервере
|
1. пакет запускается без ручной сборки на сервере
|
||||||
2. Hysteria2 скачивается с official upstream
|
2. Hysteria2 скачивается с official upstream
|
||||||
3. bundled HY2XS admin раскладывается локально из пакета
|
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`
|
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 байта) — отвергается конвертом валидации, а не системной ошибкой
|
36. пароль предельной длины, назначенный формой смены пароля, принимается формой входа: границы обеих форм совпадают с серверными. Границ **две** — 6-64 символа Unicode и не более 72 байт в UTF-8 (предел bcrypt): пароль из 36 кириллических букв (72 байта) принимается, из 37 (74 байта) — отвергается конвертом валидации, а не системной ошибкой
|
||||||
37. `HY2XS_ADMIN_INITIAL_PASSWORD` с пробелом по краям доезжает до учётной записи неизменным: значение записано в `hy2xs.env` в двойных кавычках, и вход выполняется ровно им, а не обрезанным
|
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`** — отказ приходит до первой мутации хоста; сервер остаётся нетронутым
|
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 работает как обычный символ
|
37b. API входа и импорта отвергает повреждённый UTF-8 и непарные UTF-16 surrogate escape до `encoding/json`; настоящий U+FFFD работает как обычный символ
|
||||||
38. `last_login_at` администратора обновляется после успешного входа и не меняется после неудачной попытки
|
38. `last_login_at` администратора обновляется после успешного входа и не меняется после неудачной попытки
|
||||||
|
|
||||||
## C0. Панель обязана впускать, а не слушать порт
|
## C0. Панель обязана впускать, а не слушать порт
|
||||||
@@ -93,6 +94,7 @@ BOOTSTRAP_PASS="$(read_bootstrap_field ADMIN_INITIAL_PASSWORD)"
|
|||||||
|
|
||||||
# Положительная проба: конверт успеха и выданный токен.
|
# Положительная проба: конверт успеха и выданный токен.
|
||||||
curl -sS --max-time 5 -X POST \
|
curl -sS --max-time 5 -X POST \
|
||||||
|
--user-agent 'HY2XS-Installer/1.0' \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
--data "$(jq -nc --arg u "$BOOTSTRAP_USER" --arg p "$BOOTSTRAP_PASS" '{username:$u,pass:$p}')" \
|
--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)'
|
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 и на успех.
|
# HTTP 200 сам по себе ничего не доказывает — админка отвечает 200 и на успех.
|
||||||
curl -sS --max-time 5 -X POST \
|
curl -sS --max-time 5 -X POST \
|
||||||
|
--user-agent 'HY2XS-Installer/1.0' \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
--data "$(jq -nc --arg u "$BOOTSTRAP_USER" --arg p "$(head -c 18 /dev/urandom | base64)" '{username:$u,pass:$p}')" \
|
--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 \
|
http://127.0.0.1:8080/api/auth/login \
|
||||||
@@ -110,7 +113,9 @@ unset BOOTSTRAP_PASS
|
|||||||
```
|
```
|
||||||
|
|
||||||
Ожидается `20000` и ненулевая длина токена. Сам токен не печатается: это
|
Ожидается `20000` и ненулевая длина токена. Сам токен не печатается: это
|
||||||
действующая сессия администратора.
|
действующая сессия администратора. Явный User-Agent обязателен: стандартный
|
||||||
|
`curl/<version>` осознанно отклоняется scanner middleware с HTTP 403 ещё до
|
||||||
|
разбора JSON. Значение совпадает с production smoke и не маскируется под браузер.
|
||||||
|
|
||||||
## C1. Семантический smoke конфига
|
## C1. Семантический smoke конфига
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import type { InstallContext, InstallOptions } from "../types/context";
|
import type { InstallContext, InstallOptions } from "../types/context";
|
||||||
import { fileExists, readText, writeTextAtomic } from "../lib/fs";
|
import { fileExists, readText, writeTextAtomic } from "../lib/fs";
|
||||||
import { runMutatingVisible } from "../lib/process";
|
import { runMutatingVisible } from "../lib/process";
|
||||||
|
import { resetFailedUnit } from "../lib/systemd";
|
||||||
import { info, setOperationContext, step, stepDone } from "../lib/log";
|
import { info, setOperationContext, step, stepDone } from "../lib/log";
|
||||||
import { readPackageValue } from "../lib/packageMeta";
|
import { readPackageValue } from "../lib/packageMeta";
|
||||||
import { REPAIR_HINT, buildInstallStateRecord } from "../lib/installState";
|
import { REPAIR_HINT, buildInstallStateRecord } from "../lib/installState";
|
||||||
import { persistInstallState } from "../lib/installStateWriter";
|
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 { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
|
||||||
import {
|
import {
|
||||||
ORCHESTRATOR_INSTALL_DIR,
|
ORCHESTRATOR_INSTALL_DIR,
|
||||||
ORCHESTRATOR_INSTALL_PATH,
|
ORCHESTRATOR_INSTALL_PATH,
|
||||||
ORCHESTRATOR_SYMLINK_PATH,
|
ORCHESTRATOR_SYMLINK_PATH,
|
||||||
RUNTIME_PACKAGE_DIR
|
RUNTIME_PACKAGE_DIR,
|
||||||
} from "../config/profile";
|
} from "../config/profile";
|
||||||
import { preflight } from "../steps/preflight";
|
import { preflight } from "../steps/preflight";
|
||||||
import { bootstrapRuntime } from "../steps/bootstrap";
|
import { bootstrapRuntime } from "../steps/bootstrap";
|
||||||
@@ -27,7 +32,7 @@ import {
|
|||||||
cleanupFirewallRollback,
|
cleanupFirewallRollback,
|
||||||
disarmFirewallRollback,
|
disarmFirewallRollback,
|
||||||
operationKeyFor,
|
operationKeyFor,
|
||||||
rollbackFirewallNow
|
rollbackFirewallNow,
|
||||||
} from "../steps/firewall";
|
} from "../steps/firewall";
|
||||||
import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env";
|
import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env";
|
||||||
import { smoke } from "../steps/smoke";
|
import { smoke } from "../steps/smoke";
|
||||||
@@ -125,7 +130,7 @@ function newOwnership(): OperationOwnership {
|
|||||||
firewallTouched: false,
|
firewallTouched: false,
|
||||||
postInstallTouched: false,
|
postInstallTouched: false,
|
||||||
bootstrapSecretTouched: false,
|
bootstrapSecretTouched: false,
|
||||||
servicesStarted: false
|
servicesStarted: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,14 +151,14 @@ function installOwnedPaths(context: InstallContext): string[] {
|
|||||||
ORCHESTRATOR_INSTALL_DIR,
|
ORCHESTRATOR_INSTALL_DIR,
|
||||||
ORCHESTRATOR_INSTALL_PATH,
|
ORCHESTRATOR_INSTALL_PATH,
|
||||||
ORCHESTRATOR_SYMLINK_PATH,
|
ORCHESTRATOR_SYMLINK_PATH,
|
||||||
RUNTIME_PACKAGE_DIR
|
RUNTIME_PACKAGE_DIR,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeInstallState(
|
async function writeInstallState(
|
||||||
context: InstallContext,
|
context: InstallContext,
|
||||||
phase: InstallPhase,
|
phase: InstallPhase,
|
||||||
lastError: string
|
lastError: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const record = buildInstallStateRecord({
|
const record = buildInstallStateRecord({
|
||||||
productVersion: context.packageVersion,
|
productVersion: context.packageVersion,
|
||||||
@@ -168,7 +173,7 @@ async function writeInstallState(
|
|||||||
installed: phase === "installed",
|
installed: phase === "installed",
|
||||||
ownedPaths: installOwnedPaths(context),
|
ownedPaths: installOwnedPaths(context),
|
||||||
lastError,
|
lastError,
|
||||||
repairHint: phase === "installed" ? undefined : REPAIR_HINT
|
repairHint: phase === "installed" ? undefined : REPAIR_HINT,
|
||||||
});
|
});
|
||||||
|
|
||||||
await persistInstallState(record);
|
await persistInstallState(record);
|
||||||
@@ -178,7 +183,7 @@ async function advanceInstallState(
|
|||||||
context: InstallContext,
|
context: InstallContext,
|
||||||
ownership: OperationOwnership,
|
ownership: OperationOwnership,
|
||||||
phase: InstallPhase,
|
phase: InstallPhase,
|
||||||
lastError = ""
|
lastError = "",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Флаг взводится ПЕРЕД записью, а не после неё: см. комментарий к
|
// Флаг взводится ПЕРЕД записью, а не после неё: см. комментарий к
|
||||||
// stateTouched. Частично выполненная запись маркера — это уже изменение
|
// stateTouched. Частично выполненная запись маркера — это уже изменение
|
||||||
@@ -211,7 +216,7 @@ async function advanceInstallState(
|
|||||||
export function classifyFailure(
|
export function classifyFailure(
|
||||||
ownership: OperationOwnership,
|
ownership: OperationOwnership,
|
||||||
phase: InstallPhase,
|
phase: InstallPhase,
|
||||||
error?: unknown
|
error?: unknown,
|
||||||
): FailureKind {
|
): FailureKind {
|
||||||
if (error instanceof FirewallGuardFiredError) {
|
if (error instanceof FirewallGuardFiredError) {
|
||||||
return "firewall_guard_fired";
|
return "firewall_guard_fired";
|
||||||
@@ -256,10 +261,12 @@ export function classifyFailure(
|
|||||||
async function rollbackFailedInstall(
|
async function rollbackFailedInstall(
|
||||||
context: InstallContext,
|
context: InstallContext,
|
||||||
ownership: OperationOwnership,
|
ownership: OperationOwnership,
|
||||||
failureKind: FailureKind
|
failureKind: FailureKind,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (failureKind === "fatal_pre_apply") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +282,7 @@ async function rollbackFailedInstall(
|
|||||||
name: "firewall",
|
name: "firewall",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
await rollbackFirewallNow(context);
|
await rollbackFirewallNow(context);
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,23 +302,31 @@ async function rollbackFailedInstall(
|
|||||||
name: "stop services",
|
name: "stop services",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin`;
|
await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin`;
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "disable services",
|
name: "disable services",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin`;
|
await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin`;
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "reset failed services",
|
name: "reset failed hysteria-server",
|
||||||
run: async () => {
|
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 {
|
} 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);
|
await runRollbackStages(stages);
|
||||||
@@ -319,11 +334,15 @@ async function rollbackFailedInstall(
|
|||||||
|
|
||||||
export async function install(options: InstallOptions): Promise<void> {
|
export async function install(options: InstallOptions): Promise<void> {
|
||||||
setOperationContext(`install-${Date.now().toString(36)}`);
|
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) {
|
if (options.sourceConfigPath && !hasSourceConfig) {
|
||||||
throw new Error(`config source not found: ${options.sourceConfigPath}`);
|
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 sourceConfigRaw = await readText(sourceConfigPath);
|
||||||
const config = parseRuntimeEnv(sourceConfigRaw);
|
const config = parseRuntimeEnv(sourceConfigRaw);
|
||||||
|
|
||||||
@@ -331,18 +350,48 @@ export async function install(options: InstallOptions): Promise<void> {
|
|||||||
mode: "install",
|
mode: "install",
|
||||||
options,
|
options,
|
||||||
config,
|
config,
|
||||||
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
|
packageVersion: await readPackageValue(
|
||||||
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
|
options.packageDir,
|
||||||
|
"package.version",
|
||||||
|
"unknown",
|
||||||
|
),
|
||||||
|
packageBuildId: await readPackageValue(
|
||||||
|
options.packageDir,
|
||||||
|
"package.build_id",
|
||||||
|
"unknown",
|
||||||
|
),
|
||||||
installDate: new Date().toISOString(),
|
installDate: new Date().toISOString(),
|
||||||
hysteriaVersion: "unknown",
|
hysteriaVersion: "unknown",
|
||||||
hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown"),
|
hysteriaResolution: await readPackageValue(
|
||||||
hysteriaTargetVersion: await readPackageValue(options.packageDir, "hysteria.version", ""),
|
options.packageDir,
|
||||||
hysteriaArtifactUrl: await readPackageValue(options.packageDir, "hysteria.url", ""),
|
"hysteria.resolution",
|
||||||
hysteriaArtifactSha256: await readPackageValue(options.packageDir, "hysteria.sha256", "")
|
"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) {
|
if (
|
||||||
throw new Error("missing Hysteria lock metadata in package: hysteria.version/hysteria.url/hysteria.sha256");
|
!context.hysteriaTargetVersion ||
|
||||||
|
!context.hysteriaArtifactUrl ||
|
||||||
|
!context.hysteriaArtifactSha256
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"missing Hysteria lock metadata in package: hysteria.version/hysteria.url/hysteria.sha256",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownership = newOwnership();
|
const ownership = newOwnership();
|
||||||
@@ -356,7 +405,10 @@ export async function install(options: InstallOptions): Promise<void> {
|
|||||||
// Всё, что дальше, уже создаёт наши собственные пути, и повторная проверка
|
// Всё, что дальше, уже создаёт наши собственные пути, и повторная проверка
|
||||||
// опознала бы их как чужую установку.
|
// опознала бы их как чужую установку.
|
||||||
step("preflight");
|
step("preflight");
|
||||||
await preflight(context, { requireCapabilities: false, checkCleanHost: true });
|
await preflight(context, {
|
||||||
|
requireCapabilities: false,
|
||||||
|
checkCleanHost: true,
|
||||||
|
});
|
||||||
stepDone("preflight");
|
stepDone("preflight");
|
||||||
|
|
||||||
await advanceInstallState(context, ownership, "preflight_ok");
|
await advanceInstallState(context, ownership, "preflight_ok");
|
||||||
@@ -372,7 +424,10 @@ export async function install(options: InstallOptions): Promise<void> {
|
|||||||
await installDeps(context);
|
await installDeps(context);
|
||||||
stepDone("system dependencies");
|
stepDone("system dependencies");
|
||||||
step("preflight capabilities");
|
step("preflight capabilities");
|
||||||
await preflight(context, { requireCapabilities: true, checkCleanHost: false });
|
await preflight(context, {
|
||||||
|
requireCapabilities: true,
|
||||||
|
checkCleanHost: false,
|
||||||
|
});
|
||||||
stepDone("preflight capabilities");
|
stepDone("preflight capabilities");
|
||||||
await advanceInstallState(context, ownership, "deps_ok");
|
await advanceInstallState(context, ownership, "deps_ok");
|
||||||
phase = "deps_ok";
|
phase = "deps_ok";
|
||||||
@@ -387,7 +442,7 @@ export async function install(options: InstallOptions): Promise<void> {
|
|||||||
await writeTextAtomic(options.runtimeConfigPath, renderRuntimeEnv(config), {
|
await writeTextAtomic(options.runtimeConfigPath, renderRuntimeEnv(config), {
|
||||||
mode: 0o600,
|
mode: 0o600,
|
||||||
owner: "root",
|
owner: "root",
|
||||||
group: "root"
|
group: "root",
|
||||||
});
|
});
|
||||||
stepDone("write runtime env");
|
stepDone("write runtime env");
|
||||||
await advanceInstallState(context, ownership, "runtime_env_written");
|
await advanceInstallState(context, ownership, "runtime_env_written");
|
||||||
@@ -468,8 +523,13 @@ export async function install(options: InstallOptions): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await cleanupFirewallRollback(context);
|
await cleanupFirewallRollback(context);
|
||||||
} catch (cleanupError) {
|
} catch (cleanupError) {
|
||||||
const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
const cleanupMessage =
|
||||||
info(`firewall rollback data cleanup failed after a successful install: ${cleanupMessage}`);
|
cleanupError instanceof Error
|
||||||
|
? cleanupError.message
|
||||||
|
: String(cleanupError);
|
||||||
|
info(
|
||||||
|
`firewall rollback data cleanup failed after a successful install: ${cleanupMessage}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
@@ -498,7 +558,12 @@ export async function install(options: InstallOptions): Promise<void> {
|
|||||||
: "failed";
|
: "failed";
|
||||||
|
|
||||||
await persistFailureState(() =>
|
await persistFailureState(() =>
|
||||||
advanceInstallState(context, ownership, failurePhase, `${failureKind}: ${message}`)
|
advanceInstallState(
|
||||||
|
context,
|
||||||
|
ownership,
|
||||||
|
failurePhase,
|
||||||
|
`${failureKind}: ${message}`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Диагностика — best effort, откат — обязателен.
|
// Диагностика — best effort, откат — обязателен.
|
||||||
@@ -512,8 +577,12 @@ export async function install(options: InstallOptions): Promise<void> {
|
|||||||
await diagnosticsCollect(options);
|
await diagnosticsCollect(options);
|
||||||
} catch (diagnosticsError) {
|
} catch (diagnosticsError) {
|
||||||
const diagnosticsMessage =
|
const diagnosticsMessage =
|
||||||
diagnosticsError instanceof Error ? diagnosticsError.message : String(diagnosticsError);
|
diagnosticsError instanceof Error
|
||||||
info(`diagnostics collection failed, continuing with rollback: ${diagnosticsMessage}`);
|
? diagnosticsError.message
|
||||||
|
: String(diagnosticsError);
|
||||||
|
info(
|
||||||
|
`diagnostics collection failed, continuing with rollback: ${diagnosticsMessage}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await rollbackFailedInstall(context, ownership, failureKind);
|
await rollbackFailedInstall(context, ownership, failureKind);
|
||||||
|
|||||||
@@ -12,6 +12,17 @@
|
|||||||
|
|
||||||
export const REDACTED = "<redacted>";
|
export const REDACTED = "<redacted>";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ENV-флаги, чьи имена похожи на секрет, но значения являются диагностически
|
||||||
|
* полезным публичным состоянием. Исключения здесь только точные: общий
|
||||||
|
* детектор секретов нельзя ослаблять совпадением по префиксу или суффиксу.
|
||||||
|
*/
|
||||||
|
const NON_SECRET_ENV_KEYS = new Set(["HY2XS_FORCE_PASSWORD_CHANGE"]);
|
||||||
|
|
||||||
|
/** Сохранённый контракт прежнего ENV-детектора, но применённый после разбора key. */
|
||||||
|
const ENV_SECRET_KEY_PATTERN =
|
||||||
|
/(PASSWORD|PASSWD|PASSPHRASE|PASS|SECRET|TOKEN|CREDENTIAL|APIKEY|AUTHORIZATION|COOKIE|BEARER|SIGNATURE)/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Маркеры имён полей. Список покрывает известные секреты HY2XS/Hysteria и
|
* Маркеры имён полей. Список покрывает известные секреты HY2XS/Hysteria и
|
||||||
* типовые имена, под которыми секрет может появиться в будущем upstream-поле.
|
* типовые имена, под которыми секрет может появиться в будущем upstream-поле.
|
||||||
@@ -36,11 +47,14 @@ export const SECRET_KEY_MARKERS: readonly string[] = [
|
|||||||
"authorization",
|
"authorization",
|
||||||
"cookie",
|
"cookie",
|
||||||
"bearer",
|
"bearer",
|
||||||
"signature"
|
"signature",
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Узлы, где секретом являются все значения карты, а не имя ключа. */
|
/** Узлы, где секретом являются все значения карты, а не имя ключа. */
|
||||||
const SECRET_MAP_PATHS: readonly string[] = ["auth.userpass", "acme.dns.config"];
|
const SECRET_MAP_PATHS: readonly string[] = [
|
||||||
|
"auth.userpass",
|
||||||
|
"acme.dns.config",
|
||||||
|
];
|
||||||
|
|
||||||
export function isSecretKey(key: string): boolean {
|
export function isSecretKey(key: string): boolean {
|
||||||
const lowered = key.toLowerCase();
|
const lowered = key.toLowerCase();
|
||||||
@@ -92,7 +106,9 @@ function redactValueDeep(value: unknown): unknown {
|
|||||||
}
|
}
|
||||||
if (value && typeof value === "object") {
|
if (value && typeof value === "object") {
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
for (const [key, child] of Object.entries(
|
||||||
|
value as Record<string, unknown>
|
||||||
|
)) {
|
||||||
out[key] = redactValueDeep(child);
|
out[key] = redactValueDeep(child);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -138,8 +154,15 @@ function redactNode(value: unknown, path: readonly string[]): unknown {
|
|||||||
|
|
||||||
export function redactKeyValueSecrets(content: string): string {
|
export function redactKeyValueSecrets(content: string): string {
|
||||||
return content.replace(
|
return content.replace(
|
||||||
/^([A-Z0-9_]*(PASSWORD|PASSWD|PASSPHRASE|PASS|SECRET|TOKEN|CREDENTIAL|APIKEY|AUTHORIZATION|COOKIE|BEARER|SIGNATURE)[A-Z0-9_]*=).*$/gm,
|
/^([A-Z0-9_]+)=(.*?)(\r?)$/gm,
|
||||||
`$1${REDACTED}`
|
(line, key: string, value: string, eol: string) => {
|
||||||
|
const isKnownBoolean =
|
||||||
|
NON_SECRET_ENV_KEYS.has(key) && /^(?:true|false)$/.test(value);
|
||||||
|
if (isKnownBoolean || !ENV_SECRET_KEY_PATTERN.test(key)) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
return `${key}=${REDACTED}${eol}`;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +230,10 @@ export function redactLogText(content: string): string {
|
|||||||
LOG_SECRET_ASSIGNMENT_PATTERN,
|
LOG_SECRET_ASSIGNMENT_PATTERN,
|
||||||
(match, key: string, separator: string, rawValue: string) => {
|
(match, key: string, separator: string, rawValue: string) => {
|
||||||
const [value, trailing] = splitTrailingPunctuation(rawValue);
|
const [value, trailing] = splitTrailingPunctuation(rawValue);
|
||||||
if (value === REDACTED || value.toLowerCase() === ENCODED_REDACTED.toLowerCase()) {
|
if (
|
||||||
|
value === REDACTED ||
|
||||||
|
value.toLowerCase() === ENCODED_REDACTED.toLowerCase()
|
||||||
|
) {
|
||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
const rendered = separator === ":" ? ": " : "=";
|
const rendered = separator === ":" ? ": " : "=";
|
||||||
|
|||||||
@@ -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 { info } from "../lib/log";
|
||||||
import { readText } from "../lib/fs";
|
import { readText } from "../lib/fs";
|
||||||
import { parseEnvFile } from "../lib/envFile";
|
import { parseEnvFile } from "../lib/envFile";
|
||||||
import { runReadOnly, runReadOnlySecret, runMutatingVisible } from "../lib/process";
|
import {
|
||||||
import { ADMIN_LOGIN_PATH, HYSTERIA_MACHINE_AUTH_PATH, hysteriaMachineAuthUrl } from "../config/profile";
|
runReadOnly,
|
||||||
|
runReadOnlySecret,
|
||||||
|
runMutatingVisible,
|
||||||
|
} from "../lib/process";
|
||||||
|
import {
|
||||||
|
ADMIN_LOGIN_PATH,
|
||||||
|
HYSTERIA_MACHINE_AUTH_PATH,
|
||||||
|
hysteriaMachineAuthUrl,
|
||||||
|
} from "../config/profile";
|
||||||
import { assertHysteriaConfigMatchesProfile } from "./configAssertions";
|
import { assertHysteriaConfigMatchesProfile } from "./configAssertions";
|
||||||
import { assertEffectiveFirewallIsOurs } from "./firewall";
|
import { assertEffectiveFirewallIsOurs } from "./firewall";
|
||||||
|
|
||||||
@@ -82,7 +90,8 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
|||||||
1000,
|
1000,
|
||||||
async () => runReadOnlySecret`systemctl is-active hysteria-server || true`,
|
async () => runReadOnlySecret`systemctl is-active hysteria-server || true`,
|
||||||
(state) => state.trim() === "active",
|
(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(
|
await retry(
|
||||||
"systemd hy2xs-admin active",
|
"systemd hy2xs-admin active",
|
||||||
@@ -90,7 +99,8 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
|||||||
1000,
|
1000,
|
||||||
async () => runReadOnlySecret`systemctl is-active hy2xs-admin || true`,
|
async () => runReadOnlySecret`systemctl is-active hy2xs-admin || true`,
|
||||||
(state) => state.trim() === "active",
|
(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) {
|
if (context.options.skipSmoke) {
|
||||||
@@ -103,24 +113,38 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
|||||||
15,
|
15,
|
||||||
1000,
|
1000,
|
||||||
async () => runReadOnlySecret`ss -H -ltn`,
|
async () => runReadOnlySecret`ss -H -ltn`,
|
||||||
(lines) => hasTcpListener(lines, context.config.uiBindHost, context.config.uiPort),
|
(lines) =>
|
||||||
(lines, error) => new Error(`ui listener not ready on ${context.config.uiBindHost}:${context.config.uiPort}: ${lines ?? String(error)}`),
|
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(
|
await retry(
|
||||||
"hysteria udp listener readiness",
|
"hysteria udp listener readiness",
|
||||||
15,
|
15,
|
||||||
1000,
|
1000,
|
||||||
async () => runReadOnlySecret`ss -H -lun`,
|
async () => runReadOnlySecret`ss -H -lun`,
|
||||||
(lines) => hasUdpListener(lines, context.config.hysteriaBindHost, context.config.hysteriaPort),
|
(lines) =>
|
||||||
(lines, error) => new Error(`hysteria udp listener not ready on 0.0.0.0:${context.config.hysteriaPort}: ${lines ?? String(error)}`),
|
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(
|
await retry(
|
||||||
"admin healthz readiness",
|
"admin healthz readiness",
|
||||||
15,
|
15,
|
||||||
1000,
|
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) => /"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 раннерами.
|
// Всё, что ниже, — НАБЛЮДЕНИЕ, и оно выполняется read-only раннерами.
|
||||||
@@ -131,7 +155,9 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
|||||||
// не меняет диагностируемую систему» невозможно было включить guard'ом — он
|
// не меняет диагностируемую систему» невозможно было включить 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/hysteria/config.yaml`;
|
||||||
await runReadOnly`test -s /etc/hy2xs/hy2xs.env`;
|
await runReadOnly`test -s /etc/hy2xs/hy2xs.env`;
|
||||||
await runReadOnly`test -s /etc/hysteria/post-install.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") {
|
if (context.config.uiBindHost === "127.0.0.1") {
|
||||||
const tcp = await runReadOnlySecret`ss -H -ltn`;
|
const tcp = await runReadOnlySecret`ss -H -ltn`;
|
||||||
if (hasTcpListener(tcp, "0.0.0.0", context.config.uiPort)) {
|
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} '`;
|
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 machineAuthUrlNoToken = `http://127.0.0.1:${context.config.uiPort}${HYSTERIA_MACHINE_AUTH_PATH}`;
|
||||||
const machineAuthUrl = hysteriaMachineAuthUrl(
|
const machineAuthUrl = hysteriaMachineAuthUrl(
|
||||||
context.config.uiPort,
|
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") {
|
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(
|
const invalidAuthResponse = await retry(
|
||||||
"auth invalid credentials",
|
"auth invalid credentials",
|
||||||
5,
|
5,
|
||||||
1000,
|
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) => /"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) {
|
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)) {
|
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") {
|
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") {
|
if (context.mode === "install") {
|
||||||
const adminConPass = (await readBootstrapAdminSecret(context)).ADMIN_CON_PASS ?? "";
|
const adminConPass =
|
||||||
|
(await readBootstrapAdminSecret(context)).ADMIN_CON_PASS ?? "";
|
||||||
if (!adminConPass) {
|
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(
|
await retry(
|
||||||
"auth valid credentials",
|
"auth valid credentials",
|
||||||
10,
|
10,
|
||||||
1000,
|
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) => /"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",
|
"trafficStats valid secret",
|
||||||
10,
|
10,
|
||||||
1000,
|
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) => /^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)) {
|
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` только разбирает файл и правил не применяет — это проверка
|
// `nft -c` только разбирает файл и правил не применяет — это проверка
|
||||||
@@ -237,13 +292,18 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
|||||||
// синтаксически валидный — ruleset, проверка проходила зелёной, и операция
|
// синтаксически валидный — ruleset, проверка проходила зелёной, и операция
|
||||||
// объявляла успешной установку, работающую на firewall, который она же
|
// объявляла успешной установку, работающую на 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);
|
await assertEffectiveFirewallIsOurs(context);
|
||||||
|
|
||||||
// Семантическая проверка установленного конфига: разбираем YAML и сверяем
|
// Семантическая проверка установленного конфига: разбираем YAML и сверяем
|
||||||
// с production-профилем, а не ищем подстроки.
|
// с production-профилем, а не ищем подстроки.
|
||||||
info("verifying effective Hysteria config against HY2XS production profile");
|
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);
|
await assertEffectiveHysteriaVersion(context);
|
||||||
}
|
}
|
||||||
@@ -296,25 +356,23 @@ async function assertAdminLoginWorks(context: RuntimeContext): Promise<void> {
|
|||||||
// доступа. Логин берётся настоящий — тот же, что у администратора, — чтобы
|
// доступа. Логин берётся настоящий — тот же, что у администратора, — чтобы
|
||||||
// запрос шёл тем же путём, что и реальный вход, и доходил до проверки пароля.
|
// запрос шёл тем же путём, что и реальный вход, и доходил до проверки пароля.
|
||||||
const rejectedPassword = randomBytes(24).toString("base64url");
|
const rejectedPassword = randomBytes(24).toString("base64url");
|
||||||
const rejectedPayload = JSON.stringify({
|
|
||||||
username: context.config.adminUser,
|
|
||||||
pass: rejectedPassword
|
|
||||||
});
|
|
||||||
const rejectedBody = await retry(
|
const rejectedBody = await retry(
|
||||||
"admin login rejects wrong credentials",
|
"admin login rejects wrong credentials",
|
||||||
10,
|
10,
|
||||||
1000,
|
1000,
|
||||||
async () =>
|
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) => isRejectedLogin(body),
|
||||||
(body, error) =>
|
(body, error) =>
|
||||||
new Error(
|
new Error(
|
||||||
`admin login did not reject invalid credentials: ${describeRejectionFailure(body, error)}\n` +
|
`admin login did not reject invalid credentials: ${describeRejectionFailure(body, error)}\n` +
|
||||||
`Панель обязана отвечать конвертом отказа с причиной ${INVALID_CREDENTIALS_CODE}. ` +
|
`Панель обязана отвечать конвертом отказа с причиной ${INVALID_CREDENTIALS_CODE}. ` +
|
||||||
`Отсутствие такого конверта означает, что запрос не доживает до проверки учётных данных.`
|
`Отсутствие такого конверта означает, что запрос не доживает до проверки учётных данных.`,
|
||||||
)
|
),
|
||||||
|
);
|
||||||
|
info(
|
||||||
|
`admin login rejects wrong credentials: ${describeRejection(rejectedBody)}`,
|
||||||
);
|
);
|
||||||
info(`admin login rejects wrong credentials: ${describeRejection(rejectedBody)}`);
|
|
||||||
|
|
||||||
if (context.mode !== "install") {
|
if (context.mode !== "install") {
|
||||||
return;
|
return;
|
||||||
@@ -341,27 +399,107 @@ async function assertAdminLoginWorks(context: RuntimeContext): Promise<void> {
|
|||||||
// пароля с настоящим невероятно, но проверить это здесь можно точно, и тогда
|
// пароля с настоящим невероятно, но проверить это здесь можно точно, и тогда
|
||||||
// «отвергнуто» гарантированно означает «отвергнуто», а не «не совпало».
|
// «отвергнуто» гарантированно означает «отвергнуто», а не «не совпало».
|
||||||
if (rejectedPassword === adminPassword) {
|
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(
|
const response = await retry(
|
||||||
"admin login with bootstrap credentials",
|
"admin login with bootstrap credentials",
|
||||||
10,
|
10,
|
||||||
1000,
|
1000,
|
||||||
async () =>
|
async () => requestAdminLogin(loginUrl, adminUser, adminPassword),
|
||||||
runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data ${payload} ${loginUrl}`,
|
|
||||||
(body) => isSuccessfulLogin(body),
|
(body) => isSuccessfulLogin(body),
|
||||||
(body, error) =>
|
(body, error) =>
|
||||||
new Error(
|
new Error(
|
||||||
`admin panel refused the bootstrap login it created itself: ${describeLoginFailure(body, error)}\n` +
|
`admin panel refused the bootstrap login it created itself: ${describeLoginFailure(body, error)}\n` +
|
||||||
`Порт открыт и /healthz отвечает, но войти в панель нельзя — установка не считается выполненной.`
|
`Порт открыт и /healthz отвечает, но войти в панель нельзя — установка не считается выполненной.`,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
info(`admin login accepted: ${describeIssuedToken(response)}`);
|
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.
|
* Успех определяется по КОНВЕРТУ, а не по коду HTTP.
|
||||||
*
|
*
|
||||||
@@ -372,8 +510,9 @@ async function assertAdminLoginWorks(context: RuntimeContext): Promise<void> {
|
|||||||
* Выданный токен требуется отдельно: `code: 20000` без `accessToken` означал бы
|
* Выданный токен требуется отдельно: `code: 20000` без `accessToken` означал бы
|
||||||
* панель, которая пускает и не выдаёт сессию.
|
* панель, которая пускает и не выдаёт сессию.
|
||||||
*/
|
*/
|
||||||
function isSuccessfulLogin(body: string): boolean {
|
export function isSuccessfulLogin(body: string): boolean {
|
||||||
return /"code"\s*:\s*20000/.test(body) && /"accessToken"\s*:\s*"[^"]+"/.test(body);
|
const envelope = parseLoginEnvelope(body);
|
||||||
|
return envelope?.code === 20000 && accessTokenOf(envelope) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -398,11 +537,12 @@ const INVALID_CREDENTIALS_CODE = "invalid_credentials";
|
|||||||
* скажем, по недоступной базе;
|
* скажем, по недоступной базе;
|
||||||
* отсутствие accessToken — панель не выдала сессию.
|
* отсутствие accessToken — панель не выдала сессию.
|
||||||
*/
|
*/
|
||||||
function isRejectedLogin(body: string): boolean {
|
export function isRejectedLogin(body: string): boolean {
|
||||||
|
const envelope = parseLoginEnvelope(body);
|
||||||
return (
|
return (
|
||||||
/"code"\s*:\s*50000/.test(body) &&
|
envelope?.code === 50000 &&
|
||||||
new RegExp(`"code"\\s*:\\s*"${INVALID_CREDENTIALS_CODE}"`).test(body) &&
|
rejectionCodesOf(envelope).includes(INVALID_CREDENTIALS_CODE) &&
|
||||||
!/"accessToken"\s*:\s*"[^"]+"/.test(body)
|
!carriesAccessToken(envelope)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,11 +554,14 @@ function isRejectedLogin(body: string): boolean {
|
|||||||
* если панель по недоразумению впустила, в ответе лежит токен доступа, а этот
|
* если панель по недоразумению впустила, в ответе лежит токен доступа, а этот
|
||||||
* текст уезжает в журнал установки и в diagnostics-бандл.
|
* текст уезжает в журнал установки и в diagnostics-бандл.
|
||||||
*/
|
*/
|
||||||
function describeRejectionFailure(body: string | undefined, error: unknown): string {
|
function describeRejectionFailure(
|
||||||
|
body: string | undefined,
|
||||||
|
error: unknown,
|
||||||
|
): string {
|
||||||
if (body === undefined) {
|
if (body === undefined) {
|
||||||
return `запрос не выполнен: ${String(error)}`;
|
return `запрос не выполнен: ${String(error)}`;
|
||||||
}
|
}
|
||||||
if (/"accessToken"\s*:\s*"[^"]+"/.test(body)) {
|
if (carriesAccessToken(parseLoginEnvelope(body))) {
|
||||||
return "панель ВЫДАЛА токен доступа на заведомо неверные учётные данные";
|
return "панель ВЫДАЛА токен доступа на заведомо неверные учётные данные";
|
||||||
}
|
}
|
||||||
return describeRejection(body);
|
return describeRejection(body);
|
||||||
@@ -426,9 +569,9 @@ function describeRejectionFailure(body: string | undefined, error: unknown): str
|
|||||||
|
|
||||||
/** Как выглядит отказ: код конверта и код причины, без тела. */
|
/** Как выглядит отказ: код конверта и код причины, без тела. */
|
||||||
function describeRejection(body: string): string {
|
function describeRejection(body: string): string {
|
||||||
const envelope = body.match(/"code"\s*:\s*(\d+)/);
|
const envelope = parseLoginEnvelope(body);
|
||||||
const reason = body.match(/"code"\s*:\s*"([a-z_]+)"/);
|
const reason = rejectionCodesOf(envelope)[0];
|
||||||
return `code=${envelope ? envelope[1] : "нет"}, причина=${reason ? reason[1] : "нет"}, токен не выдан`;
|
return `code=${typeof envelope?.code === "number" ? envelope.code : "нет"}, причина=${reason ?? "нет"}, токен не выдан`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Читает /etc/hy2xs/bootstrap-admin.secret тем же парсером, которым он написан.
|
// Читает /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));
|
return parseEnvFile(await readText(context.config.bootstrapAdminSecretPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,21 +597,26 @@ async function readBootstrapAdminSecret(context: RuntimeContext): Promise<Record
|
|||||||
* текст этой ошибки уезжает в журнал установки и в diagnostics-бандл, который
|
* текст этой ошибки уезжает в журнал установки и в diagnostics-бандл, который
|
||||||
* операторы пересылают в переписке. Поэтому наружу выдаётся только код ответа.
|
* операторы пересылают в переписке. Поэтому наружу выдаётся только код ответа.
|
||||||
*/
|
*/
|
||||||
function describeLoginFailure(body: string | undefined, error: unknown): string {
|
function describeLoginFailure(
|
||||||
|
body: string | undefined,
|
||||||
|
error: unknown,
|
||||||
|
): string {
|
||||||
if (body === undefined) {
|
if (body === undefined) {
|
||||||
return `запрос не выполнен: ${String(error)}`;
|
return `запрос не выполнен: ${String(error)}`;
|
||||||
}
|
}
|
||||||
const code = body.match(/"code"\s*:\s*(\d+)/);
|
const envelope = parseLoginEnvelope(body);
|
||||||
if (code) {
|
if (typeof envelope?.code === "number") {
|
||||||
return `ответ с code=${code[1]} и без токена доступа`;
|
return `ответ с code=${envelope.code} и без токена доступа`;
|
||||||
}
|
}
|
||||||
return "ответ не является конвертом API админки";
|
return "ответ не является конвертом API админки";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Подтверждение выдачи токена без самого токена. */
|
/** Подтверждение выдачи токена без самого токена. */
|
||||||
function describeIssuedToken(body: string): string {
|
function describeIssuedToken(body: string): string {
|
||||||
const tokenType = body.match(/"tokenType"\s*:\s*"([^"]*)"/);
|
const data = objectOrNull(parseLoginEnvelope(body)?.data);
|
||||||
return tokenType ? `выдан токен типа ${tokenType[1]}` : "выдан токен доступа";
|
return typeof data?.tokenType === "string" && data.tokenType.length > 0
|
||||||
|
? `выдан токен типа ${data.tokenType}`
|
||||||
|
: "выдан токен доступа";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -474,7 +624,9 @@ function describeIssuedToken(body: string): string {
|
|||||||
* пакета. На reconfigure metadata может относиться к другому пакету, поэтому
|
* пакета. На reconfigure metadata может относиться к другому пакету, поэтому
|
||||||
* расхождение там — предупреждение, а не отказ.
|
* расхождение там — предупреждение, а не отказ.
|
||||||
*/
|
*/
|
||||||
async function assertEffectiveHysteriaVersion(context: RuntimeContext): Promise<void> {
|
async function assertEffectiveHysteriaVersion(
|
||||||
|
context: RuntimeContext,
|
||||||
|
): Promise<void> {
|
||||||
const packagedVersion = context.hysteriaVersion.trim();
|
const packagedVersion = context.hysteriaVersion.trim();
|
||||||
if (!packagedVersion || packagedVersion === "unknown") {
|
if (!packagedVersion || packagedVersion === "unknown") {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -12,8 +12,14 @@ import {
|
|||||||
ADMIN_USERNAME_MAX_LENGTH,
|
ADMIN_USERNAME_MAX_LENGTH,
|
||||||
ADMIN_USERNAME_MIN_LENGTH,
|
ADMIN_USERNAME_MIN_LENGTH,
|
||||||
ADMIN_USERNAME_PATTERN,
|
ADMIN_USERNAME_PATTERN,
|
||||||
isValidAdminPassword
|
isValidAdminPassword,
|
||||||
} from "../src/config/profile";
|
} from "../src/config/profile";
|
||||||
|
import {
|
||||||
|
ADMIN_LOGIN_SMOKE_USER_AGENT,
|
||||||
|
buildAdminLoginRequest,
|
||||||
|
isRejectedLogin,
|
||||||
|
isSuccessfulLogin,
|
||||||
|
} from "../src/steps/smoke";
|
||||||
import { baselineConfig, envText } from "./fixtures";
|
import { baselineConfig, envText } from "./fixtures";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,7 +37,7 @@ const REPO_ROOT = path.resolve(import.meta.dir, "..", "..");
|
|||||||
function goContract(): string {
|
function goContract(): string {
|
||||||
return fs.readFileSync(
|
return fs.readFileSync(
|
||||||
path.join(REPO_ROOT, "apps", "credential", "admin.go"),
|
path.join(REPO_ROOT, "apps", "credential", "admin.go"),
|
||||||
"utf8"
|
"utf8",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +47,9 @@ function goConst(name: string): string {
|
|||||||
if (numeric) {
|
if (numeric) {
|
||||||
return numeric[1];
|
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) {
|
if (backticked) {
|
||||||
return backticked[1];
|
return backticked[1];
|
||||||
}
|
}
|
||||||
@@ -68,14 +76,24 @@ describe("контракт учётных данных совпадает с а
|
|||||||
// Go-бинарника и импортировать его константы не может. Проверяется поэтому
|
// Go-бинарника и импортировать его константы не может. Проверяется поэтому
|
||||||
// не отсутствие копии, а её совпадение с оригиналом — сверкой по исходнику.
|
// не отсутствие копии, а её совпадение с оригиналом — сверкой по исходнику.
|
||||||
test("границы совпадают с credential.Admin*", () => {
|
test("границы совпадают с credential.Admin*", () => {
|
||||||
expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(goConst("AdminUsernameMinLength"));
|
expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(
|
||||||
expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(goConst("AdminUsernameMaxLength"));
|
goConst("AdminUsernameMinLength"),
|
||||||
expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(goConst("AdminPasswordMinLength"));
|
);
|
||||||
expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(goConst("AdminPasswordMaxLength"));
|
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 — ОТДЕЛЬНАЯ константа и отдельная сверка: пока его тут не
|
// Предел bcrypt — ОТДЕЛЬНАЯ константа и отдельная сверка: пока его тут не
|
||||||
// было, оркестратор пропускал в hy2xs.env пароль, который админка не могла
|
// было, оркестратор пропускал в hy2xs.env пароль, который админка не могла
|
||||||
// захешировать, и установка заканчивалась сервером без администратора.
|
// захешировать, и установка заканчивалась сервером без администратора.
|
||||||
expect(String(ADMIN_PASSWORD_MAX_BYTES)).toBe(goConst("AdminPasswordMaxBytes"));
|
expect(String(ADMIN_PASSWORD_MAX_BYTES)).toBe(
|
||||||
|
goConst("AdminPasswordMaxBytes"),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("набор символов логина совпадает с классом из Go", () => {
|
test("набор символов логина совпадает с классом из Go", () => {
|
||||||
@@ -83,8 +101,11 @@ describe("контракт учётных данных совпадает с а
|
|||||||
// raw-строке, в TypeScript — в обычной, где обратный слеш удваивается.
|
// raw-строке, в TypeScript — в обычной, где обратный слеш удваивается.
|
||||||
const declared = goConst("adminUsernameCharacterClass");
|
const declared = goConst("adminUsernameCharacterClass");
|
||||||
const mirrored = ADMIN_USERNAME_PATTERN.source.replace(
|
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);
|
expect(mirrored).toBe(declared);
|
||||||
});
|
});
|
||||||
@@ -105,7 +126,10 @@ describe("контракт учётных данных совпадает с а
|
|||||||
test("путь формы входа собран из базового пространства имён API", () => {
|
test("путь формы входа собран из базового пространства имён API", () => {
|
||||||
expect(ADMIN_LOGIN_PATH).toBe("/api/auth/login");
|
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('auth.POST("/login"');
|
||||||
expect(router).toContain('authApi.Group("/auth")');
|
expect(router).toContain('authApi.Group("/auth")');
|
||||||
});
|
});
|
||||||
@@ -122,66 +146,81 @@ describe("HY2XS_ADMIN_USER проверяется при разборе окру
|
|||||||
// шесть. Установка проходила целиком, а панель не впускала никого.
|
// шесть. Установка проходила целиком, а панель не впускала никого.
|
||||||
test("значение по умолчанию не короче минимума", () => {
|
test("значение по умолчанию не короче минимума", () => {
|
||||||
const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_USER: null }));
|
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 создаёт одну учётную запись, а админка при
|
// без явного HY2XS_ADMIN_USER создаёт одну учётную запись, а админка при
|
||||||
// повреждённом окружении ищет другую.
|
// повреждённом окружении ищет другую.
|
||||||
test("умолчание совпадает с запасным значением админки и с packaged env", () => {
|
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"');
|
expect(dao).toContain('adminUser = "hy2xsadmin"');
|
||||||
|
|
||||||
const packaged = fs.readFileSync(
|
const packaged = fs.readFileSync(
|
||||||
path.join(REPO_ROOT, "package", "config", "hy2xs.env"),
|
path.join(REPO_ROOT, "package", "config", "hy2xs.env"),
|
||||||
"utf8"
|
"utf8",
|
||||||
);
|
);
|
||||||
expect(packaged).toContain("HY2XS_ADMIN_USER=hy2xsadmin");
|
expect(packaged).toContain("HY2XS_ADMIN_USER=hy2xsadmin");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("слишком короткий логин роняет установку", () => {
|
test("слишком короткий логин роняет установку", () => {
|
||||||
expect(() => baselineConfig({ HY2XS_ADMIN_USER: "admin" })).toThrow(
|
expect(() => baselineConfig({ HY2XS_ADMIN_USER: "admin" })).toThrow(
|
||||||
/invalid HY2XS_ADMIN_USER/
|
/invalid HY2XS_ADMIN_USER/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("слишком длинный логин роняет установку", () => {
|
test("слишком длинный логин роняет установку", () => {
|
||||||
expect(() =>
|
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/);
|
).toThrow(/invalid HY2XS_ADMIN_USER/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("логин с пробелом, кириллицей или переводом строки роняет установку", () => {
|
test("логин с пробелом, кириллицей или переводом строки роняет установку", () => {
|
||||||
for (const rejected of ["hy2xs admin", "админ12", "hy2xs\tadmin"]) {
|
for (const rejected of ["hy2xs admin", "админ12", "hy2xs\tadmin"]) {
|
||||||
expect(() => baselineConfig({ HY2XS_ADMIN_USER: rejected })).toThrow(
|
expect(() => baselineConfig({ HY2XS_ADMIN_USER: rejected })).toThrow(
|
||||||
/invalid HY2XS_ADMIN_USER/
|
/invalid HY2XS_ADMIN_USER/,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("отказ называет и границы, и набор символов", () => {
|
test("отказ называет и границы, и набор символов", () => {
|
||||||
expect(() => baselineConfig({ HY2XS_ADMIN_USER: "admin" })).toThrow(
|
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 и оттуда в базу. Пробел по краям превращался
|
// Значение уезжает в hy2xs.env и оттуда в базу. Пробел по краям превращался
|
||||||
// бы в часть имени учётной записи, и вход отказывал бы «неверным логином» —
|
// бы в часть имени учётной записи, и вход отказывал бы «неверным логином» —
|
||||||
// отказом, который невозможно связать с причиной.
|
// отказом, который невозможно связать с причиной.
|
||||||
test("окружающие пробелы снимаются, а не уезжают в имя учётной записи", () => {
|
test("окружающие пробелы снимаются, а не уезжают в имя учётной записи", () => {
|
||||||
expect(baselineConfig({ HY2XS_ADMIN_USER: " hy2xsadmin " }).adminUser).toBe("hy2xsadmin");
|
expect(
|
||||||
|
baselineConfig({ HY2XS_ADMIN_USER: " hy2xsadmin " }).adminUser,
|
||||||
|
).toBe("hy2xsadmin");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("точка в логине принимается: набор не сужается задним числом", () => {
|
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 проверяется при разборе окружения", () => {
|
describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разборе окружения", () => {
|
||||||
test("сгенерированный пароль проходит контракт панели", () => {
|
test("сгенерированный пароль проходит контракт панели", () => {
|
||||||
for (const raw of ["", "__GENERATE__"]) {
|
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;
|
const length = [...config.adminInitialPassword].length;
|
||||||
expect(length).toBeGreaterThanOrEqual(ADMIN_PASSWORD_MIN_LENGTH);
|
expect(length).toBeGreaterThanOrEqual(ADMIN_PASSWORD_MIN_LENGTH);
|
||||||
expect(length).toBeLessThanOrEqual(ADMIN_PASSWORD_MAX_LENGTH);
|
expect(length).toBeLessThanOrEqual(ADMIN_PASSWORD_MAX_LENGTH);
|
||||||
@@ -189,26 +228,31 @@ describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разбо
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("слишком короткий пароль роняет установку", () => {
|
test("слишком короткий пароль роняет установку", () => {
|
||||||
expect(() => baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: "12345" })).toThrow(
|
expect(() =>
|
||||||
/invalid HY2XS_ADMIN_INITIAL_PASSWORD/
|
baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: "12345" }),
|
||||||
);
|
).toThrow(/invalid HY2XS_ADMIN_INITIAL_PASSWORD/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("слишком длинный пароль роняет установку", () => {
|
test("слишком длинный пароль роняет установку", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
baselineConfig({
|
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/);
|
).toThrow(/invalid HY2XS_ADMIN_INITIAL_PASSWORD/);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Набор символов пароля сервером не ограничивается, и оркестратор не имеет
|
// Набор символов пароля сервером не ограничивается, и оркестратор не имеет
|
||||||
// права быть строже: отвергнутый здесь пароль сервер принял бы.
|
// права быть строже: отвергнутый здесь пароль сервер принял бы.
|
||||||
test("пароль из произвольных символов принимается", () => {
|
test("пароль из произвольных символов принимается", () => {
|
||||||
for (const password of ["p@ssw0rd?!", "пароль-администратора", "a b c d e f"]) {
|
for (const password of [
|
||||||
expect(baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: password }).adminInitialPassword).toBe(
|
"p@ssw0rd?!",
|
||||||
password
|
"пароль-администратора",
|
||||||
);
|
"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("границы пароля проверяются и в символах, и в байтах", () => {
|
test("границы пароля проверяются и в символах, и в байтах", () => {
|
||||||
// Проверка самой таблицы: если «я» перестанет быть двухбайтовой, случаи
|
// Проверка самой таблицы: если «я» перестанет быть двухбайтовой, случаи
|
||||||
// ниже проверяли бы не то, что написано.
|
// ниже проверяли бы не то, что написано.
|
||||||
expect(Buffer.byteLength("я".repeat(36), "utf8")).toBe(ADMIN_PASSWORD_MAX_BYTES);
|
expect(Buffer.byteLength("я".repeat(36), "utf8")).toBe(
|
||||||
expect(Buffer.byteLength("😀".repeat(18), "utf8")).toBe(ADMIN_PASSWORD_MAX_BYTES);
|
ADMIN_PASSWORD_MAX_BYTES,
|
||||||
|
);
|
||||||
|
expect(Buffer.byteLength("😀".repeat(18), "utf8")).toBe(
|
||||||
|
ADMIN_PASSWORD_MAX_BYTES,
|
||||||
|
);
|
||||||
|
|
||||||
const accepted = [
|
const accepted = [
|
||||||
"a".repeat(ADMIN_PASSWORD_MIN_LENGTH),
|
"a".repeat(ADMIN_PASSWORD_MIN_LENGTH),
|
||||||
@@ -235,13 +283,14 @@ describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разбо
|
|||||||
"я".repeat(36),
|
"я".repeat(36),
|
||||||
"😀".repeat(18),
|
"😀".repeat(18),
|
||||||
"abcde ",
|
"abcde ",
|
||||||
" ".repeat(ADMIN_PASSWORD_MIN_LENGTH)
|
" ".repeat(ADMIN_PASSWORD_MIN_LENGTH),
|
||||||
];
|
];
|
||||||
for (const password of accepted) {
|
for (const password of accepted) {
|
||||||
expect(isValidAdminPassword(password)).toBe(true);
|
expect(isValidAdminPassword(password)).toBe(true);
|
||||||
expect(baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: `"${password}"` }).adminInitialPassword).toBe(
|
expect(
|
||||||
password
|
baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: `"${password}"` })
|
||||||
);
|
.adminInitialPassword,
|
||||||
|
).toBe(password);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rejected = [
|
const rejected = [
|
||||||
@@ -249,13 +298,13 @@ describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разбо
|
|||||||
"a".repeat(ADMIN_PASSWORD_MAX_LENGTH + 1),
|
"a".repeat(ADMIN_PASSWORD_MAX_LENGTH + 1),
|
||||||
"я".repeat(37),
|
"я".repeat(37),
|
||||||
"😀".repeat(19),
|
"😀".repeat(19),
|
||||||
"я".repeat(ADMIN_PASSWORD_MAX_LENGTH)
|
"я".repeat(ADMIN_PASSWORD_MAX_LENGTH),
|
||||||
];
|
];
|
||||||
for (const password of rejected) {
|
for (const password of rejected) {
|
||||||
expect(isValidAdminPassword(password)).toBe(false);
|
expect(isValidAdminPassword(password)).toBe(false);
|
||||||
expect(() => baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: `"${password}"` })).toThrow(
|
expect(() =>
|
||||||
/invalid HY2XS_ADMIN_INITIAL_PASSWORD/
|
baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: `"${password}"` }),
|
||||||
);
|
).toThrow(/invalid HY2XS_ADMIN_INITIAL_PASSWORD/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -264,11 +313,13 @@ describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разбо
|
|||||||
// отправило бы оператора сокращать пароль, отвергнутый не за это.
|
// отправило бы оператора сокращать пароль, отвергнутый не за это.
|
||||||
test("отказ называет и символы, и байты", () => {
|
test("отказ называет и символы, и байты", () => {
|
||||||
const tooManyBytes = `"${"я".repeat(ADMIN_PASSWORD_MAX_LENGTH)}"`;
|
const tooManyBytes = `"${"я".repeat(ADMIN_PASSWORD_MAX_LENGTH)}"`;
|
||||||
expect(() => baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: tooManyBytes })).toThrow(
|
expect(() =>
|
||||||
new RegExp(`${ADMIN_PASSWORD_MAX_BYTES} байт`)
|
baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: tooManyBytes }),
|
||||||
);
|
).toThrow(new RegExp(`${ADMIN_PASSWORD_MAX_BYTES} байт`));
|
||||||
expect(() => baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: tooManyBytes })).toThrow(
|
expect(() =>
|
||||||
new RegExp(`${ADMIN_PASSWORD_MIN_LENGTH}.*${ADMIN_PASSWORD_MAX_LENGTH}`)
|
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("сгенерированный пароль проходит контракт целиком", () => {
|
test("сгенерированный пароль проходит контракт целиком", () => {
|
||||||
for (let i = 0; i < 50; i += 1) {
|
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);
|
expect(isValidAdminPassword(config.adminInitialPassword)).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -290,7 +343,10 @@ describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разбо
|
|||||||
|
|
||||||
describe("smoke выполняет настоящий вход в панель", () => {
|
describe("smoke выполняет настоящий вход в панель", () => {
|
||||||
const 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 объявлял успешной
|
// панель» только фактом LISTEN и ответом /healthz, RC2 объявлял успешной
|
||||||
@@ -303,23 +359,38 @@ describe("smoke выполняет настоящий вход в панель",
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("успех определяется конвертом ответа и выданным токеном", () => {
|
test("успех определяется конвертом ответа и выданным токеном", () => {
|
||||||
// Админка отвечает HTTP 200 и на отказ: проверка по коду HTTP приняла бы за
|
expect(
|
||||||
// успешный вход любой отказ.
|
isSuccessfulLogin(
|
||||||
const source = smoke();
|
JSON.stringify({
|
||||||
expect(source).toMatch(/"code"\\s\*:\\s\*20000/);
|
code: 20000,
|
||||||
expect(source).toMatch(/accessToken/);
|
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("учётные данные не попадают ни в команду ошибки, ни в сообщение", () => {
|
test("учётные данные не попадают ни в команду ошибки, ни в сообщение", () => {
|
||||||
const source = smoke();
|
const source = smoke();
|
||||||
const start = source.indexOf("async function assertAdminLoginWorks");
|
const start = source.indexOf("async function assertAdminLoginWorks");
|
||||||
expect(start).toBeGreaterThan(-1);
|
expect(start).toBeGreaterThan(-1);
|
||||||
const body = source.slice(start, source.indexOf("\nfunction isSuccessfulLogin"));
|
const body = source.slice(start, source.indexOf("\ntype JsonObject"));
|
||||||
|
|
||||||
// runReadOnly (без Secret) кладёт команду в текст ошибки, а команда несёт
|
// runReadOnly (без Secret) кладёт команду в текст ошибки, а команда несёт
|
||||||
// пароль администратора.
|
// пароль администратора.
|
||||||
expect(body).not.toMatch(/(?<!Secret)runReadOnly`/);
|
expect(body).not.toMatch(/(?<!Secret)runReadOnly`/);
|
||||||
expect(body).toMatch(/runReadOnlySecret`/);
|
expect(body.match(/runReadOnlySecret`/g)).toHaveLength(1);
|
||||||
|
|
||||||
// Подстановка в tagged template — это НЕ утечка: значения там проходят
|
// Подстановка в tagged template — это НЕ утечка: значения там проходят
|
||||||
// через shellQuote и в текст ошибки не попадают. Утечкой была бы подстановка
|
// через shellQuote и в текст ошибки не попадают. Утечкой была бы подстановка
|
||||||
@@ -327,12 +398,17 @@ describe("smoke выполняет настоящий вход в панель",
|
|||||||
// в журнал установки и в diagnostics-бандл.
|
// в журнал установки и в diagnostics-бандл.
|
||||||
const messages = [
|
const messages = [
|
||||||
...body.matchAll(/\binfo\(([\s\S]*?)\);/g),
|
...body.matchAll(/\binfo\(([\s\S]*?)\);/g),
|
||||||
...body.matchAll(/new Error\(([\s\S]*?)\);/g)
|
...body.matchAll(/new Error\(([\s\S]*?)\);/g),
|
||||||
].map((match) => match[1]);
|
].map((match) => match[1]);
|
||||||
expect(messages.length).toBeGreaterThan(0);
|
expect(messages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
for (const message of messages) {
|
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}`);
|
expect(message).not.toContain(`\${${secret}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,11 +419,42 @@ describe("smoke выполняет настоящий вход в панель",
|
|||||||
expect(body).toContain("describeIssuedToken(response)");
|
expect(body).toContain("describeIssuedToken(response)");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("тело запроса собирается JSON.stringify, а не интерполяцией", () => {
|
test("обе пробы используют один wire-helper", () => {
|
||||||
// Пароль задаёт оператор: кавычка или обратный слеш в нём сломали бы сам
|
const source = codeOf(smoke());
|
||||||
// запрос, и проверка объявила бы рабочую установку сломанной.
|
expect(source.match(/requestAdminLogin\(/g)).toHaveLength(3); // два вызова и определение
|
||||||
const body = smoke();
|
const helperStart = source.indexOf("async function requestAdminLogin");
|
||||||
expect(body).toContain("JSON.stringify({ username: adminUser, pass: adminPassword })");
|
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("отрицательная проба не зависит от режима операции", () => {
|
test("отрицательная проба не зависит от режима операции", () => {
|
||||||
@@ -357,7 +464,10 @@ describe("smoke выполняет настоящий вход в панель",
|
|||||||
// дефект RC2.
|
// дефект RC2.
|
||||||
const source = smoke();
|
const source = smoke();
|
||||||
const start = source.indexOf("async function assertAdminLoginWorks");
|
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 rejected = body.indexOf("admin login rejects wrong credentials");
|
||||||
const modeGate = body.indexOf('context.mode !== "install"');
|
const modeGate = body.indexOf('context.mode !== "install"');
|
||||||
@@ -371,26 +481,53 @@ describe("smoke выполняет настоящий вход в панель",
|
|||||||
// закончился пятисоткой. Админка отвечает HTTP 200 и на успешный вход тоже,
|
// закончился пятисоткой. Админка отвечает HTTP 200 и на успешный вход тоже,
|
||||||
// поэтому «неверные учётные данные отвергнуты» этой проверкой не следовало.
|
// поэтому «неверные учётные данные отвергнуты» этой проверкой не следовало.
|
||||||
test("отрицательная проба сверяет конверт отказа, а не код HTTP", () => {
|
test("отрицательная проба сверяет конверт отказа, а не код HTTP", () => {
|
||||||
const source = smoke();
|
const rejected = {
|
||||||
|
code: 50000,
|
||||||
expect(source).toContain("isRejectedLogin");
|
type: "no",
|
||||||
// Код конверта отказа, доменная причина и ОТСУТСТВИЕ токена — три признака,
|
errors: [{ code: "invalid_credentials", message: "wrong password" }],
|
||||||
// и каждый закрывает свой способ ошибиться.
|
data: null,
|
||||||
expect(source).toMatch(/"code"\\s\*:\\s\*50000/);
|
};
|
||||||
expect(source).toContain('INVALID_CREDENTIALS_CODE = "invalid_credentials"');
|
expect(isRejectedLogin(JSON.stringify(rejected))).toBe(true);
|
||||||
|
expect(isRejectedLogin(JSON.stringify({ ...rejected, code: 50001 }))).toBe(
|
||||||
const start = source.indexOf("function isRejectedLogin");
|
false,
|
||||||
const body = source.slice(start, source.indexOf("\n/**", start));
|
);
|
||||||
expect(body).toContain("!/\"accessToken\"");
|
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", () => {
|
test("код причины отказа совпадает с constant.ErrCodeInvalidCredentials", () => {
|
||||||
const goErrors = fs.readFileSync(
|
const goErrors = fs.readFileSync(
|
||||||
path.join(REPO_ROOT, "apps", "model", "constant", "error.go"),
|
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(declared).not.toBeNull();
|
||||||
expect(smoke()).toContain(`INVALID_CREDENTIALS_CODE = "${declared![1]}"`);
|
expect(smoke()).toContain(`INVALID_CREDENTIALS_CODE = "${declared![1]}"`);
|
||||||
});
|
});
|
||||||
@@ -406,7 +543,9 @@ describe("smoke выполняет настоящий вход в панель",
|
|||||||
// acceptance.sh и в codeOf во frontend-contract.test.ts.
|
// acceptance.sh и в codeOf во frontend-contract.test.ts.
|
||||||
expect(codeOf(source)).not.toContain("definitely-not-the-admin-password");
|
expect(codeOf(source)).not.toContain("definitely-not-the-admin-password");
|
||||||
expect(source).toContain('randomBytes(24).toString("base64url")');
|
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-секрет читается ПАРСЕРОМ того же формата, которым он написан.
|
// Bootstrap-секрет читается ПАРСЕРОМ того же формата, которым он написан.
|
||||||
@@ -419,7 +558,9 @@ describe("smoke выполняет настоящий вход в панель",
|
|||||||
const source = smoke();
|
const source = smoke();
|
||||||
|
|
||||||
expect(source).toContain("readBootstrapAdminSecret");
|
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='`) при этом законны и остаются: они
|
// ключей (`grep -q '^ADMIN_USER='`) при этом законны и остаются: они
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import { redactEnv } from "../src/lib/redaction";
|
|||||||
const directories: string[] = [];
|
const directories: string[] = [];
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
await Promise.all(
|
||||||
|
directories
|
||||||
|
.splice(0)
|
||||||
|
.map((directory) => rm(directory, { recursive: true, force: true }))
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function source(bytes: Uint8Array): Promise<string> {
|
async function source(bytes: Uint8Array): Promise<string> {
|
||||||
@@ -22,14 +26,31 @@ async function source(bytes: Uint8Array): Promise<string> {
|
|||||||
|
|
||||||
describe("безопасная подготовка diagnostics", () => {
|
describe("безопасная подготовка diagnostics", () => {
|
||||||
test("секрет редактируется до выдачи текста на запись", async () => {
|
test("секрет редактируется до выдачи текста на запись", async () => {
|
||||||
const path = await source(new TextEncoder().encode("HY2XS_ADMIN_CON_PASS=top-secret\n"));
|
const path = await source(
|
||||||
|
new TextEncoder().encode("HY2XS_ADMIN_CON_PASS=top-secret\n")
|
||||||
|
);
|
||||||
const prepared = await prepareDiagnosticFile(path, redactEnv);
|
const prepared = await prepareDiagnosticFile(path, redactEnv);
|
||||||
expect(prepared).toContain("<redacted>");
|
expect(prepared).toContain("<redacted>");
|
||||||
expect(prepared).not.toContain("top-secret");
|
expect(prepared).not.toContain("top-secret");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("диагностический boolean виден, а соседний пароль скрыт", async () => {
|
||||||
|
const path = await source(
|
||||||
|
new TextEncoder().encode(
|
||||||
|
"HY2XS_FORCE_PASSWORD_CHANGE=false\nHY2XS_ADMIN_INITIAL_PASSWORD=top-secret\n"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const prepared = await prepareDiagnosticFile(path, redactEnv);
|
||||||
|
|
||||||
|
expect(prepared).toContain("HY2XS_FORCE_PASSWORD_CHANGE=false");
|
||||||
|
expect(prepared).toContain("HY2XS_ADMIN_INITIAL_PASSWORD=<redacted>");
|
||||||
|
expect(prepared).not.toContain("top-secret");
|
||||||
|
});
|
||||||
|
|
||||||
test("повреждённый UTF-8 заменяется безопасной причиной, а не U+FFFD", async () => {
|
test("повреждённый UTF-8 заменяется безопасной причиной, а не U+FFFD", async () => {
|
||||||
const path = await source(new Uint8Array([0x53, 0x45, 0x43, 0x52, 0x45, 0x54, 0x3d, 0xff]));
|
const path = await source(
|
||||||
|
new Uint8Array([0x53, 0x45, 0x43, 0x52, 0x45, 0x54, 0x3d, 0xff])
|
||||||
|
);
|
||||||
const prepared = await prepareDiagnosticFile(path, redactEnv);
|
const prepared = await prepareDiagnosticFile(path, redactEnv);
|
||||||
expect(prepared).toContain("некорректный UTF-8");
|
expect(prepared).toContain("некорректный UTF-8");
|
||||||
expect(prepared).not.toContain("SECRET=");
|
expect(prepared).not.toContain("SECRET=");
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
redactLogText,
|
redactLogText,
|
||||||
redactYaml,
|
redactYaml,
|
||||||
redactYamlFallback,
|
redactYamlFallback,
|
||||||
sanitizeUrlValue
|
sanitizeUrlValue,
|
||||||
} from "../src/lib/redaction";
|
} from "../src/lib/redaction";
|
||||||
|
|
||||||
const MACHINE_TOKEN = "hy2xs-machine-token-must-not-leak";
|
const MACHINE_TOKEN = "hy2xs-machine-token-must-not-leak";
|
||||||
@@ -58,7 +58,10 @@ describe("редакция серверного конфига", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("результат остаётся валидным YAML", () => {
|
test("результат остаётся валидным YAML", () => {
|
||||||
const parsed = Bun.YAML.parse(redactYaml(SERVER_CONFIG)) as Record<string, unknown>;
|
const parsed = Bun.YAML.parse(redactYaml(SERVER_CONFIG)) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
expect(parsed.listen).toBe("0.0.0.0:443");
|
expect(parsed.listen).toBe("0.0.0.0:443");
|
||||||
const auth = parsed.auth as Record<string, unknown>;
|
const auth = parsed.auth as Record<string, unknown>;
|
||||||
const http = auth.http as Record<string, unknown>;
|
const http = auth.http as Record<string, unknown>;
|
||||||
@@ -69,7 +72,10 @@ describe("редакция серверного конфига", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("несекретные поля сохраняются: диагностика должна оставаться полезной", () => {
|
test("несекретные поля сохраняются: диагностика должна оставаться полезной", () => {
|
||||||
const parsed = Bun.YAML.parse(redactYaml(SERVER_CONFIG)) as Record<string, unknown>;
|
const parsed = Bun.YAML.parse(redactYaml(SERVER_CONFIG)) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
const acme = parsed.acme as Record<string, unknown>;
|
const acme = parsed.acme as Record<string, unknown>;
|
||||||
expect(acme.email).toBe("admin@example.com");
|
expect(acme.email).toBe("admin@example.com");
|
||||||
expect(acme.ca).toBe("letsencrypt");
|
expect(acme.ca).toBe("letsencrypt");
|
||||||
@@ -92,7 +98,10 @@ describe("редакция серверного конфига", () => {
|
|||||||
test("вложенное дерево под секретным ключом вырезается целиком", () => {
|
test("вложенное дерево под секретным ключом вырезается целиком", () => {
|
||||||
const yaml = `acme:\n dns:\n name: cloudflare\n config:\n api_token: leak-me\n zone: example.com\n`;
|
const yaml = `acme:\n dns:\n name: cloudflare\n config:\n api_token: leak-me\n zone: example.com\n`;
|
||||||
const parsed = Bun.YAML.parse(redactYaml(yaml)) as Record<string, unknown>;
|
const parsed = Bun.YAML.parse(redactYaml(yaml)) as Record<string, unknown>;
|
||||||
const dns = (parsed.acme as Record<string, unknown>).dns as Record<string, unknown>;
|
const dns = (parsed.acme as Record<string, unknown>).dns as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
const cfg = dns.config as Record<string, unknown>;
|
const cfg = dns.config as Record<string, unknown>;
|
||||||
expect(cfg.api_token).toBe(REDACTED);
|
expect(cfg.api_token).toBe(REDACTED);
|
||||||
// acme.dns.config — карта, где секретны все значения, а не только ключ.
|
// acme.dns.config — карта, где секретны все значения, а не только ключ.
|
||||||
@@ -158,7 +167,7 @@ describe("редакция env-артефактов", () => {
|
|||||||
"HY2_AUTH_MODE=http",
|
"HY2_AUTH_MODE=http",
|
||||||
`HY2_AUTH_URL=http://127.0.0.1:8080/internal/hysteria/auth?access_token=${MACHINE_TOKEN}`,
|
`HY2_AUTH_URL=http://127.0.0.1:8080/internal/hysteria/auth?access_token=${MACHINE_TOKEN}`,
|
||||||
`HY2_OBFS_PASSWORD=${OBFS_PASSWORD}`,
|
`HY2_OBFS_PASSWORD=${OBFS_PASSWORD}`,
|
||||||
"HY2_PORT=443"
|
"HY2_PORT=443",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
// Регрессия: HY2_AUTH_URL не подходит ни под один key-маркер, но несёт
|
// Регрессия: HY2_AUTH_URL не подходит ни под один key-маркер, но несёт
|
||||||
@@ -166,7 +175,9 @@ describe("редакция env-артефактов", () => {
|
|||||||
test("секрет внутри URL-значения вырезается, даже если имя ключа несекретное", () => {
|
test("секрет внутри URL-значения вырезается, даже если имя ключа несекретное", () => {
|
||||||
const redacted = redactEnv(POST_INSTALL);
|
const redacted = redactEnv(POST_INSTALL);
|
||||||
expect(redacted).not.toContain(MACHINE_TOKEN);
|
expect(redacted).not.toContain(MACHINE_TOKEN);
|
||||||
expect(redacted).toContain("HY2_AUTH_URL=http://127.0.0.1:8080/internal/hysteria/auth");
|
expect(redacted).toContain(
|
||||||
|
"HY2_AUTH_URL=http://127.0.0.1:8080/internal/hysteria/auth"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ключи-секреты вырезаются по имени", () => {
|
test("ключи-секреты вырезаются по имени", () => {
|
||||||
@@ -180,13 +191,40 @@ describe("редакция env-артефактов", () => {
|
|||||||
expect(redacted).toContain("HY2_PORT=443");
|
expect(redacted).toContain("HY2_PORT=443");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("boolean-флаг принудительной смены пароля остаётся диагностируемым", () => {
|
||||||
|
const flags = [
|
||||||
|
"HY2XS_FORCE_PASSWORD_CHANGE=false",
|
||||||
|
"HY2XS_FORCE_PASSWORD_CHANGE=true",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
expect(redactEnv(flags)).toBe(flags);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("исключение для boolean-флага точное и не ослабляет защиту", () => {
|
||||||
|
const input = [
|
||||||
|
"HY2XS_FORCE_PASSWORD_CHANGE_SUFFIX=must-hide",
|
||||||
|
"HY2XS_FORCE_PASSWORD_CHANGE=malformed-secret",
|
||||||
|
"HY2XS_ADMIN_INITIAL_PASSWORD=initial-password",
|
||||||
|
"HY2XS_ADMIN_CON_PASS=connection-password",
|
||||||
|
`HY2XS_HYSTERIA_OBFS_PASSWORD=${OBFS_PASSWORD}`,
|
||||||
|
].join("\n");
|
||||||
|
const redacted = redactEnv(input);
|
||||||
|
|
||||||
|
expect(redacted).not.toContain("must-hide");
|
||||||
|
expect(redacted).not.toContain("initial-password");
|
||||||
|
expect(redacted).not.toContain("connection-password");
|
||||||
|
expect(redacted).not.toContain(OBFS_PASSWORD);
|
||||||
|
expect(redacted.match(/<redacted>/g)).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
test("runtime-конфиг теряет все секреты", () => {
|
test("runtime-конфиг теряет все секреты", () => {
|
||||||
const runtime = [
|
const runtime = [
|
||||||
"HY2XS_CONFIG_SCHEMA_VERSION=2",
|
"HY2XS_CONFIG_SCHEMA_VERSION=2",
|
||||||
|
"HY2XS_FORCE_PASSWORD_CHANGE=false",
|
||||||
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${MACHINE_TOKEN}`,
|
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${MACHINE_TOKEN}`,
|
||||||
`HY2XS_HYSTERIA_OBFS_PASSWORD=${OBFS_PASSWORD}`,
|
`HY2XS_HYSTERIA_OBFS_PASSWORD=${OBFS_PASSWORD}`,
|
||||||
"HY2XS_ADMIN_CON_PASS=connection-password",
|
"HY2XS_ADMIN_CON_PASS=connection-password",
|
||||||
"HY2XS_ADMIN_INITIAL_PASSWORD=initial-password"
|
"HY2XS_ADMIN_INITIAL_PASSWORD=initial-password",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
const redacted = redactEnv(runtime);
|
const redacted = redactEnv(runtime);
|
||||||
@@ -195,16 +233,21 @@ describe("редакция env-артефактов", () => {
|
|||||||
expect(redacted).not.toContain("connection-password");
|
expect(redacted).not.toContain("connection-password");
|
||||||
expect(redacted).not.toContain("initial-password");
|
expect(redacted).not.toContain("initial-password");
|
||||||
expect(redacted).toContain("HY2XS_CONFIG_SCHEMA_VERSION=2");
|
expect(redacted).toContain("HY2XS_CONFIG_SCHEMA_VERSION=2");
|
||||||
|
expect(redacted).toContain("HY2XS_FORCE_PASSWORD_CHANGE=false");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("санитизация URL", () => {
|
describe("санитизация URL", () => {
|
||||||
test("креденшелы в URL вырезаются", () => {
|
test("креденшелы в URL вырезаются", () => {
|
||||||
expect(sanitizeUrlValue("https://user:hunter2@example.com/x")).not.toContain("hunter2");
|
expect(
|
||||||
|
sanitizeUrlValue("https://user:hunter2@example.com/x")
|
||||||
|
).not.toContain("hunter2");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("секретные query-параметры вырезаются, остальные сохраняются", () => {
|
test("секретные query-параметры вырезаются, остальные сохраняются", () => {
|
||||||
const out = sanitizeUrlValue("https://example.com/x?access_token=abc&page=2");
|
const out = sanitizeUrlValue(
|
||||||
|
"https://example.com/x?access_token=abc&page=2"
|
||||||
|
);
|
||||||
expect(out).not.toContain("abc");
|
expect(out).not.toContain("abc");
|
||||||
expect(out).toContain("page=2");
|
expect(out).toContain("page=2");
|
||||||
});
|
});
|
||||||
@@ -245,7 +288,7 @@ describe("редакция журналов в diagnostics-бандле", () =>
|
|||||||
for (const line of [
|
for (const line of [
|
||||||
`starting with access_token=${MACHINE_TOKEN}`,
|
`starting with access_token=${MACHINE_TOKEN}`,
|
||||||
`trafficStats secret=${MACHINE_TOKEN} rejected`,
|
`trafficStats secret=${MACHINE_TOKEN} rejected`,
|
||||||
`obfs password: ${OBFS_PASSWORD}`
|
`obfs password: ${OBFS_PASSWORD}`,
|
||||||
]) {
|
]) {
|
||||||
const out = redactLogText(line);
|
const out = redactLogText(line);
|
||||||
expect(out).not.toContain(MACHINE_TOKEN);
|
expect(out).not.toContain(MACHINE_TOKEN);
|
||||||
@@ -288,14 +331,22 @@ describe("маркеры секретных имён", () => {
|
|||||||
"cookie",
|
"cookie",
|
||||||
"bearer",
|
"bearer",
|
||||||
"signature",
|
"signature",
|
||||||
"passphrase"
|
"passphrase",
|
||||||
]) {
|
]) {
|
||||||
expect(isSecretKey(key)).toBe(true);
|
expect(isSecretKey(key)).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("не задевают обычные поля", () => {
|
test("не задевают обычные поля", () => {
|
||||||
for (const key of ["listen", "type", "domains", "minPacketSize", "email", "ca", "dir"]) {
|
for (const key of [
|
||||||
|
"listen",
|
||||||
|
"type",
|
||||||
|
"domains",
|
||||||
|
"minPacketSize",
|
||||||
|
"email",
|
||||||
|
"ca",
|
||||||
|
"dir",
|
||||||
|
]) {
|
||||||
expect(isSecretKey(key)).toBe(false);
|
expect(isSecretKey(key)).toBe(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
HYSTERIA_HASHES_ASSET,
|
HYSTERIA_HASHES_ASSET,
|
||||||
HYSTERIA_LINUX_AMD64_ASSET,
|
HYSTERIA_LINUX_AMD64_ASSET,
|
||||||
selectLatestStableRelease,
|
selectLatestStableRelease,
|
||||||
type GithubRelease
|
type GithubRelease,
|
||||||
} from "../src/build/hysteriaRelease";
|
} from "../src/build/hysteriaRelease";
|
||||||
|
|
||||||
function assetUrl(tag: string, name: string): string {
|
function assetUrl(tag: string, name: string): string {
|
||||||
@@ -21,13 +21,13 @@ function release(tag: string): GithubRelease {
|
|||||||
assets: [
|
assets: [
|
||||||
{
|
{
|
||||||
name: HYSTERIA_LINUX_AMD64_ASSET,
|
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,
|
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"],
|
["commands", "install.ts"],
|
||||||
["steps", "hysteria.ts"],
|
["steps", "hysteria.ts"],
|
||||||
["lib", "packageMeta.ts"],
|
["lib", "packageMeta.ts"],
|
||||||
["commands", "reconfigure.ts"]
|
["commands", "reconfigure.ts"],
|
||||||
];
|
];
|
||||||
|
|
||||||
test("install-time код не импортирует build-time резолвер", () => {
|
test("install-time код не импортирует build-time резолвер", () => {
|
||||||
@@ -83,13 +83,19 @@ describe("install-time никогда не резолвит latest", () => {
|
|||||||
|
|
||||||
test("установка берёт версию, url и sha256 только из metadata пакета", () => {
|
test("установка берёт версию, url и sha256 только из metadata пакета", () => {
|
||||||
const install = srcFile("commands", "install.ts");
|
const install = srcFile("commands", "install.ts");
|
||||||
expect(install).toContain('readPackageValue(options.packageDir, "hysteria.version"');
|
for (const key of ["hysteria.version", "hysteria.url", "hysteria.sha256"]) {
|
||||||
expect(install).toContain('readPackageValue(options.packageDir, "hysteria.url"');
|
expect(install).toMatch(
|
||||||
expect(install).toContain('readPackageValue(options.packageDir, "hysteria.sha256"');
|
new RegExp(
|
||||||
|
`readPackageValue\\(\\s*options\\.packageDir,\\s*"${key.replace(".", "\\.")}"`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("отсутствие любой части lock-метаданных прерывает установку", () => {
|
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("установленный бинарник сверяется с закреплённой версией", () => {
|
test("установленный бинарник сверяется с закреплённой версией", () => {
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ import { persistFailureState, runRollbackStages } from "../src/lib/rollback";
|
|||||||
// Пути считаются от файла теста, а не от cwd: `bun test` запускается и из корня
|
// Пути считаются от файла теста, а не от cwd: `bun test` запускается и из корня
|
||||||
// репозитория (сборка), и из orchestrator/ (разработчик).
|
// репозитория (сборка), и из orchestrator/ (разработчик).
|
||||||
function source(relativeToSrc: string): string {
|
function source(relativeToSrc: string): string {
|
||||||
return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8");
|
return readFileSync(
|
||||||
|
join(import.meta.dir, "..", "src", relativeToSrc),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("стадии отката независимы", () => {
|
describe("стадии отката независимы", () => {
|
||||||
@@ -33,7 +36,7 @@ describe("стадии отката независимы", () => {
|
|||||||
const executed: string[] = [];
|
const executed: string[] = [];
|
||||||
const failures = await runRollbackStages([
|
const failures = await runRollbackStages([
|
||||||
{ name: "firewall", run: async () => void executed.push("firewall") },
|
{ 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"]);
|
expect(executed).toEqual(["firewall", "services"]);
|
||||||
@@ -49,10 +52,13 @@ describe("стадии отката независимы", () => {
|
|||||||
run: async () => {
|
run: async () => {
|
||||||
executed.push("firewall");
|
executed.push("firewall");
|
||||||
throw new Error("nft: command failed");
|
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"]);
|
expect(executed).toEqual(["firewall", "restore", "stop"]);
|
||||||
@@ -67,15 +73,15 @@ describe("стадии отката независимы", () => {
|
|||||||
name: "firewall",
|
name: "firewall",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
throw new Error("first");
|
throw new Error("first");
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{ name: "healthy", run: async () => undefined },
|
{ name: "healthy", run: async () => undefined },
|
||||||
{
|
{
|
||||||
name: "restore configuration",
|
name: "restore configuration",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
throw new Error("second");
|
throw new Error("second");
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(failures).toHaveLength(2);
|
expect(failures).toHaveLength(2);
|
||||||
@@ -91,14 +97,14 @@ describe("стадии отката независимы", () => {
|
|||||||
name: "a",
|
name: "a",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
throw new Error("boom");
|
throw new Error("boom");
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "b",
|
name: "b",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
throw new Error("boom");
|
throw new Error("boom");
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
expect(failures).toHaveLength(2);
|
expect(failures).toHaveLength(2);
|
||||||
});
|
});
|
||||||
@@ -109,8 +115,8 @@ describe("стадии отката независимы", () => {
|
|||||||
name: "weird",
|
name: "weird",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
throw "строковая ошибка";
|
throw "строковая ошибка";
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
expect(failures[0]).toContain("строковая ошибка");
|
expect(failures[0]).toContain("строковая ошибка");
|
||||||
});
|
});
|
||||||
@@ -135,7 +141,7 @@ describe("состояние отказа пишется best effort", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
persistFailureState(async () => {
|
persistFailureState(async () => {
|
||||||
throw new Error("ENOSPC: no space left on device");
|
throw new Error("ENOSPC: no space left on device");
|
||||||
})
|
}),
|
||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -173,7 +179,10 @@ describe("install: откат обязателен после операцион
|
|||||||
// Прямых await-вызовов отката в теле rollbackFailedInstall быть не должно:
|
// Прямых await-вызовов отката в теле rollbackFailedInstall быть не должно:
|
||||||
// именно они и образовывали отменяемую цепочку.
|
// именно они и образовывали отменяемую цепочку.
|
||||||
const start = installSource.indexOf("async function 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)");
|
expect(body).toContain("await rollbackFirewallNow(context)");
|
||||||
// Вызов существует только внутри стадии.
|
// Вызов существует только внутри стадии.
|
||||||
const firewallAt = body.indexOf("await rollbackFirewallNow(context)");
|
const firewallAt = body.indexOf("await rollbackFirewallNow(context)");
|
||||||
@@ -182,14 +191,19 @@ describe("install: откат обязателен после операцион
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("остановка сервисов остаётся отдельными стадиями", () => {
|
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}"`);
|
expect(installSource).toContain(`name: "${stage}"`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("чужие сервисы по-прежнему не трогаются", () => {
|
test("чужие сервисы по-прежнему не трогаются", () => {
|
||||||
expect(installSource).toContain(
|
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, в какую бы строку его ни перенёс
|
// выполняться ВНУТРИ persistFailureState, в какую бы строку его ни перенёс
|
||||||
// редактор.
|
// редактор.
|
||||||
const wrapped = reconfigureSource.match(
|
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();
|
expect(wrapped).not.toBeNull();
|
||||||
});
|
});
|
||||||
@@ -222,8 +236,12 @@ describe("reconfigure: откат обязателен после операци
|
|||||||
|
|
||||||
// Регрессия: отказ rollbackFirewallNow отменял rollbackCurrentState целиком.
|
// Регрессия: отказ rollbackFirewallNow отменял rollbackCurrentState целиком.
|
||||||
test("порядок сохранён: сначала firewall, затем конфиги", () => {
|
test("порядок сохранён: сначала firewall, затем конфиги", () => {
|
||||||
const firewall = reconfigureSource.indexOf("await rollbackFirewallNow(context)");
|
const firewall = reconfigureSource.indexOf(
|
||||||
const restore = reconfigureSource.indexOf("await rollbackCurrentState(context)");
|
"await rollbackFirewallNow(context)",
|
||||||
|
);
|
||||||
|
const restore = reconfigureSource.indexOf(
|
||||||
|
"await rollbackCurrentState(context)",
|
||||||
|
);
|
||||||
const stages = reconfigureSource.indexOf("await runRollbackStages(stages)");
|
const stages = reconfigureSource.indexOf("await runRollbackStages(stages)");
|
||||||
expect(firewall).toBeGreaterThan(-1);
|
expect(firewall).toBeGreaterThan(-1);
|
||||||
expect(firewall).toBeLessThan(restore);
|
expect(firewall).toBeLessThan(restore);
|
||||||
@@ -242,14 +260,19 @@ describe("reconfigure: откат обязателен после операци
|
|||||||
const start = reconfigureSource.indexOf("function restoreStages(");
|
const start = reconfigureSource.indexOf("function restoreStages(");
|
||||||
const body = reconfigureSource.slice(
|
const body = reconfigureSource.slice(
|
||||||
start,
|
start,
|
||||||
reconfigureSource.indexOf("async function rollbackCurrentState")
|
reconfigureSource.indexOf("async function rollbackCurrentState"),
|
||||||
);
|
);
|
||||||
const muted = body
|
const muted = body
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.filter((line) => line.includes("runMutatingVisible`"))
|
.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("восстановление разбито на независимые стадии", () => {
|
test("восстановление разбито на независимые стадии", () => {
|
||||||
@@ -260,7 +283,7 @@ describe("reconfigure: откат обязателен после операци
|
|||||||
"restore nftables files",
|
"restore nftables files",
|
||||||
"apply restored ruleset",
|
"apply restored ruleset",
|
||||||
"reload systemd units",
|
"reload systemd units",
|
||||||
"restart services"
|
"restart services",
|
||||||
]) {
|
]) {
|
||||||
expect(reconfigureSource).toContain(`"${stage}"`);
|
expect(reconfigureSource).toContain(`"${stage}"`);
|
||||||
}
|
}
|
||||||
@@ -269,7 +292,9 @@ describe("reconfigure: откат обязателен после операци
|
|||||||
// Неполное восстановление обязано доходить до итоговой сводки как отказ.
|
// Неполное восстановление обязано доходить до итоговой сводки как отказ.
|
||||||
test("частичное восстановление не выдаётся за успешное", () => {
|
test("частичное восстановление не выдаётся за успешное", () => {
|
||||||
expect(reconfigureSource).toContain("restoreFailures.length > 0");
|
expect(reconfigureSource).toContain("restoreFailures.length > 0");
|
||||||
expect(reconfigureSource).toContain("восстановление состояния выполнено не полностью");
|
expect(reconfigureSource).toContain(
|
||||||
|
"восстановление состояния выполнено не полностью",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -282,12 +307,18 @@ describe("install: команды отката не глушат собстве
|
|||||||
// них не выполнялось никогда.
|
// них не выполнялось никогда.
|
||||||
test("стадии остановки сервисов не подавляют код возврата", () => {
|
test("стадии остановки сервисов не подавляют код возврата", () => {
|
||||||
const start = installSource.indexOf("async function 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"),
|
||||||
|
);
|
||||||
const muted = body
|
const muted = body
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.filter((line) => line.includes("runMutatingVisible`"))
|
.filter((line) => line.includes("runMutatingVisible`"))
|
||||||
.filter((line) => line.includes("|| true"));
|
.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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+14
-9
@@ -63,15 +63,16 @@ Windows и macOS можно использовать для редактиров
|
|||||||
и сверяет каждый архив с контрольной суммой из `versions.env`.
|
и сверяет каждый архив с контрольной суммой из `versions.env`.
|
||||||
6. Выполняет `verify_versions_contract`: рассинхрон версий роняет сборку до создания tarball.
|
6. Выполняет `verify_versions_contract`: рассинхрон версий роняет сборку до создания tarball.
|
||||||
7. Прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`).
|
7. Прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`).
|
||||||
8. Разрешает upstream-версию Hysteria, берёт ожидаемый SHA-256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт.
|
8. Рано прогоняет dependency-free контракты панели.
|
||||||
9. Проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS.
|
9. Разрешает upstream-версию Hysteria, берёт ожидаемый SHA-256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт.
|
||||||
10. Копирует package skeleton.
|
10. Проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS.
|
||||||
11. Собирает install-only orchestrator в standalone binary.
|
11. Копирует package skeleton и собирает install-only orchestrator.
|
||||||
12. Собирает frontend и backend HY2XS admin в Linux amd64 binary, проставляя версию админки через ldflags.
|
12. Устанавливает frozen frontend lock-граф, runtime-компилирует все сообщения RU/EN реальным `vue-i18n`, проверяет типы и собирает production bundle.
|
||||||
13. Прогоняет `go vet` и `go test` для HY2XS admin (после сборки frontend, потому что `go:embed all:dist` требует готовых ассетов).
|
13. Собирает backend HY2XS admin в Linux amd64 binary, проставляя версию через ldflags.
|
||||||
14. Записывает metadata и checksums.
|
14. Прогоняет `go vet` и `go test` для HY2XS admin (после сборки frontend, потому что `go:embed all:dist` требует готовых ассетов).
|
||||||
15. Создаёт `dist/hy2xs-install-<version>.tar.gz`.
|
15. Проверяет зависимости через `govulncheck ./...` и `pnpm audit` по всему lock-графу.
|
||||||
16. Проверяет архив и прогоняет acceptance-проверки.
|
16. Записывает metadata и checksums.
|
||||||
|
17. Создаёт архив и прогоняет его acceptance-проверки.
|
||||||
|
|
||||||
## Контракт версий
|
## Контракт версий
|
||||||
|
|
||||||
@@ -99,6 +100,10 @@ Go-коде больше нет: она уже успела разъехатьс
|
|||||||
только политика `HYSTERIA_CHANNEL`, результат резолва — в
|
только политика `HYSTERIA_CHANNEL`, результат резолва — в
|
||||||
[`hysteria-lock.env`](hysteria-lock.env)).
|
[`hysteria-lock.env`](hysteria-lock.env)).
|
||||||
|
|
||||||
|
Security overrides frontend находятся в `apps/frontend/pnpm-workspace.yaml`:
|
||||||
|
это канонический файл настроек pnpm. Поле `pnpm` в `package.json` для этой цели
|
||||||
|
не используется, потому что новые версии package manager его игнорируют.
|
||||||
|
|
||||||
## Версия Hysteria: разрешение и compatibility gate
|
## Версия Hysteria: разрешение и compatibility gate
|
||||||
|
|
||||||
Builder не хранит версию Hysteria вручную. По умолчанию он определяет последнюю стабильную версию сам и замораживает её в пакете.
|
Builder не хранит версию Hysteria вручную. По умолчанию он определяет последнюю стабильную версию сам и замораживает её в пакете.
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ main() {
|
|||||||
log_step "Checking orchestrator contracts"
|
log_step "Checking orchestrator contracts"
|
||||||
run_orchestrator_tests
|
run_orchestrator_tests
|
||||||
|
|
||||||
# Контракты панели проверяются рано: они не требуют ни собранного bundle, ни
|
# Dependency-free контракты панели проверяются рано. Runtime-компиляция
|
||||||
# установленных зависимостей, и падать на них после резолва Hysteria и сборки
|
# словарей запускается внутри bundle_ui сразу после frozen install, потому
|
||||||
# артефактов означало бы платить минутами за ошибку, видимую сразу.
|
# что ей нужен реальный vue-i18n из frontend lock-графа.
|
||||||
log_step "Checking HY2XS admin frontend contracts"
|
log_step "Checking HY2XS admin frontend contracts"
|
||||||
run_frontend_tests
|
run_frontend_tests
|
||||||
|
|
||||||
|
|||||||
@@ -1059,24 +1059,48 @@ EOF
|
|||||||
|| fail "acceptance: smoke больше не выполняет настоящий вход в панель"
|
|| fail "acceptance: smoke больше не выполняет настоящий вход в панель"
|
||||||
code_has orchestrator/src/steps/smoke.ts -F -- 'ADMIN_LOGIN_PATH' \
|
code_has orchestrator/src/steps/smoke.ts -F -- 'ADMIN_LOGIN_PATH' \
|
||||||
|| fail "acceptance: адрес формы входа перестал браться из профиля"
|
|| fail "acceptance: адрес формы входа перестал браться из профиля"
|
||||||
# Успех определяется КОНВЕРТОМ: админка отвечает 200 OK и на отказ тоже.
|
# Успех определяется разобранным КОНВЕРТОМ: админка отвечает 200 OK и на
|
||||||
code_has orchestrator/src/steps/smoke.ts -F -- '"code"\s*:\s*20000' \
|
# отказ тоже, а regex по сырому тексту способен принять подстроку в message.
|
||||||
|
code_has orchestrator/src/steps/smoke.ts -F -- 'envelope?.code === 20000' \
|
||||||
|| fail "acceptance: успех входа снова определяется кодом HTTP, а не конвертом ответа"
|
|| fail "acceptance: успех входа снова определяется кодом HTTP, а не конвертом ответа"
|
||||||
# Команда несёт пароль администратора и не имеет права попасть в текст ошибки:
|
# Positive и negative probe обязаны пользоваться одной транспортной функцией:
|
||||||
# runReadOnly (без Secret) кладёт её туда, а текст уезжает в журнал установки
|
# две независимые curl-команды и разошлись в RC3 с production middleware.
|
||||||
# и в diagnostics-бандл. Проверяется ТЕЛО функции, а не файл: остальной smoke
|
local login_body login_request_body
|
||||||
# законно пользуется обоими раннерами.
|
|
||||||
local login_body
|
|
||||||
login_body="$(sed -n '/^async function assertAdminLoginWorks/,/^}/p' orchestrator/src/steps/smoke.ts)" \
|
login_body="$(sed -n '/^async function assertAdminLoginWorks/,/^}/p' orchestrator/src/steps/smoke.ts)" \
|
||||||
|| fail "acceptance: не удалось прочитать проверку входа в панель"
|
|| fail "acceptance: не удалось прочитать проверку входа в панель"
|
||||||
[ -n "$login_body" ] \
|
[ -n "$login_body" ] \
|
||||||
|| fail "acceptance: функция проверки входа в панель не найдена"
|
|| 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: запрос входа не выполняется секретным раннером"
|
|| fail "acceptance: запрос входа не выполняется секретным раннером"
|
||||||
# `runReadOnly`curl` совпадает только с ГОЛОЙ формой: у секретной после
|
# `runReadOnly`curl` совпадает только с ГОЛОЙ формой: у секретной после
|
||||||
# `runReadOnly` идёт `Secret`, а не обратная кавычка.
|
# `runReadOnly` идёт `Secret`, а не обратная кавычка.
|
||||||
! grep -q 'runReadOnly`curl' <<<"$login_body" \
|
! grep -q 'runReadOnly`curl' <<<"$login_request_body" \
|
||||||
|| fail "acceptance: запрос входа выполняется раннером, кладущим команду в текст ошибки"
|
|| 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"
|
log_step "Acceptance: the negative login probe proves a rejection"
|
||||||
# Проверка `code.trim() === "200"` доказывала ровно одно: что запрос не
|
# Проверка `code.trim() === "200"` доказывала ровно одно: что запрос не
|
||||||
@@ -1084,6 +1108,8 @@ EOF
|
|||||||
# поэтому «неверные учётные данные отвергнуты» из неё не следовало.
|
# поэтому «неверные учётные данные отвергнуты» из неё не следовало.
|
||||||
code_has orchestrator/src/steps/smoke.ts -F -- 'isRejectedLogin' \
|
code_has orchestrator/src/steps/smoke.ts -F -- 'isRejectedLogin' \
|
||||||
|| fail "acceptance: отрицательная проба снова определяет отказ по коду HTTP"
|
|| 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"' \
|
code_has orchestrator/src/steps/smoke.ts -F -- 'INVALID_CREDENTIALS_CODE = "invalid_credentials"' \
|
||||||
|| fail "acceptance: отрицательная проба не сверяет доменный код отказа"
|
|| fail "acceptance: отрицательная проба не сверяет доменный код отказа"
|
||||||
# Литерал в исходнике теоретически может оказаться настоящим паролем — тогда
|
# Литерал в исходнике теоретически может оказаться настоящим паролем — тогда
|
||||||
@@ -1319,14 +1345,20 @@ $piped_matcher"
|
|||||||
|| fail "acceptance: прогон тестов оркестратора не фиксируется результатом"
|
|| fail "acceptance: прогон тестов оркестратора не фиксируется результатом"
|
||||||
grep -q 'ADMIN_TESTS_PASSED' tools/build/lib/package.sh \
|
grep -q 'ADMIN_TESTS_PASSED' tools/build/lib/package.sh \
|
||||||
|| fail "acceptance: прогон тестов админки не фиксируется результатом"
|
|| fail "acceptance: прогон тестов админки не фиксируется результатом"
|
||||||
|
grep -q 'FRONTEND_CONTRACT_TESTS_PASSED' tools/build/lib/package.sh \
|
||||||
|
|| fail "acceptance: ранние контракты панели не фиксируются результатом"
|
||||||
|
grep -q 'FRONTEND_RUNTIME_TESTS_PASSED' tools/build/lib/package.sh \
|
||||||
|
|| fail "acceptance: runtime-компиляция локализации не фиксируется результатом"
|
||||||
grep -q 'FRONTEND_TESTS_PASSED' tools/build/lib/package.sh \
|
grep -q 'FRONTEND_TESTS_PASSED' tools/build/lib/package.sh \
|
||||||
|| fail "acceptance: прогон контрактных тестов панели не фиксируется результатом"
|
|| fail "acceptance: общий результат тестов панели не фиксируется"
|
||||||
"$BUN_BIN" -e '
|
"$BUN_BIN" -e '
|
||||||
const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8");
|
const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8");
|
||||||
for (const [fn, flag] of [
|
for (const [fn, flag] of [
|
||||||
["run_orchestrator_tests()", "ORCHESTRATOR_TESTS_PASSED=\"true\""],
|
["run_orchestrator_tests()", "ORCHESTRATOR_TESTS_PASSED=\"true\""],
|
||||||
["run_admin_tests()", "ADMIN_TESTS_PASSED=\"true\""],
|
["run_admin_tests()", "ADMIN_TESTS_PASSED=\"true\""],
|
||||||
["run_frontend_tests()", "FRONTEND_TESTS_PASSED=\"true\""]
|
["run_frontend_tests()", "FRONTEND_CONTRACT_TESTS_PASSED=\"true\""],
|
||||||
|
["bundle_ui()", "FRONTEND_RUNTIME_TESTS_PASSED=\"true\""],
|
||||||
|
["bundle_ui()", "FRONTEND_TESTS_PASSED=\"true\""]
|
||||||
]) {
|
]) {
|
||||||
const start = source.indexOf(fn);
|
const start = source.indexOf(fn);
|
||||||
if (start < 0) throw new Error("не найдена функция " + fn);
|
if (start < 0) throw new Error("не найдена функция " + fn);
|
||||||
@@ -1340,6 +1372,19 @@ $piped_matcher"
|
|||||||
if (set < 0) throw new Error(fn + ": результат прогона не фиксируется");
|
if (set < 0) throw new Error(fn + ": результат прогона не фиксируется");
|
||||||
if (set < run) throw new Error(fn + ": результат объявляется раньше, чем получен");
|
if (set < run) throw new Error(fn + ": результат объявляется раньше, чем получен");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bundleStart = source.indexOf("bundle_ui()");
|
||||||
|
const bundleRest = source.slice(bundleStart);
|
||||||
|
const bundleEnd = bundleRest.search(/\n\}[\r\n]/);
|
||||||
|
const bundle = bundleEnd < 0 ? bundleRest : bundleRest.slice(0, bundleEnd);
|
||||||
|
const install = bundle.indexOf("install --frozen-lockfile");
|
||||||
|
const runtime = bundle.indexOf("test test/i18n-runtime.test.ts");
|
||||||
|
const typecheck = bundle.indexOf("run typecheck");
|
||||||
|
const build = bundle.indexOf("run build:prod");
|
||||||
|
if (install < 0 || runtime < 0) throw new Error("runtime i18n gate не найден в bundle_ui");
|
||||||
|
if (!(install < runtime && runtime < typecheck && typecheck < build)) {
|
||||||
|
throw new Error("порядок frontend gate обязан быть install -> i18n runtime -> typecheck -> build");
|
||||||
|
}
|
||||||
' || fail "acceptance: утверждение о пройденных тестах обязано следовать за прогоном"
|
' || fail "acceptance: утверждение о пройденных тестах обязано следовать за прогоном"
|
||||||
|
|
||||||
# Детектор гонок — часть прогона админки, а не пожелание.
|
# Детектор гонок — часть прогона админки, а не пожелание.
|
||||||
@@ -1579,6 +1624,17 @@ run_single_owner_acceptance() {
|
|||||||
grep -q 'persistFailureState(' "$rollback_command" \
|
grep -q 'persistFailureState(' "$rollback_command" \
|
||||||
|| fail "acceptance: запись состояния отказа в $rollback_command не помечена как best effort"
|
|| fail "acceptance: запись состояния отказа в $rollback_command не помечена как best effort"
|
||||||
done
|
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 '
|
"$BUN_BIN" -e '
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
for (const [file, write] of [
|
for (const [file, write] of [
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ run_orchestrator_tests() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
run_frontend_tests() {
|
run_frontend_tests() {
|
||||||
# Контракты панели, которые не проверяются ни типами, ни сборкой bundle:
|
# Ранние контракты панели, которые не требуют установленных зависимостей:
|
||||||
# цвет иконок в спрайте, совпадение словарей локализации, соответствие кодов
|
# цвет иконок в спрайте, совпадение словарей локализации, соответствие кодов
|
||||||
# ошибок серверным константам, единственность адреса атрибуции.
|
# ошибок серверным константам, единственность адреса атрибуции.
|
||||||
#
|
#
|
||||||
@@ -80,8 +80,8 @@ run_frontend_tests() {
|
|||||||
"$BUN_BIN" test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts \
|
"$BUN_BIN" test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts \
|
||||||
|| fail "HY2XS admin frontend contract tests failed"
|
|| fail "HY2XS admin frontend contract tests failed"
|
||||||
|
|
||||||
FRONTEND_TESTS_PASSED="true"
|
FRONTEND_CONTRACT_TESTS_PASSED="true"
|
||||||
export FRONTEND_TESTS_PASSED
|
export FRONTEND_CONTRACT_TESTS_PASSED
|
||||||
}
|
}
|
||||||
|
|
||||||
run_admin_tests() {
|
run_admin_tests() {
|
||||||
@@ -146,6 +146,9 @@ build_orchestrator() {
|
|||||||
bundle_ui() {
|
bundle_ui() {
|
||||||
local ui_src="${UI_SRC:-apps}"
|
local ui_src="${UI_SRC:-apps}"
|
||||||
|
|
||||||
|
[ "${FRONTEND_CONTRACT_TESTS_PASSED:-false}" = "true" ] \
|
||||||
|
|| fail "bundle_ui: ранние контракты панели не проверялись"
|
||||||
|
|
||||||
rm -rf "$ADMIN_BUILD_DIR"
|
rm -rf "$ADMIN_BUILD_DIR"
|
||||||
mkdir -p "$ADMIN_BUILD_DIR"
|
mkdir -p "$ADMIN_BUILD_DIR"
|
||||||
|
|
||||||
@@ -164,6 +167,12 @@ bundle_ui() {
|
|||||||
|
|
||||||
"$PNPM_BIN" install --frozen-lockfile
|
"$PNPM_BIN" install --frozen-lockfile
|
||||||
|
|
||||||
|
# Словари являются JavaScript-объектами и компилируются vue-i18n только
|
||||||
|
# при вызове t(). Поэтому ни vue-tsc, ни Vite не видят синтаксическую
|
||||||
|
# ошибку сообщения. Gate использует тот же установленный runtime, обходит
|
||||||
|
# все строки обоих языков и выполняется до typecheck и production bundle.
|
||||||
|
"$BUN_BIN" test test/i18n-runtime.test.ts
|
||||||
|
|
||||||
# Проверка типов идёт ПЕРЕД сборкой bundle, и это обязательный шаг релиза.
|
# Проверка типов идёт ПЕРЕД сборкой bundle, и это обязательный шаг релиза.
|
||||||
#
|
#
|
||||||
# Раньше её здесь не было вовсе: `build:prod` означал
|
# Раньше её здесь не было вовсе: `build:prod` означал
|
||||||
@@ -176,7 +185,7 @@ bundle_ui() {
|
|||||||
# кода, который не проходит проверку типов, нет.
|
# кода, который не проходит проверку типов, нет.
|
||||||
"$PNPM_BIN" run typecheck
|
"$PNPM_BIN" run typecheck
|
||||||
"$PNPM_BIN" run build:prod
|
"$PNPM_BIN" run build:prod
|
||||||
)
|
) || fail "HY2XS admin frontend runtime tests or bundle failed"
|
||||||
|
|
||||||
# Версия админки приходит из versions.env через ldflags: один и тот же
|
# Версия админки приходит из versions.env через ldflags: один и тот же
|
||||||
# исполняемый файл обязан знать версию пакета, из которого он собран.
|
# исполняемый файл обязан знать версию пакета, из которого он собран.
|
||||||
@@ -196,6 +205,12 @@ bundle_ui() {
|
|||||||
mkdir -p "$STAGE_DIR/ui/hy2xs-admin/docs/sql"
|
mkdir -p "$STAGE_DIR/ui/hy2xs-admin/docs/sql"
|
||||||
install -m 0644 "$ui_src/docs/sql/schema.sql" "$STAGE_DIR/ui/hy2xs-admin/docs/sql/schema.sql"
|
install -m 0644 "$ui_src/docs/sql/schema.sql" "$STAGE_DIR/ui/hy2xs-admin/docs/sql/schema.sql"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Оба утверждения выставляются только после dependency-free контрактов,
|
||||||
|
# runtime-компиляции словарей и успешной сборки production bundle.
|
||||||
|
FRONTEND_RUNTIME_TESTS_PASSED="true"
|
||||||
|
FRONTEND_TESTS_PASSED="true"
|
||||||
|
export FRONTEND_RUNTIME_TESTS_PASSED FRONTEND_TESTS_PASSED
|
||||||
}
|
}
|
||||||
|
|
||||||
write_metadata() {
|
write_metadata() {
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ import {
|
|||||||
ADMIN_USERNAME_PATTERN,
|
ADMIN_USERNAME_PATTERN,
|
||||||
isValidAdminPassword,
|
isValidAdminPassword,
|
||||||
} from "../../apps/frontend/src/constants/credentials";
|
} from "../../apps/frontend/src/constants/credentials";
|
||||||
|
import {
|
||||||
|
PEER_NAME_CHARSET,
|
||||||
|
PEER_NAME_MAX_LENGTH,
|
||||||
|
PEER_NAME_MIN_LENGTH,
|
||||||
|
PEER_NAME_PATTERN,
|
||||||
|
PEER_NAME_PUNCTUATION,
|
||||||
|
} from "../../apps/frontend/src/constants/peer";
|
||||||
import ru from "../../apps/frontend/src/lang/package/ru";
|
import ru from "../../apps/frontend/src/lang/package/ru";
|
||||||
import en from "../../apps/frontend/src/lang/package/en";
|
import en from "../../apps/frontend/src/lang/package/en";
|
||||||
|
|
||||||
@@ -310,9 +317,9 @@ describe("страницы журнала", () => {
|
|||||||
|
|
||||||
// Без width/min-width Element Plus делит ширину практически поровну, и
|
// Без width/min-width Element Plus делит ширину практически поровну, и
|
||||||
// сообщение — единственное содержимое журнала — получает треть строки.
|
// сообщение — единственное содержимое журнала — получает треть строки.
|
||||||
const columns = [...viewer.matchAll(/<el-table-column[\s\S]*?(?:\/>|>)/g)].map(
|
const columns = [
|
||||||
(match) => match[0]
|
...viewer.matchAll(/<el-table-column[\s\S]*?(?:\/>|>)/g),
|
||||||
);
|
].map((match) => match[0]);
|
||||||
expect(columns.length).toBeGreaterThanOrEqual(3);
|
expect(columns.length).toBeGreaterThanOrEqual(3);
|
||||||
for (const column of columns) {
|
for (const column of columns) {
|
||||||
expect(column).toMatch(/\b(width|min-width)=/);
|
expect(column).toMatch(/\b(width|min-width)=/);
|
||||||
@@ -408,7 +415,9 @@ describe("действия над пиром", () => {
|
|||||||
// вызов здесь больше не стоит, обязан называть его по имени — иначе он
|
// вызов здесь больше не стоит, обязан называть его по имени — иначе он
|
||||||
// бесполезен. Это тот же урок, что записан в code_without_comments в
|
// бесполезен. Это тот же урок, что записан в code_without_comments в
|
||||||
// acceptance.sh, и он повторился прямо на этой проверке.
|
// acceptance.sh, и он повторился прямо на этой проверке.
|
||||||
const confirms = [...codeOf(peerList()).matchAll(/ElMessageBox\.confirm\(/g)];
|
const confirms = [
|
||||||
|
...codeOf(peerList()).matchAll(/ElMessageBox\.confirm\(/g),
|
||||||
|
];
|
||||||
// Единственный вызов — внутри confirmAction, который переводит отмену в
|
// Единственный вызов — внутри confirmAction, который переводит отмену в
|
||||||
// обычное `false`.
|
// обычное `false`.
|
||||||
expect(confirms.length).toBe(1);
|
expect(confirms.length).toBe(1);
|
||||||
@@ -523,7 +532,9 @@ describe("страница конфигурации Hysteria", () => {
|
|||||||
// Сам модуль view-модели удалён вместе с дефолтами: пока он существует,
|
// Сам модуль view-модели удалён вместе с дефолтами: пока он существует,
|
||||||
// его легко подключить обратно.
|
// его легко подключить обратно.
|
||||||
expect(
|
expect(
|
||||||
fs.existsSync(path.join(FRONTEND_SRC, "api", "config", "hysteriaViewModel.ts"))
|
fs.existsSync(
|
||||||
|
path.join(FRONTEND_SRC, "api", "config", "hysteriaViewModel.ts")
|
||||||
|
)
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -532,7 +543,12 @@ describe("страница конфигурации Hysteria", () => {
|
|||||||
test("страница не изображает редактируемую форму", () => {
|
test("страница не изображает редактируемую форму", () => {
|
||||||
const source = vueCodeOf(configPage());
|
const source = vueCodeOf(configPage());
|
||||||
|
|
||||||
for (const editable of ["<el-input", "<el-switch", "<el-select", "v-model"]) {
|
for (const editable of [
|
||||||
|
"<el-input",
|
||||||
|
"<el-switch",
|
||||||
|
"<el-select",
|
||||||
|
"v-model",
|
||||||
|
]) {
|
||||||
expect(source).not.toContain(editable);
|
expect(source).not.toContain(editable);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -606,7 +622,15 @@ describe("страница конфигурации Hysteria", () => {
|
|||||||
expect(types).not.toMatch(/\bsecret\s*[?]?:/);
|
expect(types).not.toMatch(/\bsecret\s*[?]?:/);
|
||||||
// Секции, которых production-профиль не описывает, в модели ответа не
|
// Секции, которых production-профиль не описывает, в модели ответа не
|
||||||
// объявляются вовсе: они попадают в `drift` списком имён.
|
// объявляются вовсе: они попадают в `drift` списком имён.
|
||||||
for (const section of ["outbounds", "masquerade", "resolver", "sniff", "acl", "mimic", "realm"]) {
|
for (const section of [
|
||||||
|
"outbounds",
|
||||||
|
"masquerade",
|
||||||
|
"resolver",
|
||||||
|
"sniff",
|
||||||
|
"acl",
|
||||||
|
"mimic",
|
||||||
|
"realm",
|
||||||
|
]) {
|
||||||
expect(types).not.toContain(`${section}:`);
|
expect(types).not.toContain(`${section}:`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -651,10 +675,14 @@ describe("страница конфигурации Hysteria", () => {
|
|||||||
// существуют, «универсальный редактор Hysteria» отрастает заново.
|
// существуют, «универсальный редактор Hysteria» отрастает заново.
|
||||||
test("компоненты генерического редактора удалены", () => {
|
test("компоненты генерического редактора удалены", () => {
|
||||||
for (const component of ["MapAdd", "ImputMultiple", "UnitSelect"]) {
|
for (const component of ["MapAdd", "ImputMultiple", "UnitSelect"]) {
|
||||||
expect(fs.existsSync(path.join(FRONTEND_SRC, "components", component))).toBe(false);
|
expect(
|
||||||
|
fs.existsSync(path.join(FRONTEND_SRC, "components", component))
|
||||||
|
).toBe(false);
|
||||||
}
|
}
|
||||||
expect(
|
expect(
|
||||||
fs.existsSync(path.join(FRONTEND_SRC, "views", "hysteria", "list", "components"))
|
fs.existsSync(
|
||||||
|
path.join(FRONTEND_SRC, "views", "hysteria", "list", "components")
|
||||||
|
)
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -701,7 +729,9 @@ describe("контракт учётных данных администрато
|
|||||||
if (numeric) {
|
if (numeric) {
|
||||||
return numeric[1];
|
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) {
|
if (backticked) {
|
||||||
return backticked[1];
|
return backticked[1];
|
||||||
}
|
}
|
||||||
@@ -716,22 +746,37 @@ describe("контракт учётных данных администрато
|
|||||||
// принял бы, и оператор остаётся снаружи с паролем, который на самом деле
|
// принял бы, и оператор остаётся снаружи с паролем, который на самом деле
|
||||||
// подходит.
|
// подходит.
|
||||||
test("границы и набор символов совпадают с Go-контрактом", () => {
|
test("границы и набор символов совпадают с Go-контрактом", () => {
|
||||||
expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(goConst("AdminUsernameMinLength"));
|
expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(
|
||||||
expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(goConst("AdminUsernameMaxLength"));
|
goConst("AdminUsernameMinLength")
|
||||||
expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(goConst("AdminPasswordMinLength"));
|
);
|
||||||
expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(goConst("AdminPasswordMaxLength"));
|
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 — отдельная константа и отдельная сверка. Пока панель знала
|
// Предел bcrypt — отдельная константа и отдельная сверка. Пока панель знала
|
||||||
// только границу в символах, она предлагала оператору назначить пароль из
|
// только границу в символах, она предлагала оператору назначить пароль из
|
||||||
// 64 кириллических букв (128 байт), сервер его принимал всеми проверками, а
|
// 64 кириллических букв (128 байт), сервер его принимал всеми проверками, а
|
||||||
// хеширование отказывало — и оператор получал системную ошибку на штатной
|
// хеширование отказывало — и оператор получал системную ошибку на штатной
|
||||||
// смене пароля.
|
// смене пароля.
|
||||||
expect(String(ADMIN_PASSWORD_MAX_BYTES)).toBe(goConst("AdminPasswordMaxBytes"));
|
expect(String(ADMIN_PASSWORD_MAX_BYTES)).toBe(
|
||||||
|
goConst("AdminPasswordMaxBytes")
|
||||||
|
);
|
||||||
expect(ADMIN_USERNAME_CHARSET).toBe(goConst("AdminUsernameCharset"));
|
expect(ADMIN_USERNAME_CHARSET).toBe(goConst("AdminUsernameCharset"));
|
||||||
|
|
||||||
const declared = goConst("adminUsernameCharacterClass");
|
const declared = goConst("adminUsernameCharacterClass");
|
||||||
const mirrored = ADMIN_USERNAME_PATTERN.source
|
const mirrored = ADMIN_USERNAME_PATTERN.source
|
||||||
.replace(/^\^\[/, "")
|
.replace(/^\^\[/, "")
|
||||||
.replace(new RegExp(`\\]\\{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}\\}\\$$`), "");
|
.replace(
|
||||||
|
new RegExp(
|
||||||
|
`\\]\\{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}\\}\\$$`
|
||||||
|
),
|
||||||
|
""
|
||||||
|
);
|
||||||
expect(mirrored).toBe(declared);
|
expect(mirrored).toBe(declared);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -781,9 +826,8 @@ describe("контракт учётных данных администрато
|
|||||||
// Встроенных границ на пароле поэтому быть не должно вовсе: длина
|
// Встроенных границ на пароле поэтому быть не должно вовсе: длина
|
||||||
// принадлежит правилу, а не соседям по объекту.
|
// принадлежит правилу, а не соседям по объекту.
|
||||||
test("на пароле нет встроенных min/max от async-validator", async () => {
|
test("на пароле нет встроенных min/max от async-validator", async () => {
|
||||||
const { adminPasswordFormRule } = await import(
|
const { adminPasswordFormRule } =
|
||||||
"../../apps/frontend/src/constants/credentials"
|
await import("../../apps/frontend/src/constants/credentials");
|
||||||
);
|
|
||||||
|
|
||||||
// Правило одно на обе формы, и границ как СВОЙСТВ правила у него нет: длина
|
// Правило одно на обе формы, и границ как СВОЙСТВ правила у него нет: длина
|
||||||
// живёт внутри валидатора, где её можно посчитать правильно.
|
// живёт внутри валидатора, где её можно посчитать правильно.
|
||||||
@@ -810,8 +854,12 @@ describe("контракт учётных данных администрато
|
|||||||
test("правило пароля считает code points и байты, а не UTF-16", () => {
|
test("правило пароля считает code points и байты, а не UTF-16", () => {
|
||||||
// Проверка самой таблицы: если «я» перестанет быть двухбайтовой, случаи
|
// Проверка самой таблицы: если «я» перестанет быть двухбайтовой, случаи
|
||||||
// ниже проверяли бы не то, что написано.
|
// ниже проверяли бы не то, что написано.
|
||||||
expect(new TextEncoder().encode("я".repeat(36)).length).toBe(ADMIN_PASSWORD_MAX_BYTES);
|
expect(new TextEncoder().encode("я".repeat(36)).length).toBe(
|
||||||
expect(new TextEncoder().encode("😀".repeat(18)).length).toBe(ADMIN_PASSWORD_MAX_BYTES);
|
ADMIN_PASSWORD_MAX_BYTES
|
||||||
|
);
|
||||||
|
expect(new TextEncoder().encode("😀".repeat(18)).length).toBe(
|
||||||
|
ADMIN_PASSWORD_MAX_BYTES
|
||||||
|
);
|
||||||
|
|
||||||
for (const accepted of [
|
for (const accepted of [
|
||||||
"a".repeat(ADMIN_PASSWORD_MIN_LENGTH),
|
"a".repeat(ADMIN_PASSWORD_MIN_LENGTH),
|
||||||
@@ -854,7 +902,9 @@ describe("контракт учётных данных администрато
|
|||||||
// Соседи запрещённых диапазонов обязаны проходить: правило описывает ровно
|
// Соседи запрещённых диапазонов обязаны проходить: правило описывает ровно
|
||||||
// множество systemd, а не окрестность подозрительных значений.
|
// множество systemd, а не окрестность подозрительных значений.
|
||||||
for (const accepted of [0xfdcf, 0xfdf0, 0xfffd, 0x10fffd]) {
|
for (const accepted of [0xfdcf, 0xfdf0, 0xfffd, 0x10fffd]) {
|
||||||
expect(isValidAdminPassword(`abcde${String.fromCodePoint(accepted)}`)).toBe(true);
|
expect(
|
||||||
|
isValidAdminPassword(`abcde${String.fromCodePoint(accepted)}`)
|
||||||
|
).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Именно тот случай, ради которого встроенные min/max убраны: три эмодзи —
|
// Именно тот случай, ради которого встроенные min/max убраны: три эмодзи —
|
||||||
@@ -868,9 +918,8 @@ describe("контракт учётных данных администрато
|
|||||||
// обязательно». Сервер ведёт себя так же (DTO приводит пустой пароль к «не
|
// обязательно». Сервер ведёт себя так же (DTO приводит пустой пароль к «не
|
||||||
// задано» до проверки правил).
|
// задано» до проверки правил).
|
||||||
test("пустой пароль остаётся на правиле required", async () => {
|
test("пустой пароль остаётся на правиле required", async () => {
|
||||||
const { adminPasswordFormRule } = await import(
|
const { adminPasswordFormRule } =
|
||||||
"../../apps/frontend/src/constants/credentials"
|
await import("../../apps/frontend/src/constants/credentials");
|
||||||
);
|
|
||||||
const rule = adminPasswordFormRule("format");
|
const rule = adminPasswordFormRule("format");
|
||||||
|
|
||||||
const errors: (Error | undefined)[] = [];
|
const errors: (Error | undefined)[] = [];
|
||||||
@@ -886,11 +935,15 @@ describe("контракт учётных данных администрато
|
|||||||
test("на пароль не навешен набор символов", () => {
|
test("на пароль не навешен набор символов", () => {
|
||||||
for (const form of [loginForm(), changePasswordForm()]) {
|
for (const form of [loginForm(), changePasswordForm()]) {
|
||||||
const code = vueCodeOf(form);
|
const code = vueCodeOf(form);
|
||||||
expect(code).not.toContain("ADMIN_USERNAME_PATTERN,\n message: t(\"credentials.passwordFormat\"");
|
expect(code).not.toContain(
|
||||||
|
'ADMIN_USERNAME_PATTERN,\n message: t("credentials.passwordFormat"'
|
||||||
|
);
|
||||||
expect(code).not.toMatch(/passwordPattern/);
|
expect(code).not.toMatch(/passwordPattern/);
|
||||||
}
|
}
|
||||||
// Правило логина — единственное место, где вообще упоминается набор.
|
// Правило логина — единственное место, где вообще упоминается набор.
|
||||||
expect(vueCodeOf(changePasswordForm())).not.toContain("ADMIN_USERNAME_PATTERN");
|
expect(vueCodeOf(changePasswordForm())).not.toContain(
|
||||||
|
"ADMIN_USERNAME_PATTERN"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Требование НАЗЫВАЕТСЯ, а не сообщается фактом нарушения: набор символов
|
// Требование НАЗЫВАЕТСЯ, а не сообщается фактом нарушения: набор символов
|
||||||
@@ -917,7 +970,8 @@ describe("контракт учётных данных администрато
|
|||||||
for (const placeholder of ["{min}", "{max}", "{charset}"]) {
|
for (const placeholder of ["{min}", "{max}", "{charset}"]) {
|
||||||
expect(reason).toContain(placeholder);
|
expect(reason).toContain(placeholder);
|
||||||
}
|
}
|
||||||
const passwordReason = (dictionary as any).error?.code?.admin_password_format ?? "";
|
const passwordReason =
|
||||||
|
(dictionary as any).error?.code?.admin_password_format ?? "";
|
||||||
for (const placeholder of ["{min}", "{max}", "{maxBytes}"]) {
|
for (const placeholder of ["{min}", "{max}", "{maxBytes}"]) {
|
||||||
expect(passwordReason).toContain(placeholder);
|
expect(passwordReason).toContain(placeholder);
|
||||||
}
|
}
|
||||||
@@ -944,11 +998,15 @@ describe("индикация ошибки на форме входа", () => {
|
|||||||
const styles = loginForm();
|
const styles = loginForm();
|
||||||
|
|
||||||
// Индикация переехала на элемент, который оператор и видит полем.
|
// Индикация переехала на элемент, который оператор и видит полем.
|
||||||
expect(styles).toMatch(/&\.is-error\s*\{[\s\S]*?border-color:\s*var\(--el-color-danger\)/);
|
expect(styles).toMatch(
|
||||||
|
/&\.is-error\s*\{[\s\S]*?border-color:\s*var\(--el-color-danger\)/
|
||||||
|
);
|
||||||
|
|
||||||
// Штатная тень враппера гасится селектором, который повторяет чужой и
|
// Штатная тень враппера гасится селектором, который повторяет чужой и
|
||||||
// добавляет атрибут scoped-стиля, — то есть выигрывает по специфичности.
|
// добавляет атрибут scoped-стиля, — то есть выигрывает по специфичности.
|
||||||
expect(styles).toContain(":deep(.el-form-item__content .el-input__wrapper)");
|
expect(styles).toContain(
|
||||||
|
":deep(.el-form-item__content .el-input__wrapper)"
|
||||||
|
);
|
||||||
|
|
||||||
// Победа специфичностью, а не силой: `!important` перекрыл бы и те
|
// Победа специфичностью, а не силой: `!important` перекрыл бы и те
|
||||||
// состояния, о которых правило ничего не знает.
|
// состояния, о которых правило ничего не знает.
|
||||||
@@ -966,27 +1024,128 @@ describe("индикация ошибки на форме входа", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("подсказки формы пира", () => {
|
describe("подсказки формы пира", () => {
|
||||||
|
const peerContract = () =>
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(REPO_ROOT, "apps", "service", "peer_import.go"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
function peerGoConst(name: string): string {
|
||||||
|
const source = peerContract();
|
||||||
|
const numeric = source.match(new RegExp(`\\b${name}\\s*=\\s*(\\d+)`));
|
||||||
|
if (numeric) {
|
||||||
|
return numeric[1];
|
||||||
|
}
|
||||||
|
const backticked = source.match(
|
||||||
|
new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``)
|
||||||
|
);
|
||||||
|
if (backticked) {
|
||||||
|
return backticked[1];
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`константа ${name} не найдена в apps/service/peer_import.go`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("frontend-контракт совпадает с серверным", () => {
|
||||||
|
expect(String(PEER_NAME_MIN_LENGTH)).toBe(peerGoConst("PeerNameMinLength"));
|
||||||
|
expect(String(PEER_NAME_MAX_LENGTH)).toBe(peerGoConst("PeerNameMaxLength"));
|
||||||
|
expect(PEER_NAME_CHARSET).toBe(peerGoConst("PeerNameCharset"));
|
||||||
|
expect(PEER_NAME_CHARSET.split(/\s+/).at(-1)).toBe(PEER_NAME_PUNCTUATION);
|
||||||
|
|
||||||
|
const declaredPattern = peerContract().match(
|
||||||
|
/peerNamePattern\s*=\s*regexp\.MustCompile\(`([^`]+)`\)/
|
||||||
|
);
|
||||||
|
expect(declaredPattern).not.toBeNull();
|
||||||
|
expect(PEER_NAME_PATTERN.source).toBe(declaredPattern![1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("форма использует общий контракт без собственных копий", () => {
|
||||||
|
const form = readSource("views", "peer", "list", "index.vue");
|
||||||
|
expect(form).toContain("@/constants/peer");
|
||||||
|
expect(form).toContain("pattern: PEER_NAME_PATTERN");
|
||||||
|
expect(form).toContain("min: PEER_NAME_MIN_LENGTH");
|
||||||
|
expect(form).toContain("max: PEER_NAME_MAX_LENGTH");
|
||||||
|
expect(form).toContain("charset: PEER_NAME_CHARSET");
|
||||||
|
expect(form).toContain("punctuation: PEER_NAME_PUNCTUATION");
|
||||||
|
expect(form).not.toMatch(/const\s+PEER_NAME_PATTERN\s*=/);
|
||||||
|
expect(form).not.toContain('charset: "a-z A-Z 0-9');
|
||||||
|
});
|
||||||
|
|
||||||
// Подсказка обещала более узкий набор символов, чем принимает сервер: она
|
// Подсказка обещала более узкий набор символов, чем принимает сервер: она
|
||||||
// осталась от правила, действовавшего до EX-03. Оператор не имел причин
|
// осталась от правила, действовавшего до EX-03. Оператор не имел причин
|
||||||
// пробовать разрешённые символы.
|
// пробовать разрешённые символы.
|
||||||
test("подсказка имени описывает действующий набор символов", () => {
|
test("подсказка имени описывает действующий набор символов", () => {
|
||||||
// Набор символов объявлен на сервере ровно один раз; подсказка обязана
|
// Набор символов объявлен на сервере ровно один раз; подсказка обязана
|
||||||
// описывать именно его, а не набор из предыдущего поколения правила.
|
// описывать именно его, а не набор из предыдущего поколения правила.
|
||||||
const rule = fs.readFileSync(
|
expect(PEER_NAME_PUNCTUATION).toContain("!@#");
|
||||||
path.join(REPO_ROOT, "apps", "service", "peer_import.go"),
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
const declared = rule.match(/PeerNameCharset\s*=\s*`([^`]+)`/);
|
|
||||||
expect(declared).not.toBeNull();
|
|
||||||
|
|
||||||
// «a-z A-Z 0-9 !@#$%^&*()_+-=» -> «!@#$%^&*()_+-=»
|
|
||||||
const punctuation = declared![1].split(/\s+/).at(-1)!;
|
|
||||||
expect(punctuation).toContain("!@#");
|
|
||||||
|
|
||||||
for (const dictionary of [ru, en] as const) {
|
for (const dictionary of [ru, en] as const) {
|
||||||
const hint = (dictionary as any).peer?.form?.nameHint ?? "";
|
const hint = (dictionary as any).peer?.form?.nameHint ?? "";
|
||||||
expect(hint).toContain(punctuation);
|
expect(hint).toContain("{punctuation}");
|
||||||
expect(hint).toContain("32");
|
expect(hint).toContain("{min}");
|
||||||
|
expect(hint).toContain("{max}");
|
||||||
|
expect(hint).not.toContain(PEER_NAME_PUNCTUATION);
|
||||||
|
expect(hint).not.toContain(
|
||||||
|
`${PEER_NAME_MIN_LENGTH} to ${PEER_NAME_MAX_LENGTH}`
|
||||||
|
);
|
||||||
|
expect(hint).not.toContain(
|
||||||
|
`от ${PEER_NAME_MIN_LENGTH} до ${PEER_NAME_MAX_LENGTH}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("контракт Element Plus", () => {
|
||||||
|
test("radio-компоненты не используют label как value", () => {
|
||||||
|
const deprecated: string[] = [];
|
||||||
|
|
||||||
|
for (const file of sourceFiles(FRONTEND_SRC).filter((candidate) =>
|
||||||
|
candidate.endsWith(".vue")
|
||||||
|
)) {
|
||||||
|
const source = fs.readFileSync(file, "utf8");
|
||||||
|
for (const match of source.matchAll(
|
||||||
|
/<el-radio(?:-button)?\b[\s\S]*?>/g
|
||||||
|
)) {
|
||||||
|
const tag = match[0];
|
||||||
|
if (/\blabel\s*=/.test(tag) && !/\bvalue\s*=/.test(tag)) {
|
||||||
|
deprecated.push(`${path.relative(REPO_ROOT, file)}: ${tag}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(deprecated).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("политика frontend-зависимостей", () => {
|
||||||
|
test("security overrides находятся в читаемой pnpm конфигурации", () => {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(REPO_ROOT, "apps", "frontend", "package.json"),
|
||||||
|
"utf8"
|
||||||
|
)
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
expect(manifest.pnpm).toBeUndefined();
|
||||||
|
|
||||||
|
const workspace = fs.readFileSync(
|
||||||
|
path.join(REPO_ROOT, "apps", "frontend", "pnpm-workspace.yaml"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
expect(workspace).toMatch(/^overrides:/m);
|
||||||
|
for (const [dependency, resolution] of Object.entries({
|
||||||
|
lodash: ">=4.18.1",
|
||||||
|
"lodash-es": ">=4.18.1",
|
||||||
|
"brace-expansion": ">=1.1.18",
|
||||||
|
picomatch: ">=2.3.2",
|
||||||
|
"js-yaml": ">=4.3.1",
|
||||||
|
"table>ajv": ">=8.18.0",
|
||||||
|
browserslist: "4.28.7",
|
||||||
|
})) {
|
||||||
|
const dependencyKey = dependency.includes(">")
|
||||||
|
? `"${dependency}"`
|
||||||
|
: dependency;
|
||||||
|
expect(workspace).toContain(`${dependencyKey}: "${resolution}"`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user