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,333 @@
|
||||
# HY2XS production runbook
|
||||
|
||||
## 1. Supported target
|
||||
|
||||
- clean Debian 13 amd64
|
||||
- single host install profile
|
||||
- IPv4-only runtime model
|
||||
|
||||
## 2. Required prerequisites
|
||||
|
||||
```bash
|
||||
apt-get update
|
||||
apt-get install -y sudo ca-certificates curl iproute2 tar openssl nftables systemd
|
||||
```
|
||||
|
||||
`sudo` обязателен для permission smoke-checks от имени runtime-пользователей.
|
||||
|
||||
Предварительная ручная установка `sudo` до запуска `./install.sh` не требуется: на clean-host install-flow ставит его на стадии deps до выполнения smoke-checks.
|
||||
|
||||
## 3. Required open ports
|
||||
|
||||
- UDP `${HY2XS_HYSTERIA_PORT}`
|
||||
- TCP `${HY2XS_UI_PORT}` (обычно localhost bind)
|
||||
- TCP `${HY2XS_SSH_PORT}`
|
||||
- TCP 80/443 для ACME (в зависимости от типа challenge)
|
||||
|
||||
## 4. Clean host assumptions
|
||||
|
||||
- нет legacy-конфликта по runtime-users (`hysteria`, `hy2xs-admin`)
|
||||
- нет конфликтующего не-HY2XS nftables entrypoint
|
||||
- install запускается от root
|
||||
|
||||
## 5. Install command
|
||||
|
||||
```bash
|
||||
./install.sh --non-interactive
|
||||
```
|
||||
|
||||
Важно: packaged baseline использует `HY2XS_SSH_PORT=2323` по умолчанию.
|
||||
На target-хосте оператор обязан выставить свой рабочий SSH-порт в `/etc/hy2xs/hy2xs.env`
|
||||
и применить изменения через `reconfigure --apply`.
|
||||
|
||||
## 6. Post-install verification
|
||||
|
||||
```bash
|
||||
systemctl status hysteria-server
|
||||
systemctl status hy2xs-admin
|
||||
ss -H -lun | grep ':443'
|
||||
ss -H -ltn | grep ':8080'
|
||||
nft list ruleset
|
||||
cat /var/lib/hy2xs/install-state.json
|
||||
```
|
||||
|
||||
## 7. Permission verification
|
||||
|
||||
```bash
|
||||
ls -l /etc/hy2xs/hy2xs.env
|
||||
ls -l /etc/hysteria/config.yaml
|
||||
|
||||
sudo -u hysteria test -r /etc/hysteria/config.yaml
|
||||
sudo -u hy2xs-admin test -r /etc/hysteria/config.yaml
|
||||
sudo -u hy2xs-admin test ! -w /etc/hysteria/config.yaml
|
||||
sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env
|
||||
```
|
||||
|
||||
## 8. Firewall recovery
|
||||
|
||||
Если `install`/`reconfigure` падают после firewall apply:
|
||||
|
||||
- rollback guard не должен отменяться до успешного smoke;
|
||||
- для recovery использовать вывод оркестратора и перезапускать apply только после устранения root-cause.
|
||||
|
||||
Откат после операционного отказа выполняется целиком и сам по себе не может
|
||||
быть отменён: ни неудачной записью состояния в `/var/lib/hy2xs`, ни отказом
|
||||
одной из своих стадий. Поэтому в журнале нужно читать две разные вещи:
|
||||
|
||||
| Строка в журнале | Что она означает |
|
||||
| --- | --- |
|
||||
| `failed to persist failure state, continuing with the mandatory rollback` | маркер не обновился (обычно заполненный диск), но восстановление выполнено; после освобождения места запустить `doctor` |
|
||||
| `rollback stage "<имя>" failed, continuing with the remaining stages` | конкретная половина восстановления не отработала; остальные выполнены |
|
||||
| `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки |
|
||||
| `rollback completed: N stage(s) succeeded` | восстановление отработало полностью |
|
||||
| `manual recovery data preserved at /run/hy2xs/rollback/<op>` | firewall восстановлен не полностью; прежние `nftables.conf` и `hy2xs.nft` лежат по этому пути |
|
||||
| `firewall rollback guard armed: … fires in 45s (timer accuracy 1s)` | guard взведён; с этого момента операция обязана снять его до фиксации успеха |
|
||||
| `firewall rollback guard disarmed and proven inactive` | guard снят, и это подтверждено состоянием юнитов и отсутствием маркера срабатывания |
|
||||
| `automatic firewall rollback has already fired` | guard успел сработать; сервер работает на **прежнем** firewall, операция обязана завершиться отказом |
|
||||
| `firewall rollback guard <unit> is still in state "…"` | остановить guard не удалось; фиксация успеха запрещена, разбирайтесь с systemd |
|
||||
| `unable to verify firewall rollback guard state; systemd query failed` | состояние guard'а недоказуемо; операция не начата, чинить нужно systemd, а не ждать |
|
||||
|
||||
Отдельно про сработавший guard. Окно 45 секунд намеренно короче худшего случая
|
||||
smoke и не обязано его покрывать: доказательством служит не время, а маркер
|
||||
|
||||
```text
|
||||
/run/hy2xs/rollback/<op-id>/auto-rollback-fired
|
||||
```
|
||||
|
||||
Если он есть — операция откатывается независимо от результата smoke, и в
|
||||
маркере установки появляется `phase: firewall_guard_fired`. Это значит: сервер
|
||||
жив и работает на прежнем firewall, а причину, по которой проход не уложился в
|
||||
окно, надо искать в journal — обычно это медленный старт одного из сервисов.
|
||||
|
||||
Юнит автоотката при частичном восстановлении уходит в `failed`, поэтому его
|
||||
стоит прочитать целиком:
|
||||
|
||||
```bash
|
||||
journalctl -u 'hy2xs-fw-rollback-*' --no-pager
|
||||
```
|
||||
|
||||
Резервные копии не удаляются, пока восстановление не подтверждено, и переживают
|
||||
долговечную запись `phase: installed`. Поэтому при разборе неудачи всегда
|
||||
осмысленно посмотреть:
|
||||
|
||||
```bash
|
||||
ls -la /run/hy2xs/rollback/ # копии firewall текущей операции
|
||||
ls -la /etc/hy2xs/backups/ # копия состояния до последнего reconfigure
|
||||
cat /etc/hy2xs/backups/*/manifest.json
|
||||
```
|
||||
|
||||
Имя каталога совпадает с полем `op_id` в `/var/lib/hy2xs/install-state.json`.
|
||||
|
||||
Манифест прямо говорит, какие файлы существовали до операции, а какие нет:
|
||||
запись `"present": false` означает, что откат обязан был файл **удалить**, а не
|
||||
восстановить.
|
||||
|
||||
Наружу оркестратор всегда пробрасывает **исходную** ошибку операции, а не
|
||||
проблему внутри отката: последняя — это информация о том, что осталось не
|
||||
восстановленным, а не причина отказа.
|
||||
|
||||
## 8a. Одна операция за раз
|
||||
|
||||
`install`, `reconfigure`, `repair` и `doctor` сериализованы эксклюзивным
|
||||
замком:
|
||||
|
||||
```text
|
||||
/run/lock/hy2xs-orchestrator.lock
|
||||
```
|
||||
|
||||
Вторая операция отказывает сразу и **до первой мутации** — до снятия резервной
|
||||
копии, до записи конфигов, до firewall:
|
||||
|
||||
```text
|
||||
another HY2XS operation is already in progress: reconfigure (pid 4242, started at …)
|
||||
```
|
||||
|
||||
`doctor` тоже берёт замок: диагностика в середине транзакции описывает
|
||||
промежуточное состояние сервера и выдаёт бессмысленные ошибки по временным
|
||||
несоответствиям.
|
||||
|
||||
`status` и `diagnostics collect` замок **не** берут — они нужны в том числе во
|
||||
время долгой операции, — но сообщают о ней:
|
||||
|
||||
```bash
|
||||
hy2xs-orchestrator status --package-dir /usr/local/lib/hy2xs/package
|
||||
# "operation_in_progress": "reconfigure (pid 4242, started at …)"
|
||||
```
|
||||
|
||||
Замок снимается при любом завершении держателя: штатном, по `Ctrl+C`, по
|
||||
SIGTERM от systemd и при обрыве SSH. Если процесс был убит `kill -9`, следующая
|
||||
операция обнаружит мёртвого держателя и переиспользует замок сама:
|
||||
|
||||
```text
|
||||
operation lock … is held by install (pid 1234), which is no longer running; reclaiming it
|
||||
```
|
||||
|
||||
**Замка при этом недостаточно, и это важно.** Он действует, пока жив
|
||||
процесс-держатель, а rollback guard firewall — отдельный объект systemd,
|
||||
переживающий свой процесс. Аварийно умершая операция оставляет guard
|
||||
вооружённым, и он способен вернуть прежний firewall уже посреди следующей
|
||||
операции. Поэтому каждый захват замка проходит ещё и через барьер покоя:
|
||||
|
||||
```text
|
||||
previous HY2XS operation is no longer running, but its firewall rollback guard
|
||||
is still armed: hy2xs-fw-rollback-<op-id>.timer (active/waiting)
|
||||
```
|
||||
|
||||
Условие старта — не «PID предыдущей мёртв», а «у предыдущей не осталось
|
||||
исполнителей, способных изменить систему». Ждать нужно не больше 45–46 секунд с
|
||||
момента применения firewall; `failed` у guard покою не мешает и означает, что
|
||||
пора смотреть `journalctl -u 'hy2xs-fw-rollback-*'` и запускать `repair`.
|
||||
|
||||
Второй отказ того же барьера выглядит иначе и требует другого действия:
|
||||
|
||||
```text
|
||||
unable to verify firewall rollback guard state; systemd query failed,
|
||||
refusing to start a lifecycle operation
|
||||
```
|
||||
|
||||
Здесь ждать бессмысленно. Барьер обязан **доказать** покой, а не предположить
|
||||
его: отсутствие ответа systemd — это отсутствие наблюдения, а не наблюдение
|
||||
отсутствия guard'а. Смотрите `systemctl status` и повторяйте операцию после того,
|
||||
как systemd отвечает.
|
||||
|
||||
`/run/lock` — это tmpfs, поэтому перезагрузка снимает замок в любом случае.
|
||||
Удалять файл руками нужно только если в нём оказалось непонятное содержимое:
|
||||
такой замок сознательно не переиспользуется автоматически — непонятый файл не
|
||||
доказывает, что операции нет.
|
||||
|
||||
## 9. Reconfigure flow
|
||||
|
||||
```bash
|
||||
hy2xs-orchestrator reconfigure --package-dir /usr/local/lib/hy2xs/package --config /etc/hy2xs/hy2xs.env --dry-run
|
||||
hy2xs-orchestrator reconfigure --package-dir /usr/local/lib/hy2xs/package --config /etc/hy2xs/hy2xs.env --apply
|
||||
```
|
||||
|
||||
## 10. Admin bootstrap credentials
|
||||
|
||||
- `HY2XS_ADMIN_INITIAL_PASSWORD` и `HY2XS_ADMIN_CON_PASS` — install-only bootstrap поля.
|
||||
- изменение значений в `/etc/hy2xs/hy2xs.env` после install не выполняет автоматическую ротацию существующих credentials.
|
||||
|
||||
## 11. IPv4/IPv6 policy
|
||||
|
||||
- HY2XS работает в IPv4-only режиме.
|
||||
- если IPv6 включён на хосте/провайдере — это вне baseline и должно быть отдельно управляемо оператором.
|
||||
- `HY2XS_DNS_AAAA_POLICY` управляет реакцией preflight на DNS AAAA:
|
||||
- `strict` (default) — install/reconfigure прекращается при наличии AAAA;
|
||||
- `warn` — выводится warning и выполнение продолжается;
|
||||
- `off` — AAAA-проверка игнорируется.
|
||||
|
||||
## 11a. Публичный endpoint
|
||||
|
||||
- preflight проверяет, что A-записи `HY2XS_PUBLIC_HOST` (и `HY2XS_DOMAIN`, если
|
||||
он отличается) ведут на публичные IPv4 **этого** сервера;
|
||||
- проверка работает в `install`, `reconfigure` и `doctor`;
|
||||
- адрес сервера определяется локально по интерфейсам, без обращения к внешним
|
||||
сервисам определения IP;
|
||||
- `HY2XS_PUBLIC_ENDPOINT_POLICY` управляет строгостью:
|
||||
- `strict` (default) — расхождение останавливает операцию;
|
||||
- `warn` — warning и продолжение (NAT, floating IP, anycast);
|
||||
- `off` — сравнение не выполняется;
|
||||
- отсутствие A-записи остаётся фатальным при любом значении политики.
|
||||
|
||||
Типичный сценарий, ради которого это сделано: провайдер принудительно сменил
|
||||
IPv4, DNS остался старым. До v1 `doctor` в такой ситуации отвечал успехом, а
|
||||
клиентская ссылка отправляла людей на чужую машину.
|
||||
|
||||
## 12. Validation command
|
||||
|
||||
```bash
|
||||
hy2xs-orchestrator doctor --package-dir /usr/local/lib/hy2xs/package --config /etc/hy2xs/hy2xs.env
|
||||
```
|
||||
|
||||
Команда выполняет preflight + smoke как post-install/post-reboot validation.
|
||||
|
||||
`doctor` **не перезапускает сервисы**: он диагностирует работающую установку.
|
||||
Раньше он собирал контекст с параметрами по умолчанию и звал общий smoke, а тот
|
||||
первым же действием выполняет `systemctl restart hysteria-server hy2xs-admin` —
|
||||
то есть команда, которую этот раздел предлагает запускать при подозрении на
|
||||
проблему, гарантированно обрывала все живые VPN-соединения, включая случай,
|
||||
когда с сервисом всё в порядке. Диагностика, меняющая то, что диагностирует,
|
||||
отвечает не на заданный вопрос: после рестарта проверяется уже другое состояние.
|
||||
|
||||
Остальные проверки smoke выполняются полностью — слушатели, права и владельцы
|
||||
файлов, machine auth (включая негативные случаи), семантика
|
||||
`/etc/hysteria/config.yaml` против production-профиля, версия установленного
|
||||
бинаря. Состояние сервера ни одна из них не меняет.
|
||||
|
||||
Перезапуск сервисов остаётся операцией `install`, `reconfigure --apply` и
|
||||
`repair` — там он является частью применения изменений, а не проверкой.
|
||||
|
||||
## 13. Admin UI access via SSH tunnel
|
||||
|
||||
Production policy: UI остаётся loopback-only (`HY2XS_UI_BIND_HOST=127.0.0.1`), внешний доступ к `8080/tcp` не открывается.
|
||||
Операторский доступ выполняется через SSH local forwarding.
|
||||
|
||||
Windows tunnel command:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Open in browser: `http://127.0.0.1:8080/#/login`.
|
||||
|
||||
Если туннель падает с `administratively prohibited`, проверить effective sshd-конфиг:
|
||||
|
||||
```bash
|
||||
sshd -T | grep -E '^(port|allowtcpforwarding|permitopen|gatewayports|passwordauthentication|permitrootlogin) '
|
||||
```
|
||||
|
||||
Recommended sshd hardening fragment:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
## 14. Secret-safe config sharing
|
||||
|
||||
Для передачи конфигов в тикеты/чаты используйте встроенную redaction-команду:
|
||||
|
||||
```bash
|
||||
hy2xs-orchestrator redact-config --config /etc/hy2xs/hy2xs.env --out /root/hy2xs.redacted.env
|
||||
hy2xs-orchestrator redact-config --config /etc/hysteria/post-install.env --out /root/post-install.redacted.env
|
||||
hy2xs-orchestrator redact-config --config /etc/hysteria/config.yaml --out /root/hysteria-config.redacted.yaml --format yaml
|
||||
```
|
||||
|
||||
Инварианты:
|
||||
- команда не выводит исходные секреты в stdout;
|
||||
- требуется выбрать ровно один режим: `--in-place` или `--out <path>`;
|
||||
- `--format auto` пытается определить формат по имени файла, при неоднозначности используйте `--format env|yaml`;
|
||||
- YAML редактируется структурно (документ разбирается и обходится как дерево),
|
||||
поэтому вложенные секреты вроде `auth.http.url?access_token=…` не переживают
|
||||
редакцию, а результат остаётся валидным YAML;
|
||||
- в env-файлах секрет вырезается и из URL-значения, даже если имя ключа
|
||||
несекретное — например, `HY2_AUTH_URL` в `post-install.env`.
|
||||
|
||||
Та же редакция применяется к diagnostics-бандлу
|
||||
(`hy2xs-orchestrator diagnostics collect`), который собирается автоматически при
|
||||
неудачной установке или реконфигурации. Бандл предназначен для передачи наружу,
|
||||
поэтому попадающие в него `hy2xs.env`, `post-install.env` и `config.yaml`
|
||||
редактируются перед упаковкой.
|
||||
|
||||
При отказе **до** начала применения изменений (`fatal_pre_apply`) бандл не
|
||||
собирается: его сбор сам создал бы каталоги в `/var/log/hy2xs` на сервере,
|
||||
который мы обещали не трогать.
|
||||
|
||||
Reference in New Issue
Block a user