#!/usr/bin/env bash set -euo pipefail # Разрешение upstream-версии Hysteria и compatibility gate. # # Ключевой инвариант: "latest" вычисляется на build machine и замораживается # в metadata пакета. Target-сервер никогда не обращается к moving "latest". HYSTERIA_LOCK_FILE="tools/build/hysteria-lock.env" HYSTERIA_WORK_DIR="tools/build/output/hysteria" # stable — определить последнюю стабильную версию через upstream API (default); # pinned — использовать закоммиченный tools/build/hysteria-lock.env без сети. HYSTERIA_CHANNEL="${HYSTERIA_CHANNEL:-stable}" # Явный пин конкретной версии вида v2.12.2. Побеждает автоматический выбор. HYSTERIA_VERSION_OVERRIDE="${HYSTERIA_VERSION_OVERRIDE:-}" # Обновлять ли tools/build/hysteria-lock.env резолвнутыми значениями. HYSTERIA_WRITE_LOCK="${HYSTERIA_WRITE_LOCK:-false}" # Compatibility gate обязателен для production-сборки. HYSTERIA_COMPAT_GATE="${HYSTERIA_COMPAT_GATE:-true}" # Сверка скачанного бинарника с upstream hashes.txt. Отключается только # break-glass: без неё SHA-256 считается от уже скачанного файла и является # trust-on-first-use, а не проверкой происхождения артефакта. HYSTERIA_VERIFY_UPSTREAM_HASHES="${HYSTERIA_VERIFY_UPSTREAM_HASHES:-true}" HYSTERIA_LINUX_AMD64_ASSET="hysteria-linux-amd64" HYSTERIA_GATE_PORT="${HYSTERIA_GATE_PORT:-34443}" HYSTERIA_GATE_STATS_PORT="${HYSTERIA_GATE_STATS_PORT:-34712}" HYSTERIA_GATE_TIMEOUT_SECONDS="${HYSTERIA_GATE_TIMEOUT_SECONDS:-15}" validate_hysteria_version_format() { local value="$1" grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' <<<"$value" \ || fail "invalid Hysteria version format: $value (expected vX.Y.Z)" } validate_hysteria_sha256_format() { local value="$1" case "$value" in replace-with-*|"") fail "HYSTERIA_ARTIFACT_SHA256 must be a real release sha256" ;; esac grep -Eq '^[a-fA-F0-9]{64}$' <<<"$value" \ || fail "HYSTERIA_ARTIFACT_SHA256 must be a 64-char hex SHA256, got: $value" } load_pinned_hysteria_lock() { [ -f "$HYSTERIA_LOCK_FILE" ] || fail "missing Hysteria lock file: $HYSTERIA_LOCK_FILE" # shellcheck disable=SC1090 . "$HYSTERIA_LOCK_FILE" [ -n "${HYSTERIA_VERSION:-}" ] || fail "HYSTERIA_VERSION is required in $HYSTERIA_LOCK_FILE" [ -n "${HYSTERIA_ARTIFACT_URL:-}" ] || fail "HYSTERIA_ARTIFACT_URL is required in $HYSTERIA_LOCK_FILE" [ -n "${HYSTERIA_ARTIFACT_SHA256:-}" ] || fail "HYSTERIA_ARTIFACT_SHA256 is required in $HYSTERIA_LOCK_FILE" validate_hysteria_version_format "$HYSTERIA_VERSION" validate_hysteria_sha256_format "$HYSTERIA_ARTIFACT_SHA256" HYSTERIA_RESOLUTION="pinned" HYSTERIA_RESOLVED_AT="${HYSTERIA_RESOLVED_AT:-unknown}" # Сумма в lock-файле уже была проверена против upstream hashes.txt в тот # момент, когда lock записывался. HYSTERIA_SHA_SOURCE="hy2xs-lock" export HYSTERIA_VERSION HYSTERIA_ARTIFACT_URL HYSTERIA_ARTIFACT_SHA256 export HYSTERIA_RESOLUTION HYSTERIA_RESOLVED_AT HYSTERIA_SHA_SOURCE } resolve_hysteria_from_upstream() { local resolver_args=(--channel stable) if [ -n "$HYSTERIA_VERSION_OVERRIDE" ]; then validate_hysteria_version_format "$HYSTERIA_VERSION_OVERRIDE" resolver_args+=(--version "$HYSTERIA_VERSION_OVERRIDE") log_info "Resolving pinned Hysteria version override: $HYSTERIA_VERSION_OVERRIDE" else log_info "Resolving latest stable Hysteria release from upstream" fi local resolver_output resolver_output="$(cd orchestrator && "$BUN_BIN" run tools/resolve-hysteria.ts "${resolver_args[@]}")" \ || fail "Hysteria release resolution failed" local key value line HYSTERIA_VERSION="" HYSTERIA_TAG="" HYSTERIA_ARTIFACT_URL="" HYSTERIA_HASHES_URL="" HYSTERIA_PUBLISHED_AT="" HYSTERIA_RESOLUTION="" while IFS= read -r line; do [ -n "$line" ] || continue key="${line%%=*}" value="${line#*=}" case "$key" in HYSTERIA_VERSION) HYSTERIA_VERSION="$value" ;; HYSTERIA_TAG) HYSTERIA_TAG="$value" ;; HYSTERIA_ARTIFACT_URL) HYSTERIA_ARTIFACT_URL="$value" ;; HYSTERIA_HASHES_URL) HYSTERIA_HASHES_URL="$value" ;; HYSTERIA_PUBLISHED_AT) HYSTERIA_PUBLISHED_AT="$value" ;; HYSTERIA_RESOLUTION) HYSTERIA_RESOLUTION="$value" ;; *) fail "unexpected resolver output line: $line" ;; esac done <<<"$resolver_output" [ -n "$HYSTERIA_VERSION" ] || fail "resolver did not return HYSTERIA_VERSION" [ -n "$HYSTERIA_ARTIFACT_URL" ] || fail "resolver did not return HYSTERIA_ARTIFACT_URL" [ -n "$HYSTERIA_HASHES_URL" ] || fail "resolver did not return HYSTERIA_HASHES_URL" validate_hysteria_version_format "$HYSTERIA_VERSION" HYSTERIA_RESOLVED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" log_info "Resolved Hysteria $HYSTERIA_VERSION (tag ${HYSTERIA_TAG:-unknown}, published ${HYSTERIA_PUBLISHED_AT:-unknown})" export HYSTERIA_VERSION HYSTERIA_TAG HYSTERIA_ARTIFACT_URL HYSTERIA_HASHES_URL export HYSTERIA_PUBLISHED_AT HYSTERIA_RESOLUTION HYSTERIA_RESOLVED_AT } # Ожидаемый digest берётся из upstream-ассета hashes.txt ДО того, как он # попадёт в HY2XS lock. Иначе замороженная сумма подтверждает только то, что # файл не изменился после скачивания. resolve_expected_sha_from_upstream() { [ "$HYSTERIA_VERIFY_UPSTREAM_HASHES" = "true" ] || { log_info "WARNING: upstream hashes.txt verification is disabled (HYSTERIA_VERIFY_UPSTREAM_HASHES=$HYSTERIA_VERIFY_UPSTREAM_HASHES)" return 0 } [ -n "${HYSTERIA_HASHES_URL:-}" ] || fail "upstream hashes.txt URL is unknown" mkdir -p "$HYSTERIA_WORK_DIR" local hashes_file="$HYSTERIA_WORK_DIR/hashes.txt" log_info "Downloading upstream checksums: $HYSTERIA_HASHES_URL" rm -f "$hashes_file" curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location \ "$HYSTERIA_HASHES_URL" -o "$hashes_file" \ || fail "failed to download upstream hashes.txt: $HYSTERIA_HASHES_URL" [ -s "$hashes_file" ] || fail "downloaded upstream hashes.txt is empty" local upstream_sha upstream_sha="$(cd orchestrator && "$BUN_BIN" run tools/parse-upstream-hashes.ts \ --file "../$hashes_file" --asset "$HYSTERIA_LINUX_AMD64_ASSET")" \ || fail "could not read the expected SHA-256 from upstream hashes.txt" validate_hysteria_sha256_format "$upstream_sha" HYSTERIA_ARTIFACT_SHA256="$upstream_sha" HYSTERIA_SHA_SOURCE="upstream-hashes" export HYSTERIA_ARTIFACT_SHA256 HYSTERIA_SHA_SOURCE log_info "Expected Hysteria SHA-256 from upstream hashes.txt: $upstream_sha" } download_hysteria_artifact() { mkdir -p "$HYSTERIA_WORK_DIR" HYSTERIA_BINARY="$HYSTERIA_WORK_DIR/hysteria-linux-amd64" log_info "Downloading $HYSTERIA_ARTIFACT_URL" rm -f "$HYSTERIA_BINARY" curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location \ "$HYSTERIA_ARTIFACT_URL" -o "$HYSTERIA_BINARY" \ || fail "failed to download Hysteria artifact: $HYSTERIA_ARTIFACT_URL" [ -s "$HYSTERIA_BINARY" ] || fail "downloaded Hysteria artifact is empty" local actual_sha actual_sha="$(sha256sum "$HYSTERIA_BINARY" | awk '{print $1}')" if [ -n "${HYSTERIA_ARTIFACT_SHA256:-}" ]; then validate_hysteria_sha256_format "$HYSTERIA_ARTIFACT_SHA256" [ "$actual_sha" = "$HYSTERIA_ARTIFACT_SHA256" ] \ || fail "Hysteria artifact sha256 mismatch: expected $HYSTERIA_ARTIFACT_SHA256 (${HYSTERIA_SHA_SOURCE:-unknown source}), got $actual_sha" log_info "Hysteria artifact verified against ${HYSTERIA_SHA_SOURCE:-unknown source}: $actual_sha" else # Сюда попадаем только при выключенной сверке с upstream: сумма # фиксируется как локальный fingerprint пакета, без проверки происхождения. HYSTERIA_ARTIFACT_SHA256="$actual_sha" HYSTERIA_SHA_SOURCE="local-download" log_info "Computed Hysteria artifact sha256 without upstream verification: $HYSTERIA_ARTIFACT_SHA256" fi export HYSTERIA_ARTIFACT_SHA256 HYSTERIA_BINARY HYSTERIA_SHA_SOURCE chmod 0755 "$HYSTERIA_BINARY" local reported_version reported_version="$("$HYSTERIA_BINARY" version 2>/dev/null | grep -Eo 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)" [ -n "$reported_version" ] || fail "unable to read version from downloaded Hysteria binary" [ "$reported_version" = "$HYSTERIA_VERSION" ] \ || fail "downloaded Hysteria binary reports $reported_version, expected $HYSTERIA_VERSION" log_info "Verified Hysteria binary: $HYSTERIA_VERSION ($HYSTERIA_ARTIFACT_SHA256)" } assert_gate_port_is_free() { local port="$1" local proto_flag="$2" if command -v ss >/dev/null 2>&1; then # Вывод собирается ДО поиска: `grep -q` обрывает чтение на первом совпадении, # и под `pipefail` SIGPIPE у продюсера превратил бы найденный занятый порт в # ненулевой статус, то есть в вывод «порт свободен». local sockets sockets="$(ss -H "$proto_flag" 2>/dev/null | awk '{print $4}')" || sockets="" if grep -Eq "[:.]${port}$" <<<"$sockets"; then fail "compatibility gate port $port is already in use; override HYSTERIA_GATE_PORT/HYSTERIA_GATE_STATS_PORT" fi fi } gate_generate_self_signed_cert() { local dir="$1" openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ -subj /CN=hy2xs-compat-gate \ -keyout "$dir/gate.key" -out "$dir/gate.crt" >/dev/null 2>&1 \ || fail "compatibility gate could not generate a self-signed certificate" } gate_run_single_profile() { local obfs_type="$1" local gate_dir="$2" local config_path="$gate_dir/config-$obfs_type.yaml" local log_path="$gate_dir/hysteria-$obfs_type.log" log_info "Compatibility gate: rendering canonical config (obfs=$obfs_type)" ( cd orchestrator "$BUN_BIN" run tools/render-canonical-config.ts \ --package-dir ../package \ --obfs "$obfs_type" \ --tls-mode file \ --cert "$gate_dir/gate.crt" \ --key "$gate_dir/gate.key" \ --port "$HYSTERIA_GATE_PORT" \ --traffic-stats-port "$HYSTERIA_GATE_STATS_PORT" \ --out "$config_path" ) || fail "BUILD FAILED: HY2XS could not render canonical config for obfs=$obfs_type" grep -q "type: $obfs_type" "$config_path" \ || fail "compatibility gate rendered a config without obfs type $obfs_type" log_info "Compatibility gate: starting Hysteria $HYSTERIA_VERSION with generated config (obfs=$obfs_type)" HYSTERIA_DISABLE_UPDATE_CHECK=1 "$HYSTERIA_BINARY" server -c "$config_path" >"$log_path" 2>&1 & local gate_pid=$! local ready="false" local waited=0 while [ "$waited" -lt "$HYSTERIA_GATE_TIMEOUT_SECONDS" ]; do if ! kill -0 "$gate_pid" 2>/dev/null; then break fi if command -v ss >/dev/null 2>&1; then local udp_sockets udp_sockets="$(ss -H -lun 2>/dev/null | awk '{print $4}')" || udp_sockets="" if grep -Eq "[:.]${HYSTERIA_GATE_PORT}$" <<<"$udp_sockets"; then ready="true" break fi fi sleep 1 waited=$((waited + 1)) done if [ "$ready" != "true" ]; then kill "$gate_pid" 2>/dev/null || true wait "$gate_pid" 2>/dev/null || true log_info "--- Hysteria compatibility gate output (obfs=$obfs_type) ---" cat "$log_path" >&2 || true fail "BUILD FAILED: unsupported Hysteria stable $HYSTERIA_VERSION (obfs=$obfs_type config was rejected)" fi kill "$gate_pid" 2>/dev/null || true wait "$gate_pid" 2>/dev/null || true log_info "Compatibility gate passed for obfs=$obfs_type" } run_hysteria_compatibility_gate() { if [ "$HYSTERIA_COMPAT_GATE" != "true" ]; then log_info "WARNING: Hysteria compatibility gate is disabled (HYSTERIA_COMPAT_GATE=$HYSTERIA_COMPAT_GATE)" return 0 fi require_tool openssl [ -x "${HYSTERIA_BINARY:-}" ] || fail "compatibility gate requires a verified Hysteria binary" assert_gate_port_is_free "$HYSTERIA_GATE_PORT" "-lun" assert_gate_port_is_free "$HYSTERIA_GATE_STATS_PORT" "-ltn" local gate_dir gate_dir="$(mktemp -d)" # shellcheck disable=SC2064 trap "rm -rf '$gate_dir'" RETURN gate_generate_self_signed_cert "$gate_dir" gate_run_single_profile "gecko" "$gate_dir" gate_run_single_profile "salamander" "$gate_dir" log_info "Compatibility gate: Hysteria $HYSTERIA_VERSION accepts the HY2XS production config" } write_hysteria_lock_file() { if [ "$HYSTERIA_WRITE_LOCK" != "true" ]; then return 0 fi log_info "Updating $HYSTERIA_LOCK_FILE with resolved values" cat >"$HYSTERIA_LOCK_FILE" <