b99be7d514
Сборка не собиралась: два контракта приёмки роняли её на корректном коде.
verify_api_namespace_contract искал возвращение legacy-пространства имён
через grep по '/hui' и находил router_test.go, который ПЕРЕЧИСЛЯЕТ этот
префикс, чтобы доказать отсутствие маршрута, и сам versions.sh, где строка
стоит в тексте проверки. Падение приходило шестым шагом из четырнадцати, до
резолва Hysteria. За ним прятался второй такой же: проверка транзакционности
импорта пиров брала файл от начала applyPeerImportEntry и до конца, захватывая
объявленные ниже ExistPeerName и UpdatePeerLastConnectionAt.
Обе проверки теперь смотрят на код, а не на упоминания: добавлены помощники
code_without_comments и code_mentions_in, а отсутствие legacy-маршрута
доказывает тест на таблице маршрутов собранного роутера.
Планировщик стал собственностью процесса. InitCron вызывался из runServer и
на каждом вызове создавал новый cron.New(), не сохраняя ссылку; cron.Stop()
не вызывался нигде. Смена RESET_TRAFFIC_CRON выполняла StopServer(), точка
входа крутила for { runServer() } — и каждая правка добавляла целый
дублирующий набор джоб, а старое расписание сброса продолжало работать.
Фиксированные джобы регистрируются один раз, расписание переносится на месте
по EntryID, HTTP-сервер не трогается. Добавлено штатное завершение по SIGTERM.
Выражение проверяется до записи в базу тем же парсером (cron.ParseStandard),
которым его разбирает планировщик: раньше невалидная строка сохранялась, API
отвечал успехом, а сброс трафика молча исчезал.
updateConfigs стал атомарным: полная проверка партии, одна транзакция,
применение к рантайму. Прежний тест ставил запрещённый ключ первым и не
смотрел в базу — поймать частичное применение он был неспособен.
Удалены четыре ключа таблицы config без единого потребителя: HYSTERIA2_ENABLE,
HYSTERIA2_CONFIG (второй источник истины, читался первым), HYSTERIA2_TRAFFIC_TIME
и HYSTERIA2_CONFIG_REMARK. Имя профиля в share URI выводится из имени пира.
Безопасность:
- bootstrap-пароль администратора больше не генерируется и не пишется в журнал,
который отдаётся кнопкой выгрузки; отсутствие env — отказ старта;
- собственный журнал админки санитизируется наравне с чужим;
- golang-jwt/jwt v3 -> v5: GO-2025-3553 не имеет исправленной версии в v3 и
достижима с неаутентифицированного запроса; набор алгоритмов подписи
зафиксирован через WithValidMethods;
- удалён вход по несолёному SHA-224 из предыдущего поколения;
- убран modulo bias в util.RandomString — единственном генераторе секретов;
- пир установщика защищён во всех путях записи, а не только в импорте;
- удалена латентная паника в service.GetToken и недостижимая ветка GetAdminInfo,
проверявшая меньше, чем middleware.
Toolchain: Go 1.21.13 -> 1.26.7, Node 20.19.0 (EOL) -> 24.20.0. На прежнем
графе govulncheck находил 21 вызываемую уязвимость, 17 из них в stdlib,
попадающей в production-бинарь. Сейчас — ноль. Добавлен обязательный шаг
проверки зависимостей (govulncheck + pnpm audit) с записью результата в
metadata пакета.
310 lines
14 KiB
Bash
310 lines
14 KiB
Bash
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
# Загрузка и проверка контракта versions.env.
|
||
#
|
||
# versions.env отвечает на один вопрос: «какую версию продукта, на какой
|
||
# платформе и каким toolchain мы воспроизводимо собираем». Всё остальное
|
||
# (прикладные зависимости, конкретная версия Hysteria) живёт в собственных
|
||
# lock-механизмах.
|
||
#
|
||
# Подход — проверка, а не генерация. Генерируемые исходники ломают чистый
|
||
# чекаут: `bun test`, `tsc` и `go test` должны работать до запуска сборки.
|
||
# Поэтому profile.ts / package.json / hy2xs.env остаются обычными файлами,
|
||
# а verify_versions_contract роняет сборку при рассинхроне.
|
||
|
||
VERSIONS_ENV_FILE="${VERSIONS_ENV_FILE:-$ROOT_DIR/versions.env}"
|
||
|
||
REQUIRED_CONTRACT_VARS=(
|
||
HY2XS_VERSION
|
||
HY2XS_RELEASE_LINE
|
||
HY2XS_CONFIG_SCHEMA_VERSION
|
||
HY2XS_BUILD_OS
|
||
HY2XS_BUILD_OS_VERSION
|
||
HY2XS_BUILD_ARCH
|
||
HY2XS_TARGET_OS
|
||
HY2XS_TARGET_OS_VERSION
|
||
HY2XS_TARGET_ARCH
|
||
GO_VERSION
|
||
GO_LINUX_AMD64_SHA256
|
||
BUN_VERSION
|
||
BUN_LINUX_X64_SHA256
|
||
BUN_LINUX_X64_BASELINE_SHA256
|
||
NODE_VERSION
|
||
NODE_LINUX_X64_SHA256
|
||
PNPM_VERSION
|
||
HYSTERIA_CHANNEL
|
||
GOVULNCHECK_VERSION
|
||
PNPM_AUDIT_LEVEL
|
||
)
|
||
|
||
validate_sha256_value() {
|
||
local name="$1"
|
||
local value="$2"
|
||
printf '%s' "$value" | grep -Eq '^[a-f0-9]{64}$' \
|
||
|| fail "versions.env: $name must be a 64-char lowercase hex SHA256, got: $value"
|
||
}
|
||
|
||
load_versions_contract() {
|
||
[ -f "$VERSIONS_ENV_FILE" ] || fail "missing versions contract: $VERSIONS_ENV_FILE"
|
||
|
||
# HYSTERIA_CHANNEL допускает override из окружения: офлайн-сборка из
|
||
# закоммиченного lock не должна требовать правки versions.env.
|
||
local preset_channel="${HYSTERIA_CHANNEL:-}"
|
||
|
||
# shellcheck disable=SC1090
|
||
. "$VERSIONS_ENV_FILE"
|
||
|
||
if [ -n "$preset_channel" ]; then
|
||
HYSTERIA_CHANNEL="$preset_channel"
|
||
fi
|
||
|
||
local var value
|
||
for var in "${REQUIRED_CONTRACT_VARS[@]}"; do
|
||
value="${!var:-}"
|
||
[ -n "$value" ] || fail "versions.env: missing required $var"
|
||
done
|
||
|
||
validate_sha256_value GO_LINUX_AMD64_SHA256 "$GO_LINUX_AMD64_SHA256"
|
||
validate_sha256_value BUN_LINUX_X64_SHA256 "$BUN_LINUX_X64_SHA256"
|
||
validate_sha256_value BUN_LINUX_X64_BASELINE_SHA256 "$BUN_LINUX_X64_BASELINE_SHA256"
|
||
validate_sha256_value NODE_LINUX_X64_SHA256 "$NODE_LINUX_X64_SHA256"
|
||
|
||
printf '%s' "$HY2XS_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \
|
||
|| fail "versions.env: HY2XS_VERSION must be X.Y.Z, got: $HY2XS_VERSION"
|
||
printf '%s' "$HY2XS_RELEASE_LINE" | grep -Eq '^[0-9]+$' \
|
||
|| fail "versions.env: HY2XS_RELEASE_LINE must be an integer, got: $HY2XS_RELEASE_LINE"
|
||
printf '%s' "$HY2XS_CONFIG_SCHEMA_VERSION" | grep -Eq '^[0-9]+$' \
|
||
|| fail "versions.env: HY2XS_CONFIG_SCHEMA_VERSION must be an integer"
|
||
|
||
# HY2XS_TARGET_OS — это дистрибутив (debian), а GOOS/`target_os` в metadata
|
||
# оперируют ядром. Производная величина, а не второй источник истины.
|
||
case "$HY2XS_TARGET_OS" in
|
||
debian) HY2XS_TARGET_GOOS=linux ;;
|
||
*) fail "versions.env: unsupported HY2XS_TARGET_OS: $HY2XS_TARGET_OS" ;;
|
||
esac
|
||
export HY2XS_TARGET_GOOS
|
||
|
||
# Мост к deps.sh: там больше нет собственных значений по умолчанию.
|
||
GO_REQUIRED="$GO_VERSION"
|
||
BUN_REQUIRED="$BUN_VERSION"
|
||
NODE_REQUIRED="$NODE_VERSION"
|
||
PNPM_REQUIRED="$PNPM_VERSION"
|
||
GO_ARCHIVE_SHA256="$GO_LINUX_AMD64_SHA256"
|
||
NODE_ARCHIVE_SHA256="$NODE_LINUX_X64_SHA256"
|
||
|
||
export HY2XS_VERSION HY2XS_RELEASE_LINE HY2XS_CONFIG_SCHEMA_VERSION
|
||
export HY2XS_BUILD_OS HY2XS_BUILD_OS_VERSION HY2XS_BUILD_ARCH
|
||
export HY2XS_TARGET_OS HY2XS_TARGET_OS_VERSION HY2XS_TARGET_ARCH
|
||
export GO_VERSION GO_LINUX_AMD64_SHA256
|
||
export BUN_VERSION BUN_LINUX_X64_SHA256 BUN_LINUX_X64_BASELINE_SHA256
|
||
export NODE_VERSION NODE_LINUX_X64_SHA256 PNPM_VERSION HYSTERIA_CHANNEL
|
||
export GO_REQUIRED BUN_REQUIRED NODE_REQUIRED PNPM_REQUIRED
|
||
export GO_ARCHIVE_SHA256 NODE_ARCHIVE_SHA256
|
||
export GOVULNCHECK_VERSION PNPM_AUDIT_LEVEL
|
||
}
|
||
|
||
# Ожидаемая контрольная сумма Bun зависит от выбранного артефакта: на CPU без
|
||
# AVX2 скачивается bun-linux-x64-baseline, и одного digest'а не хватает.
|
||
expected_bun_sha256() {
|
||
case "${BUN_ARTIFACT:-}" in
|
||
bun-linux-x64)
|
||
printf '%s' "$BUN_LINUX_X64_SHA256"
|
||
;;
|
||
bun-linux-x64-baseline)
|
||
printf '%s' "$BUN_LINUX_X64_BASELINE_SHA256"
|
||
;;
|
||
*)
|
||
fail "expected_bun_sha256: unknown BUN_ARTIFACT: ${BUN_ARTIFACT:-<unset>}"
|
||
;;
|
||
esac
|
||
}
|
||
|
||
read_json_string_field() {
|
||
local file="$1"
|
||
local field="$2"
|
||
grep -Eo "\"${field}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$file" \
|
||
| head -n1 \
|
||
| sed -E "s/.*:[[:space:]]*\"([^\"]*)\"/\1/"
|
||
}
|
||
|
||
read_env_field() {
|
||
local file="$1"
|
||
local key="$2"
|
||
grep -E "^${key}=" "$file" | head -n1 | cut -d= -f2-
|
||
}
|
||
|
||
expect_equal() {
|
||
local label="$1"
|
||
local actual="$2"
|
||
local expected="$3"
|
||
[ "$actual" = "$expected" ] \
|
||
|| fail "versions contract mismatch: $label is '$actual', versions.env declares '$expected'"
|
||
}
|
||
|
||
# Сравнение идёт с константами, СКОМПИЛИРОВАННЫМИ в оркестратор, а не с
|
||
# текстом исходников: grep по файлу не доказывает, что в бинарь попало то же.
|
||
verify_orchestrator_contract() {
|
||
local output
|
||
output="$(cd orchestrator && "$BUN_BIN" run tools/print-contract.ts)" \
|
||
|| fail "versions contract: orchestrator contract dump failed"
|
||
|
||
local line key value
|
||
while IFS= read -r line; do
|
||
[ -n "$line" ] || continue
|
||
key="${line%%=*}"
|
||
value="${line#*=}"
|
||
case "$key" in
|
||
HY2XS_CONFIG_SCHEMA_VERSION)
|
||
expect_equal "orchestrator HY2XS_CONFIG_SCHEMA_VERSION" "$value" "$HY2XS_CONFIG_SCHEMA_VERSION"
|
||
;;
|
||
HY2XS_RELEASE_LINE)
|
||
expect_equal "orchestrator HY2XS_RELEASE_LINE" "$value" "$HY2XS_RELEASE_LINE"
|
||
;;
|
||
HY2XS_TARGET_OS_VERSION)
|
||
expect_equal "orchestrator target OS version" "$value" "$HY2XS_TARGET_OS_VERSION"
|
||
;;
|
||
HY2XS_TARGET_ARCH)
|
||
expect_equal "orchestrator target arch" "$value" "$HY2XS_TARGET_ARCH"
|
||
;;
|
||
HY2XS_ADMIN_API_BASE)
|
||
HY2XS_ADMIN_API_BASE="$value"
|
||
;;
|
||
HY2XS_HYSTERIA_MACHINE_AUTH_PATH)
|
||
HY2XS_HYSTERIA_MACHINE_AUTH_PATH="$value"
|
||
;;
|
||
*)
|
||
fail "versions contract: unexpected orchestrator contract line: $line"
|
||
;;
|
||
esac
|
||
done <<<"$output"
|
||
|
||
[ -n "${HY2XS_ADMIN_API_BASE:-}" ] \
|
||
|| fail "versions contract: orchestrator did not report HY2XS_ADMIN_API_BASE"
|
||
[ -n "${HY2XS_HYSTERIA_MACHINE_AUTH_PATH:-}" ] \
|
||
|| fail "versions contract: orchestrator did not report HY2XS_HYSTERIA_MACHINE_AUTH_PATH"
|
||
export HY2XS_ADMIN_API_BASE HY2XS_HYSTERIA_MACHINE_AUTH_PATH
|
||
}
|
||
|
||
# API namespace — это runtime-контракт между тремя компонентами.
|
||
#
|
||
# Путь machine-auth уезжает в /etc/hysteria/config.yaml и в post-install.env,
|
||
# то есть по нему Hysteria обращается к админке. Пока строка была размазана по
|
||
# шаблонам, smoke, тестам и e2e, любое расхождение обнаруживалось только на
|
||
# живом сервере. Здесь она сверяется во всех местах сразу — против константы,
|
||
# скомпилированной в оркестратор.
|
||
verify_api_namespace_contract() {
|
||
local go_auth_path go_api_base
|
||
|
||
go_auth_path="$(grep -Eo 'HysteriaMachineAuthPath[[:space:]]*=[[:space:]]*"[^"]*"' apps/model/constant/api.go \
|
||
| head -n1 | sed -E 's/.*"([^"]*)"/\1/')"
|
||
go_api_base="$(grep -Eo 'AdminAPIBase[[:space:]]*=[[:space:]]*"[^"]*"' apps/model/constant/api.go \
|
||
| head -n1 | sed -E 's/.*"([^"]*)"/\1/')"
|
||
|
||
expect_equal "apps constant.HysteriaMachineAuthPath" "$go_auth_path" "$HY2XS_HYSTERIA_MACHINE_AUTH_PATH"
|
||
expect_equal "apps constant.AdminAPIBase" "$go_api_base" "$HY2XS_ADMIN_API_BASE"
|
||
|
||
local frontend_base
|
||
frontend_base="$(grep -Eo 'const API_BASE = "[^"]*"' apps/frontend/src/utils/request.ts \
|
||
| head -n1 | sed -E 's/.*"([^"]*)"/\1/')"
|
||
expect_equal "frontend API_BASE" "$frontend_base" "$HY2XS_ADMIN_API_BASE"
|
||
|
||
grep -qF "${HY2XS_HYSTERIA_MACHINE_AUTH_PATH}?access_token={{HYSTERIA_API_SECRET}}" \
|
||
package/templates/hysteria/config.yaml.tpl \
|
||
|| fail "versions contract: hysteria template does not use ${HY2XS_HYSTERIA_MACHINE_AUTH_PATH}"
|
||
grep -qF "${HY2XS_HYSTERIA_MACHINE_AUTH_PATH}?access_token={{HYSTERIA_API_SECRET}}" \
|
||
package/templates/env/post-install.env.tpl \
|
||
|| fail "versions contract: post-install env template does not use ${HY2XS_HYSTERIA_MACHINE_AUTH_PATH}"
|
||
|
||
# Старое пространство имён не имеет права вернуться ни в один компонент.
|
||
# Историческое имя допустимо только в docs/14-legacy-cleanup.md и в
|
||
# legacy-маркерах clean-host: там это имя чужого артефакта, а не наше.
|
||
#
|
||
# Сканируются ТОЛЬКО runtime production sources и то, что уезжает в пакет.
|
||
# Файлы, задача которых — доказать отсутствие legacy-пространства, из скана
|
||
# исключены: они обязаны называть его по имени, иначе проверять нечего.
|
||
#
|
||
# Регрессия приёмки. Раньше исключений не было, а в список каталогов входили
|
||
# tools/build и orchestrator/test. Контракт ловил сам себя: grep возвращал
|
||
# apps/router/router_test.go (регрессионный тест на отсутствие маршрута) и
|
||
# этот самый файл, где строка '/hui' стоит в тексте проверки. Сборка падала
|
||
# на шаге versions contract — то есть добавление теста, закрепляющего
|
||
# очистку, гарантированно ломало сборку.
|
||
#
|
||
# Гарантия при этом не ослабла, а переехала на слой, где она сильнее: скан
|
||
# исходников доказывает отсутствие СТРОКИ, а TestRouterHasNoLegacyNamespace
|
||
# проверяет ТАБЛИЦУ МАРШРУТОВ собранного роутера. Поэтому существование
|
||
# этого теста тоже входит в контракт и проверяется ниже.
|
||
local legacy_hits
|
||
legacy_hits="$(grep -rlF --exclude='*_test.go' '/hui' \
|
||
apps/model apps/router apps/controller apps/service apps/middleware \
|
||
apps/cmd apps/dao apps/util apps/frontend/src \
|
||
orchestrator/src package/templates tools/test 2>/dev/null || true)"
|
||
[ -z "$legacy_hits" ] \
|
||
|| fail "versions contract: legacy /hui namespace came back in: $legacy_hits"
|
||
|
||
grep -q 'func TestRouterHasNoLegacyNamespace' apps/router/router_test.go \
|
||
|| fail "versions contract: the legacy-namespace route test disappeared from apps/router/router_test.go"
|
||
}
|
||
|
||
verify_go_toolchain_contract() {
|
||
local declared_go_line contract_minor go_minor
|
||
declared_go_line="$(grep -E '^go[[:space:]]+[0-9]' apps/go.mod | head -n1 | awk '{print $2}')"
|
||
[ -n "$declared_go_line" ] || fail "versions contract: apps/go.mod has no 'go' directive"
|
||
|
||
contract_minor="$(printf '%s' "$GO_VERSION" | cut -d. -f2)"
|
||
go_minor="$(printf '%s' "$declared_go_line" | cut -d. -f2)"
|
||
|
||
[ "$(printf '%s' "$GO_VERSION" | cut -d. -f1)" = "$(printf '%s' "$declared_go_line" | cut -d. -f1)" ] \
|
||
|| fail "versions contract: apps/go.mod major version $declared_go_line is incompatible with GO_VERSION $GO_VERSION"
|
||
[ "$go_minor" -le "$contract_minor" ] \
|
||
|| fail "versions contract: apps/go.mod requires go $declared_go_line, but GO_VERSION is $GO_VERSION"
|
||
}
|
||
|
||
verify_versions_contract() {
|
||
log_step "Verifying versions contract (versions.env)"
|
||
|
||
expect_equal "PACKAGE_VERSION" "${PACKAGE_VERSION:-}" "$HY2XS_VERSION"
|
||
|
||
expect_equal \
|
||
"orchestrator/package.json packageManager" \
|
||
"$(read_json_string_field orchestrator/package.json packageManager)" \
|
||
"bun@$BUN_VERSION"
|
||
|
||
expect_equal \
|
||
"apps/frontend/package.json packageManager" \
|
||
"$(read_json_string_field apps/frontend/package.json packageManager)" \
|
||
"pnpm@$PNPM_VERSION"
|
||
|
||
expect_equal \
|
||
"package/config/hy2xs.env HY2XS_CONFIG_SCHEMA_VERSION" \
|
||
"$(read_env_field package/config/hy2xs.env HY2XS_CONFIG_SCHEMA_VERSION)" \
|
||
"$HY2XS_CONFIG_SCHEMA_VERSION"
|
||
|
||
verify_orchestrator_contract
|
||
verify_api_namespace_contract
|
||
verify_go_toolchain_contract
|
||
|
||
expect_equal "build host OS" "$HY2XS_BUILD_OS" "debian"
|
||
expect_equal "build host arch" "$HY2XS_BUILD_ARCH" "amd64"
|
||
expect_equal "target OS" "$HY2XS_TARGET_OS" "debian"
|
||
expect_equal "target arch" "$HY2XS_TARGET_ARCH" "amd64"
|
||
|
||
log_info "versions contract is consistent: HY2XS $HY2XS_VERSION (release line $HY2XS_RELEASE_LINE, config schema $HY2XS_CONFIG_SCHEMA_VERSION)"
|
||
}
|
||
|
||
# Проверяется уже собранный бинарь: это единственное доказательство, что
|
||
# ldflags действительно доехали до артефакта.
|
||
verify_admin_version_contract() {
|
||
local binary="$1"
|
||
local reported expected
|
||
|
||
[ -x "$binary" ] || fail "versions contract: admin binary not found: $binary"
|
||
|
||
expected="v$HY2XS_VERSION"
|
||
reported="$("$binary" version 2>/dev/null | grep -Eo 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)"
|
||
|
||
[ -n "$reported" ] || fail "versions contract: admin binary did not report a version"
|
||
expect_equal "hy2xs-admin reported version" "$reported" "$expected"
|
||
}
|