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 скриптов, приёмка прогнана против дерева.
293 lines
12 KiB
Bash
293 lines
12 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
|
||
)
|
||
|
||
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
|
||
}
|
||
|
||
# Ожидаемая контрольная сумма 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: там это имя чужого артефакта, а не наше.
|
||
#
|
||
# Список каталогов покрывает КАЖДЫЙ слой, который может собрать URL: раньше
|
||
# в нём не было apps/middleware, apps/cmd, apps/dao и apps/util, то есть
|
||
# ровно тех мест, где живут HTTP-логгер, точка входа сервера и helpers.
|
||
local legacy_hits
|
||
legacy_hits="$(grep -rlF '/hui' \
|
||
apps/model apps/router apps/controller apps/service apps/middleware \
|
||
apps/cmd apps/dao apps/util apps/frontend/src \
|
||
orchestrator/src orchestrator/test package/templates \
|
||
tools/test tools/build 2>/dev/null || true)"
|
||
[ -z "$legacy_hits" ] \
|
||
|| fail "versions contract: legacy /hui namespace came back in: $legacy_hits"
|
||
}
|
||
|
||
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"
|
||
}
|