Предыдущий проход сделал правильным порядок «сначала долговременная запись, потом разрыв сессии» и правильно запретил откат при неудаче разрыва. Способа прийти к согласованному состоянию ПОТОМ он не дал: у двух операций повтор не работал вовсе. Импорт, заменивший 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
29 KiB
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 — не формально доказанный минимум, а подтверждённая рабочая конфигурация того прогона; см. отчёт приёмки. Практическое следствие: сборочная машина примерно с 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».
Что в нём есть:
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
Чего в нём нет и быть не должно:
- Прикладных зависимостей (Vue, Gin, GORM, npm/Go модули). У них уже есть
канонические lock-механизмы:
apps/frontend/pnpm-lock.yaml,orchestrator/bun.lock,apps/go.sum. Второй слой неизбежно разъедется с настоящим графом зависимостей. - Конкретной версии 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 — обязательный шаг релиза
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.
Контракт теперь читается так:
проверка типов 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:
-ldflags "-s -w -X 'hy2xs-admin/model/constant.Version=v${HY2XS_VERSION}'"
Собственной константы версии в Go-коде больше нет: она уже успела разъехаться с версией пакета.
Что делает builder
- Проверяет структуру проекта.
- Загружает и проверяет контракт
versions.env. - Прогоняет тесты и типы оркестратора.
- Разрешает upstream-версию Hysteria и проходит compatibility gate.
- Компилирует оркестратор из Bun/TypeScript в install-артефакт.
- Собирает / подготавливает HY2XS admin и сверяет его версию с контрактом.
- Прогоняет тесты HY2XS admin (после сборки frontend:
go:embed all:distтребует готовых ассетов). - Копирует артефакты UI в package staging directory.
- Кладёт entrypoint, templates, docs и service files.
- Формирует итоговый install package.
- Считает manifest/checksum.
- Проверяет архив и прогоняет acceptance-проверки.
- Выдаёт один переносимый результат для target machine.
Что builder не делает
- не ставит Hysteria2 на локальной машине «для продакшена»
- не превращается в CI/CD платформу
- не генерирует update pipeline
- не делает uninstall manifests
- не готовит миграции между старыми инсталляциями
Рекомендуемая структура
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:
- Проверяет ОС и архитектуру по
versions.env(HY2XS_BUILD_*). - Проверяет структуру репозитория и lock-файлы.
- Доставляет отсутствующие системные build-зависимости через
apt-get. - Проверяет версии Go, Bun, Node.js и pnpm по
versions.env. - При несовпадении версий скачивает управляемый локальный toolchain в
.toolchain/и сверяет каждый архив с контрольной суммой изversions.env. - Собирает только Linux amd64 артефакты.
- Записывает версии toolchain в metadata пакета.
Собственных значений по умолчанию у tools/build/lib/deps.sh больше нет: без
загруженного контракта сборка падает сразу, а не собирает пакет на неизвестном
toolchain.
Отношение к Hysteria2
Сам бинарь Hysteria2 не вендорится в install package как baseline-правило.
Причина:
- ядро Hysteria рассматривается как stable upstream component;
- целевая установка скачивает его с official upstream, но строго по замороженным координатам.
Разрешение версии на сборке
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:
6493dfff…f94 build/hysteria-linux-amd64
f24f63be…189 build/hysteria-linux-amd64-avx
Поэтому порядок теперь такой:
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.
Порядок:
- скачать артефакт и сверить SHA-256 с upstream
hashes.txt; - сверить
hysteria versionс разрешённой версией; - отрендерить канонический конфиг HY2XS тем же кодом, что работает на target (
orchestrator/tools/render-canonical-config.ts); - запустить реальный бинарник Hysteria с этим конфигом — для Gecko и для Salamander;
- только после этого собирать release package.
При несовместимости ломается сборка:
BUILD FAILED: unsupported Hysteria stable v2.13.0
Это осознанно: ошибка должна проявиться на build machine, а не на сервере оператора.
Инварианты
Система считается правильной, если:
- builder запускается на Debian 13 amd64 build host, не как target-side build step
- пакет можно перенести на чистый Debian 13
- на сервере нет отдельного build step
- bundled UI уже находится внутри пакета
- оркестратор authored as Bun/TypeScript, но на target приходит как готовый install-артефакт
- Hysteria2 подтягивается install layer'ом с upstream по замороженным координатам, а не собирается на target из исходников
- выход новой версии Hysteria после сборки не меняет содержимое уже собранного пакета
- несовместимый upstream ломает сборку, а не установку у пользователя
- контрольная сумма Hysteria подтверждена upstream-ассетом
hashes.txt, а не только локальным пересчётом - версии продукта, платформы и toolchain объявлены в одном месте, а рассинхрон роняет сборку до создания tarball