Files
HY2XS_flamy/docs/build/02-build-layer-and-package.md
T
founder 8dcb50a07c fix(admin): дать отзыву доступа вторую попытку, а лимиту устройств — порядок снимков
Предыдущий проход сделал правильным порядок «сначала долговременная запись,
потом разрыв сессии» и правильно запретил откат при неудаче разрыва. Способа
прийти к согласованному состоянию ПОТОМ он не дал: у двух операций повтор не
работал вовсе.

Импорт, заменивший auth_id: после неудавшегося /kick старое значение не
хранится нигде, повтор того же файла читает из базы уже новое и рвёт его, а
cron пропускал незнакомый authID молча — dao.ListPeer просто не возвращала
строку. Живая сессия оставалась навсегда.

Снижение maxDevices: повтор формы даёт 1 < 1 -> false, разрыва больше нет.
Лимит устройств в политику доступа не входит и входить не должен — это
свойство сессий, — поэтому механизма схождения у него не было.

enforcePeerAccess стал сверкой живых сессий: обход идёт по каждому authID из
/online. Нет строки в базе -> kick; peerAccessDenied -> kick; непригодный
maxDevices -> kick; устройств больше разрешённого -> kick. Отказ базы при этом
не рвёт ничего. Ни таблицы отложенных операций, ни очереди retry: список живых
сессий уже есть, и это /online.

Отдельно закрыт второй TOCTOU лимита устройств. Учёт выданных разрешений
закрыл сравнение двух одинаковых снимков, но сетевой запрос выполнялся вне
блокировки, поэтому снимки приходили в резервацию в произвольном порядке и
устаревший откатывал lastOnline назад, возвращая уже занятое место. Это не
data race — память защищена мьютексом, и -race здесь молчит принципиально.
Последовательность «прочитать /online -> занять место» выполняется под замком
по authId; глобальный замок не годится, внутри идёт сетевой запрос.

Учёт разрешений больше не растёт бесконечно: запись снималась только на ветке
отказа, поэтому в карте копились удалённые пиры и переписанные импортом
идентификаторы. Уборка идёт по фактической картине подключений.

Гейты приёмки доращены под все три инварианта и проверены в обе стороны.
Go 1.26.7 -> 1.26.8. Документация приведена в соответствие в двух местах,
где описывала снятую архитектуру.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md
2026-09-02 07:15:43 +05:00

