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,518 @@
# Operations and troubleshooting
## Цель документа
Зафиксировать минимальный operational контур после установки.
## Что должен помнить оператор
### 0. Target prerequisites обязательны
На target-хосте install-flow сам обеспечивает системные зависимости (deps stage):
```bash
apt-get update
apt-get install -y sudo ca-certificates curl iproute2 tar openssl nftables systemd
```
`sudo` обязателен для smoke-проверок прав от имени runtime-пользователей (`hysteria`, `hy2xs-admin`), но его не нужно ставить вручную заранее: на clean-host он устанавливается на deps-стадии до smoke.
Важно: packaged baseline использует `HY2XS_SSH_PORT=2323` по умолчанию. На target-хосте это значение обязательно нужно привести к фактическому рабочему SSH-порту оператора в `/etc/hy2xs/hy2xs.env` и применить через `reconfigure --apply`.
### 1. Builder и target — разные миры
Если нужно изменить состав install package, это делается в локальном builder layer, а не на target server.
### 2. UI приезжает из нашего пакета
Если проблема в UI, сначала смотреть:
- какой `HY2XS_ADMIN_SOURCE`
- какой `HY2XS_ADMIN_BUILD_ID`
- тот ли пакет вообще стоит на сервере
### 3. Hysteria приходит из upstream
Если проблема в ядре Hysteria, сначала смотреть:
- какую фактическую версию оркестратор установил
- что записано в `HY2_VERSION`
- не связано ли поведение со свежим upstream release
- какая версия Hysteria зафиксирована в metadata установленного пакета
Для обновления бинарника Hysteria2 используйте новый release install package.
Изменение runtime env не обновляет бинарник Hysteria2.
### 4. Оркестратор — Bun/TypeScript, но target не билдит его
Если проблема в install flow, сначала смотреть:
- какой `ORCH_BUILD_ID`
- какой `ORCH_ENTRYPOINT`
- не подменён ли install package вручную
## Базовые команды проверки
Проверка сервисов:
```bash
systemctl status hysteria-server
systemctl status hy2xs-admin
```
Проверка порта:
```bash
ss -uln
```
Проверка firewall:
```bash
nft list ruleset
```
Проверка `post-install.env`:
```bash
cat /etc/hysteria/post-install.env
```
Проверка логов через journald:
```bash
journalctl -u hysteria-server.service -n 100 --no-pager
journalctl -u hy2xs-admin.service -n 100 --no-pager
```
Источник логов в UI:
- Страница «Логи Hysteria» читает записи из journald unit `hysteria-server.service`.
- Экспорт «Логи Hysteria» также формируется из journald (`journalctl`), а не из отдельного файла `hysteria2.log`.
Проверка install-state marker:
```bash
cat /var/lib/hy2xs/install-state.json
```
Если `reconfigure` сообщает об отсутствии marker, нужно повторно выполнить чистый install и только потом применять runtime-изменения.
## Auth endpoint fail checklist
```bash
systemctl status hysteria-server
systemctl status hy2xs-admin
sudo -u hy2xs-admin test -r /etc/hysteria/config.yaml
curl -sS -X POST \
-H 'Content-Type: application/json' \
--data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' \
http://127.0.0.1:8080/internal/hysteria/auth
curl -sS \
-H "Authorization: <trafficStatsSecret>" \
http://127.0.0.1:36712/online
```
## Типовые проблемы
### Установка отказывается: обнаружена предыдущая установка
Отказ происходит в **PHASE 0**, до любой мутации. Сервер остался в том
состоянии, в котором был: ни `/usr/local/lib/hy2xs`, ни
`/var/lib/hy2xs/install-state.json`, ни работающие службы не тронуты.
В тексте отказа перечислены конкретные найденные маркеры. Порядок действий —
[14-legacy-cleanup.md](14-legacy-cleanup.md): сохранить данные, посмотреть план
`tools/legacy/purge-v0.sh`, выполнить очистку, установить заново.
Проверить хост, ничего не устанавливая:
```bash
./orchestrator/hy2xs-orchestrator preflight-install --package-dir "$(pwd)"
```
### `reconfigure`/`repair` отказываются: маркер чужого поколения
```text
Маркер установки /var/lib/hy2xs/install-state.json не относится к текущему
поколению HY2XS.
```
`installed: true` сам по себе ничего не доказывает: такой же маркер мог
остаться от `0.x`. Обе команды проверяют `product`, `release_line` и
`config_schema_version`.
Посмотреть, что видит оркестратор:
```bash
hy2xs-orchestrator status --package-dir /usr/local/lib/hy2xs/package \
| grep -o '"install_state_generation":"[^"]*"'
```
`"current"` — маркер текущего поколения; `"foreign"` — требуется чистая
переустановка; `"absent"` — установки нет.
### Незавершённая установка текущего поколения
Если установка упала **после** начала применения изменений, полная очистка не
нужна:
```bash
hy2xs-orchestrator repair \
--package-dir /usr/local/lib/hy2xs/package \
--config /etc/hy2xs/hy2xs.env \
--allow-partial-state
```
Флаг обязателен и осознан: без него `repair` работает только поверх полностью
успешной установки.
### Операция отказывает: уже выполняется другая
```text
another HY2XS operation is already in progress: reconfigure (pid 4242, started at …)
```
`install`, `reconfigure`, `repair` и `doctor` сериализованы замком
`/run/lock/hy2xs-orchestrator.lock`. Это не перестраховка: production paths —
`/etc/hysteria/config.yaml`, unit-файлы, `/etc/nftables.conf`,
`/var/lib/hy2xs/install-state.json` — общие, и две одновременные операции
записывают их поверх друг друга, после чего откат одной «восстанавливает»
состояние поверх изменений другой.
Отказ происходит **до** первой мутации, поэтому сервер не тронут. Что делать:
```bash
# кто держит замок
cat /run/lock/hy2xs-orchestrator.lock
# что делает держатель
ps -o pid,etime,cmd -p "$(sed -n 's/.*"pid": *\([0-9]*\).*/\1/p' /run/lock/hy2xs-orchestrator.lock)"
```
Дождитесь завершения. Замок снимается сам при любом завершении держателя,
включая `Ctrl+C`, SIGTERM и обрыв SSH, а мёртвого держателя следующая операция
обнаруживает и переиспользует замок самостоятельно.
Удалять файл руками нужно ровно в одном случае — если оркестратор сообщил, что
содержимое замка не является корректной записью:
```text
operation lock … exists but is not a valid HY2XS lock record
```
Такой замок сознательно не снимается автоматически: непонятый файл не
доказывает, что операции нет.
`status` и `diagnostics collect` замок не берут и работают во время операции.
В отчёте `status` при этом появляется поле `operation_in_progress` — читайте
состояние как снимок незавершённой транзакции, а не как итог.
### Операция отказывает: guard предыдущей операции ещё вооружён
```text
previous HY2XS operation is no longer running, but its firewall rollback guard
is still armed: hy2xs-fw-rollback-<op-id>.timer (active/waiting)
```
Предыдущая операция умерла аварийно **после** применения firewall. Её процесса
уже нет — замка может не быть тоже, — но rollback guard это отдельный объект
systemd, и он переживает свой процесс. Если начать новую операцию сейчас, guard
сработает посреди неё и вернёт firewall, существовавший до **предыдущей**
операции.
Ничего делать не нужно, кроме как подождать: окно guard — 45 секунд с момента
применения firewall плюс точность таймера (`AccuracySec=1s`), то есть не больше
46 секунд.
```bash
# сколько ещё ждать и что именно висит
systemctl list-units --all --plain 'hy2xs-fw-rollback-*'
hy2xs-orchestrator status --package-dir /usr/local/lib/hy2xs/package
```
`--plain` здесь не для красоты: у юнита в состоянии `failed` systemctl печатает
первой колонкой маркер `●`, и без флага его легко не заметить в списке.
Когда guard сработает, отработавший таймер выгрузится (`RemainAfterElapse=no`),
и операция пройдёт. Состояние `failed` у сервиса покою не мешает: оно означает,
что откат отработал не полностью, и это как раз повод запустить `repair`, а не
ждать дальше — подробности в `journalctl -u 'hy2xs-fw-rollback-*'`. Такой
`failed`-юнит остаётся загруженным до `systemctl reset-failed`, но операцию не
блокирует.
### Операция отказывает: состояние guard'а не удалось выяснить
```text
unable to verify firewall rollback guard state; systemd query failed,
refusing to start a lifecycle operation: systemctl list-units failed: …
```
Это **не** «guard вооружён», и ждать здесь нечего. Барьер обязан доказать, что у
предыдущей операции не осталось исполнителей, способных изменить firewall;
`systemctl` не ответил, доказательства нет, и операция отказывает до первой
мутации.
Отсутствие ответа не равно отсутствию guard'а: systemd мог быть жив, а взведённый
таймер — существовать. Разрешить операцию в этой ситуации означало бы допустить
срабатывание старого таймера поверх новой операции.
```bash
systemctl status
systemctl list-units --all --plain 'hy2xs-fw-rollback-*'
journalctl -u 'hy2xs-fw-rollback-*' --no-pager
```
Разбирайтесь с systemd/D-Bus и повторяйте операцию. Отдельно ускорять ничего не
нужно: `systemd-run` требуется в preflight, поэтому без работающего systemd
операция всё равно не прошла бы — барьер лишь сообщает об этом раньше и точнее.
### Установка отказала с `firewall_guard_fired`
```text
automatic firewall rollback has already fired
phase: firewall_guard_fired
```
Это означает: автоматический откат firewall сработал раньше, чем операция успела
снять guard. Сервер жив и доступен, но работает на **прежнем** firewall, а не на
том, который сгенерировала операция. Именно поэтому фиксация успеха запрещена,
даже если smoke успел сойтись, — иначе сервер считался бы настроенным с чужими
правилами, что особенно дорого при смене порта Hysteria, SSH или ACME.
Окно guard — 45 секунд (плюс точность таймера, `AccuracySec=1s`), и оно не
обязано покрывать smoke. Причину ищите в том, почему проход в него не уложился:
```bash
journalctl -u 'hy2xs-fw-rollback-*' --no-pager
journalctl -u hysteria-server -u hy2xs-admin --since '-10 min' --no-pager
```
Обычная причина — медленный старт одного из сервисов. После устранения
root-cause операция запускается заново; откат уже вернул сервер в исходное
состояние.
### Сервер установился, но UI не работает
Проверить:
- разложился ли bundled UI
- корректен ли unit `hy2xs-admin`
- совпадает ли `HY2XS_ADMIN_INSTALL_DIR` с реальностью
- не сломан ли bind host / port
### Admin UI access via SSH tunnel
Production-модель для UI: `HY2XS_UI_BIND_HOST=127.0.0.1`, внешний доступ к `8080/tcp` не открывается.
Доступ оператора выполняется через SSH local forwarding.
Windows-команда туннеля:
```bash
ssh -p 2323 \
-i C:\Users\kirap\.ssh\id_ed25519_uk1 \
-N \
-L 127.0.0.1:8080:127.0.0.1:8080 \
root@185.156.108.141
```
После запуска открыть `http://127.0.0.1:8080/#/login`.
Если SSH-туннель не поднимается (`administratively prohibited`), проверить effective SSH policy:
```bash
sshd -T | grep -E '^(port|allowtcpforwarding|permitopen|gatewayports|passwordauthentication|permitrootlogin) '
```
Рекомендуемый фрагмент hardening `sshd_config`:
```sshconfig
Port 2323
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
AllowTcpForwarding local
PermitOpen 127.0.0.1:8080 localhost:8080
GatewayPorts no
X11Forwarding no
AllowAgentForwarding no
MaxAuthTries 3
LoginGraceTime 20
ClientAliveInterval 300
ClientAliveCountMax 2
```
### Hysteria скачалась, но не стартует
Проверить:
- валиден ли config
- совпадают ли listen port и firewall rule
- домен / SNI / TLS policy
- реальную установленную версию Hysteria
### Тестовый клиент не подключается
Проверить:
- `server_name`
- порт
- `obfs.password`
- auth material
- что используется совместимый клиентский конфиг
### Install/reconfigure падает на DNS AAAA
Проверить значение `HY2XS_DNS_AAAA_POLICY` в `/etc/hy2xs/hy2xs.env`:
- `strict` (default): AAAA приводит к fail в IPv4-only профиле;
- `warn`: warning + продолжение;
- `off`: AAAA-check отключён.
Для production baseline рекомендуется `strict`.
### `DNS IPv4 mismatch`: DNS ведёт не на этот сервер
Сообщение выглядит так:
```text
DNS IPv4 mismatch for HY2XS_PUBLIC_HOST fi.api.withen.pro:
DNS A records: 185.xxx.xxx.10
server public IPv4: 185.xxx.xxx.27
Update the DNS A record before using this server.
```
Это не ложное срабатывание, а именно то, ради чего проверка сделана: сервисы на
машине живы, но публичный endpoint ведёт куда-то ещё. Чаще всего — после
принудительной смены IPv4 провайдером.
Что делать:
1. сверить фактический адрес сервера: `ip -4 addr show scope global`;
2. обновить A-запись у DNS-провайдера;
3. дождаться истечения TTL;
4. повторить `hy2xs-orchestrator doctor`.
`doctor` безопасно запускать на работающем сервере: он не перезапускает
сервисы и живые соединения не рвёт. Раньше это было не так — команда звала
общий smoke, который начинается с `systemctl restart hysteria-server
hy2xs-admin`, и диагностика подозрения на проблему сама создавала обрыв у всех
подключённых клиентов.
Безопасность здесь — инвариант рантайма, а не свойство текущего кода. `doctor`
целиком выполняется под тем же read-only guard, что и PHASE 0 установки: любая
запись в файл и любой мутирующий вызов под ним отказывают. Раньше от рестарта
защищал один принудительный флаг, а остальные проверки smoke — чтение прав,
владельцев и синтаксиса `nftables` — выполнялись мутирующими раннерами, поэтому
настоящая мутация, случайно добавленная в smoke, была бы разрешена молча.
При этом диагностика не сужается: слушатели, `healthz`, права на файлы, machine
auth, `trafficStats`, версия бинаря, семантика `/etc/hysteria/config.yaml` и
синтаксис `nft` проверяются полностью.
### Где проходит граница read-only
Guard действует внутри процесса оркестратора. Он не способен запретить
побочный эффект, который вызвал бы HTTP-запрос в **другом** процессе, поэтому
эта половина границы держится не им, а составом проб.
Существенный случай — machine-auth. Успешная авторизация пира заставляет админку
выполнить `UPDATE peer.last_connection_at`, то есть диагностика изменила бы
отображаемое «последнее подключение» у `bootstrap-admin-peer`. Поэтому проба с
**действующим** паролем выполняется только в режиме `install`; `doctor` работает
в режиме `reconfigure` и до неё не доходит. Полный happy-path авторизации
проверяют установка и E2E, а не диагностика.
`doctor` отправляет только пробы, которые заведомо не проходят авторизацию
(отсутствующий machine token, неверные учётные данные, некорректный тип поля) и
читающие запросы (`/healthz`, `trafficStats /online`). Ни одна из них не
изменяет данные.
Честная формулировка гарантии:
> `doctor` не изменяет конфигурацию, состояние сервисов, firewall и данные.
Единственный след, который он оставляет, — записи в журнале админки: пробы
проходят через обычный обработчик логирования, как любой запрос. Это не
состояние системы, но и не «совсем ничего», поэтому сказано прямо.
Вариант `server public IPv4:` пустой означает, что на интерфейсах нет ни одного
публичного маршрутизируемого IPv4 — сервер за NAT. Это топология вне baseline;
осознанное решение оформляется через `HY2XS_PUBLIC_ENDPOINT_POLICY=warn`.
Если в A-записях присутствует правильный адрес **и** посторонний, проверка тоже
отказывает. HY2XS — single-host профиль: второй backend за тем же именем
означает, что часть клиентов попадёт не на этот сервер.
### Скорость не соответствует ожиданиям
Проверить:
- `bandwidth.*` на сервере
- клиентские `up_mbps/down_mbps`
- нет ли ложного ожидания, что один только host BBR решает speed policy
### Изменили `post-install.env`, но runtime не изменился
Это ожидаемо.
`post-install.env` — reference file, а не autoreconcile engine.
Редактировать нужно `/etc/hy2xs/hy2xs.env` и затем запускать `reconfigure --dry-run/--apply`.
### Изменили bootstrap-поля, но пароль admin не сменился
Это ожидаемо.
`HY2XS_ADMIN_INITIAL_PASSWORD` и `HY2XS_ADMIN_CON_PASS` используются только как bootstrap-данные при первичной установке.
Для ротации существующих credentials нужен отдельный flow на уровне account-management.
### `hy2xs-admin` не стартует: «HY2XS_ADMIN_INITIAL_PASSWORD не задан»
Означает, что учётной записи администратора в базе нет, а переменной, из которой
её положено создать, — тоже.
Придумывать пароль самостоятельно админка не будет: такой пароль не знал бы
никто, кроме журнала, а раньше именно он туда и попадал открытым текстом.
Сообщение говорит о повреждённом контракте запуска.
Что проверять:
```bash
systemctl cat hy2xs-admin | grep EnvironmentFile
grep -c '^HY2XS_ADMIN_INITIAL_PASSWORD=' /etc/hy2xs/hy2xs.env
grep -c '^ADMIN_INITIAL_PASSWORD=' /etc/hy2xs/bootstrap-admin.secret
```
Починка — `hy2xs-orchestrator repair --allow-partial-state`: значения принадлежат
оркестратору, он же приводит `hy2xs.env` и `bootstrap-admin.secret` в
согласованное состояние.
Аналогичное сообщение про `HY2XS_ADMIN_CON_PASS` относится к пиру установщика.
Его секрет продублирован в `bootstrap-admin.secret`, откуда его читает проверка
machine-auth, поэтому придуманный секрет разошёлся бы с файлом и первая же
проверка подключения провалилась бы.
### Забыт пароль администратора
```bash
systemctl stop hy2xs-admin
set -a; . /etc/hy2xs/hy2xs.env; set +a
"$HY2XS_INSTALL_DIR/hy2xs-admin" reset-admin
systemctl start hy2xs-admin
```
Runtime env подключается намеренно: из него берутся пути к базе и журналу
(`HY2XS_DATA_DIR`, `HY2XS_LOG_DIR`) — те же, с которыми работает юнит.
Команда печатает новые логин и пароль в консоль и требует смены пароля при
первом входе. Работает поверх существующей установки; на машине без базы она
осмысленно откажет — это инструмент восстановления, а не установки.
### Автоматический сброс трафика не срабатывает
Проверьте сохранённое расписание:
```bash
journalctl -u hy2xs-admin | grep RESET_TRAFFIC_CRON
```
Невалидное выражение теперь отклоняется API до записи в базу, поэтому попасть в
это состояние можно только правкой базы в обход продукта. Планировщик в таком
случае поднимается без джобы сброса и пишет ERROR — старт сервиса при этом не
прерывается намеренно: на панели висит `/internal/hysteria/auth`, и её отказ
положил бы подключения пользователей.
Починка — сохранить корректное значение в разделе настроек панели; оно
применяется сразу, без перезапуска сервиса. Пустое значение — легальное и
означает «автоматический сброс выключен».
## Правила эксплуатации
1. Не править сервер как будто на нём есть builder.
2. Не считать bundled UI источником install-policy.
3. Не считать `post-install.env` автоматическим механизмом применения изменений.
4. Не расширять install-only baseline до lifecycle-manager без отдельного проектного решения.
5. Не смешивать install baseline и access/bot platform в одной документации.
6. Не включать IPv6 в runtime-политике HY2XS (проект IPv4-only).