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:
@@ -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`.
|
||||
@@ -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) |
|
||||
Reference in New Issue
Block a user