516 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Build layer and package
## Цель документа
Зафиксировать локальный слой сборки и формат итогового install package.
## Базовое решение
В baseline builder остаётся **shell-first** для packaging-слоя.
То есть:
- основной packaging pipeline — **sh/bash**
- оркестратор при этом пишется на **Bun + TypeScript**
- builder локально компилирует оркестратор в готовый install-артефакт
- target machine не должна сама собирать или доустанавливать JS/TS toolchain
Причина простая: packaging можно держать простым, а оркестратор — typed и модульным.
## Где работает builder
Production builder работает на отдельном build host:
- Debian 13
- amd64 / x86_64
- bash
- доступ к интернету для apt и скачивания toolchain
В текущей production-модели сборка выполняется **на Debian 13 amd64**, а не на Windows/macOS dev-машине.
Builder не является частью target install flow: на target server приезжает уже готовый install package, без JS/TS/Go build step.
### Память build-хоста
Самый требовательный шаг сборки — не компиляция, а `govulncheck`: он строит
граф достижимости по всему модулю вместе со stdlib.
На прогоне `v1.0.0-rc1` машина с ~1.9 GiB RAM и **нулевым swap** получила
`govulncheck`, убитый Linux OOM killer. После подключения временного swap 4 GiB
полный security gate прошёл.
4 GiB swap — не формально доказанный минимум, а подтверждённая рабочая
конфигурация того прогона; см.
[отчёт приёмки](../acceptance/2026-09-01-v1.0.0-rc1-host-acceptance.md).
Практическое следствие: сборочная машина примерно с 2 GiB RAM без swap может
оказаться недостаточной, и отказ выглядит как убитый процесс, а не как
внятная ошибка инструмента.
Это требование к сборочной машине, а не к target-серверу: `govulncheck` в
runtime-артефакт не попадает.
## Что хранится в репозитории проекта
Минимум:
- исходники оркестратора на **Bun + TypeScript**
- shell packaging scripts
- шаблоны конфигов
- systemd unit templates
- docs
- исходный код **HY2XS admin**
- шаблоны для `post-install.env`
- package metadata
## Контракт версий: `versions.env`
Корневой `versions.env`**единственный источник истины** для контракта
«продукт / платформа / toolchain».
Что в нём есть:
```bash
HY2XS_VERSION=1.0.0
HY2XS_RELEASE_LINE=1
HY2XS_CONFIG_SCHEMA_VERSION=2
HY2XS_BUILD_OS=debian
HY2XS_BUILD_OS_VERSION=13
HY2XS_BUILD_ARCH=amd64
HY2XS_TARGET_OS=debian
HY2XS_TARGET_OS_VERSION=13
HY2XS_TARGET_ARCH=amd64
GO_VERSION=1.26.8
GO_LINUX_AMD64_SHA256=<sha256>
BUN_VERSION=1.3.13
BUN_LINUX_X64_SHA256=<sha256>
BUN_LINUX_X64_BASELINE_SHA256=<sha256>
NODE_VERSION=24.20.0
NODE_LINUX_X64_SHA256=<sha256>
PNPM_VERSION=9.15.9
HYSTERIA_CHANNEL=stable
GOVULNCHECK_VERSION=v1.7.0
PNPM_AUDIT_LEVEL=high
```
Чего в нём **нет** и быть не должно:
1. **Прикладных зависимостей** (Vue, Gin, GORM, npm/Go модули). У них уже есть
канонические lock-механизмы: `apps/frontend/pnpm-lock.yaml`,
`orchestrator/bun.lock`, `apps/go.sum`. Второй слой неизбежно разъедется с
настоящим графом зависимостей.
2. **Конкретной версии Hysteria.** Здесь живёт только *политика* выбора
(`HYSTERIA_CHANNEL`); результат резолва замораживается в
`tools/build/hysteria-lock.env`. Пин версии здесь вернул бы ручное
обновление, от которого мы ушли.
Два Bun-артефакта зафиксированы отдельно намеренно: `select_bun_artifact()`
выбирает `bun-linux-x64` или `bun-linux-x64-baseline` по наличию AVX2, поэтому
одной контрольной суммы архитектурно недостаточно.
### Почему версии toolchain — это вопрос безопасности, а не удобства
Go здесь не просто сборщик: им компилируется `hy2xs-admin`, и его stdlib целиком
попадает в production-бинарь. Поэтому версия выбирается по политике поддержки Go
(major поддерживается, пока не вышли две более новые), а не по тому, на чём
собиралось раньше.
Цифры, ради которых это записано. На `GO_VERSION=1.21.13` — линия, давно вне
поддержки — `govulncheck ./...` находил **21 вызываемую уязвимость**, из них 17 в
одной только stdlib. После перехода на линию 1.26 и обновления графа
зависимостей — **ноль**.
По той же причине здесь держится актуальный **patch**-релиз, а не просто
поддерживаемая линия: «на один патч позади» — свойство выпускаемого артефакта,
а не среды сборки. Смена patch-версии затрагивает два места сразу —
`GO_VERSION` с контрольной суммой здесь и `toolchain` в `apps/go.mod`, — и
расхождение между ними роняет сборку на `verify_go_toolchain_contract`.
Node живёт только на build-хосте и в артефакт не попадает, но 20.x достигла EOL,
то есть перестала получать security-обновления, а собирает она код, который
уезжает в production. Отсюда LTS-линия 24.
Bun обновляется отдельно от остальных: оркестратор собирается через
`bun build --compile`, то есть Bun runtime физически входит в исполняемый файл.
Смена его minor-версии — это смена рантайма внутри артефакта, и она требует
полного прохода `bun test → tsc → compile → приёмка на Debian`, а не строки в
общем патче.
### Проверка типов frontend — обязательный шаг релиза
```text
pnpm run typecheck → vue-tsc --noEmit → ОБЯЗАН пройти
pnpm run build:prod → vite build
pnpm run verify → typecheck, затем build
```
`bundle_ui()` запускает `typecheck` **до** сборки bundle: собирать production
bundle из кода, который не проходит проверку типов, незачем. Порядок и сам факт
наличия шага проверяются приёмкой.
До v1 этой гарантии не было. `build:prod` означал `vite build && vue-tsc
--noEmit`, но `vue-tsc` был версии `0.35.0` (2022 год) и шаблоны Vue
практически не типизировал: проверка проходила зелёной, не давая гарантии,
которую обещает. Хуже того — на Vue 3.5 она ломается сама, потому что не знает
`vue/jsx-runtime`, то есть пережить обновление Vue всё равно не могла.
Современный `vue-tsc 3.3` на том же коде дал **142 ошибки**: 141 × `TS18048`
(«possibly undefined» при обращении к необязательным секциям конфига Hysteria в
шаблоне) и одна `TS2322`, всё в двух файлах представления Hysteria. Ожидавшегося
класса «`DefaultRow` несовместим с `PeerVo`» на Element Plus 2.3 не было вовсе —
он появился позже, вместе с обновлением Element Plus до 2.14, где слоты таблицы
типизированы строже.
Закрыто это не подавлением, а границей: `api/config/hysteriaViewModel.ts`
превращает ответ сервера в модель, где присутствие каждой секции — свойство
типа. Подробности — в [docs/04](../admin/04-admin-panel.md).
Контракт теперь читается так:
> проверка типов SFC-шаблонов проходит, и это доказывает сборка, а не намерение.
### Проверка зависимостей на уязвимости
`tools/build/lib/security.sh` — обязательный шаг сборки между тестами админки и
записью metadata:
| Проверка | Что покрывает | Порог |
| --- | --- | --- |
| `govulncheck ./...` | Go-граф **и stdlib**, с анализом достижимости: уязвимость считается только при наличии пути вызова из нашего кода | любая вызываемая |
| `pnpm audit` | **весь** lock-граф frontend, включая build tooling, без анализа достижимости | `PNPM_AUDIT_LEVEL` |
Про «весь граф» отдельно, потому что здесь стояло `--prod` с обоснованием
«devDependencies в артефакт не попадают».
Для frontend build tooling это обоснование неверно по существу. `vite` и
`rollup` действительно не копируются на production-сервер как `node_modules`.
Но они **исполняются на build-машине, читают наши исходники и порождают тот
самый production-бандл**, который уезжает в артефакт. Уязвимость в них — это
уязвимость в том, что мы выпускаем.
Это не гипотеза: DOM clobbering в Rollup затрагивал именно генерируемый бандл, а
проверка по одному production-подграфу его не показывала. По всему графу тот же
прогон дал 33 предупреждения против нуля.
Версия `govulncheck` пиньтся в `versions.env`, а база уязвимостей подтягивается
на каждом запуске: пин инструмента не должен превращаться в пин знаний о мире.
Аварийного выхода у шага **нет**, и это отличает его от `ALLOW_DIRTY_BUILD`.
Результат уезжает в `metadata/package.env` полем `dependency_security_gate`,
которое принимает единственное значение `true`: по готовому tarball видно, что
он проверялся, потому что непроверенного tarball не бывает.
Две переменные обхода здесь существовали и были описаны как способ выпустить
релиз, зная об уязвимости. Способом они не были: финальная приёмка архива
требует буквально `dependency_security_gate=true`, поэтому сборка с любой из них
доходила до конца — компиляция, бандл, тесты, метаданные, tar — и падала на
последнем шаге. Продукт документировал операцию, которую сам же запрещал.
Противоречие закрыто в пользу строгой политики; отсутствие обходов проверяется
приёмкой, а не только описано здесь.
Контракт читается однозначно:
> релизный артефакт HY2XS невозможно собрать с непройденной проверкой
> зависимостей.
Новое advisory чинится обновлением графа (`apps/go.sum`,
`apps/frontend/pnpm-lock.yaml`) или версии toolchain в `versions.env`. Для
локальной работы обходить нечего: `go test ./...`, `govulncheck ./...` и
`pnpm audit` запускаются напрямую и tarball не создают.
### Тесты и типы
Та же политика и по той же причине. Аварийного выхода у этого шага **нет**:
переменной, отключающей тесты, не существует.
Проверяется на трёх участках:
| Шаг сборки | Что запускается |
| --- | --- |
| `run_orchestrator_tests` | `bun x tsc --noEmit`, `bun test` |
| `bundle_ui` | `pnpm run typecheck` (`vue-tsc --noEmit`) до сборки bundle |
| `run_admin_tests` | `go vet ./...`, `go test ./...` |
Готовый пакет объявляет об этом полем `tests_gate=true` в
`metadata/package.env` — так же, как `dependency_security_gate` и
`hysteria_compat_gate`. Значение у поля ровно одно, потому что не бывает
пакета, собранного с пропущенными тестами: обе функции прогона выставляют свой
флаг **после** успешного завершения, а `write_metadata` отказывается писать
метаданные, если хотя бы один из них не выставлен. То есть поле остаётся
утверждением о результате, а не переключателем.
Здесь существовала переменная, описанная как «аварийное отключение тестов; для
release-сборок недопустимо». Недопустимость держалась исключительно на этой
фразе: ни metadata, ни финальная приёмка архива не проверяли, что тесты
запускались, поэтому сборка с ней доходила до конца и выдавала внешне
неотличимый production-tarball. Глушила она при этом не только тесты, но и
`tsc --noEmit` с `go vet` — то есть проверку типов и статический анализ того
самого кода, который уезжает в production. История — в `CHANGELOG.md`.
### Проверка, а не генерация
`profile.ts`, `package/config/hy2xs.env` и `packageManager` в двух `package.json`
остаются обычными файлами. Сборка их **не генерирует**, а сверяет шагом
`verify_versions_contract`.
Причина: генерируемые исходники ломают чистый чекаут — `bun test`, `tsc` и
`go test` должны работать до запуска сборки. Проверка даёт тот же инвариант
дешевле.
`verify_versions_contract` сверяет:
| Что | С чем |
| --- | --- |
| `PACKAGE_VERSION` | `HY2XS_VERSION` |
| `orchestrator/package.json``packageManager` | `bun@$BUN_VERSION` |
| `apps/frontend/package.json``packageManager` | `pnpm@$PNPM_VERSION` |
| `package/config/hy2xs.env` → схема | `HY2XS_CONFIG_SCHEMA_VERSION` |
| константы, **скомпилированные** в оркестратор | схема, release line, целевая платформа, API namespace |
| `constant.AdminAPIBase` / `constant.HysteriaMachineAuthPath` (Go) | константы оркестратора |
| `API_BASE` фронтенда | `constant.AdminAPIBase` |
| шаблоны Hysteria и `post-install.env` | `HYSTERIA_MACHINE_AUTH_PATH` |
| `apps/go.mod` → директива `go` | `GO_VERSION` |
| `metadata/package.env` | версия, release line, схема, target |
| `hy2xs-admin version` (готовый бинарь) | `v$HY2XS_VERSION` |
Контракт оркестратора сверяется не grep'ом по исходникам, а выводом
`orchestrator/tools/print-contract.ts`: это доказывает, что в бинарь попало то
же значение.
API namespace попал в этот список не для красоты. Путь machine-auth
записывается в `/etc/hysteria/config.yaml` и в `post-install.env`, то есть по
нему Hysteria обращается к админке. Пока строка была продублирована в шаблонах,
smoke, тестах, приёмке и e2e, расхождение обнаруживалось только на живом
сервере.
### Версии в `package.json` — не версия продукта
`orchestrator/package.json` объявляет `version: 0.1.0`, а
`apps/frontend/package.json``version: 0.0.0`. Это placeholder'ы приватных
пакетов, которые никуда не публикуются; единственная версия продукта живёт в
`versions.env` (`HY2XS_VERSION`) и оттуда доезжает до `metadata/package.env`,
install-state и бинарника админки. Ни одно из этих двух чисел не участвует в
контракте версий и не должно восприниматься как release version.
Версия админки приходит в бинарь через ldflags:
```bash
-ldflags "-s -w -X 'hy2xs-admin/model/constant.Version=v${HY2XS_VERSION}'"
```
Собственной константы версии в Go-коде больше нет: она уже успела разъехаться с
версией пакета.
## Что делает builder
1. Проверяет структуру проекта.
2. Загружает и проверяет контракт `versions.env`.
3. Прогоняет тесты и типы оркестратора.
4. Разрешает upstream-версию Hysteria и проходит compatibility gate.
5. Компилирует оркестратор из Bun/TypeScript в install-артефакт.
6. Собирает / подготавливает HY2XS admin и сверяет его версию с контрактом.
7. Прогоняет тесты HY2XS admin (после сборки frontend: `go:embed all:dist` требует готовых ассетов).
8. Копирует артефакты UI в package staging directory.
9. Кладёт entrypoint, templates, docs и service files.
10. Формирует итоговый install package.
11. Считает manifest/checksum.
12. Проверяет архив и прогоняет acceptance-проверки.
13. Выдаёт один переносимый результат для target machine.
## Что builder не делает
- не ставит Hysteria2 на локальной машине «для продакшена»
- не превращается в CI/CD платформу
- не генерирует update pipeline
- не делает uninstall manifests
- не готовит миграции между старыми инсталляциями
## Рекомендуемая структура
```text
project/
├── tools/
│ └── build/
│ ├── build.sh
│ ├── README.md
│ └── lib/
├── orchestrator/
│ ├── package.json
│ ├── bun.lock
│ ├── tsconfig.json
│ └── src/
├── package/
│ ├── install.sh
│ ├── orchestrator/
│ ├── templates/
│ └── systemd/
├── ui/
│ └── hy2xs-admin/
├── docs/
└── dist/
```
## Формат итогового пакета
Итоговый пакет должен содержать:
- install-only orchestrator artifact
- bundled HY2XS admin
- unit templates
- config templates
- docs / examples
- manifest версии проекта
Итоговый пакет **не должен** содержать:
- builder scripts
- исходную локальную build-среду
- временные каталоги сборки
- мусор CI
- target-side dependency install step для оркестратора
## Production builder bootstrap
`tools/build/build.sh` должен быть самодостаточным для Debian 13 amd64:
1. Проверяет ОС и архитектуру по `versions.env` (`HY2XS_BUILD_*`).
2. Проверяет структуру репозитория и lock-файлы.
3. Доставляет отсутствующие системные build-зависимости через `apt-get`.
4. Проверяет версии Go, Bun, Node.js и pnpm по `versions.env`.
5. При несовпадении версий скачивает управляемый локальный toolchain в `.toolchain/`
и **сверяет каждый архив с контрольной суммой из `versions.env`**.
6. Собирает только Linux amd64 артефакты.
7. Записывает версии toolchain в metadata пакета.
Собственных значений по умолчанию у `tools/build/lib/deps.sh` больше нет: без
загруженного контракта сборка падает сразу, а не собирает пакет на неизвестном
toolchain.
## Отношение к Hysteria2
Сам бинарь Hysteria2 **не вендорится** в install package как baseline-правило.
Причина:
- ядро Hysteria рассматривается как stable upstream component;
- целевая установка скачивает его с official upstream, но **строго по замороженным координатам**.
### Разрешение версии на сборке
```text
SOURCE
resolve latest stable (HyNetworks/hysteria, только теги app/vX.Y.Z)
resolve exact release asset (hysteria-linux-amd64 + hashes.txt)
download hashes.txt → ожидаемый SHA-256 от upstream
download artifact + сверка с ожидаемым SHA-256
compatibility gate (реальный бинарник принимает канонический конфиг HY2XS)
PACKAGE METADATA
version = vX.Y.Z
exact_url = <immutable release asset>
sha256 = <...>
resolution = latest-stable | pinned | override
TARGET SERVER
скачивает уже конкретный неизменяемый артефакт
```
Так одновременно выполняются оба требования: «по умолчанию брать последнюю стабильную» и «production-установка должна быть детерминированной и проверяемой».
Переменные builder:
| Переменная | Значение по умолчанию | Назначение |
| --- | --- | --- |
| `HYSTERIA_CHANNEL` | `stable` | `stable` — разрешить последнюю стабильную через upstream API; `pinned` — взять `tools/build/hysteria-lock.env` без сети |
| `HYSTERIA_VERSION_OVERRIDE` | пусто | Закрепить конкретную версию `vX.Y.Z` |
| `HYSTERIA_COMPAT_GATE` | `true` | Compatibility gate; для release-сборок обязателен |
| `HYSTERIA_WRITE_LOCK` | `false` | Записать разрешённые значения обратно в `tools/build/hysteria-lock.env` |
| `HYSTERIA_VERIFY_UPSTREAM_HASHES` | `true` | Сверять артефакт с upstream `hashes.txt`; отключение — только break-glass |
| `GITHUB_TOKEN` | пусто | Опционально, чтобы не упереться в anonymous rate limit |
### Проверка происхождения артефакта
Раньше SHA-256 считался локально от уже скачанного файла. Это защищает target от
последующей подмены, но не доказывает, что builder скачал именно ожидаемый
upstream artifact: сумма фиксирует то, что пришло, каким бы оно ни было
(trust-on-first-use).
Upstream публикует контрольные суммы релиза отдельным ассетом `hashes.txt`:
```text
6493dfff…f94 build/hysteria-linux-amd64
f24f63be…189 build/hysteria-linux-amd64-avx
```
Поэтому порядок теперь такой:
```text
download hysteria-linux-amd64
download hashes.txt
ожидаемый SHA-256 из upstream
сверка скачанного бинарника
и только после этого — запись SHA-256 в HY2XS lock и metadata
```
Сопоставление идёт по базовому имени и строго на равенство: `build/` — часть
пути, а `hysteria-linux-amd64-avx` — другой артефакт, который не должен совпасть
по префиксу. Разбор вынесен в `parseUpstreamHashes` и покрыт тестами.
Источник ожидаемой суммы фиксируется в `metadata/package.env`
(`hysteria_sha_source=upstream-hashes | hy2xs-lock | local-download`).
Дополнительно:
- версия, URL и SHA256 фиксируются в metadata install package (`metadata/hysteria.version`, `metadata/hysteria.url`, `metadata/hysteria.sha256`);
- способ выбора версии фиксируется в `metadata/hysteria.resolution` и `metadata/package.env`;
- runtime `reconfigure` не обновляет и не откатывает бинарник Hysteria2;
- install flow валидирует SHA256 и фактическую версию установленного бинарника;
- install-time код не обращается к upstream API и не использует moving `latest` — это проверяется тестами и acceptance-шагом сборки.
### Compatibility gate
Gate защищает от ситуации, когда upstream меняет схему конфигурации, а builder молча собирает неработающий HY2XS.
Порядок:
1. скачать артефакт и сверить SHA-256 с upstream `hashes.txt`;
2. сверить `hysteria version` с разрешённой версией;
3. отрендерить канонический конфиг HY2XS тем же кодом, что работает на target (`orchestrator/tools/render-canonical-config.ts`);
4. запустить реальный бинарник Hysteria с этим конфигом — для Gecko и для Salamander;
5. только после этого собирать release package.
При несовместимости ломается сборка:
```text
BUILD FAILED: unsupported Hysteria stable v2.13.0
```
Это осознанно: ошибка должна проявиться на build machine, а не на сервере оператора.
## Инварианты
Система считается правильной, если:
1. builder запускается на Debian 13 amd64 build host, не как target-side build step
2. пакет можно перенести на чистый Debian 13
3. на сервере нет отдельного build step
4. bundled UI уже находится внутри пакета
5. оркестратор authored as Bun/TypeScript, но на target приходит как готовый install-артефакт
6. Hysteria2 подтягивается install layer'ом с upstream по замороженным координатам, а не собирается на target из исходников
7. выход новой версии Hysteria после сборки не меняет содержимое уже собранного пакета
8. несовместимый upstream ломает сборку, а не установку у пользователя
9. контрольная сумма Hysteria подтверждена upstream-ассетом `hashes.txt`, а не только локальным пересчётом
10. версии продукта, платформы и toolchain объявлены в одном месте, а рассинхрон роняет сборку до создания tarball