fix(admin): закрыть обещания панели, которые продукт не выполнял

Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.

Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».

Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.

Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.

Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.

Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.

Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.

docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
This commit is contained in:
2026-09-01 07:27:15 +05:00
parent a1f0db22c2
commit c0a43ae915
86 changed files with 6237 additions and 1819 deletions
@@ -0,0 +1,674 @@
# HY2XS 1.0.0-rc1 — отчёт build/host acceptance
**Дата прогона:** 2026-09-01
**Вердикт:** `RC ACCEPTED WITH RELEASE-REQUIRED UX FIXES`
Прогон выполнялся не как статический аудит исходного кода, а как фактическая
release/host acceptance RC-сборки: сборка артефакта, установка на
переустановленный Debian 13 и проверка работающего сервера.
Release candidate:
```text
Version: 1.0.0-rc1
Source commit: a1f0db22c2f6c0b0436789b64e82f53bfa314327
Artifact: hy2xs-install-1.0.0.tar.gz
SHA-256: 7fd18a34f56ebb7e9cf62f579e857d6ed3f6a9711075a17da22818a4b079683c
Target: Debian 13 / amd64
Hysteria: v2.12.2
Default obfs: gecko
Fallback obfs: salamander
```
> Публичный IPv4 тестового хоста в отчёте заменён на `198.51.100.10`
> (RFC 5737, документационный диапазон). Доменное имя и номер SSH-порта
> оставлены: без них шаги прогона невоспроизводимы.
---
## 1. Что проверялось
* воспроизводимость release build;
* соответствие version contract;
* тесты оркестратора;
* typecheck и сборка frontend;
* тесты Go;
* гейты уязвимостей зависимостей;
* разрешение и фиксация актуального stable Hysteria;
* upstream SHA-256 Hysteria;
* совместимость сгенерированной production-конфигурации с Gecko и Salamander;
* содержимое готового release archive;
* внутренние контрольные суммы пакета;
* clean-host boundary;
* read-only PHASE 0;
* отказ установки поверх HY2XS 0.x;
* фактическая чистая установка на переустановленный Debian 13;
* systemd;
* nftables takeover;
* firewall rollback guard;
* ACME;
* runtime Hysteria;
* runtime админки;
* install-state;
* `status`;
* read-only `doctor`;
* доступ к admin UI исключительно через SSH local forwarding;
* базовые функциональные операции admin UI.
Полный внешний Hysteria/Gecko dataplane через пользовательский desktop-клиент
сознательно отложен до готовности собственного C#/sing-box клиента HY2XS. Это
не подменяется server-side self-test — см. раздел 12.
---
## 2. Build acceptance
Финальная release-сборка выполнена из `a1f0db22c2f6c0b0436789b64e82f53bfa314327`.
Рабочее дерево перед сборкой было чистым.
Orchestrator:
```text
399 pass
0 fail
932 expect() calls
18 test files
```
Hysteria:
```text
Resolved stable: v2.12.2
Tag: app/v2.12.2
Published: 2026-08-23
```
Upstream SHA-256:
```text
6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94
```
Скачанный бинарь совпал с upstream `hashes.txt`.
Compatibility gate:
```text
Gecko PASS
Salamander PASS
```
Сгенерированная production-конфигурация HY2XS принята Hysteria `v2.12.2`.
Frontend:
```text
vue-tsc --noEmit PASS
vite production PASS
```
Go:
```text
go test PASS
```
Security:
```text
govulncheck v1.7.0 PASS
reachable vulns 0
pnpm audit high+ PASS
high/critical vulns 0
```
Полный release acceptance дошёл до:
```text
[hy2xs-build] Built dist/hy2xs-install-1.0.0.tar.gz
```
Проверки release pipeline включают в том числе read-only installer boundary,
семантику отката, firewall guard, сериализацию операций, транзакционный импорт
пиров, редактирование секретов, гигиену зависимостей и обязательность
test/security-гейтов.
### 2.1. Требование к памяти build-хоста
Первый `govulncheck` был убит Linux OOM killer на машине с:
```text
RAM: ~1.9 GiB
Swap: 0
```
После подключения временного swap 4 GiB полный security gate прошёл.
Это не runtime-дефект HY2XS, но требование к сборочной машине: около 2 GiB RAM
без swap может быть недостаточно для `govulncheck`. 4 GiB swap здесь — не
формально доказанный минимум, а подтверждённая рабочая конфигурация данного
прогона. См. [docs/build/02-build-layer-and-package.md](../build/02-build-layer-and-package.md).
---
## 3. Исправления release verifier, сделанные во время приёмки
Приёмка выявила несколько ошибок не продукта, а самого release verifier. Они
были исправлены до формирования принятого RC.
### 3.1. Ранний выход matcher'а и `pipefail`
Обнаружен антипаттерн вида `printf … | grep -q …` при `set -o pipefail`. На
достаточно большом выводе продюсера раннее завершение `grep -q` способно
привести продюсера к `SIGPIPE`, и статус всей конструкции становится 141 —
ненулевым именно тогда, когда совпадение НАЙДЕНО.
56 проверок переведены на форму без опасного pipeline.
Кроме того, исправлена более существенная проблема: прежнее
`2>/dev/null || true` превращало ошибку или опечатку в пути файла в пустой
ввод, а отрицательная проверка после этого получала ложный PASS. Теперь
отсутствие ожидаемого исходного файла — ошибка приёмки.
Примечание: единичное первоначальное падение на LICENSE нельзя доказанно
объяснить этим механизмом — размер LICENSE был ниже воспроизведённого порога
буфера канала. После исправлений содержимое LICENSE, его копия в архиве и
контрольные суммы подтверждены отдельно.
### 3.2. Проверки кода против комментариев
Выявлены три ложных совпадения: `virtual:svg-icons-register`, прежние имена
раннеров, `cancelFirewallRollback`. Все они находились в комментариях и прозе,
а приёмка трактовала присутствие строки как возвращение исполняемого кода.
Семантика гейтов исправлена: SVG проверяется по реальному runtime/build
contract; определение раннера учитывает форму идентификатора;
`cancelFirewallRollback` проверяется как declaration/call form, а не как любое
упоминание строки.
Наивный общий разбор `/* … */` намеренно не добавлен: неполный лексер может
удалить настоящее содержимое внутри строкового или регулярного литерала и
создать уже опасный ложный PASS.
### 3.3. Устаревший gate reconfigure
Приёмка ожидала прежний вызов `classifyReconfigureFailure(ownership)` после
того, как фактический контракт стал `classifyReconfigureFailure(ownership, error)`.
Новая архитектура:
```text
обычные ошибки -> классификация по ownership
FirewallGuardFired -> типизированное исключение
текст error.message -> не участвует
```
Gate приведён к фактическому контракту.
---
## 4. Artifact acceptance
Release archive `hy2xs-install-1.0.0.tar.gz`, SHA-256:
```text
7fd18a34f56ebb7e9cf62f579e857d6ed3f6a9711075a17da22818a4b079683c
```
Хеш независимо пересчитан после копирования архива на Windows и совпал с
серверным.
Metadata пакета:
```text
name=HY2XS
license=AGPL-3.0-only
version=1.0.0
release_line=1
config_schema_version=2
source_git_commit=a1f0db22c2f6
dirty_tree=false
build_profile=production
dependency_security_gate=true
tests_gate=true
hysteria_source=official-upstream
hysteria_version=v2.12.2
hysteria_sha_source=upstream-hashes
hysteria_channel=stable
hysteria_resolution=latest-stable
hysteria_compat_gate=true
```
Полный `sha256sum -c metadata/checksums.txt` для распакованного пакета
завершился без ошибок.
RC опубликован отдельным tag/release `v1.0.0-rc1`.
---
## 5. D0 — установка поверх legacy HY2XS
До переустановки ОС RC был запущен на действующем сервере HY2XS 0.x.
Ожидаемое поведение:
```text
PHASE 0
→ обнаружить legacy markers
→ завершиться до первой persistent mutation
```
Фактический результат: `RC=1`.
Installer обнаружил старые:
```text
/etc/hy2xs
/etc/hysteria
/var/lib/hy2xs
/var/lib/hy2xs-admin
/var/lib/hysteria
/usr/local/lib/hy2xs
/usr/local/bin/hysteria
/usr/local/bin/hy2xs-orchestrator
/etc/nftables.d/hy2xs.nft
systemd units
admin installation
```
и сообщил:
```text
HY2XS v1 не поддерживает установку поверх и не мигрирует состояние 0.x.
Ни один файл на сервере не изменён.
```
Контрольный before/after diff показал только изменение активной базы SQLite
legacy-админки. Отдельный idle-тест без installer подтвердил, что `h_ui.db`
сама меняет hash и mtime примерно каждые 20 секунд при работающем legacy
`hy2xs-admin`.
Следовательно:
```text
D0 legacy detection PASS
D0 fail-before-apply PASS
D0 zero product mutation PASS
```
---
## 6. Baseline чистого хоста
Для дальнейшей проверки ОС была переустановлена.
```text
Debian GNU/Linux 13
amd64
kernel 6.12.85+deb13-amd64
RAM ~1.9 GiB
Swap 0
```
Сеть:
```text
198.51.100.10/24
fi.api.withen.pro -> 198.51.100.10
```
До установки:
```text
HY2XS paths absent
Hysteria paths absent
HY2XS units absent
Hysteria unit absent
nft ruleset empty
UDP 443 free
TCP 80 free
TCP 443 free
```
Единственный ожидаемый внешний listener — SSH :2323.
Clean-host contract подтверждён фактическим составом хоста.
---
## 7. Чистая установка
Установка выполнялась непосредственно из ранее созданного и проверенного RC
artifact. Пакет не пересобирался на target-сервере.
Production profile:
```text
schema 2
domain fi.api.withen.pro
public host fi.api.withen.pro
public port 443
SSH 2323
firewall mode takeover
staged firewall true
admin bind 127.0.0.1
admin port 8080
admin public access false
TLS ACME
ACME challenge HTTP
ACME email admin@withen.pro
Hysteria port 443/udp
obfs gecko
IPv6 disabled
DNS AAAA policy strict
public endpoint policy strict
```
Результат: `INSTALL_RC=0`.
Фактически прошли:
```text
PHASE 0
package checksums
clean-host preflight
operation lock
orchestrator bootstrap
system dependencies
capability preflight
filesystem
runtime env
bundled admin
Hysteria download
Hysteria SHA verification
config generation
systemd installation
firewall staged apply
rollback guard
post-install env
bootstrap admin secret
smoke
firewall guard disarm
durable install commit
rollback cleanup
```
---
## 8. Firewall
До применения firewall создан rollback snapshot. Candidate-конфигурация
проверена до активации:
```text
nft -c -f hy2xs.nft.candidate
nft -c -f nftables.conf.candidate
```
После этого создан transient rollback timer:
```text
deadline: 45 s
AccuracySec: 1 s
RemainAfterElapse=no
```
Firewall применён только после успешного arm guard.
После smoke:
```text
guard disarmed
installed state durably committed
rollback files removed
```
Post-install:
```text
rollback_guard_active=false
rollback_guard_state=quiescent
```
Transient guard units отсутствуют. Candidate-файлы отсутствуют. В
`/run/hy2xs/rollback` остался только пустой родительский каталог.
Действующие правила:
```text
table inet hy2xs
input policy drop
allow loopback
allow established/related
allow TCP/2323 IPv4
allow TCP/80 IPv4
allow UDP/443 IPv4
allow ICMP echo-request
```
---
## 9. Состояние runtime
Hysteria:
```text
v2.12.2
active
enabled
UDP 0.0.0.0:443
trafficStats 127.0.0.1:36712
```
Админка:
```text
active
enabled
TCP 127.0.0.1:8080
```
nftables: `active`, `enabled`.
Let's Encrypt ACME:
```text
authorization valid
certificate obtained successfully
```
Install state:
```text
product=hy2xs
release_line=1
config_schema_version=2
product_version=1.0.0
installed=true
phase=installed
last_error=""
```
Секретные runtime-файлы имеют ограниченные permissions.
`/usr/local/bin/hy2xs-orchestrator` является symlink, конечный исполняемый файл
имеет `0755 root:root`.
---
## 10. `status` и `doctor`
С корректным runtime `--package-dir`:
```text
STATUS_RC=0
DOCTOR_RC=0
```
`status` подтвердил:
```text
services active
firewall valid
firewall entrypoint hy2xs-managed
install generation current
operation none
rollback guard quiescent
runtime state running
install state installed
```
`doctor` прошёл preflight и smoke.
Отдельно проверен read-only contract `doctor` — по фактическим PID процессов,
а не только статическим тестом:
```text
до doctor: hysteria PID = 5041, admin PID = 5042
после doctor: hysteria PID = 5041, admin PID = 5042
doctor Hysteria restart NO
doctor admin restart NO
```
Post-install состояние systemd/firewall/install-state соответствует ожидаемому
контракту.
---
## 11. Admin UI — ручная функциональная проверка
Доступ:
```text
SSH local forwarding
127.0.0.1:8080
```
Публичный admin listener отсутствует.
Проверены: вход, дашборд, список пиров, создание пира, генерация share URI.
Дашборд получает данные CPU/RAM/disk/runtime.
Создание пира фактически работает **при ручном указании секрета** — см. UX-02 в
[перечне дефектов](2026-09-01-v1.0.0-rc1-ux-findings.md).
Share URI формируется в ожидаемом production-формате:
```text
hysteria2://<peer-secret>@<host>:443/
?insecure=0
&obfs=gecko
&obfs-password=<server-obfs-secret>
&sni=<host>
#<peer-name>
```
Реальный секрет из тестовой URI в документацию не переносится.
**Незакрытое действие среды:** одна тестовая URI была выведена за пределы admin
UI, поэтому соответствующий тестовый пир перед дальнейшим использованием среды
следует удалить или пересоздать с новым секретом.
---
## 12. Отложенная проверка Gecko E2E
Полноценный внешний client E2E на этом прогоне не выполнялся.
Причина не в обнаруженном server-side дефекте. Целевой пользовательский клиент
HY2XS ещё разрабатывается:
```text
C#
sing-box core
HY2XS desktop shell
```
Практически пригодных сторонних клиентов с необходимой Gecko-поддержкой
недостаточно для того, чтобы считать их корректной reference implementation.
При этом уже подтверждено:
```text
Hysteria v2.12.2 Gecko config compatibility PASS
Hysteria v2.12.2 Salamander compatibility PASS
server startup PASS
ACME PASS
UDP/443 listener PASS
peer auth/control-plane smoke PASS
share URI generator PASS
```
Статус внешнего Gecko E2E:
```text
DEFERRED — WAITING FOR HY2XS DESKTOP CLIENT
```
Он обязателен для окончательной ecosystem acceptance «server + client», но
отсутствие стороннего Gecko-клиента не следует трактовать как отказ текущего
server/orchestrator/admin RC.
---
## 13. Итоговый статус прогона
```text
SOURCE AUDIT PASS
BUILD PASS
TEST GATE PASS
SECURITY GATE PASS
ARTIFACT INTEGRITY PASS
LEGACY D0 PASS
CLEAN HOST PASS
FRESH INSTALL PASS
ACME PASS
SYSTEMD PASS
NFTABLES PASS
FIREWALL ROLLBACK GUARD PASS
INSTALL STATE PASS
STATUS PASS
DOCTOR PASS
DOCTOR READ-ONLY PASS
ADMIN LOGIN PASS
ADMIN DASHBOARD PASS
PEER CREATE PARTIAL / UX DEFECT
SHARE URI GENERATION PASS
EXTERNAL GECKO CLIENT E2E DEFERRED
EXTERNAL SALAMANDER E2E DEFERRED
FINAL v1.0.0 NOT YET ACCEPTED
```
`v1.0.0-rc1` сохраняется как успешно прошедший server/install RC.
Что требуется до финального `v1.0.0` — см.
[перечень дефектов и план закрытия](2026-09-01-v1.0.0-rc1-ux-findings.md).
---
## 14. Замечания по шуму в логах прогона
Две записи в журнале прогона к HY2XS отношения не имеют:
* `ystemctl` — опечатка в shell;
* пустой `journalctl -u hysteria-server --since '-2 min'` — ожидаемо, поскольку
реального внешнего клиента в этот момент не подключали.
@@ -0,0 +1,285 @@
# 1.0.0-rc1 — дефекты приёмки и их закрытие
Относится к прогону
[2026-09-01, `v1.0.0-rc1`](2026-09-01-v1.0.0-rc1-host-acceptance.md).
Ни один из перечисленных дефектов не является P0 safety blocker и не
дискредитирует пройденную server acceptance. Все они заметно ухудшают работу
оператора и закрыты до финального `v1.0.0`.
Раздел «Найдено сверх отчёта» описывает дефекты того же класса, обнаруженные
при разборе корневых причин: искали причину одного отказа — нашли механизм,
порождавший несколько.
## Сводка
| ID | Дефект | Приоритет | Статус |
| --- | --- | --- | --- |
| UX-01 | Некорректный цвет SVG-иконок | P1 | закрыт |
| UX-02 | Необязательный секрет пира фактически обязателен | P1 | закрыт |
| UX-03 | Сообщение `Invalid` неинформативно | P1 | закрыт |
| UX-04 | Плейсхолдеры слишком персонализированы | P2 | закрыт |
| UX-05 | Нет атрибуции Flamy в боковом меню | P1 | закрыт |
| EX-01 | Фильтр списка пиров ломается после очистки | P1 | закрыт |
| EX-02 | Правила имени пира противоречили друг другу | P1 | закрыт |
| EX-03 | Набор символов имени пира допускал `/ : ; . ,` | P1 | закрыт |
| EX-04 | Истечение сессии не обрабатывалось | P1 | закрыт |
| EX-05 | `id` требовался и в пути, и в теле запроса | P2 | закрыт |
| EX-06 | Обработчик транспортных ошибок падал сам | P2 | закрыт |
---
## UX-01 — некорректный цвет SVG-иконок
**Наблюдалось:** иконки логина и бокового меню отображались почти чёрными и не
соответствовали теме.
**Корневая причина.** Контракт `currentColor` в панели УЖЕ существовал —
`fill: currentcolor` объявлен и в `SvgIcon/index.vue`, и в `styles/sidebar.scss`.
Он не действовал, потому что восемь из семнадцати ассетов несли литеральный
атрибут `fill="#000000"` прямо на `<path>`, а атрибут представления перебивает
унаследованное CSS-свойство. Под это попали ВСЕ семь иконок бокового меню
(`report`, `users`, `hysteria`, `setting`, `error`, `log-system`,
`log-hysteria`) на фоне `--menuBg: #181818`, а также `user` на форме входа.
Соседняя `password` литерального цвета не несёт и рисовалась белой — отсюда и
ощущение, что иконки не соответствуют друг другу.
Ни одна существующая проверка этого не видела: гейт приёмки проверял у ассетов
только наличие системы координат.
**Как закрыто.**
1. Литеральный цвет убран из монохромных ассетов: они несут `fill="currentColor"`.
2. Многоцветные ассеты (`download`, `upload`) объявлены явным списком
`MULTICOLOR_ICONS` и под проверку цвета не попадают — их палитра является
частью ассета.
3. Преобразование файла в `<symbol>` и контракт ассета вынесены в чистый модуль
`SvgIcon/symbol.ts`: без Vite и DOM, поэтому проверяются тестом и гейтом, а
не только глазами на живой странице.
4. У `SvgIcon` убран проп `color` и атрибут `fill` на `<use>` — он приглашал
чинить цвет точечно в обход общего контракта.
5. Цвета в рантайме НЕ переписываются: источник истины — файл. Молчаливая
нормализация скрывала бы ровно тот дефект, который контракт обязан делать
видимым.
**Чем закреплено:** `tools/test/frontend-sprite.test.ts` (контракт всех
ассетов, обе ветки нормализации, наличие обеих половин контракта — ассета и
CSS, запрет CSS-фильтров и селекторов по имени иконки) и соответствующие гейты
приёмки в `tools/build/lib/acceptance.sh`.
**Что проверяется вручную** (машина этого не докажет): фактический цвет на
светлой и тёмной теме, в состояниях hover и active, в свёрнутом меню.
---
## UX-02 — необязательный секрет фактически обязателен
**Наблюдалось:** подпись под полем обещает «оставьте пустым — сгенерируем
автоматически», пустое поле блокирует создание пира и выдаёт `Invalid`.
**Корневая причина.** Не отсутствие автогенерации: `service.CreatePeer` умел
генерировать секрет и делал это. Запрос до неё не доходил.
В `go-playground/validator` тег `omitempty` НЕ пропускает правило, если поле
объявлено указателем и указатель не nil. Помощник `hasValue` (`baked_in.go`):
```go
if fl.(*validate).fldIsPointer && getValue(field) != nil {
return true
}
```
Для `*string`, указывающего на пустую строку, это возвращает «значение есть».
Панель отправляет `secret: ""`, правило `min=6` применяется к пустой строке и
отказывает.
**Как закрыто.** Не тегом на одном поле, а механизмом: между разбором тела и
проверкой правил добавлен шаг нормализации DTO (`dto.Normalizable`). Он
приводит «поле отсутствует», «пустая строка» и «одни пробелы» к одному
состоянию для тех полей, где отсутствие значения законно.
Граница проходит по каждому полю ОТДЕЛЬНО и это существенно: у `remark` пустая
строка означает «убрать пометку», у `disabled` ноль означает «включён», у
`quotaBytes` ноль — нулевую квоту. Общее правило «пусто → не задано» молча
сломало бы все три.
Генерация названа явным шагом сервисного слоя — `service.GeneratePeerSecret` на
базе `util.RandomString` (`crypto/rand` с отбрасыванием смещённых байтов). Тот
же вызов используется импортом: пир, созданный формой, и пир, импортированный
без секрета, теперь неотличимы.
**Чем закреплено:** матрица «отсутствует / пусто / пробелы / перевод строки →
генерируется», границы `5 → отказ, 6 → приём, 128 → приём, 129 → отказ`,
неповторяемость сгенерированных секретов и — главное — проверка того, что
сгенерированный секрет НЕМЕДЛЕННО аутентифицирует пира через
`service.Hysteria2Auth`. То, что секрет записан, ничего не значит, пока по нему
не проходит доступ.
---
## UX-03 — сообщение `Invalid` неинформативно
**Корневая причина.** `validateField` схлопывал любую ошибку любого поля в
`constant.InvalidError = "invalid"`, а `vo.Fail` определял HTTP-семантику
СРАВНЕНИЕМ текста сообщения с тремя известными литералами — тот же антипаттерн,
который запрещён панели, только на сервере.
**Как закрыто.**
* Ответ об ошибке несёт `errors: [{code, field, message, params}]`.
* Отказ разбора тела (`body_invalid`) отделён от нарушения правила.
* Коды правил различают границы числа и границы длины строки
(`min` / `min_length`): оператору это разные фразы.
* Доменные отказы получили коды: `peer_name_taken`, `peer_name_reserved`,
`peer_bootstrap_identity_locked`, `invalid_credentials`.
* `vo` больше не выводит код из текста — код передаётся аргументом.
* Панель выбирает локализованную фразу ПО КОДУ и подставляет причины под
соответствующие поля формы; текст сервера остаётся ответом для клиента без UI
и запасным вариантом для неизвестного кода.
* Числа правил приходят в `params`, поэтому второй копии границ в панели нет.
Отдельно: отказ входа кодируется как `invalid_credentials`, но НЕ уточняется —
«такого администратора нет» и «пароль не тот» остаются неразличимы снаружи,
иначе форма входа становится способом проверять существование имён. Отказ базы
при этом остаётся системной ошибкой: выдавать «неверный логин или пароль» при
недоступной SQLite значит отправить оператора искать несуществующую опечатку.
---
## UX-04 — плейсхолдеры слишком персонализированы
Заменено на нейтральный компактный baseline:
| Поле | Было | Стало |
| --- | --- | --- |
| Имя | `например, ivan-laptop` | `client-01` |
| Комментарий | `например, Ноутбук Ивана, отдел продаж` | `ноутбук` |
Префикс «например,» убран: плейсхолдер и так является примером. Подсказки под
полями остались подробными; подсказка имени теперь называет фактические границы
(6-32 символа).
---
## UX-05 — атрибуция Flamy в боковом меню
Внизу бокового меню добавлена подпись «Разработано во **Flamy**», где `Flamy`
ссылка на `https://flamy.studio` фирменным цветом, с
`target="_blank"` и `rel="noopener noreferrer"`.
Адрес объявлен ОДИН раз в `apps/frontend/src/constants/branding.ts` и
принадлежит приложению: он не читается ни из `hy2xs.env`, ни из config API, ни
из таблицы `config`, ни из настроек панели. Оператор HY2XS не должен иметь
возможности переназначить, куда ведёт подпись разработчика.
Вёрстка: высота области прокрутки меню вычитает `$sidebarFooterHeight`, поэтому
пункты меню не могут наехать на подпись даже при длинном списке — им физически
некуда. В свёрнутом меню (54 px) остаётся только имя-ссылка; на узком экране
меню уходит в off-canvas на полную ширину.
**Чем закреплено:** `tools/test/frontend-contract.test.ts` — единственность
адреса в исходниках панели, отсутствие его в операторских поверхностях, наличие
футера в меню, учёт его высоты, атрибуты безопасности внешней ссылки. Плюс
гейты приёмки.
---
# Найдено сверх отчёта
## EX-01 — фильтр списка пиров ломался после очистки
`el-input` с крестиком очистки ставит пустую строку, axios сериализует её как
`?name=`, и та же ловушка `omitempty` на указателе (см. UX-02) отказывала
поиску пиров с `invalid`. Список пиров ломался в один клик по крестику.
Закрыто тем же механизмом нормализации; закреплено регрессионным тестом.
## EX-02 — правила имени пира противоречили друг другу
На поле стояли `min=1,max=32` И `validateStr`, требовавший 6-32 символа. Имя из
трёх символов проходило одно правило и отказывалось на другом, а оператор видел
`invalid` рядом с подсказкой «короткий идентификатор пира».
Длина перенесена внутрь одного правила `peerName`. Действующая граница — 6-32,
то есть та, которая уже была задокументирована и закреплена тестами импорта.
## EX-03 — набор символов имени пира допускал `/ : ; . ,`
Слой контроллеров нёс собственную копию правила:
```text
^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$
```
с комментарием «тот же набор символов, что и у импорта». Набор был другим:
дефис внутри класса не экранирован, поэтому `+-=` образует ДИАПАЗОН и впускает
`, - . / 0-9 : ; < =`. Через панель проходило имя `peer/name`, которое импорт
того же пира отклонял, — при том что имя пира уезжает во fragment клиентской
ссылки и в автогенерируемый секрет.
Правило объявлено один раз (`service.IsValidPeerName`) и используется обеими
дверями в таблицу пиров.
Набор символов ЛОГИНА администратора при этом сознательно НЕ сужен: он
записан явно, но повторяет прежнее фактическое множество. Имя администратора
приходит из `HY2XS_ADMIN_USER`, оркестратор его не ограничивает, и сужение
правила означало бы, что установка с логином вроде `admin.ops` перестаёт
пускать оператора в панель. Это закреплено отдельным тестом, чтобы попытка
«навести порядок» роняла сборку, а не вход на живом сервере.
## EX-04 — истечение сессии не обрабатывалось
Ветка «сессия истекла, войдите заново» была недостижима в двух местах сразу.
Сервер отвечает HTTP 200 на любой отказ, поэтому обработчик ошибок axios для
отказов API не вызывался вовсе — а ветка сессии жила именно там. Условие в ней
проверяло `code === "A0230"` и поле `msg`, которых в этом API никогда не было.
Ключ локализации `common.sessionExpired` существовал и был мёртвым.
Вдобавок истёкший токен уезжал с кодом системной ошибки: `vo` не узнавал
`token expired` среди трёх известных строк.
Закрыто: `ParseToken` возвращает объявленные значения ошибок вместо свежих
строк, middleware различает истечение и недействительность через `errors.Is`,
ответ несёт код `session_expired`, панель показывает диалог и возвращает на
форму входа. Диалог показывается один раз, даже когда истёкший токен уронил
несколько параллельных запросов страницы.
Побочно: сброс сессии больше не зовёт `localStorage.clear()`, который заодно
стирал выбранный оператором язык панели.
## EX-05 — `id` требовался и в пути, и в теле
`PeerUpdateDto` встраивал `IdDto` с правилом `required`, поэтому тело запроса
обязано было повторять идентификатор из адреса. Панель его повторяла, поэтому
расхождение не проявлялось; любой другой клиент, сделавший `PATCH /peers/7` без
`"id": 7` в теле, получал отказ — при том что значение из тела всё равно
затирается значением из пути.
Заодно убрана недостижимая запасная ветка `resolveID`, читавшая идентификатор
из тела: она вызывала разбор тела, которое обработчик читает следом второй раз,
а gin его не буферизует. То есть запасной путь не сработал бы ровно тогда,
когда понадобился бы.
## EX-06 — обработчик транспортных ошибок падал сам
Обработчик читал `error.response.data`, не проверив `error.response`. При
обрыве соединения или таймауте он падал с `TypeError` и подменял настоящую
причину отказом внутри себя. Теперь сетевой отказ отличается от отказа сервера
и сообщается отдельной фразой.
---
## Что осталось сделать до финального v1.0.0
1. пересобрать `rc2` и повторить build/security acceptance;
2. установить `rc2` на тестовый хост;
3. проверить визуально: цвет иконок в обеих темах, hover/active, свёрнутое меню,
подпись Flamy на узком экране;
4. проверить создание пира с пустым секретом и работоспособность его share URI
на живом сервере;
5. выполнить reconfigure/fault matrix D1-D1h на `rc2`;
6. выполнить reboot acceptance;
7. отдельно проверить Salamander fallback;
8. после готовности HY2XS Desktop — внешний Gecko E2E;
9. удалить или пересоздать тестовый пир, чья URI была выведена за пределы
admin UI во время прогона `rc1`.
+33
View File
@@ -0,0 +1,33 @@
# Реестр прогонов приёмки
Здесь лежат отчёты о ФАКТИЧЕСКИХ прогонах приёмки: какая сборка, на каком
хосте, что прошло и что нет. Описание самих проверок — в
[docs/testing/](../testing/README.md).
Разделение намеренное. Документ проверок переживает релизы и правится по мере
развития продукта; отчёт о прогоне относится к одному артефакту и одному хосту
и после публикации релиза не редактируется — иначе он перестаёт быть
свидетельством.
## Правила ведения
1. Один прогон — один файл `ГГГГ-ММ-ДД-<версия>-<вид>.md`.
2. Отчёт фиксирует только то, что действительно выполнялось. Отложенная
проверка отмечается как `DEFERRED` с причиной, а не опускается.
3. Реальные секреты, ключи и клиентские ссылки в отчёт не переносятся.
Публичные IP-адреса тестовых хостов обезличиваются; доменное имя и номер
порта SSH остаются, потому что без них шаги прогона невоспроизводимы.
4. Найденные дефекты живут в отдельном файле рядом с отчётом и закрываются
ссылками на коммиты, а не правкой самого отчёта.
## Прогоны
| Дата | Версия | Коммит источника | Вид | Вердикт |
| --- | --- | --- | --- | --- |
| 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` | [UX-01…UX-05 и найденное сверх отчёта](2026-09-01-v1.0.0-rc1-ux-findings.md) |