672d455467
Hardening-проход перед первой сборкой на Debian. Три из найденного не воспроизводились ни на одном dry-run и проявились бы только на живом сервере. Установка * preflight внутри install вызывался дважды и оба раза проверял clean-host. Ко второму вызову на диске лежал собственный /var/lib/hy2xs/install-state.json, записанный после первого preflight, и опознавался как маркер посторонней установки: КАЖДАЯ чистая установка падала сразу после apt-get с fatal_post_apply и оставляла сервер наполовину настроенным. Чистота хоста — условие входа в операцию, возможности платформы проверяются уже внутри PHASE 1, поэтому checkCleanHost стал отдельным параметром без умолчания. * PHASE 1 начиналась в install.sh: shell сам создавал /usr/local/lib/hy2xs, ставил бинарник, вешал symlink и копировал runtime-пакет, и только потом запускал оркестратор с его собственным preflight. Отказ того preflight объявлялся fatal_pre_apply — «на сервере ничего не изменено» — при уже созданном каталоге оркестратора. Отследить владение мутацией невозможно, пока мутируют двое: install.sh больше не изменяет ничего, раскладку выполняет steps/bootstrap.ts под ownership.bootstrapTouched, пути попали в owned_paths. Как следствие удалено деление clean-host на фазы. * diagnosticsCollect стояла перед rollback обычным await в install и в reconfigure. На заполненном диске она падает сама и отменяла откат целиком. Диагностика — best effort, откат — обязателен. * reconfigure/repair выбирали записываемую фазу отказа регулярным выражением по тексту ошибки. Переведено на ownership-флаги. Секреты * Журнал админки писал RequestURI, то есть путь вместе с query. Hysteria обращается к /internal/hysteria/auth?access_token=<секрет> при каждом подключении пира, поэтому действующий machine token оседал открытым текстом в hy2xs-admin.log, который отдаётся через ExportLog и попадает в diagnostics-бандл. Логируется путь; значения query не пишутся, имена — пишутся. Канала было два: gin.Default() печатает path?query в stdout, оттуда в journald и в тот же бандл, — панель переведена на gin.New() + Recovery(). Журналы внутри бандла и журнал Hysteria из ExportLog теперь проходят санитайз. Сравнение токена — constant time. * Config API позволял прочитать и подменить ключи приложения: getConfig и listConfig принимали произвольный ключ, а проверка записи была denylist'ом из трёх ключей оркестратора. Запрос ?key=PEER_SECRET_ENCRYPTION_KEY отдавал master-key шифрования секретов пиров. Доступ переведён на allowlist, маршрут getConfig удалён целиком — потребителей у него не было ни одного. Пиры * Импорт применялся по одной записи вне транзакции, вопреки собственному контракту. Валидация не знает, что уже лежит в базе: cross-conflict по UNIQUE(name) оставлял часть файла применённой. Применение выполняется одной транзакцией, криптоматериал считается до её открытия. * Файл импорта мог содержать хвостовой JSON-документ, который молча не применялся. После разбора проверяется io.EOF. * Экспорт разделён на «Экспорт настроек» и «Резервная копия» с секретами и подтверждением: обычный экспорт выдаёт пирам новые секреты при импорте, и прежние клиентские ссылки после переноса переставали работать. Сборка * Два stale-грепа в приёмке роняли build.sh в самом конце, внутри verify_archive. Первый искал в smoke.ts исчезнувший литерал URL, второй совпадал с router_test.go, который перечисляет удалённые маршруты, потому что проверяет их отсутствие: добавление регрессионного теста ломало сборку. * verify_archive требовал наличия мутирующей строки в install.sh. Инвариант перевёрнут: их не должно быть ни одной. Очистка * Удалены entity.LegacyAccount, миграции 002/003 и мёртвые хелперы listSQLMigrationFiles и envInt: v1 не мигрирует базу 0.x ни при каком сценарии. Номера оставшихся миграций сохранены. H UI-словарь убран из обычных доков, в docs/14 он остаётся — там это имена объектов для удаления. * Список непубличных IPv4 приведён к IANA Special-Purpose Address Registry: 203.0.113.5 из RFC-примеров считался публичным адресом сервера. Отказ резолвера отделён от отсутствия A-записи. Проверено: bun test 233, go test 71, tsc/vue-tsc, bash -n 11 скриптов, приёмка прогнана против дерева.
397 lines
22 KiB
Markdown
397 lines
22 KiB
Markdown
# 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](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 и позволяло «чинить» чужое состояние.
|
||
|
||
## Ownership и rollback
|
||
|
||
Операция ведёт учёт того, к чему она **могла прикоснуться**:
|
||
|
||
```text
|
||
stateWritten
|
||
depsTouched
|
||
filesystemTouched
|
||
uiTouched
|
||
hysteriaTouched
|
||
configTouched
|
||
unitsTouched
|
||
firewallTouched
|
||
postInstallTouched
|
||
bootstrapSecretTouched
|
||
servicesStarted
|
||
```
|
||
|
||
Формулировка выбрана намеренно. Флаг «шаг успешно завершился» отвечает не на
|
||
тот вопрос: `apt-get install` умеет распаковать половину пакетов и упасть, и
|
||
хост уже изменён, хотя шаг не закончился. Поэтому **каждый флаг взводится перед
|
||
мутирующим вызовом**, а не после него.
|
||
|
||
`stateWritten` — полноценный участник классификации. `install-state.json`
|
||
пишется сразу после успешного preflight, до `installDeps`; пока он в
|
||
классификации не учитывался, падение `apt-get` объявлялось «на сервере ничего
|
||
не изменено», rollback пропускался, а маркер оставался на хосте и ломал
|
||
следующую установку по clean-host контракту.
|
||
|
||
Классификация отказа строится **по этим флагам и фазе**, а не по тексту
|
||
сообщения об ошибке. Ранее классификация шла по подстрокам, из-за чего
|
||
preflight-ошибка со словом `nftables` приводила к откату чужого firewall.
|
||
|
||
Инварианты rollback:
|
||
|
||
- `fatal_pre_apply` по определению означает «ничего не применялось». Попасть в
|
||
него нельзя ни при одном взведённом флаге, включая `stateWritten`. В этом
|
||
случае system rollback не выполняется, `install-state.json` не пишется,
|
||
diagnostics-бандл не собирается (его сбор сам создал бы каталоги в
|
||
`/var/log/hy2xs`).
|
||
- `systemctl stop/disable` выполняется **только если текущая операция сама
|
||
развернула эти unit-файлы**.
|
||
|
||
## Инвариант публичного 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
|