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,294 @@
|
||||
# systemd and firewall
|
||||
|
||||
## Цель документа
|
||||
|
||||
Зафиксировать базовый systemd/firewall слой под новую install model.
|
||||
|
||||
## systemd: Hysteria2
|
||||
|
||||
Базовые требования:
|
||||
- отдельный unit `hysteria-server.service`
|
||||
- отдельный пользователь `hysteria`
|
||||
- автозапуск после reboot
|
||||
- restart policy для падений
|
||||
|
||||
Базовый ExecStart:
|
||||
```bash
|
||||
/usr/local/bin/hysteria server -c /etc/hysteria/config.yaml
|
||||
```
|
||||
|
||||
## systemd: HY2XS admin
|
||||
|
||||
Базовые требования:
|
||||
- отдельный unit `hy2xs-admin.service`
|
||||
- запуск от `User=hy2xs-admin`, не от root
|
||||
- отдельный install dir
|
||||
- отдельный data dir
|
||||
- отдельный жизненный цикл от Hysteria
|
||||
|
||||
Рекомендуемый hardening:
|
||||
- `NoNewPrivileges=true`
|
||||
- `PrivateTmp=true`
|
||||
- `UMask=0077`
|
||||
- `ProtectHome=true`
|
||||
- `ProtectSystem=strict`
|
||||
- `ReadOnlyPaths=/etc/hysteria/config.yaml`
|
||||
- `ReadWritePaths=/var/lib/hy2xs-admin /var/log/hy2xs`
|
||||
- `RestrictAddressFamilies=AF_INET AF_UNIX`
|
||||
- `SystemCallArchitectures=native`
|
||||
- `LockPersonality=true`
|
||||
|
||||
Для `hysteria-server.service` также обязателен sandbox-контур:
|
||||
- `ProtectSystem=strict`
|
||||
- `ReadOnlyPaths=/etc/hysteria/config.yaml`
|
||||
- `ReadWritePaths=/var/lib/hysteria`
|
||||
- `CapabilityBoundingSet=CAP_NET_BIND_SERVICE`
|
||||
|
||||
Важно:
|
||||
- HY2XS admin не должен запускаться как часть unit Hysteria
|
||||
- unit-файлы не должны быть склеены
|
||||
|
||||
## Базовая firewall-модель
|
||||
|
||||
Нужно разрешить:
|
||||
- UDP-порт Hysteria2
|
||||
- TCP-порт SSH
|
||||
- established/related traffic
|
||||
|
||||
IPv4-only policy:
|
||||
- использовать `table ip`, а не `table inet`;
|
||||
- IPv6 правила не добавлять;
|
||||
- UI работает только на `127.0.0.1` в production baseline.
|
||||
|
||||
## Firewall modes
|
||||
|
||||
`HY2XS_FIREWALL_MODE=managed`:
|
||||
- orchestrator управляет baseline nftables.
|
||||
- существующий `foreign` entrypoint блокирует install/reconfigure (fail-fast).
|
||||
|
||||
`HY2XS_FIREWALL_MODE=takeover`:
|
||||
- явный destructive takeover.
|
||||
- использовать только после ручной проверки хоста.
|
||||
|
||||
`HY2XS_FIREWALL_MODE=external`:
|
||||
- orchestrator не модифицирует nftables.
|
||||
- оператор полностью управляет firewall вручную.
|
||||
|
||||
`HY2XS_FIREWALL_MODE=off`:
|
||||
- firewall-слой оркестратора отключён.
|
||||
- `--skip-firewall` эквивалентно runtime-отключению на время операции.
|
||||
|
||||
После staged-проверки можно включать default policy `drop`.
|
||||
|
||||
## Порядок применения
|
||||
|
||||
1. Снять резервную копию `/etc/nftables.conf`, `/etc/nftables.d/hy2xs.nft` и
|
||||
состояния юнита `nftables.service` в `/run/hy2xs/rollback/<op-id>/` и
|
||||
**доказать**, что копия создана. Отказ здесь останавливает операцию до
|
||||
первой мутации.
|
||||
2. Подготовить candidate-файлы и проверить их `nft -c -f`.
|
||||
3. Подставить candidate в production-пути.
|
||||
4. Взвести rollback guard: транзиентный юнит `hy2xs-fw-rollback-<op-id>`
|
||||
с окном 45 секунд.
|
||||
```bash
|
||||
systemd-run \
|
||||
--unit=hy2xs-fw-rollback-<op-id>.service \
|
||||
--on-active=45s \
|
||||
--timer-property=RemainAfterElapse=no \
|
||||
--timer-property=AccuracySec=1s \
|
||||
/bin/sh /run/hy2xs/rollback/<op-id>/auto-rollback.sh
|
||||
```
|
||||
5. Применить ruleset и проверить SSH/Hysteria/UI.
|
||||
6. Снять guard и **доказать**, что он снят (см. ниже).
|
||||
7. Долговечно зафиксировать успех.
|
||||
8. Только после этого удалить данные отката и candidate-файлы.
|
||||
|
||||
Порядок шагов 6–8 существенен: между снятием guard и удалением данных отката
|
||||
стоит фиксация успеха, поэтому отказ записи маркера (заполненный диск,
|
||||
read-only ФС) оставляет откат выполнимым.
|
||||
|
||||
### Rollback guard
|
||||
|
||||
Guard — это защита от потери доступа к серверу. Он существует ради ситуации, в
|
||||
которой применённые правила отрезали SSH и оператор больше не может ничего
|
||||
сделать руками.
|
||||
|
||||
Окно guard намеренно короткое — 45 секунд — и намеренно **не** покрывает
|
||||
smoke: smoke на медленном, но исправном сервере может идти заметно дольше.
|
||||
Увеличение окна лечило бы гонку расширением, а не устранением.
|
||||
|
||||
#### Почему у таймера заданы `AccuracySec` и `RemainAfterElapse`
|
||||
|
||||
`OnActiveSec=45s` сам по себе **не** означает «ровно через 45 секунд».
|
||||
`systemd.timer` разрешает себе сработать в окне
|
||||
|
||||
```text
|
||||
цель ... цель + AccuracySec
|
||||
```
|
||||
|
||||
объединяя пробуждения ради экономии энергии, и умолчание `AccuracySec=` —
|
||||
`1min`. То есть без явного значения guard, про который эта страница и текст
|
||||
отказа говорят «45 секунд», по контракту systemd мог сработать и через 105.
|
||||
Поэтому точность задаётся явно: `AccuracySec=1s`, и реальное окно — **45–46
|
||||
секунд**. Коалесценция пробуждений аварийному guard'у не нужна: он взводится
|
||||
один раз за операцию и почти всегда снимается, не сработав.
|
||||
|
||||
`RemainAfterElapse=no` задаётся по другой причине. Отработавший одноразовый
|
||||
таймер обязан выгрузиться — на этом стоит право барьера покоя считать
|
||||
отсутствие юнита доказательством того, что откатывать firewall больше некому.
|
||||
`systemd-run` выставляет это свойство транзиентным таймерам сам, но инвариант,
|
||||
который держится на чужом умолчании, нигде не записан и ничем не проверяется;
|
||||
в явном виде он попадает и в journal, и в тест.
|
||||
|
||||
Вместо этого guard оставляет за собой факт:
|
||||
|
||||
```text
|
||||
/run/hy2xs/rollback/<op-id>/auto-rollback-fired
|
||||
```
|
||||
|
||||
Маркер создаётся rollback-скриптом **первым действием**, до любой проверки и до
|
||||
первой попытки восстановления. Отсюда инвариант фиксации успеха:
|
||||
|
||||
```text
|
||||
маркер auto-rollback-fired отсутствует
|
||||
И hy2xs-fw-rollback-<op-id>.timer в состоянии inactive
|
||||
И hy2xs-fw-rollback-<op-id>.service в состоянии inactive
|
||||
=> автоматический откат больше не может сработать
|
||||
```
|
||||
|
||||
Пока этот инвариант не доказан, `phase: installed` не записывается. Если guard
|
||||
успел сработать, операция **обязана** завершиться отказом — даже если smoke
|
||||
прошёл зелёным: сервер в этот момент работает на прежнем firewall, а не на том,
|
||||
который сгенерировала операция.
|
||||
|
||||
Проверка состояния юнитов идёт по `ActiveState`, а не по коду возврата
|
||||
`systemctl stop`: для транзиентного юнита, который уже отработал и был убран
|
||||
systemd, `stop` возвращает 5, и этот исход неотличим от успешного снятия
|
||||
взведённого таймера.
|
||||
|
||||
У факта срабатывания два независимых канала, и это не избыточность. Маркер —
|
||||
обычный. Отказ юнита — аварийный: если записать маркер не удалось (заполненный
|
||||
tmpfs `/run`, read-only ФС), скрипт поднимает код возврата, юнит уходит в
|
||||
`failed`, а `failed` на пути фиксации успеха запрещён так же, как и маркер.
|
||||
Без второго канала инвариант был бы верен лишь при дополнительном условии
|
||||
«guard способен записать маркер», которого никто не гарантирует.
|
||||
|
||||
Сам rollback-скрипт восстанавливает файлы и ruleset, накапливает код возврата и
|
||||
уходит в `failed` при частичном восстановлении. Состояние `nftables.service` он
|
||||
сознательно не трогает: у этого юнита `ExecStop=/usr/sbin/nft flush ruleset`, то
|
||||
есть остановка сервиса стёрла бы только что восстановленные правила. Enable и
|
||||
active восстанавливает обычный откат в процессе оркестратора, где порядок стадий
|
||||
контролируется.
|
||||
|
||||
### Guard переживает свой процесс
|
||||
|
||||
Guard — объект systemd, а не часть процесса оркестратора. Аварийно умершая
|
||||
операция оставляет его вооружённым, и он способен вернуть прежний firewall уже
|
||||
посреди **следующей** операции. Замок операций от этого не защищает: он
|
||||
действует, пока жив процесс-держатель.
|
||||
|
||||
Поэтому условие начала новой операции — не «PID предыдущей мёртв», а «у
|
||||
предыдущей не осталось исполнителей, способных изменить систему». Каждый захват
|
||||
замка проходит через барьер покоя.
|
||||
|
||||
#### Покой перечисляется белым списком
|
||||
|
||||
Покой — это `inactive` и `failed`, и **только** они. Отработавший guard больше
|
||||
ничего не сделает, а отказ по `failed` заблокировал бы `repair` — ровно тот
|
||||
инструмент, которым чинят последствия. Всё остальное считается непокоем и
|
||||
запрещает операцию.
|
||||
|
||||
Список именно белый, а не чёрный. Перечисление непокойных состояний
|
||||
(`active`, `activating`, `deactivating`, `reloading`) объявляло бы безопасным
|
||||
любое состояние, которого автор не назвал, — включая те, которых он не знал:
|
||||
systemd 257 знает ещё `maintenance` и `refreshing`, и список может пополниться
|
||||
снова. Незнакомое состояние systemd обязано блокировать операцию, а не
|
||||
проходить молча.
|
||||
|
||||
Единственное исключение — `*.timer` в `SubState=elapsed` или `dead`. `ActiveState`
|
||||
таймера отвечает на вопрос «юнит загружен и в строю», а не «он ещё может
|
||||
сработать»: в systemd `TIMER_ELAPSED` отображается в `UNIT_ACTIVE` так же, как
|
||||
`TIMER_WAITING`, и различает их только `SubState`. У наших guard'ов такого не
|
||||
бывает (`RemainAfterElapse=no`), но инвариант «барьер не залипает» не должен
|
||||
зависеть от того, чем именно создан таймер: иначе отработавший таймер запрещал
|
||||
бы install/reconfigure/repair/doctor навсегда — и запрещал бы ради отката,
|
||||
который уже произошёл. Триггернутый сервис при этом виден барьеру отдельным
|
||||
юнитом и остаётся непокоем, пока выполняется.
|
||||
|
||||
#### Отказ запроса к systemd — это отказ операции
|
||||
|
||||
Если `systemctl` не ответил, барьер **не** считает систему спокойной:
|
||||
|
||||
```text
|
||||
unable to verify firewall rollback guard state;
|
||||
systemd query failed, refusing to start a lifecycle operation
|
||||
```
|
||||
|
||||
Отсутствие ответа — отсутствие наблюдения, а не наблюдение покоя. Обратная
|
||||
трактовка давала реальный сценарий потери firewall:
|
||||
|
||||
```text
|
||||
systemd жив, старый rollback timer взведён
|
||||
-> запрос к systemctl/D-Bus временно отказывает
|
||||
-> список guard'ов пуст
|
||||
-> барьер считает систему спокойной
|
||||
-> новая операция начинает менять firewall
|
||||
-> старый таймер срабатывает поверх неё
|
||||
```
|
||||
|
||||
Механизм, обязанный **доказать** отсутствие асинхронного исполнителя, принимал
|
||||
невозможность получить доказательство за положительный результат. Это прямо
|
||||
противоположно политике замка операций, где сомнение трактуется в пользу
|
||||
отказа.
|
||||
|
||||
Практического выигрыша у прежнего поведения не было: `systemd-run` требуется в
|
||||
preflight, поэтому без работающего systemd операция всё равно откажет — просто
|
||||
позже и с менее внятной диагностикой.
|
||||
|
||||
Состояния `hysteria-server`, `hy2xs-admin` и `nftables.service` барьер
|
||||
сознательно не проверяет: незавершённый `systemctl restart` ничего не
|
||||
откатывает, он лишь повторяет то, что новая операция сделает сама.
|
||||
|
||||
#### Один наблюдатель на барьер и на отчёт
|
||||
|
||||
Состояние guard'ов читает одна функция, и `hy2xs-orchestrator status` берёт его
|
||||
у неё же. Раньше у status была своя копия листинга, и она расходилась с
|
||||
барьером по трём пунктам сразу: без `--plain` (у `failed`-юнита первой колонкой
|
||||
идёт маркер `●`), с `|| true` (отказ systemd превращался в «guard'ов нет») и
|
||||
без разбора состояний — вооружённым считался любой найденный юнит. На практике
|
||||
это означало, что аварийно сработавший guard оставлял `failed`-сервис
|
||||
загруженным до `reset-failed`, status вечно показывал `firewall_state:
|
||||
guard_active`, а барьер тот же самый юнит считал покоем и разрешал `repair`.
|
||||
|
||||
`status` при этом остаётся отчётом: он не берёт замок и существует в том числе
|
||||
для сломанного хоста, поэтому «спросить не удалось» попадает в JSON значением
|
||||
`rollback_guard_state: "unknown"`, а не отказом команды.
|
||||
|
||||
### Проверка эффективного firewall
|
||||
|
||||
`nft -c -f /etc/nftables.conf` разбирает текущий файл, каким бы он ни был, и
|
||||
поэтому ничего не говорит о том, чей это firewall. Smoke дополнительно сверяет:
|
||||
|
||||
1. `/etc/nftables.d/hy2xs.nft` совпадает с фрагментом, отрендеренным для этой
|
||||
конфигурации;
|
||||
2. `/etc/nftables.conf` принадлежит HY2XS и подключает именно его;
|
||||
3. таблица `inet hy2xs` реально загружена в ядро.
|
||||
|
||||
Все три — наблюдение, поэтому проверка выполняется и в `doctor`, где она
|
||||
обнаруживает расхождение effective firewall с конфигурацией.
|
||||
|
||||
## Что не делаем
|
||||
|
||||
В baseline не делаем:
|
||||
- port hopping
|
||||
- сложную динамическую firewall-логику
|
||||
- смешение UI-портов и публичного транспортного порта в один firewall-контур без правил
|
||||
|
||||
## Инварианты
|
||||
|
||||
Система считается корректной, если:
|
||||
1. Hysteria и HY2XS admin работают отдельными systemd unit
|
||||
2. Hysteria слушает нужный UDP-порт
|
||||
3. SSH не ломается после применения firewall
|
||||
4. firewall-политика не противоречит listen policy
|
||||
5. после reboot оба нужных сервиса стартуют корректно
|
||||
@@ -0,0 +1,573 @@
|
||||
# Install-only orchestrator spec
|
||||
|
||||
## Цель документа
|
||||
|
||||
Зафиксировать ТЗ на оркестратор с учётом двухслойной архитектуры: builder отдельно, target install отдельно.
|
||||
|
||||
## Технологический стек оркестратора
|
||||
|
||||
Оркестратор фиксируется как:
|
||||
- **Bun + TypeScript** по исходникам
|
||||
- локальная сборка builder layer'ом
|
||||
- поставка на target в виде **готового install-артефакта**
|
||||
|
||||
Это означает:
|
||||
- на target нет `npm`, `pnpm`, `yarn` или `bun install`
|
||||
- на target нет transpile/build step
|
||||
- shell на target допустим только как thin wrapper entrypoint
|
||||
|
||||
## Главная роль оркестратора
|
||||
|
||||
Оркестратор работает **только на target machine** и умеет:
|
||||
- выполнить read-only проверку чистоты хоста (`preflight-install`)
|
||||
- выполнить первичную установку (`install`)
|
||||
- выполнить явную реконфигурацию (`reconfigure --dry-run|--apply`)
|
||||
- разложить bundled UI
|
||||
- скачать Hysteria2 из official upstream
|
||||
- создать/обновить конфиги
|
||||
- создать unit-файлы
|
||||
- применить staged firewall
|
||||
- создать `post-install.env` и runtime env-файл
|
||||
|
||||
## Оркестратор не умеет
|
||||
|
||||
- upgrade
|
||||
- standalone rollback subcommands
|
||||
- uninstall
|
||||
- repair старых неизвестных состояний
|
||||
- target-side build
|
||||
- target-side git clone исходного кода HY2XS admin
|
||||
- Telegram-бот / access delivery
|
||||
|
||||
## Предусловия
|
||||
|
||||
Оркестратор рассчитан только на:
|
||||
- чистый Debian 13
|
||||
- root/sudo install context
|
||||
- один сервер
|
||||
- одну baseline-схему
|
||||
|
||||
Если машина уже «жила своей жизнью», baseline не обещает корректной автоадаптации.
|
||||
|
||||
## Двухфазный контракт установки
|
||||
|
||||
Установка разделена на две фазы с жёсткой границей между ними:
|
||||
|
||||
```text
|
||||
PHASE 0 — READ ONLY владелец: install.sh
|
||||
проверка прав
|
||||
sha256sum -c metadata/checksums.txt
|
||||
./orchestrator/hy2xs-orchestrator preflight-install --package-dir <распакованный пакет>
|
||||
├── платформа Debian 13 amd64
|
||||
├── clean-host контракт
|
||||
└── валидация конфигурации
|
||||
↓ ноль persistent writes
|
||||
PHASE 0 PASSED
|
||||
↓ exec
|
||||
PHASE 1 — MUTATION владелец: оркестратор
|
||||
preflight (clean-host — последний раз за операцию)
|
||||
bootstrapRuntime: /usr/local/lib/hy2xs, symlink, runtime-пакет
|
||||
installDeps → filesystem → UI → Hysteria → config → units → firewall → smoke
|
||||
```
|
||||
|
||||
Ключевые свойства:
|
||||
|
||||
- `preflight-install` запускается **из распакованного пакета**, а не из
|
||||
установленного `/usr/local/lib/hy2xs`: до PHASE 1 этого каталога может не
|
||||
существовать, и создавать его нельзя.
|
||||
- Граница держится не соглашением, а **read-only guard** (`lib/guard.ts`):
|
||||
под ним `writeText`/`writeTextAtomic` и мутирующие раннеры `lib/process`
|
||||
кидают ошибку. Это проверяется тестами.
|
||||
- Внутри `install` **`preflight()` выполняется раньше первой записи
|
||||
`install-state.json`**. Отказ на этом этапе означает, что на сервере не
|
||||
изменено ничего.
|
||||
|
||||
### У мутации ровно один владелец
|
||||
|
||||
`install.sh` не изменяет на сервере ничего. Он проверяет и делает `exec`.
|
||||
|
||||
Раньше PHASE 1 начиналась в shell: установщик сам создавал
|
||||
`/usr/local/lib/hy2xs`, ставил туда бинарник, вешал symlink и копировал
|
||||
runtime-пакет, и только после этого запускал оркестратор, который выполнял
|
||||
собственный preflight. Между двумя фазами возникало окно: если второй preflight
|
||||
отказывал — сменился DNS, занялся порт, не ответил резолвер, — у оркестратора не
|
||||
был взведён ни один флаг владения, отказ классифицировался как
|
||||
`fatal_pre_apply`, и оператор читал «на сервере ничего не изменено». Хост при
|
||||
этом уже нёс каталог оркестратора, symlink и runtime-пакет, а следующий запуск
|
||||
упирался в них как в маркеры чужой установки.
|
||||
|
||||
Владение мутацией невозможно отследить, пока мутируют двое. Поэтому раскладку
|
||||
выполняет шаг `steps/bootstrap.ts` под флагом `ownership.bootstrapTouched`, и
|
||||
эти пути попадают в `owned_paths` install-state наравне со всеми остальными.
|
||||
Сборка проверяет структурно, что в `install.sh` не осталось ни одной мутирующей
|
||||
команды.
|
||||
|
||||
### clean-host проверяется до первой мутации и только там
|
||||
|
||||
`preflight()` принимает `checkCleanHost` явно, без значения по умолчанию.
|
||||
|
||||
Причина в том, что clean-host — условие **входа** в операцию, а проверка
|
||||
возможностей платформы (`systemd-run`, `nftables`, OpenSSL 3) выполняется уже
|
||||
после `installDeps`, то есть внутри PHASE 1. Пока обе проверки ехали одним
|
||||
параметром, `install` вызывал preflight дважды и оба раза с включённым
|
||||
clean-host. Ко второму вызову на диске лежал собственный
|
||||
`/var/lib/hy2xs/install-state.json`, записанный после первого preflight, — и он
|
||||
опознавался как маркер посторонней установки. Каждая чистая установка падала
|
||||
сразу после `apt-get`, получала `fatal_post_apply` и оставляла сервер
|
||||
наполовину настроенным.
|
||||
|
||||
По той же причине у списка маркеров больше нет «мягкой» версии для PHASE 1:
|
||||
пути, которые раньше приходилось исключать, теперь создаются после проверки.
|
||||
|
||||
Полный список маркеров чужой установки и порядок очистки —
|
||||
[14-legacy-cleanup.md](../operations/14-legacy-cleanup.md).
|
||||
|
||||
### Раннеры подпроцессов: два набора, а не один
|
||||
|
||||
Guard умеет останавливать только то, что через него проходит. Поэтому
|
||||
универсального раннера в `lib/process.ts` нет — есть два явных набора:
|
||||
|
||||
| Набор | Guard | Назначение |
|
||||
| --- | --- | --- |
|
||||
| `runReadOnly`, `runReadOnlySecret` | не трогает | наблюдение за системой: `ss`, `systemctl is-active`, `curl`, `getent` |
|
||||
| `runMutating`, `runMutatingVisible`, `runMutatingHidden`, `runMutatingRaw` | спрашивает разрешение | всё, что может изменить хост |
|
||||
|
||||
`*Secret`-варианты не печатают команду в текст ошибки: их аргументы несут
|
||||
machine token или пароль пира, а сообщение уходит в логи и диагностику.
|
||||
|
||||
До разделения существовал один `run`, под которым одинаково жили `ss -ltn` и
|
||||
`useradd`/`install -d`/`mkdir`. Guard стоял только на части раннеров, поэтому
|
||||
утверждение «PHASE 0 ничего не пишет» держалось на внимательности автора
|
||||
следующей правки. Выбор набора теперь — обязательное решение на месте вызова;
|
||||
возвращение старых имён ломает приёмку сборки.
|
||||
|
||||
## Маркер состояния установки
|
||||
|
||||
`/var/lib/hy2xs/install-state.json` отвечает на вопрос «эта машина — установка
|
||||
**текущего поколения** HY2XS, и в каком она состоянии». Поэтому кроме фазы он
|
||||
несёт идентификацию поколения:
|
||||
|
||||
```json
|
||||
{
|
||||
"product": "hy2xs",
|
||||
"release_line": 1,
|
||||
"config_schema_version": 2,
|
||||
"product_version": "1.0.0",
|
||||
"installed": true,
|
||||
"phase": "installed"
|
||||
}
|
||||
```
|
||||
|
||||
`reconfigure` и `repair` проверяют `product` / `release_line` /
|
||||
`config_schema_version` **до** всего остального. Флага `installed: true`
|
||||
недостаточно: такой же маркер мог остаться от 0.x.
|
||||
|
||||
`repair` дополнительно требует явного `--allow-partial-state`, чтобы работать
|
||||
поверх незавершённой установки. Разрешение не подразумевается: молчаливое
|
||||
согласие на произвольный partial marker и позволяло «чинить» чужое состояние.
|
||||
|
||||
### Запись маркера долговечна и имеет ровно одного владельца
|
||||
|
||||
`install` и `reconfigure` пишут маркер через один и тот же
|
||||
`lib/installStateWriter.ts`. Раньше писателей было два, с разными гарантиями:
|
||||
`install` перезаписывал файл на месте, `reconfigure` подставлял его атомарно.
|
||||
Слабейшая гарантия досталась команде, которая этот файл создаёт.
|
||||
|
||||
Перезапись на месте укорачивает файл до нуля и только потом наполняет. Любой
|
||||
отказ между этими моментами — потеря питания, `kill -9`, `ENOSPC` — оставляет на
|
||||
сервере половину документа:
|
||||
|
||||
```json
|
||||
{
|
||||
"product": "hy2xs",
|
||||
"release_line":
|
||||
```
|
||||
|
||||
Такой маркер не разбирается: `reconfigure`/`repair` видят его как отсутствующий,
|
||||
а clean-host — как присутствующий, причём хост к этому моменту уже изменён.
|
||||
|
||||
Атомарности при этом недостаточно, нужна **долговечность**. Порядок записи:
|
||||
|
||||
```text
|
||||
1. запись во временный файл в том же каталоге
|
||||
2. права и владелец ← до подстановки: иначе есть окно,
|
||||
в котором файл виден с чужими правами
|
||||
3. fsync временного файла ← данные на носителе, а не в page cache
|
||||
4. rename ← атомарная подстановка
|
||||
5. fsync каталога ← сама запись каталога о новом имени
|
||||
```
|
||||
|
||||
Без шагов 3 и 5 `rename()` даёт атомарность видимости, но после внезапной
|
||||
перезагрузки ext4 штатно отдаёт по этому пути нулевой файл или отсутствие файла.
|
||||
Для метаданных восстановления это неприемлемо.
|
||||
|
||||
Есть ещё один уровень: при первой установке сам каталог `/var/lib/hy2xs`
|
||||
создаётся прямо сейчас, и запись «hy2xs» в `/var/lib` тоже обязана быть
|
||||
долговечной. Иначе возможно состояние, в котором и файл, и его каталог сброшены
|
||||
на носитель, а каталог из родителя исчез — то есть маркер пропал целиком.
|
||||
Поэтому `ensureDir` сообщает, был ли каталог **фактически создан**, и при
|
||||
создании синхронизирует родителя. На последующих обновлениях маркера каталог уже
|
||||
существует, и лишний `fsync` родителя не выполняется.
|
||||
|
||||
## Ownership и rollback
|
||||
|
||||
Операция ведёт учёт того, к чему она **могла прикоснуться**:
|
||||
|
||||
```text
|
||||
stateTouched
|
||||
depsTouched
|
||||
filesystemTouched
|
||||
uiTouched
|
||||
hysteriaTouched
|
||||
configTouched
|
||||
unitsTouched
|
||||
firewallTouched
|
||||
postInstallTouched
|
||||
bootstrapSecretTouched
|
||||
servicesStarted
|
||||
```
|
||||
|
||||
Формулировка выбрана намеренно. Флаг «шаг успешно завершился» отвечает не на
|
||||
тот вопрос: `apt-get install` умеет распаковать половину пакетов и упасть, и
|
||||
хост уже изменён, хотя шаг не закончился. Поэтому **каждый флаг взводится перед
|
||||
мутирующим вызовом**, а не после него.
|
||||
|
||||
`stateTouched` — полноценный участник классификации. `install-state.json`
|
||||
пишется сразу после успешного preflight, до `installDeps`; пока он в
|
||||
классификации не учитывался, падение `apt-get` объявлялось «на сервере ничего
|
||||
не изменено», rollback пропускался, а маркер оставался на хосте и ломал
|
||||
следующую установку по clean-host контракту.
|
||||
|
||||
Флаг называется `touched`, а не `written`, и это не косметика. Запись маркера —
|
||||
три операции (`mkdir`, `write`, `chown`), и отказ последней оставляет файл на
|
||||
диске. Пока флаг взводился **после** успешной записи, такой отказ давал
|
||||
классификацию `fatal_pre_apply` — «на сервере ничего не изменено» — при уже
|
||||
существующем `/var/lib/hy2xs/install-state.json`.
|
||||
|
||||
Классификация отказа строится **по этим флагам и фазе**, а не по тексту
|
||||
сообщения об ошибке. Ранее классификация шла по подстрокам, из-за чего
|
||||
preflight-ошибка со словом `nftables` приводила к откату чужого firewall.
|
||||
|
||||
Инварианты rollback:
|
||||
|
||||
- `fatal_pre_apply` по определению означает «ничего не применялось». Попасть в
|
||||
него нельзя ни при одном взведённом флаге, включая `stateTouched`. В этом
|
||||
случае system rollback не выполняется, `install-state.json` не пишется,
|
||||
diagnostics-бандл не собирается (его сбор сам создал бы каталоги в
|
||||
`/var/log/hy2xs`).
|
||||
- `systemctl stop/disable` выполняется **только если текущая операция сама
|
||||
развернула эти unit-файлы**.
|
||||
|
||||
### После операционного отказа откат выполняется целиком
|
||||
|
||||
Порядок в обработчике ошибки один и тот же в `install` и `reconfigure`:
|
||||
|
||||
```text
|
||||
запись состояния отказа → best effort
|
||||
сбор диагностики → best effort
|
||||
откат → обязателен
|
||||
```
|
||||
|
||||
Обе первые операции пишут на диск (`/var/lib/hy2xs`, `/var/log/hy2xs`), то есть
|
||||
падают ровно на заполненном диске и read-only ФС — там, где откат нужнее всего.
|
||||
Пока хотя бы одна из них стояла обычным `await`, её собственный отказ уносил
|
||||
управление наружу, и восстановление не выполнялось вовсе: применённый firewall и
|
||||
развёрнутые сервисы оставались на сервере. Для диагностики это было закрыто
|
||||
раньше, для записи состояния — нет.
|
||||
|
||||
Второй инвариант — **стадии отката независимы**:
|
||||
|
||||
| Команда | Стадии |
|
||||
| --- | --- |
|
||||
| `install` | firewall → stop services → disable services → reset failed services |
|
||||
| `reconfigure` | firewall → restore configuration |
|
||||
|
||||
Каждая стадия — это `systemctl`, `cp`, `rm -rf` или `nft`, то есть каждая умеет
|
||||
упасть сама. Пока они стояли цепочкой `await`, отказ первой отменял все
|
||||
следующие. В `reconfigure` это означало сервер одновременно с применённым
|
||||
сломанным firewall **и** без восстановленных из `/etc/hy2xs/backups` конфигов —
|
||||
то есть худший сценарий отказа лишался обеих половин восстановления сразу.
|
||||
|
||||
Стадии выполняются последовательно и в объявленном порядке; независимость
|
||||
означает «отказ не прерывает остальные», а не «выполняется как попало».
|
||||
Отказавшие стадии перечисляются в журнале, а наружу пробрасывается **исходная**
|
||||
ошибка операции: проблема внутри отката — это дополнительная информация о том,
|
||||
что осталось не восстановленным, а не замена диагноза.
|
||||
|
||||
Команды внутри стадий **не глушат собственные ошибки**. Это правило обратно
|
||||
тому, что действовало раньше. Пока непрерывность держалась на `|| true` в каждой
|
||||
команде, стадия физически не могла сообщить, что восстановление не выполнилось:
|
||||
`cp`, `nft -f`, `systemctl daemon-reload` и `systemctl restart` возвращали ноль
|
||||
при любом исходе, и «restore configuration» никогда не попадала в список
|
||||
отказавших. Непрерывность обеспечивает стадийный раннер; подавление кода
|
||||
возврата после его появления стало не защитой, а маскировкой.
|
||||
|
||||
### Порядок фиксации успеха
|
||||
|
||||
Данные, по которым выполняется откат, обязаны пережить долговечную запись
|
||||
успеха:
|
||||
|
||||
```text
|
||||
smoke PASS
|
||||
↓
|
||||
durable phase = smoke_ok
|
||||
↓
|
||||
disarm автоматического отката по таймеру ← резервные копии ОСТАЮТСЯ
|
||||
↓
|
||||
durable phase = installed ← точка фиксации
|
||||
↓
|
||||
cleanup резервных копий ← best effort
|
||||
```
|
||||
|
||||
Раньше снятие таймера и удаление копий выполнял один вызов, стоявший **до**
|
||||
записи `installed`. Отсюда следовал разрыв:
|
||||
|
||||
```text
|
||||
smoke PASS
|
||||
→ таймер снят, резервные копии УДАЛЕНЫ
|
||||
→ запись "installed" падает (ENOSPC / EIO / read-only ФС)
|
||||
→ обработчик ошибки → обязательный откат
|
||||
→ "firewall rollback skipped: no HY2XS rollback markers found"
|
||||
```
|
||||
|
||||
То есть ровно тот отказ записи маркера, который был специально сделан
|
||||
безопасным, случался после уничтожения единственных данных для отката: откат
|
||||
запускался, но откатывать ему было нечем.
|
||||
|
||||
Уборка после точки фиксации выполняется best-effort намеренно: невозможность
|
||||
удалить временные данные в `/run` — мусор, а не причина объявить успешную
|
||||
установку неуспешной.
|
||||
|
||||
### Резервные копии: строгие и привязанные к операции
|
||||
|
||||
Две отдельные гарантии, которых раньше не было ни у firewall, ни у
|
||||
`reconfigure`.
|
||||
|
||||
**Копия обязана существовать до первой мутации.** Копирование выполнялось как
|
||||
`cp ... || true`, поэтому отказ (заполненный `/run`, ошибка ввода-вывода, права)
|
||||
игнорировался, а операция шла менять систему, не имея того, на что рассчитывает
|
||||
откат. Теперь копирование строгое, факт создания проверяется, а маркер
|
||||
готовности `prepared` ставится **после** проверенных копий, а не до них.
|
||||
|
||||
**Копия принадлежит конкретной операции.** `reconfigure` хранил копии всех
|
||||
операций одним общим набором `*.bak` в `/etc/hy2xs/backups`. Отсюда сценарий:
|
||||
|
||||
```text
|
||||
reconfigure A → config.yaml.bak создан
|
||||
reconfigure B → создание копии упало, ошибка скрыта
|
||||
→ B меняет конфигурацию
|
||||
→ B падает → откат восстанавливает копию, снятую операцией A
|
||||
```
|
||||
|
||||
Сервер возвращался не в состояние «до B», а в более старое — и это выглядело
|
||||
успешным откатом. Теперь копия лежит в `/etc/hy2xs/backups/<op-id>/` с
|
||||
манифестом:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"opId": "2026-08-30T10-00-00.000Z",
|
||||
"entries": [
|
||||
{ "path": "/etc/hysteria/config.yaml", "present": true, "stored": "etc_hysteria_config.yaml" },
|
||||
{ "path": "/etc/nftables.d/hy2xs.nft", "present": false, "stored": null }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Отсутствие файла — **записанный факт**, а не вывод из неудачи `cp`: по этому
|
||||
полю откат решает, восстанавливать файл или удалять его. Разбор манифеста
|
||||
строгий, включая проверку `opId`: восстановление по частично понятому манифесту
|
||||
или по копии чужой операции опаснее отказа.
|
||||
|
||||
**Артефакты восстановления удаляются только после подтверждённого
|
||||
восстановления.** `rollbackFirewallNow` раньше скрывала ошибки `cp` и `nft`, а
|
||||
затем безусловно удаляла копии — худшая комбинация, при которой неудача
|
||||
восстановления не видна, а данные для ручной починки уничтожены. Теперь при
|
||||
любом отказе стадии копии сохраняются, и в журнале появляется
|
||||
`manual recovery data preserved at …`.
|
||||
|
||||
## Инвариант публичного endpoint
|
||||
|
||||
`preflight` проверяет, что публичный endpoint ведёт **на этот сервер**. Так как
|
||||
preflight общий для `install`, `reconfigure` и `doctor`, инвариант действует во
|
||||
всех трёх сценариях.
|
||||
|
||||
Алгоритм:
|
||||
|
||||
```text
|
||||
1. локальные публичные IPv4 из node:os networkInterfaces()
|
||||
(минус 0/8, 10/8, 100.64/10, 127/8, 169.254/16,
|
||||
172.16/12, 192.168/16, 224/4, 240/4)
|
||||
|
||||
2. HY2XS_PUBLIC_HOST
|
||||
IPv4-литерал → обязан быть в локальном множестве
|
||||
домен → все A-записи обязаны быть в локальном множестве
|
||||
|
||||
3. HY2XS_DOMAIN, если задан и отличается от publicHost → та же проверка
|
||||
|
||||
4. AAAA-политика остаётся отдельной
|
||||
```
|
||||
|
||||
Проверяется именно `HY2XS_PUBLIC_HOST`, потому что в `hysteria2://` уезжает он,
|
||||
а не TLS-домен. По умолчанию они совпадают, но архитектурно это разные
|
||||
сущности, и до v1 проверялся только `HY2XS_DOMAIN`.
|
||||
|
||||
Адрес сервера определяется **локально**. Внешние сервисы определения IP не
|
||||
используются: они добавили бы `doctor` сетевую зависимость и превратили бы
|
||||
недоступность стороннего сервиса в ложный отказ установки.
|
||||
|
||||
Несколько публичных IPv4 у сервера — норма: достаточно, чтобы DNS указывал на
|
||||
один из них. Обратное неверно: лишняя A-запись рядом с правильной означает
|
||||
второй, чужой backend за тем же именем. HY2XS — single-host профиль, поэтому
|
||||
это ошибка конфигурации DNS, а не балансировка.
|
||||
|
||||
Строгость управляется `HY2XS_PUBLIC_ENDPOINT_POLICY`:
|
||||
|
||||
| Значение | Поведение |
|
||||
| --- | --- |
|
||||
| `strict` (по умолчанию) | расхождение останавливает операцию |
|
||||
| `warn` | печатается предупреждение, операция продолжается |
|
||||
| `off` | сравнение не выполняется |
|
||||
|
||||
Ослабление предназначено для топологий вне baseline (NAT, floating IP, anycast).
|
||||
Отсутствие A-записи остаётся фатальным при любом значении: имя без A-записи не
|
||||
работает ни в какой топологии.
|
||||
|
||||
## Что приходит на target
|
||||
|
||||
На target должен попадать уже готовый package, содержащий:
|
||||
- thin install entrypoint
|
||||
- compiled orchestrator artifact
|
||||
- bundled HY2XS admin
|
||||
- templates
|
||||
- unit files
|
||||
- docs/examples
|
||||
- metadata package version / build id
|
||||
|
||||
## Логическая модульность
|
||||
|
||||
Даже если на target приезжает один собранный артефакт, внутри исходников оркестратор должен быть разложен по шагам:
|
||||
- preflight
|
||||
- deps
|
||||
- filesystem
|
||||
- hysteria
|
||||
- ui
|
||||
- systemd
|
||||
- firewall
|
||||
- env
|
||||
- smoke
|
||||
|
||||
## Что делает оркестратор по шагам
|
||||
|
||||
1. Проверяет, что ОС — Debian 13, и что хост чист (**до любой мутации**).
|
||||
2. Проверяет базовые зависимости и install context.
|
||||
3. Создаёт каталоги установки.
|
||||
4. Разворачивает bundled HY2XS admin.
|
||||
5. Скачивает pinned Hysteria2 binary из package metadata, проверяет SHA256 и выполняет install.
|
||||
6. Генерирует Hysteria config.
|
||||
7. Создаёт systemd unit для Hysteria.
|
||||
8. Создаёт systemd unit для HY2XS admin.
|
||||
9. Применяет nftables baseline.
|
||||
10. Создаёт `post-install.env`.
|
||||
11. Запускает сервисы и выполняет smoke-check.
|
||||
|
||||
## Модель поставки
|
||||
|
||||
Рекомендуемая baseline-модель:
|
||||
- исходники оркестратора хранятся в `orchestrator/`
|
||||
- builder выполняет локальную сборку через Bun
|
||||
- в install package кладётся готовый артефакт, который запускается thin wrapper'ом
|
||||
|
||||
Например:
|
||||
- `package/install.sh` — проверка контекста и вызов оркестратора
|
||||
- `package/orchestrator/hy2xs-orchestrator` — собранный артефакт
|
||||
|
||||
## Логирование и коды возврата
|
||||
|
||||
Оркестратор должен:
|
||||
- печатать понятные step-based сообщения
|
||||
- завершаться ненулевым кодом при ошибке
|
||||
- не скрывать первичный источник падения
|
||||
- разделять preflight/config/runtime ошибки хотя бы на уровне текста
|
||||
|
||||
## Политика ошибок
|
||||
|
||||
- Любой конфликт неизвестного старого состояния = stop with error.
|
||||
- Никакой сложной автомиграции.
|
||||
- Ошибки должны быть текстовыми и пригодными для диагностики.
|
||||
- Для install/reconfigure допустим bounded rollback при failure-сценариях firewall/systemd/config/smoke.
|
||||
|
||||
## CLI baseline
|
||||
|
||||
Команды:
|
||||
- `preflight-install --package-dir <path> [--config <source-env>]`
|
||||
- `install --package-dir <path> [--config <source-env>]`
|
||||
- `reconfigure --package-dir <path> --config /etc/hy2xs/hy2xs.env --dry-run`
|
||||
- `reconfigure --package-dir <path> --config /etc/hy2xs/hy2xs.env --apply`
|
||||
- `repair --package-dir <path> --config /etc/hy2xs/hy2xs.env [--allow-partial-state]`
|
||||
- `redact-config --config <path> (--in-place | --out <path>) [--format auto|env|yaml]`
|
||||
|
||||
`preflight-install` не принимает `--skip-*`: эти флаги влияют на мутацию, а
|
||||
PHASE 0 ничего не меняет.
|
||||
|
||||
`--allow-partial-state` допустим только для `repair`.
|
||||
|
||||
Инварианты:
|
||||
- только IPv4 bind/listen;
|
||||
- TLS modes: `acme | file | self_signed_dev`;
|
||||
- `trafficStats.secret` отдельный от `JWT_SECRET`;
|
||||
- `HY2XS_CONFIG_SCHEMA_VERSION` — обязательное поле; его отсутствие трактуется
|
||||
как legacy-конфигурация и отклоняется, а не заменяется значением по умолчанию;
|
||||
- install flow фиксирует фактически установленную версию Hysteria в snapshot;
|
||||
- версия/URL/SHA256 Hysteria берутся из metadata install package;
|
||||
- `reconfigure` не обновляет бинарник Hysteria, только runtime-слой;
|
||||
- при `reconfigure --apply`: backup -> staged apply -> smoke -> rollback on fail.
|
||||
|
||||
## Семантическая проверка сгенерированного конфига
|
||||
|
||||
`assertHysteriaConfigMatchesProfile` разбирает YAML и сверяет его с
|
||||
production-профилем, а не ищет подстроки. Проверяются, в частности:
|
||||
|
||||
- `listen`, ровно один подтип `obfs` и его соответствие `obfs.type`;
|
||||
- размеры пакетов Gecko;
|
||||
- `bandwidth`, `disableLossCompensation`, `ignoreClientBandwidth`;
|
||||
- `congestion.type` / `bbrProfile`;
|
||||
- весь QUIC baseline, **включая `maxIdleTimeout`**;
|
||||
- `trafficStats.listen` и непустой `secret`;
|
||||
- `auth.type`, **точный** `auth.http.url` (host/port/path/token) и
|
||||
`auth.http.insecure`;
|
||||
- ACME: `type`, `email`, `ca`, `dir`, `listenHost`, первый домен;
|
||||
- отсутствие посторонних секций верхнего уровня.
|
||||
|
||||
Сообщение об ошибке для `auth.http.url` намеренно не печатает сам токен: текст
|
||||
уходит в логи и в diagnostics-бандл.
|
||||
|
||||
## Редактирование секретов
|
||||
|
||||
`redact-config` и diagnostics-бандл используют **структурную** редакцию: YAML
|
||||
разбирается и обходится как дерево.
|
||||
|
||||
Это не косметика. Построчное правило `auth:\s*(.*)` подставляло маркер в
|
||||
заголовок mapping'а и оставляло нетронутым вложенный
|
||||
`auth.http.url` с `access_token=<секрет>`, то есть бандл уносил machine token
|
||||
наружу. Значение может лежать где угодно в дереве, поэтому обходить нужно
|
||||
дерево.
|
||||
|
||||
Редактируются:
|
||||
- поля с секретоподобным именем (`password`, `secret`, `token`, `apiKey`,
|
||||
`privateKey`, `authorization`, `cookie`, `bearer`, `signature`, …);
|
||||
- карты, где секретны все значения (`auth.userpass`, `acme.dns.config`);
|
||||
- учётные данные и секретные query-параметры внутри URL — в том числе в
|
||||
env-файлах, где имя ключа (`HY2_AUTH_URL`) ни под один маркер не подходит.
|
||||
|
||||
Гарантия формулируется честно: **known secrets + secret-shaped unknown
|
||||
fields**. Обобщённый sanitizer не может пообещать, что под правило попадёт
|
||||
любой будущий секрет.
|
||||
|
||||
## Что не реализовывать
|
||||
|
||||
- update subcommands
|
||||
- rollback subcommands
|
||||
- uninstall subcommands
|
||||
- reconcile logic
|
||||
- выдачу пользовательских ключей или bot workflow
|
||||
@@ -0,0 +1,164 @@
|
||||
# Runtime env и post-install snapshot
|
||||
|
||||
## Цель документа
|
||||
|
||||
Зафиксировать двухслойную модель:
|
||||
|
||||
- editable runtime env: `/etc/hy2xs/hy2xs.env`
|
||||
- generated deploy snapshot: `/etc/hysteria/post-install.env`
|
||||
|
||||
## Зачем нужен файл
|
||||
|
||||
После первичной установки оператору нужны:
|
||||
|
||||
1) runtime-файл, который оркестратор читает и валидирует;
|
||||
2) snapshot-файл фактического deploy-состояния.
|
||||
|
||||
В snapshot видно:
|
||||
- какой пакет был установлен
|
||||
- какой build артефакт использован
|
||||
- какой стек оркестратора применён
|
||||
- какая версия Hysteria реально установилась
|
||||
- какой build HY2XS admin разложен на target
|
||||
- какие базовые параметры сети и портов заданы
|
||||
|
||||
Именно для этого создаётся `post-install.env`.
|
||||
|
||||
## Чего файл не делает
|
||||
|
||||
Этот файл:
|
||||
- не делает оркестратор update-manager'ом
|
||||
- не гарантирует автоматическое применение изменений
|
||||
- не заменяет runtime-конфиги
|
||||
- не превращает target в builder
|
||||
|
||||
## Рекомендуемые пути
|
||||
|
||||
```bash
|
||||
/etc/hy2xs/hy2xs.env
|
||||
/etc/hysteria/post-install.env
|
||||
```
|
||||
|
||||
`/etc/hy2xs/hy2xs.env` и `/etc/hysteria/post-install.env` должны иметь права `0600 root:root`.
|
||||
|
||||
`/etc/hysteria/config.yaml` должен иметь права `0640 hysteria:hy2xs-admin` (UI только читает).
|
||||
|
||||
## Минимальный набор переменных
|
||||
|
||||
### Deploy / package
|
||||
- `DEPLOY_TARGET_OS`
|
||||
- `DEPLOY_TIMESTAMP` (last apply timestamp)
|
||||
- `PACKAGE_NAME`
|
||||
- `PACKAGE_BUILD_ID`
|
||||
- `PACKAGE_VERSION`
|
||||
|
||||
### Orchestrator
|
||||
- `ORCH_SOURCE_STACK=bun-typescript`
|
||||
- `ORCH_BUILD_MODE`
|
||||
- `ORCH_BUILD_ID`
|
||||
- `ORCH_ENTRYPOINT`
|
||||
|
||||
### Общие
|
||||
- `HY2XS_CONFIG_SCHEMA_VERSION`
|
||||
- `DEPLOY_DOMAIN`
|
||||
- `PUBLIC_HOST`
|
||||
- `PUBLIC_PORT`
|
||||
- `SSH_PORT`
|
||||
- `HY2XS_FIREWALL_MODE`
|
||||
- `HY2XS_FIREWALL_STAGED_APPLY`
|
||||
- `HY2XS_ADMIN_USER`
|
||||
- `HY2XS_FORCE_PASSWORD_CHANGE`
|
||||
- `HY2XS_ALLOW_SELF_SIGNED_DEV`
|
||||
|
||||
### Hysteria
|
||||
- `HY2_SOURCE=official-upstream`
|
||||
- `HY2_VERSION` — фактически установленная версия
|
||||
- `HY2_RESOLUTION` — как версия была выбрана при сборке: `latest-stable`, `pinned` или `override`
|
||||
- `HY2_TLS_MODE`
|
||||
- `HY2_ACME_EMAIL`
|
||||
- `HY2_TLS_CERT_PATH`
|
||||
- `HY2_TLS_KEY_PATH`
|
||||
- `HY2_LISTEN_HOST`
|
||||
- `HY2_PORT`
|
||||
- `HY2_AUTH_MODE`
|
||||
- `HY2_AUTH_URL`
|
||||
- `HY2_TRAFFIC_STATS_LISTEN`
|
||||
- `HY2_OBFS_TYPE` — `gecko` или `salamander`
|
||||
- `HY2_OBFS_PASSWORD`
|
||||
- `HY2_GECKO_MIN_PACKET_SIZE`
|
||||
- `HY2_GECKO_MAX_PACKET_SIZE`
|
||||
- `HY2_BANDWIDTH_UP`
|
||||
- `HY2_BANDWIDTH_DOWN`
|
||||
- `HY2_DISABLE_LOSS_COMPENSATION`
|
||||
- `HY2_IGNORE_CLIENT_BANDWIDTH`
|
||||
- `HY2_CONGESTION_TYPE`
|
||||
- `HY2_BBR_PROFILE`
|
||||
- `HY2_DISABLE_STATELESS_RESET`
|
||||
- `HY2_CONFIG_PATH`
|
||||
|
||||
### HY2XS admin
|
||||
- `HY2XS_ADMIN_ENABLED`
|
||||
- `HY2XS_ADMIN_SOURCE`
|
||||
- `HY2XS_ADMIN_BUILD_ID`
|
||||
- `HY2XS_ADMIN_BIND_HOST`
|
||||
- `HY2XS_ADMIN_PORT`
|
||||
- `HY2XS_ADMIN_INSTALL_DIR`
|
||||
- `HY2XS_ADMIN_DATA_DIR`
|
||||
- `HY2XS_ADMIN_LOG_DIR`
|
||||
|
||||
## Как работать с файлами
|
||||
|
||||
Правильная модель:
|
||||
1. оркестратор создаёт `hy2xs.env` и `post-install.env` при установке;
|
||||
2. оператор редактирует только `hy2xs.env`;
|
||||
3. оператор запускает `reconfigure --dry-run`, затем `reconfigure --apply`;
|
||||
4. оркестратор обновляет runtime и перезаписывает snapshot.
|
||||
|
||||
### Политики проверок DNS
|
||||
|
||||
В `/etc/hy2xs/hy2xs.env` есть две независимые политики, обе по умолчанию
|
||||
`strict`:
|
||||
|
||||
| Переменная | Что проверяет |
|
||||
| --- | --- |
|
||||
| `HY2XS_DNS_AAAA_POLICY` | наличие AAAA-записи при IPv4-only профиле |
|
||||
| `HY2XS_PUBLIC_ENDPOINT_POLICY` | что A-записи публичного endpoint ведут на публичные IPv4 этого сервера |
|
||||
|
||||
`HY2XS_PUBLIC_ENDPOINT_POLICY` принимает `strict` / `warn` / `off`. Ослаблять
|
||||
её имеет смысл только для топологий вне baseline: сервер за NAT, floating IP,
|
||||
anycast. Отсутствие A-записи фатально при любом значении.
|
||||
|
||||
Обе политики применяются в `install`, `reconfigure` и `doctor`, потому что
|
||||
живут в общем `preflight`.
|
||||
|
||||
### Сетевая идентичность админки
|
||||
|
||||
`HY2XS_UI_PORT`, `HY2XS_UI_BIND_HOST`, `HY2XS_DATA_DIR` и `HY2XS_LOG_DIR` —
|
||||
единственный источник истины для этих величин. Админка читает их из окружения
|
||||
юнита и не хранит собственных копий в SQLite.
|
||||
|
||||
Важно:
|
||||
- `HY2XS_ADMIN_INITIAL_PASSWORD` используется только для первичного bootstrap seed;
|
||||
- `HY2XS_ADMIN_CON_PASS` — отдельная runtime-сущность для Hysteria auth/smoke;
|
||||
- bootstrap secret хранится в явном формате `KEY=VALUE` (`ADMIN_USER`, `ADMIN_INITIAL_PASSWORD`, `ADMIN_CON_PASS`), права `0600`;
|
||||
- `HY2XS_FORCE_PASSWORD_CHANGE` в production baseline установлен в `false` (forced UX-flow пока не реализован);
|
||||
- после первичного seed перезапуски `hy2xs-admin` не должны переопределять пароль admin и `con_pass`.
|
||||
|
||||
### Immutable-bootstrap контракт
|
||||
|
||||
- `/etc/hy2xs/bootstrap-admin.secret` создаётся оркестратором только при первичной установке.
|
||||
- На `reconfigure --apply` bootstrap secret не пересоздаётся и не ротируется автоматически.
|
||||
- Изменения `HY2XS_ADMIN_INITIAL_PASSWORD` в runtime env после первичной установки не должны менять фактический пароль admin.
|
||||
- `HY2XS_ADMIN_CON_PASS` используется как bootstrap-значение при первичной установке; после создания admin account изменение этого значения в `/etc/hy2xs/hy2xs.env` не пересоздаёт и не обновляет существующий `con_pass` в SQLite.
|
||||
- Ротация `con_pass` выполняется через account-management слой UI/БД, а bootstrap snapshot остаётся неизменным.
|
||||
|
||||
## Что нельзя делать
|
||||
|
||||
- сваливать туда временный мусор
|
||||
- считать, что edit env автоматически меняет runtime без `reconfigure --apply`
|
||||
- использовать файл как замену настоящей конфигурации сервисов
|
||||
|
||||
## Пример
|
||||
|
||||
Используйте canonical runtime-файл `package/config/hy2xs.env` как базовый шаблон значений
|
||||
и переносите его параметры в `/etc/hy2xs/hy2xs.env`.
|
||||
Reference in New Issue
Block a user