4 Commits

29 changed files with 1193 additions and 171 deletions
+47 -2
View File
@@ -8,6 +8,51 @@
## [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.
@@ -2289,5 +2334,5 @@ v257.13, то есть той самой линии, что стоит на Debi
Клиентские ссылки `0.x` в любом случае перестанут работать: смена
обфускации — это изменение wire-совместимости.
[Unreleased]: https://git.flamy.studio/prod/HY2XS_flamy/compare/v1.0.0...HEAD
[1.0.0]: https://git.flamy.studio/prod/HY2XS_flamy/releases/tag/v1.0.0
[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/1.0.0-rc5
+38 -7
View File
@@ -10,6 +10,7 @@
<p align="center">
<a href="#что-это">Что это</a> ·
<a href="#статус-приёмки-v100">Приёмка v1.0.0</a> ·
<a href="#возможности">Возможности</a> ·
<a href="#быстрый-старт-для-нового-сервера">Быстрый старт</a> ·
<a href="#конфигурация-hy2xsenv">Конфигурация</a> ·
@@ -37,6 +38,22 @@ HY2XS подходит для сценария, где нужен один produ
Ключевая идея: на 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‑пакет разворачивает и настраивает:
@@ -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`. Этот каталог
принадлежит `root:root`, имеет режим `0700` и отделён от
`HY2XS_LOG_DIR`, которым владеет сервисный пользователь `hy2xs-admin`.
@@ -1008,17 +1030,18 @@ export GITHUB_TOKEN=<token>
1. проверяет контракт `versions.env` (`verify_versions_contract`);
2. прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`);
3. прогоняет контрактные тесты панели (спрайт иконок, словари локализации, коды ошибок, атрибуция);
3. прогоняет dependency-free контракты панели (спрайт иконок, совпадение словарей, коды ошибок, атрибуция и frontend/Go-контракты);
4. определяет последнюю стабильную версию Hysteria, берёт ожидаемый SHA‑256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт;
5. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander;
6. собирает orchestrator, frontend и backend, проставляя версию админки из контракта;
7. прогоняет `go vet` и `go test` для HY2XS admin;
8. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу);
9. формирует архив и прогоняет acceptance‑проверки.
6. собирает standalone-бинарник orchestrator;
7. устанавливает frontend lock-граф, runtime-компилирует каждое сообщение RU/EN реальным `vue-i18n`, затем проверяет типы и собирает frontend и backend, проставляя версию админки из контракта;
8. прогоняет `go vet` и `go test` для HY2XS admin;
9. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу);
10. формирует архив и прогоняет acceptance‑проверки.
Любой сбой на шагах 1–8 останавливает сборку до создания пакета.
Любой сбой на шагах 1–9 останавливает сборку до создания пакета.
Тесты и типы (шаги 2, 3 и 7) — такой же обязательный гейт, как проверка
Тесты и типы (шаги 2, 3, 7 и 8) — такой же обязательный гейт, как проверка
зависимостей: переменной, которая их отключает, не существует. Готовый пакет
объявляет об этом полем `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`
запускаются напрямую и tarball не создают.
Runtime-проверка словарей требует установленного frontend lock-графа:
```bash
cd apps/frontend
pnpm install --frozen-lockfile
bun test test/i18n-runtime.test.ts
```
Переменные, управляющие выбором версии Hysteria:
| Переменная | По умолчанию | Назначение |
+7 -1
View File
@@ -19,5 +19,11 @@ Frontend собирается production builder'ом из [`tools/build`](../..
```sh
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()`.
+1 -12
View File
@@ -28,7 +28,7 @@
"qrcode.vue": "3.4.1",
"vue": "^3.5.42",
"vue-echarts": "^8.1.0",
"vue-i18n": "^9.14.5",
"vue-i18n": "^11.4.10",
"vue-router": "^4.6.4"
},
"devDependencies": {
@@ -65,17 +65,6 @@
"vue-eslint-parser": "10.4.1",
"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": {
"node": "^20.19.0 || >=22.12.0"
}
+45 -38
View File
@@ -4,15 +4,6 @@ settings:
autoInstallPeers: true
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:
.:
@@ -54,8 +45,8 @@ importers:
specifier: ^8.1.0
version: 8.1.0(echarts@6.1.0)(vue@3.5.42(typescript@5.9.3))
vue-i18n:
specifier: ^9.14.5
version: 9.14.5(vue@3.5.42(typescript@5.9.3))
specifier: ^11.4.10
version: 11.4.10(vue@3.5.42(typescript@5.9.3))
vue-router:
specifier: ^4.6.4
version: 4.6.4(vue@3.5.42(typescript@5.9.3))
@@ -492,17 +483,21 @@ packages:
'@iconify/utils@3.1.4':
resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==}
'@intlify/core-base@9.14.5':
resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==}
engines: {node: '>= 16'}
'@intlify/core-base@11.4.10':
resolution: {integrity: sha512-+yJ74JRWVJokdgG9zYNMyTSzeNV3O9T4vVxk8PvLFHmI+R/BYA//cITh7vhRK37hWLZ4/kTcKcUz1dlWOpypIg==}
engines: {node: '>= 22'}
'@intlify/message-compiler@9.14.5':
resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==}
engines: {node: '>= 16'}
'@intlify/devtools-types@11.4.10':
resolution: {integrity: sha512-xZxzZsAuu6/0zoLRVQWdpXWe5Kjl0LnWpjlQA3r9u9FbLYMhapqt7IwkgQyn0Tm2GUNAqhj9eZiUmYOrB024BQ==}
engines: {node: '>= 22'}
'@intlify/shared@9.14.5':
resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==}
engines: {node: '>= 16'}
'@intlify/message-compiler@11.4.10':
resolution: {integrity: sha512-oUB/scz2EJENXDiUJ7JjZffOrH8UIZ1BuZeHvonbi5fWLavLt04aivuk2OIByOZA0tsci1bkeeQRmwhb5M8Imw==}
engines: {node: '>= 22'}
'@intlify/shared@11.4.10':
resolution: {integrity: sha512-FeImVdPeoSHTm3NBFFZHv0eRP9gQ3F4lj2puDBX5Kw7iiM1uJW6JTf39ian0K/17pbXCI3ef5i9RVsRrALqI6Q==}
engines: {node: '>= 22'}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -1525,7 +1520,7 @@ packages:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: '>=2.3.2'
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
@@ -1789,8 +1784,8 @@ packages:
resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
peerDependencies:
'@types/lodash-es': '*'
lodash: '>=4.18.1'
lodash-es: '>=4.18.1'
lodash: '*'
lodash-es: '*'
lodash.truncate@4.4.2:
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
@@ -1957,6 +1952,10 @@ packages:
picocolors@1.1.1:
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:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
@@ -2406,7 +2405,7 @@ packages:
resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==}
hasBin: true
peerDependencies:
browserslist: 4.28.7
browserslist: '>= 4.21.0'
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -2483,10 +2482,9 @@ packages:
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
vue-i18n@9.14.5:
resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==}
engines: {node: '>= 16'}
deprecated: v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html
vue-i18n@11.4.10:
resolution: {integrity: sha512-Lp+BjOxqzOY87DS6Z8KrQrpiTr9IN/Lt4kZEilwyXG2Wrx+AcU6IVsAW92HNXtVcn1HFFPV6ty41p9e/qDpyvg==}
engines: {node: '>= 22'}
peerDependencies:
vue: ^3.0.0
@@ -2782,17 +2780,23 @@ snapshots:
'@iconify/types': 2.0.0
import-meta-resolve: 4.2.0
'@intlify/core-base@9.14.5':
'@intlify/core-base@11.4.10':
dependencies:
'@intlify/message-compiler': 9.14.5
'@intlify/shared': 9.14.5
'@intlify/devtools-types': 11.4.10
'@intlify/message-compiler': 11.4.10
'@intlify/shared': 11.4.10
'@intlify/message-compiler@9.14.5':
'@intlify/devtools-types@11.4.10':
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
'@intlify/shared@9.14.5': {}
'@intlify/shared@11.4.10': {}
'@jridgewell/gen-mapping@0.3.13':
dependencies:
@@ -4113,7 +4117,7 @@ snapshots:
micromatch@4.0.8:
dependencies:
braces: 3.0.3
picomatch: 4.0.7
picomatch: 2.3.2
mime-db@1.52.0: {}
@@ -4245,6 +4249,8 @@ snapshots:
picocolors@1.1.1: {}
picomatch@2.3.2: {}
picomatch@4.0.7: {}
pinia@2.3.1(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)):
@@ -4798,10 +4804,11 @@ snapshots:
transitivePeerDependencies:
- 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:
'@intlify/core-base': 9.14.5
'@intlify/shared': 9.14.5
'@intlify/core-base': 11.4.10
'@intlify/devtools-types': 11.4.10
'@intlify/shared': 11.4.10
'@vue/devtools-api': 6.6.4
vue: 3.5.42(typescript@5.9.3)
+13
View File
@@ -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"
+29
View File
@@ -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}}$`
);
+3 -2
View File
@@ -60,7 +60,8 @@ export default {
trafficApiUnreachable: "Unreachable",
security: {
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",
},
error: {
@@ -217,7 +218,7 @@ export default {
// The character set is stated as service.IsValidPeerName accepts it. The
// previous wording described the rule that was in force before EX-03.
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",
remarkHint: "Optional operator note. It is never shown to the client.",
secretPlaceholder: "leave empty to generate one",
+1 -1
View File
@@ -240,7 +240,7 @@ export default {
// действовавшего до EX-03: подсказка обещала более узкий набор, чем
// сервер принимает, и оператор не имел причин пробовать остальные.
nameHint:
"Идентификатор пира: от 6 до 32 символов — латиница, цифры и знаки !@#$%^&*()_+-=. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
"Идентификатор пира: от {min} до {max} символов — латиница, цифры и знаки {punctuation}. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
remarkPlaceholder: "ноутбук",
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
secretPlaceholder: "оставьте пустым — сгенерируем",
+6 -4
View File
@@ -98,7 +98,9 @@
<el-col :xs="24" :sm="12" :lg="6"
><el-card shadow="never"
>{{ $t("dashboard.onlinePeers") }}:
{{ summary.hysteria.apiReachable ? summary.peers.onlinePeers : "?" }}</el-card
{{
summary.hysteria.apiReachable ? summary.peers.onlinePeers : "?"
}}</el-card
></el-col
>
<el-col :xs="24" :sm="12" :lg="6"
@@ -128,9 +130,9 @@
<div class="chart-header">
<span>{{ $t("dashboard.trafficChart") }}</span>
<el-radio-group v-model="range" size="small" @change="loadDashboard">
<el-radio-button label="1h">1h</el-radio-button>
<el-radio-button label="24h">24h</el-radio-button>
<el-radio-button label="7d">7d</el-radio-button>
<el-radio-button value="1h">1h</el-radio-button>
<el-radio-button value="24h">24h</el-radio-button>
<el-radio-button value="7d">7d</el-radio-button>
</el-radio-group>
</div>
</template>
+19 -8
View File
@@ -247,7 +247,15 @@
v-model="dataForm.name"
: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 :label="$t('peer.remark')" :error="serverErrors.remark">
<el-input
@@ -445,6 +453,13 @@ import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus";
import { ERR_CODE, isApiError } from "@/utils/api-error";
import { describeApiError, fieldErrorMap } from "@/utils/api-message";
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_MAX_LENGTH = 128;
@@ -600,9 +611,9 @@ const rules = {
pattern: PEER_NAME_PATTERN,
message: t("error.code.peer_name", {
field: t("error.field.name"),
min: 6,
max: 32,
charset: "a-z A-Z 0-9 !@#$%^&*()_+-=",
min: PEER_NAME_MIN_LENGTH,
max: PEER_NAME_MAX_LENGTH,
charset: PEER_NAME_CHARSET,
}),
trigger: ["change", "blur"],
},
+109
View File
@@ -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,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 без пересборки.**
+10
View File
@@ -27,6 +27,13 @@
| 2026-09-01 | `1.0.0-rc1` | `a1f0db22` | build + host acceptance, Debian 13 | [RC ACCEPTED WITH RELEASE-REQUIRED UX FIXES](2026-09-01-v1.0.0-rc1-host-acceptance.md) |
| 2026-09-04 | `1.0.0-rc2` | `82e5ca40` | clean install + runtime, Debian 13 | [RUNTIME REJECTED — вход в панель недоступен](2026-09-04-v1.0.0-rc2-runtime-findings.md) |
| 2026-09-07 | `1.0.0-rc3` | `bf10810c` | clean install + failure rollback, Debian 13 | [RUNTIME REJECTED — installer smoke блокируется middleware](2026-09-07-v1.0.0-rc3-runtime-findings.md) |
| 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) |
## Открытые дефекты приёмки
@@ -36,6 +43,9 @@
| 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.
## Разборы кода между прогонами
Отдельно от отчётов о прогонах: дефекты, найденные чтением дерева и сверкой с
+11
View File
@@ -85,6 +85,12 @@
сборку — vue-i18n молча отдаёт сам ключ, и оператор видит `error.code.min_length`
вместо фразы.
Совпадения ключей недостаточно: строки компилируются как Vue I18n message
format только при переводе. Специальные символы (`@`, `$`, `{}`, `|`) нельзя
вставлять в текст как произвольные данные. Динамический набор знаков передаётся
через named interpolation, а builder переводит каждую leaf-строку RU/EN и
считает ошибкой как исключение, так и compiler diagnostics в `console.error`.
**Состояние сессии** сообщается кодами `unauthorized`, `session_expired`,
`token_invalid`, `account_disabled` при `code = 50401`. Панель по ним
показывает диалог и возвращает на форму входа.
@@ -132,6 +138,11 @@
продукт: в таблицу пиров ведут две двери, и они не имеют права требовать
разного.
Frontend-зеркало границ, regex и человекочитаемого набора находится в одном
модуле `constants/peer.ts` и сверяется с Go-контрактом тестом. Пунктуация
`!@#$%^&*()_+-=` попадает в подсказку как значение `{punctuation}`, а не как
часть синтаксиса message format.
---
## 4. Таблицы журнала
+15 -8
View File
@@ -136,7 +136,7 @@ Bun обновляется отдельно от остальных: оркес
полного прохода `bun test → tsc → compile → приёмка на Debian`, а не строки в
общем патче.
### Проверка типов frontend — обязательный шаг релиза
### Runtime-компиляция локализации и типы frontend — обязательные шаги релиза
```text
pnpm run typecheck → vue-tsc --noEmit → ОБЯЗАН пройти
@@ -144,9 +144,13 @@ pnpm run build:prod → vite build
pnpm run verify → typecheck, затем build
```
`bundle_ui()` запускает `typecheck` **до** сборки bundle: собирать production
bundle из кода, который не проходит проверку типов, незачем. Порядок и сам факт
наличия шага проверяются приёмкой.
После `pnpm install --frozen-lockfile` функция `bundle_ui()` сначала запускает
`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
--noEmit`, но `vue-tsc` был версии `0.35.0` (2022 год) и шаблоны Vue
@@ -222,7 +226,9 @@ bundle из кода, который не проходит проверку ти
Уязвимость обычно приходит не из прямой зависимости, а из чужого поддерева, и
поднимать ради неё весь frontend-граф не нужно. Такие случаи закрываются
списком `pnpm.overrides` в `apps/frontend/package.json`:
списком `overrides` в `apps/frontend/pnpm-workspace.yaml`. Настройки pnpm не
хранятся в поле `pnpm` package manifest: современные версии его игнорируют, и
правило осталось бы только случайно замороженным в старом lock-файле.
| override | зачем |
| --- | --- |
@@ -253,14 +259,15 @@ bundle из кода, который не проходит проверку ти
| Шаг сборки | Что запускается |
| --- | --- |
| `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 ./...` |
Готовый пакет объявляет об этом полем `tests_gate=true` в
`metadata/package.env` — так же, как `dependency_security_gate` и
`hysteria_compat_gate`. Значение у поля ровно одно, потому что не бывает
пакета, собранного с пропущенными тестами: обе функции прогона выставляют свой
флаг **после** успешного завершения, а `write_metadata` отказывается писать
пакета, собранного с пропущенными тестами: функции прогона выставляют свои
флаги **после** успешного завершения, а `write_metadata` отказывается писать
метаданные, если хотя бы один из них не выставлен. То есть поле остаётся
утверждением о результате, а не переключателем.
+2
View File
@@ -346,6 +346,8 @@ hy2xs-orchestrator redact-config --config /etc/hysteria/config.yaml --out /root/
редакцию, а результат остаётся валидным YAML;
- в env-файлах секрет вырезается и из URL-значения, даже если имя ключа
несекретное — например, `HY2_AUTH_URL` в `post-install.env`.
- `HY2XS_FORCE_PASSWORD_CHANGE=true|false` сохраняется как диагностический
boolean; это точное исключение, остальные ключи с `PASSWORD` редактируются.
Та же редакция применяется к diagnostics-бандлу
(`hy2xs-orchestrator diagnostics collect`), который собирается автоматически при
+7
View File
@@ -144,6 +144,13 @@ anycast. Отсутствие A-записи фатально при любом
- `HY2XS_FORCE_PASSWORD_CHANGE` в production baseline установлен в `false` (forced UX-flow пока не реализован);
- после первичного 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` — это значения, которые
+10 -4
View File
@@ -18,9 +18,12 @@ cd apps && go vet ./... && go test ./...
# Проверка типов и сборка frontend
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
# Runtime-компиляция всех RU/EN-сообщений реальным vue-i18n
cd apps/frontend && bun test test/i18n-runtime.test.ts
# Сверка среды разработки с versions.env (ничего не меняет)
./tools/dev/doctor.sh
@@ -36,9 +39,12 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
## Почему контракты панели проверяет Bun, а не vitest
Проверяемые модули (`SvgIcon/symbol.ts`, `constants/branding.ts`, словари
локализации) намеренно чистые: ни Vite, ни DOM в них нет, поэтому их можно
выполнить вне браузера уже закреплённым в `versions.env` Bun.
Dependency-free модули (`SvgIcon/symbol.ts`, frontend constants и словари
локализации) намеренно чистые: ни Vite, ни DOM в них нет. Отдельный
`i18n-runtime.test.ts` запускается после `pnpm install`, потому что обязан
использовать именно `vue-i18n` из frozen lock-графа. Он обходит каждую строку
обоих словарей и считает отказом и исключение, и compiler diagnostics в
`console.error`.
Vitest с jsdom не вычисляет `currentColor` и визуальной корректности всё равно
не доказал бы, зато привёл бы в граф `pnpm audit` — а его порог считается по
+8 -3
View File
@@ -918,7 +918,8 @@ build tooling исполняется на build-машине и порождае
| Шаг сборки | Что запускается |
| --- | --- |
| `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 ./...` |
Приёмка проверяет:
@@ -926,9 +927,13 @@ build tooling исполняется на build-машине и порождае
- отключающей тесты переменной нет ни в одном модуле сборки, ни в README/docs
(место для истории — `CHANGELOG.md`);
- `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. Приёмка проверяет код, а не упоминания
+32 -6
View File
@@ -12,6 +12,17 @@
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 и
* типовые имена, под которыми секрет может появиться в будущем upstream-поле.
@@ -36,11 +47,14 @@ export const SECRET_KEY_MARKERS: readonly string[] = [
"authorization",
"cookie",
"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 {
const lowered = key.toLowerCase();
@@ -92,7 +106,9 @@ function redactValueDeep(value: unknown): unknown {
}
if (value && typeof value === "object") {
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);
}
return out;
@@ -138,8 +154,15 @@ function redactNode(value: unknown, path: readonly string[]): unknown {
export function redactKeyValueSecrets(content: string): string {
return content.replace(
/^([A-Z0-9_]*(PASSWORD|PASSWD|PASSPHRASE|PASS|SECRET|TOKEN|CREDENTIAL|APIKEY|AUTHORIZATION|COOKIE|BEARER|SIGNATURE)[A-Z0-9_]*=).*$/gm,
`$1${REDACTED}`
/^([A-Z0-9_]+)=(.*?)(\r?)$/gm,
(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,
(match, key: string, separator: string, rawValue: string) => {
const [value, trailing] = splitTrailingPunctuation(rawValue);
if (value === REDACTED || value.toLowerCase() === ENCODED_REDACTED.toLowerCase()) {
if (
value === REDACTED ||
value.toLowerCase() === ENCODED_REDACTED.toLowerCase()
) {
return match;
}
const rendered = separator === ":" ? ": " : "=";
+24 -3
View File
@@ -9,7 +9,11 @@ import { redactEnv } from "../src/lib/redaction";
const directories: string[] = [];
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> {
@@ -22,14 +26,31 @@ async function source(bytes: Uint8Array): Promise<string> {
describe("безопасная подготовка diagnostics", () => {
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);
expect(prepared).toContain("<redacted>");
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 () => {
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);
expect(prepared).toContain("некорректный UTF-8");
expect(prepared).not.toContain("SECRET=");
+63 -12
View File
@@ -6,7 +6,7 @@ import {
redactLogText,
redactYaml,
redactYamlFallback,
sanitizeUrlValue
sanitizeUrlValue,
} from "../src/lib/redaction";
const MACHINE_TOKEN = "hy2xs-machine-token-must-not-leak";
@@ -58,7 +58,10 @@ describe("редакция серверного конфига", () => {
});
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");
const auth = parsed.auth as Record<string, unknown>;
const http = auth.http as Record<string, unknown>;
@@ -69,7 +72,10 @@ describe("редакция серверного конфига", () => {
});
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>;
expect(acme.email).toBe("admin@example.com");
expect(acme.ca).toBe("letsencrypt");
@@ -92,7 +98,10 @@ describe("редакция серверного конфига", () => {
test("вложенное дерево под секретным ключом вырезается целиком", () => {
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 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>;
expect(cfg.api_token).toBe(REDACTED);
// acme.dns.config — карта, где секретны все значения, а не только ключ.
@@ -158,7 +167,7 @@ describe("редакция env-артефактов", () => {
"HY2_AUTH_MODE=http",
`HY2_AUTH_URL=http://127.0.0.1:8080/internal/hysteria/auth?access_token=${MACHINE_TOKEN}`,
`HY2_OBFS_PASSWORD=${OBFS_PASSWORD}`,
"HY2_PORT=443"
"HY2_PORT=443",
].join("\n");
// Регрессия: HY2_AUTH_URL не подходит ни под один key-маркер, но несёт
@@ -166,7 +175,9 @@ describe("редакция env-артефактов", () => {
test("секрет внутри URL-значения вырезается, даже если имя ключа несекретное", () => {
const redacted = redactEnv(POST_INSTALL);
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("ключи-секреты вырезаются по имени", () => {
@@ -180,13 +191,40 @@ describe("редакция env-артефактов", () => {
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-конфиг теряет все секреты", () => {
const runtime = [
"HY2XS_CONFIG_SCHEMA_VERSION=2",
"HY2XS_FORCE_PASSWORD_CHANGE=false",
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${MACHINE_TOKEN}`,
`HY2XS_HYSTERIA_OBFS_PASSWORD=${OBFS_PASSWORD}`,
"HY2XS_ADMIN_CON_PASS=connection-password",
"HY2XS_ADMIN_INITIAL_PASSWORD=initial-password"
"HY2XS_ADMIN_INITIAL_PASSWORD=initial-password",
].join("\n");
const redacted = redactEnv(runtime);
@@ -195,16 +233,21 @@ describe("редакция env-артефактов", () => {
expect(redacted).not.toContain("connection-password");
expect(redacted).not.toContain("initial-password");
expect(redacted).toContain("HY2XS_CONFIG_SCHEMA_VERSION=2");
expect(redacted).toContain("HY2XS_FORCE_PASSWORD_CHANGE=false");
});
});
describe("санитизация 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-параметры вырезаются, остальные сохраняются", () => {
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).toContain("page=2");
});
@@ -245,7 +288,7 @@ describe("редакция журналов в diagnostics-бандле", () =>
for (const line of [
`starting with access_token=${MACHINE_TOKEN}`,
`trafficStats secret=${MACHINE_TOKEN} rejected`,
`obfs password: ${OBFS_PASSWORD}`
`obfs password: ${OBFS_PASSWORD}`,
]) {
const out = redactLogText(line);
expect(out).not.toContain(MACHINE_TOKEN);
@@ -288,14 +331,22 @@ describe("маркеры секретных имён", () => {
"cookie",
"bearer",
"signature",
"passphrase"
"passphrase",
]) {
expect(isSecretKey(key)).toBe(true);
}
});
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);
}
});
+14 -9
View File
@@ -63,15 +63,16 @@ Windows и macOS можно использовать для редактиров
и сверяет каждый архив с контрольной суммой из `versions.env`.
6. Выполняет `verify_versions_contract`: рассинхрон версий роняет сборку до создания tarball.
7. Прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`).
8. Разрешает upstream-версию Hysteria, берёт ожидаемый SHA-256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт.
9. Проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS.
10. Копирует package skeleton.
11. Собирает install-only orchestrator в standalone binary.
12. Собирает frontend и backend HY2XS admin в Linux amd64 binary, проставляя версию админки через ldflags.
13. Прогоняет `go vet` и `go test` для HY2XS admin (после сборки frontend, потому что `go:embed all:dist` требует готовых ассетов).
14. Записывает metadata и checksums.
15. Создаёт `dist/hy2xs-install-<version>.tar.gz`.
16. Проверяет архив и прогоняет acceptance-проверки.
8. Рано прогоняет dependency-free контракты панели.
9. Разрешает upstream-версию Hysteria, берёт ожидаемый SHA-256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт.
10. Проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS.
11. Копирует package skeleton и собирает install-only orchestrator.
12. Устанавливает frozen frontend lock-граф, runtime-компилирует все сообщения RU/EN реальным `vue-i18n`, проверяет типы и собирает production bundle.
13. Собирает backend HY2XS admin в Linux amd64 binary, проставляя версию через ldflags.
14. Прогоняет `go vet` и `go test` для HY2XS admin (после сборки frontend, потому что `go:embed all:dist` требует готовых ассетов).
15. Проверяет зависимости через `govulncheck ./...` и `pnpm audit` по всему lock-графу.
16. Записывает metadata и checksums.
17. Создаёт архив и прогоняет его acceptance-проверки.
## Контракт версий
@@ -99,6 +100,10 @@ Go-коде больше нет: она уже успела разъехатьс
только политика `HYSTERIA_CHANNEL`, результат резолва — в
[`hysteria-lock.env`](hysteria-lock.env)).
Security overrides frontend находятся в `apps/frontend/pnpm-workspace.yaml`:
это канонический файл настроек pnpm. Поле `pnpm` в `package.json` для этой цели
не используется, потому что новые версии package manager его игнорируют.
## Версия Hysteria: разрешение и compatibility gate
Builder не хранит версию Hysteria вручную. По умолчанию он определяет последнюю стабильную версию сам и замораживает её в пакете.
+3 -3
View File
@@ -43,9 +43,9 @@ main() {
log_step "Checking orchestrator contracts"
run_orchestrator_tests
# Контракты панели проверяются рано: они не требуют ни собранного bundle, ни
# установленных зависимостей, и падать на них после резолва Hysteria и сборки
# артефактов означало бы платить минутами за ошибку, видимую сразу.
# Dependency-free контракты панели проверяются рано. Runtime-компиляция
# словарей запускается внутри bundle_ui сразу после frozen install, потому
# что ей нужен реальный vue-i18n из frontend lock-графа.
log_step "Checking HY2XS admin frontend contracts"
run_frontend_tests
+21 -2
View File
@@ -1345,14 +1345,20 @@ $piped_matcher"
|| fail "acceptance: прогон тестов оркестратора не фиксируется результатом"
grep -q 'ADMIN_TESTS_PASSED' tools/build/lib/package.sh \
|| 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 \
|| fail "acceptance: прогон контрактных тестов панели не фиксируется результатом"
|| fail "acceptance: общий результат тестов панели не фиксируется"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8");
for (const [fn, flag] of [
["run_orchestrator_tests()", "ORCHESTRATOR_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);
if (start < 0) throw new Error("не найдена функция " + fn);
@@ -1366,6 +1372,19 @@ $piped_matcher"
if (set < 0) 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: утверждение о пройденных тестах обязано следовать за прогоном"
# Детектор гонок — часть прогона админки, а не пожелание.
+19 -4
View File
@@ -64,7 +64,7 @@ run_orchestrator_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 \
|| fail "HY2XS admin frontend contract tests failed"
FRONTEND_TESTS_PASSED="true"
export FRONTEND_TESTS_PASSED
FRONTEND_CONTRACT_TESTS_PASSED="true"
export FRONTEND_CONTRACT_TESTS_PASSED
}
run_admin_tests() {
@@ -146,6 +146,9 @@ build_orchestrator() {
bundle_ui() {
local ui_src="${UI_SRC:-apps}"
[ "${FRONTEND_CONTRACT_TESTS_PASSED:-false}" = "true" ] \
|| fail "bundle_ui: ранние контракты панели не проверялись"
rm -rf "$ADMIN_BUILD_DIR"
mkdir -p "$ADMIN_BUILD_DIR"
@@ -164,6 +167,12 @@ bundle_ui() {
"$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, и это обязательный шаг релиза.
#
# Раньше её здесь не было вовсе: `build:prod` означал
@@ -176,7 +185,7 @@ bundle_ui() {
# кода, который не проходит проверку типов, нет.
"$PNPM_BIN" run typecheck
"$PNPM_BIN" run build:prod
)
) || fail "HY2XS admin frontend runtime tests or bundle failed"
# Версия админки приходит из versions.env через ldflags: один и тот же
# исполняемый файл обязан знать версию пакета, из которого он собран.
@@ -196,6 +205,12 @@ bundle_ui() {
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"
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() {
+201 -42
View File
@@ -17,6 +17,13 @@ import {
ADMIN_USERNAME_PATTERN,
isValidAdminPassword,
} 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 en from "../../apps/frontend/src/lang/package/en";
@@ -310,9 +317,9 @@ describe("страницы журнала", () => {
// Без width/min-width Element Plus делит ширину практически поровну, и
// сообщение — единственное содержимое журнала — получает треть строки.
const columns = [...viewer.matchAll(/<el-table-column[\s\S]*?(?:\/>|>)/g)].map(
(match) => match[0]
);
const columns = [
...viewer.matchAll(/<el-table-column[\s\S]*?(?:\/>|>)/g),
].map((match) => match[0]);
expect(columns.length).toBeGreaterThanOrEqual(3);
for (const column of columns) {
expect(column).toMatch(/\b(width|min-width)=/);
@@ -408,7 +415,9 @@ describe("действия над пиром", () => {
// вызов здесь больше не стоит, обязан называть его по имени — иначе он
// бесполезен. Это тот же урок, что записан в code_without_comments в
// acceptance.sh, и он повторился прямо на этой проверке.
const confirms = [...codeOf(peerList()).matchAll(/ElMessageBox\.confirm\(/g)];
const confirms = [
...codeOf(peerList()).matchAll(/ElMessageBox\.confirm\(/g),
];
// Единственный вызов — внутри confirmAction, который переводит отмену в
// обычное `false`.
expect(confirms.length).toBe(1);
@@ -523,7 +532,9 @@ describe("страница конфигурации Hysteria", () => {
// Сам модуль view-модели удалён вместе с дефолтами: пока он существует,
// его легко подключить обратно.
expect(
fs.existsSync(path.join(FRONTEND_SRC, "api", "config", "hysteriaViewModel.ts"))
fs.existsSync(
path.join(FRONTEND_SRC, "api", "config", "hysteriaViewModel.ts")
)
).toBe(false);
});
@@ -532,7 +543,12 @@ describe("страница конфигурации Hysteria", () => {
test("страница не изображает редактируемую форму", () => {
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);
}
});
@@ -606,7 +622,15 @@ describe("страница конфигурации Hysteria", () => {
expect(types).not.toMatch(/\bsecret\s*[?]?:/);
// Секции, которых production-профиль не описывает, в модели ответа не
// объявляются вовсе: они попадают в `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}:`);
}
});
@@ -651,10 +675,14 @@ describe("страница конфигурации Hysteria", () => {
// существуют, «универсальный редактор Hysteria» отрастает заново.
test("компоненты генерического редактора удалены", () => {
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(
fs.existsSync(path.join(FRONTEND_SRC, "views", "hysteria", "list", "components"))
fs.existsSync(
path.join(FRONTEND_SRC, "views", "hysteria", "list", "components")
)
).toBe(false);
});
});
@@ -701,7 +729,9 @@ describe("контракт учётных данных администрато
if (numeric) {
return numeric[1];
}
const backticked = source.match(new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``));
const backticked = source.match(
new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``)
);
if (backticked) {
return backticked[1];
}
@@ -716,22 +746,37 @@ describe("контракт учётных данных администрато
// принял бы, и оператор остаётся снаружи с паролем, который на самом деле
// подходит.
test("границы и набор символов совпадают с Go-контрактом", () => {
expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(goConst("AdminUsernameMinLength"));
expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(goConst("AdminUsernameMaxLength"));
expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(goConst("AdminPasswordMinLength"));
expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(goConst("AdminPasswordMaxLength"));
expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(
goConst("AdminUsernameMinLength")
);
expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(
goConst("AdminUsernameMaxLength")
);
expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(
goConst("AdminPasswordMinLength")
);
expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(
goConst("AdminPasswordMaxLength")
);
// Предел bcrypt — отдельная константа и отдельная сверка. Пока панель знала
// только границу в символах, она предлагала оператору назначить пароль из
// 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"));
const declared = goConst("adminUsernameCharacterClass");
const mirrored = ADMIN_USERNAME_PATTERN.source
.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);
});
@@ -781,9 +826,8 @@ describe("контракт учётных данных администрато
// Встроенных границ на пароле поэтому быть не должно вовсе: длина
// принадлежит правилу, а не соседям по объекту.
test("на пароле нет встроенных min/max от async-validator", async () => {
const { adminPasswordFormRule } = await import(
"../../apps/frontend/src/constants/credentials"
);
const { adminPasswordFormRule } =
await import("../../apps/frontend/src/constants/credentials");
// Правило одно на обе формы, и границ как СВОЙСТВ правила у него нет: длина
// живёт внутри валидатора, где её можно посчитать правильно.
@@ -810,8 +854,12 @@ describe("контракт учётных данных администрато
test("правило пароля считает code points и байты, а не UTF-16", () => {
// Проверка самой таблицы: если «я» перестанет быть двухбайтовой, случаи
// ниже проверяли бы не то, что написано.
expect(new TextEncoder().encode("я".repeat(36)).length).toBe(ADMIN_PASSWORD_MAX_BYTES);
expect(new TextEncoder().encode("😀".repeat(18)).length).toBe(ADMIN_PASSWORD_MAX_BYTES);
expect(new TextEncoder().encode("я".repeat(36)).length).toBe(
ADMIN_PASSWORD_MAX_BYTES
);
expect(new TextEncoder().encode("😀".repeat(18)).length).toBe(
ADMIN_PASSWORD_MAX_BYTES
);
for (const accepted of [
"a".repeat(ADMIN_PASSWORD_MIN_LENGTH),
@@ -854,7 +902,9 @@ describe("контракт учётных данных администрато
// Соседи запрещённых диапазонов обязаны проходить: правило описывает ровно
// множество systemd, а не окрестность подозрительных значений.
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 убраны: три эмодзи —
@@ -868,9 +918,8 @@ describe("контракт учётных данных администрато
// обязательно». Сервер ведёт себя так же (DTO приводит пустой пароль к «не
// задано» до проверки правил).
test("пустой пароль остаётся на правиле required", async () => {
const { adminPasswordFormRule } = await import(
"../../apps/frontend/src/constants/credentials"
);
const { adminPasswordFormRule } =
await import("../../apps/frontend/src/constants/credentials");
const rule = adminPasswordFormRule("format");
const errors: (Error | undefined)[] = [];
@@ -886,11 +935,15 @@ describe("контракт учётных данных администрато
test("на пароль не навешен набор символов", () => {
for (const form of [loginForm(), changePasswordForm()]) {
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(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}"]) {
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}"]) {
expect(passwordReason).toContain(placeholder);
}
@@ -944,11 +998,15 @@ describe("индикация ошибки на форме входа", () => {
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-стиля, — то есть выигрывает по специфичности.
expect(styles).toContain(":deep(.el-form-item__content .el-input__wrapper)");
expect(styles).toContain(
":deep(.el-form-item__content .el-input__wrapper)"
);
// Победа специфичностью, а не силой: `!important` перекрыл бы и те
// состояния, о которых правило ничего не знает.
@@ -966,27 +1024,128 @@ 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. Оператор не имел причин
// пробовать разрешённые символы.
test("подсказка имени описывает действующий набор символов", () => {
// Набор символов объявлен на сервере ровно один раз; подсказка обязана
// описывать именно его, а не набор из предыдущего поколения правила.
const rule = fs.readFileSync(
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("!@#");
expect(PEER_NAME_PUNCTUATION).toContain("!@#");
for (const dictionary of [ru, en] as const) {
const hint = (dictionary as any).peer?.form?.nameHint ?? "";
expect(hint).toContain(punctuation);
expect(hint).toContain("32");
expect(hint).toContain("{punctuation}");
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}"`);
}
});
});