6d1686b2be
Второй разбор того же слоя, уже по состоянию после 162759c. Тема: границы между
частями access-control. Прошлый проход починил одну операцию отзыва доступа и
оставил остальные; правило доступа при этом продолжало существовать в двух
экземплярах. Проведены три границы: состояние пира -> решение о доступе,
сохранённое изменение -> живая сессия, планировщик -> принадлежащая ему работа.
Правило доступа. Оно было записано двумя разными SQL-условиями: одним в выборке
Hysteria2Auth, другим в выборке cron. Второе не является отрицанием первого, и
расхождение приходилось ровно на границы — quota=0, usage=quota, now=expiresAt,
now=bannedUntil: авторизация отказывала, cron сессию не рвал. Условие cron
требовало СТРОГОГО превышения квоты, а счётчики растут порциями по ответу
Traffic Stats API, поэтому точное равенство — обычный исход очередного сбора.
Пир с исчерпанной квотой не пускался заново, но его живая сессия не разрывалась
никогда. Политика вынесена в peerAccessDenied; авторизация ищет пира только по
secret_digest, cron применяет ту же функцию. quota=-1 — единственный безлимит,
quota=0 — ноль байтов, bannedUntil=now — блокировка уже закончилась. Строка без
решающего поля трактуется как повреждённая и ведёт к отказу.
Операции, оставлявшие живую сессию. DeletePeer состоял из одного dao.DeletePeer:
строка исчезала вместе с auth_id, то есть вместе с единственным, чем эту сессию
можно было завершить, — состояние становилось невосстановимым. Разрыв при
изменении выполнялся только при disabled=1, поэтому мимо проходили смена
секрета, урезание квоты ниже израсходованного, перенос срока в прошлое и
снижение maxDevices. Импорт переписывает auth_id, секрет, квоту, срок и disabled
целиком и не трогал сессий вовсе. Все операции идут теперь через один
reconcileLiveSessions, а он — через disconnectAuthIDs, единственный вход к /kick:
он принимает готовые идентификаторы, дедуплицирует их, разбивает на части и не
обращается к базе. Импорт собирает старые auth_id ВНУТРИ транзакции (после
commit их в базе уже нет) и рвёт ПОСЛЕ commit (до него клиент успел бы
переподключиться к ещё не изменённому пиру). Правило асимметрично намеренно:
ограничение применяется немедленно, послабление — нет.
Цикл учёта. CronHandleAccount запускала горутину, которая запускала ещё две, —
для планировщика джоба заканчивалась почти мгновенно, поэтому StopCron не ждал
настоящей работы: releaseResource закрывал SQLite, а горутины продолжали в неё
писать. Параллельность обеих половин означала ещё и то, что enforcement читал
счётчики до записи снятой дельты. Джоба стала синхронной, под одним мьютексом на
весь цикл, порядок строгий. Закрыты три nil-разыменования — trafficSecretConfig,
item.AuthId и item.Id, — каждое из которых роняло процесс целиком вместе с
обработчиком machine-auth. Гейт Hysteria2IsRunning убран: util.Exec не отличает
«служба неактивна» от «спросить не удалось», и сломанный systemctl при живой
Hysteria молча отключал и учёт, и enforcement. Потеря дельты при отказе SQLite
больше не молчит: чтение /traffic?clear=1 деструктивно, и каждая потеря
считается. Checkpoint accounting в 1.0.0 намеренно не вводится — квота здесь
операционный предел доступа, а не учёт с финансово значимым каждым байтом.
Лимит устройств. Между чтением /online и ответом allow место ничем не
удерживалось: при online=max-1 два одновременных запроса получали разрешение
оба. Мьютекс вокруг /online этого не чинит — ответив allow, админка не создаёт
подключение, и следующий запрос продолжает видеть прежнее число. Появился
process-local учёт выданных, но ещё не проявившихся разрешений: решение по сумме
«подключено плюс зарезервировано», рост online снимает соответствующее их число,
протухшие снимаются по внутреннему TTL. Сеть опрашивается вне блокировки.
Гейты. Проверка «авторизация не возвращает успех из ветки ошибки» была записана
регуляркой err != nil \{[\s\S]*?return \*peer\.Id, а ленивый [\s\S]*? свободно
пересекает границы блоков: она даёт совпадение на коде из HEAD, то есть гейт
нельзя было удовлетворить, не сломав продукт. Тело ветки теперь выделяется по
балансу фигурных скобок, и логика проверена в обе стороны. go test -race стал
обязательным шагом сборки: состояние трекера разрешений и мьютекс цикла учёта
принадлежат процессу, и их корректность не наблюдаема ни в go test, ни в go vet;
пропуск при недоступном компиляторе не предусмотрен.
Панель. importPeerApi не объявлял skipErrorToast, а handleImport не имел ни try,
ни catch: после появления частичного результата отказ уходил бы необработанным
отклонением промиса, список не обновлялся бы при уже изменённой базе, а общий
перехватчик показал бы предупреждение красной ошибкой. Формулировка
peer_disconnect_failed во всех трёх местах сделана operation-neutral: через этот
код отчитываются восемь операций, а для удалённого пира прежняя фраза «новые
подключения пира запрещены» просто бессмысленна.
290 lines
15 KiB
Bash
290 lines
15 KiB
Bash
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
STAGE_DIR="tools/build/output/hy2xs-install"
|
||
ADMIN_BUILD_DIR="tools/build/output/hy2xs-admin-build"
|
||
|
||
prepare_stage() {
|
||
local version="$1"
|
||
local build_id="$2"
|
||
|
||
rm -rf "$STAGE_DIR"
|
||
mkdir -p "$STAGE_DIR" "dist"
|
||
copy_dir_contents "package" "$STAGE_DIR"
|
||
chmod 0755 "$STAGE_DIR/install.sh"
|
||
find "$STAGE_DIR" -type d -exec chmod 0755 {} +
|
||
find "$STAGE_DIR" -type f -name "*.service" -exec chmod 0644 {} +
|
||
find "$STAGE_DIR" -type f -name "*.tpl" -exec chmod 0644 {} +
|
||
find "$STAGE_DIR/config" -type f -exec chmod 0644 {} +
|
||
|
||
rm -rf "$STAGE_DIR/orchestrator" "$STAGE_DIR/ui" "$STAGE_DIR/metadata"
|
||
mkdir -p "$STAGE_DIR/orchestrator" "$STAGE_DIR/ui/hy2xs-admin" "$STAGE_DIR/metadata"
|
||
|
||
install -m 0644 LICENSE "$STAGE_DIR/LICENSE"
|
||
|
||
printf '%s\n' "$version" >"$STAGE_DIR/metadata/package.version"
|
||
printf '%s\n' "$build_id" >"$STAGE_DIR/metadata/package.build_id"
|
||
printf '%s\n' "$HY2XS_RELEASE_LINE" >"$STAGE_DIR/metadata/package.release_line"
|
||
printf '%s\n' "$HY2XS_CONFIG_SCHEMA_VERSION" >"$STAGE_DIR/metadata/package.config_schema_version"
|
||
}
|
||
|
||
# Тесты — обязательный шаг релиза, аварийного выхода у него НЕТ.
|
||
#
|
||
# Это то же решение и по той же причине, что и у проверки зависимостей.
|
||
#
|
||
# Раньше существовал SKIP_TESTS=true, и README описывал его как «аварийное
|
||
# отключение тестов; для release-сборок недопустимо». Недопустимость держалась
|
||
# исключительно на этой фразе: ни metadata, ни финальная приёмка архива не
|
||
# проверяли, что тесты вообще запускались. То есть
|
||
#
|
||
# SKIP_TESTS=true ./tools/build/build.sh
|
||
#
|
||
# доходила до конца и выдавала обычный tarball с build_profile=production и
|
||
# dependency_security_gate=true — артефакт, по которому невозможно отличить
|
||
# проверенную сборку от непроверенной.
|
||
#
|
||
# Глушил он при этом не только тесты: под тем же флагом пропускались
|
||
# `tsc --noEmit` для оркестратора и `go vet` для админки, то есть проверка типов
|
||
# и статический анализ того самого кода, который уезжает в production.
|
||
#
|
||
# Для локальной работы обходить нечего: `bun test`, `bun x tsc --noEmit`,
|
||
# `go vet ./...` и `go test ./...` запускаются напрямую и tarball не создают.
|
||
run_orchestrator_tests() {
|
||
(
|
||
cd orchestrator
|
||
"$BUN_BIN" install --frozen-lockfile
|
||
"$BUN_BIN" x tsc --noEmit
|
||
"$BUN_BIN" test
|
||
) || fail "orchestrator contract tests failed"
|
||
|
||
# Утверждение о результате выставляется ПОСЛЕ получения результата, ровно как
|
||
# DEPENDENCY_SECURITY_GATE.
|
||
ORCHESTRATOR_TESTS_PASSED="true"
|
||
export ORCHESTRATOR_TESTS_PASSED
|
||
}
|
||
|
||
run_frontend_tests() {
|
||
# Контракты панели, которые не проверяются ни типами, ни сборкой bundle:
|
||
# цвет иконок в спрайте, совпадение словарей локализации, соответствие кодов
|
||
# ошибок серверным константам, единственность адреса атрибуции.
|
||
#
|
||
# Исполняет их Bun, уже закреплённый в versions.env, а не vitest. Причина не
|
||
# в удобстве: jsdom не вычисляет currentColor и визуальной корректности всё
|
||
# равно не доказал бы, зато vitest привёл бы в граф `pnpm audit` — а его
|
||
# порог считается по ВСЕМУ lock-файлу frontend — сотню транзитивных
|
||
# зависимостей ради нулевой дополнительной гарантии.
|
||
#
|
||
# Проверяемые модули (SvgIcon/symbol.ts, constants/branding.ts, словари)
|
||
# намеренно чистые: ни Vite, ни DOM в них нет, поэтому их можно выполнить вне
|
||
# браузера.
|
||
"$BUN_BIN" test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts \
|
||
|| fail "HY2XS admin frontend contract tests failed"
|
||
|
||
FRONTEND_TESTS_PASSED="true"
|
||
export FRONTEND_TESTS_PASSED
|
||
}
|
||
|
||
run_admin_tests() {
|
||
# `go:embed all:dist` требует собранных frontend-ассетов, поэтому эта
|
||
# функция должна вызываться только после bundle_ui.
|
||
[ -d "${UI_SRC:-apps}/frontend/dist" ] \
|
||
|| fail "run_admin_tests must run after bundle_ui: ${UI_SRC:-apps}/frontend/dist is missing"
|
||
|
||
(
|
||
cd "${UI_SRC:-apps}"
|
||
GOTOOLCHAIN=local "$GO_BIN" vet ./...
|
||
GOTOOLCHAIN=local "$GO_BIN" test ./...
|
||
) || fail "HY2XS admin contract tests failed"
|
||
|
||
run_admin_race_tests
|
||
|
||
ADMIN_TESTS_PASSED="true"
|
||
export ADMIN_TESTS_PASSED
|
||
}
|
||
|
||
# Детектор гонок — отдельный прогон, и он ОБЯЗАТЕЛЕН.
|
||
#
|
||
# В продукте есть состояние, принадлежащее процессу, а не базе: учёт выданных
|
||
# разрешений на устройства (peer_admission.go) и мьютекс цикла учёта
|
||
# (cron.go). Оба существуют ровно затем, чтобы вести себя правильно под
|
||
# параллельным доступом, — то есть их корректность не наблюдаема ни в обычном
|
||
# прогоне тестов, ни в `go vet`.
|
||
#
|
||
# Пропуск при недоступном C-компиляторе здесь НЕ предусмотрен, и это осознанно.
|
||
# «Тихо не проверили» — ровно тот класс, от которого лечится запрет SKIP_TESTS
|
||
# в этом же файле: сборка, молча пропускающая проверку, выдаёт внешне
|
||
# неотличимый production-артефакт. build-essential входит в обязательные
|
||
# зависимости сборщика (см. tools/build/lib/deps.sh), поэтому попасть в ветку
|
||
# отказа можно только на сломанном окружении — и об этом нужно узнать.
|
||
#
|
||
# CGO_ENABLED=1 задаётся явно: production-сборка админки идёт с CGO_ENABLED=0,
|
||
# и унаследованное значение отключило бы детектор.
|
||
run_admin_race_tests() {
|
||
command -v cc >/dev/null 2>&1 || command -v gcc >/dev/null 2>&1 \
|
||
|| fail "race detector requires a C toolchain (build-essential); refusing to ship untested concurrency"
|
||
|
||
(
|
||
cd "${UI_SRC:-apps}"
|
||
CGO_ENABLED=1 GOTOOLCHAIN=local "$GO_BIN" test -race ./service/...
|
||
) || fail "HY2XS admin race detector found a data race"
|
||
}
|
||
|
||
build_orchestrator() {
|
||
local bun_compile_target="${BUN_COMPILE_TARGET:-bun-linux-x64}"
|
||
|
||
log_info "Building orchestrator with Bun compile target: $bun_compile_target"
|
||
|
||
(
|
||
cd orchestrator
|
||
"$BUN_BIN" install --frozen-lockfile
|
||
"$BUN_BIN" build src/cli.ts --compile --target="$bun_compile_target" --outfile ../"$STAGE_DIR"/orchestrator/hy2xs-orchestrator
|
||
)
|
||
|
||
chmod 0755 "$STAGE_DIR/orchestrator/hy2xs-orchestrator"
|
||
}
|
||
|
||
bundle_ui() {
|
||
local ui_src="${UI_SRC:-apps}"
|
||
|
||
rm -rf "$ADMIN_BUILD_DIR"
|
||
mkdir -p "$ADMIN_BUILD_DIR"
|
||
|
||
(
|
||
cd "$ui_src/frontend"
|
||
|
||
local frontend_node_old_space_size="${FRONTEND_NODE_OLD_SPACE_SIZE:-2048}"
|
||
local frontend_node_options="${NODE_OPTIONS:-}"
|
||
|
||
if [[ "$frontend_node_options" != *"--max-old-space-size="* ]]; then
|
||
frontend_node_options="${frontend_node_options:+$frontend_node_options }--max-old-space-size=${frontend_node_old_space_size}"
|
||
fi
|
||
|
||
export NODE_OPTIONS="$frontend_node_options"
|
||
log_info "Frontend Node.js options: $NODE_OPTIONS"
|
||
|
||
"$PNPM_BIN" install --frozen-lockfile
|
||
|
||
# Проверка типов идёт ПЕРЕД сборкой bundle, и это обязательный шаг релиза.
|
||
#
|
||
# Раньше её здесь не было вовсе: `build:prod` означал
|
||
# `vite build && vue-tsc --noEmit`, то есть сборка сначала тратила время на
|
||
# production bundle и только потом сообщала о типовой ошибке. Сам же
|
||
# `vue-tsc` был версии 0.35 и не находил ничего — на современном он даёт 142
|
||
# ошибки на том же коде.
|
||
#
|
||
# Теперь скрипты разделены, и порядок обратный: смысла собирать bundle из
|
||
# кода, который не проходит проверку типов, нет.
|
||
"$PNPM_BIN" run typecheck
|
||
"$PNPM_BIN" run build:prod
|
||
)
|
||
|
||
# Версия админки приходит из versions.env через ldflags: один и тот же
|
||
# исполняемый файл обязан знать версию пакета, из которого он собран.
|
||
local admin_ldflags="-s -w -X 'hy2xs-admin/model/constant.Version=v${HY2XS_VERSION}'"
|
||
|
||
(
|
||
cd "$ui_src"
|
||
GOOS="$HY2XS_TARGET_GOOS" GOARCH="$HY2XS_TARGET_ARCH" CGO_ENABLED=0 GOTOOLCHAIN=local "$GO_BIN" mod download
|
||
GOOS="$HY2XS_TARGET_GOOS" GOARCH="$HY2XS_TARGET_ARCH" CGO_ENABLED=0 GOTOOLCHAIN=local "$GO_BIN" build \
|
||
-trimpath -ldflags "$admin_ldflags" -o "../$ADMIN_BUILD_DIR/hy2xs-admin" .
|
||
)
|
||
|
||
verify_admin_version_contract "$ADMIN_BUILD_DIR/hy2xs-admin"
|
||
|
||
install -m 0755 "$ADMIN_BUILD_DIR/hy2xs-admin" "$STAGE_DIR/ui/hy2xs-admin/hy2xs-admin"
|
||
if [ -f "$ui_src/docs/sql/schema.sql" ]; then
|
||
mkdir -p "$STAGE_DIR/ui/hy2xs-admin/docs/sql"
|
||
install -m 0644 "$ui_src/docs/sql/schema.sql" "$STAGE_DIR/ui/hy2xs-admin/docs/sql/schema.sql"
|
||
fi
|
||
}
|
||
|
||
write_metadata() {
|
||
local version="$1"
|
||
local build_id="$2"
|
||
local source_git_commit
|
||
local dirty_tree="false"
|
||
|
||
source_git_commit="$(git rev-parse --short=12 HEAD 2>/dev/null || echo unknown)"
|
||
if [ -n "$(git status --porcelain 2>/dev/null || true)" ]; then
|
||
dirty_tree="true"
|
||
fi
|
||
|
||
# Значения приходят из resolve_and_verify_hysteria: они уже проверены
|
||
# по SHA-256, по `hysteria version` и по compatibility gate.
|
||
[ -n "${HYSTERIA_VERSION:-}" ] || fail "HYSTERIA_VERSION was not resolved before write_metadata"
|
||
[ -n "${HYSTERIA_ARTIFACT_URL:-}" ] || fail "HYSTERIA_ARTIFACT_URL was not resolved before write_metadata"
|
||
[ -n "${HYSTERIA_ARTIFACT_SHA256:-}" ] || fail "HYSTERIA_ARTIFACT_SHA256 was not resolved before write_metadata"
|
||
validate_hysteria_version_format "$HYSTERIA_VERSION"
|
||
validate_hysteria_sha256_format "$HYSTERIA_ARTIFACT_SHA256"
|
||
|
||
# tests_gate — утверждение о готовом артефакте, а не переключатель.
|
||
#
|
||
# Значение у него ровно одно, потому что не бывает пакета, собранного с
|
||
# пропущенными тестами: обе функции выставляют свой флаг только после
|
||
# успешного прогона. Поле нужно, чтобы это было ВИДНО по tarball — раньше
|
||
# SKIP_TESTS=true выдавала внешне неотличимый production-артефакт.
|
||
[ "${ORCHESTRATOR_TESTS_PASSED:-false}" = "true" ] \
|
||
|| fail "write_metadata: контракты оркестратора не проверялись; тесты обязательны для релизного пакета"
|
||
[ "${ADMIN_TESTS_PASSED:-false}" = "true" ] \
|
||
|| fail "write_metadata: контракты админки не проверялись; тесты обязательны для релизного пакета"
|
||
[ "${FRONTEND_TESTS_PASSED:-false}" = "true" ] \
|
||
|| fail "write_metadata: контракты панели не проверялись; тесты обязательны для релизного пакета"
|
||
|
||
{
|
||
printf 'name=HY2XS\n'
|
||
printf 'license=AGPL-3.0-only\n'
|
||
printf 'version=%s\n' "$version"
|
||
printf 'release_line=%s\n' "$HY2XS_RELEASE_LINE"
|
||
printf 'config_schema_version=%s\n' "$HY2XS_CONFIG_SCHEMA_VERSION"
|
||
printf 'build_id=%s\n' "$build_id"
|
||
printf 'build_host_os=%s%s\n' "$HY2XS_BUILD_OS" "$HY2XS_BUILD_OS_VERSION"
|
||
printf 'build_host_arch=%s\n' "$HY2XS_BUILD_ARCH"
|
||
printf 'target_os=%s\n' "$HY2XS_TARGET_GOOS"
|
||
printf 'target_arch=%s\n' "$HY2XS_TARGET_ARCH"
|
||
printf 'target_distro=%s\n' "$HY2XS_TARGET_OS"
|
||
printf 'target_version=%s\n' "$HY2XS_TARGET_OS_VERSION"
|
||
printf 'source_git_commit=%s\n' "$source_git_commit"
|
||
printf 'dirty_tree=%s\n' "$dirty_tree"
|
||
printf 'build_profile=production\n'
|
||
printf 'orchestrator_stack=Bun+TypeScript\n'
|
||
printf 'go_version=%s\n' "$($GO_BIN version)"
|
||
printf 'bun_version=%s\n' "$($BUN_BIN --version)"
|
||
printf 'bun_compile_target=%s\n' "${BUN_COMPILE_TARGET:-unknown}"
|
||
printf 'node_version=%s\n' "$($NODE_BIN --version)"
|
||
printf 'pnpm_version=%s\n' "$($PNPM_BIN --version)"
|
||
printf 'govulncheck_version=%s\n' "${GOVULNCHECK_VERSION:-unknown}"
|
||
printf 'dependency_security_gate=%s\n' "${DEPENDENCY_SECURITY_GATE:-unknown}"
|
||
printf 'tests_gate=true\n'
|
||
printf 'hysteria_source=official-upstream\n'
|
||
printf 'hysteria_target=linux-amd64\n'
|
||
printf 'hysteria_version=%s\n' "$HYSTERIA_VERSION"
|
||
printf 'hysteria_artifact_url=%s\n' "$HYSTERIA_ARTIFACT_URL"
|
||
printf 'hysteria_artifact_sha256=%s\n' "$HYSTERIA_ARTIFACT_SHA256"
|
||
printf 'hysteria_sha_source=%s\n' "${HYSTERIA_SHA_SOURCE:-unknown}"
|
||
printf 'hysteria_channel=%s\n' "${HYSTERIA_CHANNEL:-unknown}"
|
||
printf 'hysteria_resolution=%s\n' "${HYSTERIA_RESOLUTION:-unknown}"
|
||
printf 'hysteria_resolved_at=%s\n' "${HYSTERIA_RESOLVED_AT:-unknown}"
|
||
printf 'hysteria_compat_gate=%s\n' "${HYSTERIA_COMPAT_GATE:-unknown}"
|
||
} >"$STAGE_DIR/metadata/package.env"
|
||
|
||
printf '%s\n' "$HYSTERIA_VERSION" >"$STAGE_DIR/metadata/hysteria.version"
|
||
printf '%s\n' "$HYSTERIA_ARTIFACT_URL" >"$STAGE_DIR/metadata/hysteria.url"
|
||
printf '%s\n' "$HYSTERIA_ARTIFACT_SHA256" >"$STAGE_DIR/metadata/hysteria.sha256"
|
||
printf '%s\n' "${HYSTERIA_RESOLUTION:-unknown}" >"$STAGE_DIR/metadata/hysteria.resolution"
|
||
|
||
(
|
||
cd "$STAGE_DIR"
|
||
find . -type f ! -path './metadata/checksums.txt' -print0 \
|
||
| sort -z \
|
||
| xargs -0 sha256sum >metadata/checksums.txt
|
||
)
|
||
}
|
||
|
||
create_archive() {
|
||
local version="$1"
|
||
local archive="dist/hy2xs-install-${version}.tar.gz"
|
||
|
||
rm -f "$archive"
|
||
tar --owner=0 --group=0 --numeric-owner -C "tools/build/output" -czf "$archive" "hy2xs-install"
|
||
}
|