diff --git a/orchestrator/src/build/hysteriaRelease.ts b/orchestrator/src/build/hysteriaRelease.ts index 8f08109..ae8a138 100644 --- a/orchestrator/src/build/hysteriaRelease.ts +++ b/orchestrator/src/build/hysteriaRelease.ts @@ -8,6 +8,13 @@ export const HYSTERIA_UPSTREAM_REPO = "HyNetworks/hysteria"; export const HYSTERIA_LINUX_AMD64_ASSET = "hysteria-linux-amd64"; +/** + * Upstream публикует контрольные суммы релиза отдельным ассетом. Он позволяет + * проверить ПРОИСХОЖДЕНИЕ артефакта, а не только его неизменность: без него + * SHA-256 считается от уже скачанного файла, то есть является trust-on-first-use. + */ +export const HYSTERIA_HASHES_ASSET = "hashes.txt"; + /** Upstream публикует несколько семейств тегов; серверный бинарник живёт в app/vX.Y.Z. */ export const HYSTERIA_APP_TAG_PATTERN = /^app\/v(\d+)\.(\d+)\.(\d+)$/; export const HYSTERIA_VERSION_PATTERN = /^v(\d+)\.(\d+)\.(\d+)$/; @@ -32,6 +39,8 @@ export type ResolvedHysteriaRelease = { tag: string; /** Immutable release asset URL ровно в том виде, как его отдал GitHub API. */ artifactUrl: string; + /** URL ассета с контрольными суммами релиза. */ + hashesUrl: string; publishedAt: string; }; @@ -68,17 +77,15 @@ function isStable(release: GithubRelease): boolean { return release.draft !== true && release.prerelease !== true; } -export function selectLinuxAmd64Asset(release: GithubRelease): GithubAsset { - const matches = (release.assets ?? []).filter((asset) => asset.name === HYSTERIA_LINUX_AMD64_ASSET); +export function selectAssetByName(release: GithubRelease, assetName: string): GithubAsset { + const matches = (release.assets ?? []).filter((asset) => asset.name === assetName); if (matches.length === 0) { - throw new Error( - `upstream release ${release.tag_name} has no ${HYSTERIA_LINUX_AMD64_ASSET} asset` - ); + throw new Error(`upstream release ${release.tag_name} has no ${assetName} asset`); } if (matches.length > 1) { throw new Error( - `upstream release ${release.tag_name} has ${matches.length} ambiguous ${HYSTERIA_LINUX_AMD64_ASSET} assets` + `upstream release ${release.tag_name} has ${matches.length} ambiguous ${assetName} assets` ); } @@ -93,11 +100,79 @@ export function selectLinuxAmd64Asset(release: GithubRelease): GithubAsset { return { name: matches[0].name, browser_download_url: url }; } +export function selectLinuxAmd64Asset(release: GithubRelease): GithubAsset { + return selectAssetByName(release, HYSTERIA_LINUX_AMD64_ASSET); +} + +export function selectHashesAsset(release: GithubRelease): GithubAsset { + return selectAssetByName(release, HYSTERIA_HASHES_ASSET); +} + +/** + * Разбирает upstream hashes.txt. + * + * Фактический формат релиза app/v2.12.2: + * + * 6493dfff…f94 build/hysteria-linux-amd64 + * f24f63be…189 build/hysteria-linux-amd64-avx + * + * Сопоставление идёт по базовому имени и строго на равенство: префикс + * `build/` — часть пути, а `hysteria-linux-amd64-avx` — другой артефакт, + * который не должен совпасть по prefix-match. + * + * Поддерживается и форма `sha256:`: если upstream когда-нибудь сменит + * стиль, сборка не должна падать на косметике. + */ +export function parseUpstreamHashes(text: string, assetName: string): string { + const candidates: string[] = []; + + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + + const columns = line.split(/\s+/).filter(Boolean); + if (columns.length < 2) { + continue; + } + + const [first, ...rest] = columns; + const digest = first.toLowerCase().replace(/^sha256:/, ""); + if (!/^[a-f0-9]{64}$/.test(digest)) { + continue; + } + + const named = rest.some((column) => { + const basename = column.replace(/^\*/, "").split("/").pop() ?? ""; + return basename === assetName; + }); + + if (named) { + candidates.push(digest); + } + } + + if (candidates.length === 0) { + throw new Error(`upstream hashes.txt has no SHA-256 entry for ${assetName}`); + } + + const unique = [...new Set(candidates)]; + if (unique.length > 1) { + throw new Error( + `upstream hashes.txt has conflicting SHA-256 entries for ${assetName}: ${unique.join(", ")}` + ); + } + + return unique[0]; +} + function toResolved(release: GithubRelease, version: string): ResolvedHysteriaRelease { return { version, tag: release.tag_name.trim(), artifactUrl: selectLinuxAmd64Asset(release).browser_download_url, + hashesUrl: selectHashesAsset(release).browser_download_url, publishedAt: (release.published_at ?? "").trim() }; } diff --git a/orchestrator/test/hysteria-release.test.ts b/orchestrator/test/hysteria-release.test.ts index 678feaf..dde64f1 100644 --- a/orchestrator/test/hysteria-release.test.ts +++ b/orchestrator/test/hysteria-release.test.ts @@ -1,10 +1,13 @@ import { describe, expect, test } from "bun:test"; import { + HYSTERIA_HASHES_ASSET, HYSTERIA_LINUX_AMD64_ASSET, compareVersionParts, parseAppTag, + parseUpstreamHashes, parseVersion, renderLockEnv, + selectHashesAsset, selectLatestStableRelease, selectLinuxAmd64Asset, selectReleaseByVersion, @@ -12,8 +15,15 @@ import { } from "../src/build/hysteriaRelease"; /** Upstream отдаёт URL с неэкранированным слэшем в теге; берём его как есть. */ -function assetUrl(tag: string): string { - return `https://github.com/HyNetworks/hysteria/releases/download/${tag}/${HYSTERIA_LINUX_AMD64_ASSET}`; +function assetUrl(tag: string, name = HYSTERIA_LINUX_AMD64_ASSET): string { + return `https://github.com/HyNetworks/hysteria/releases/download/${tag}/${name}`; +} + +function defaultAssets(tag: string) { + return [ + { name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: assetUrl(tag) }, + { name: HYSTERIA_HASHES_ASSET, browser_download_url: assetUrl(tag, HYSTERIA_HASHES_ASSET) } + ]; } function release(tag: string, overrides: Partial = {}): GithubRelease { @@ -22,7 +32,7 @@ function release(tag: string, overrides: Partial = {}): GithubRel draft: false, prerelease: false, published_at: "2026-08-23T00:59:00Z", - assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: assetUrl(tag) }], + assets: defaultAssets(tag), ...overrides }; } @@ -98,11 +108,138 @@ describe("selectLatestStableRelease", () => { const encoded = "https://github.com/HyNetworks/hysteria/releases/download/app%2Fv2.12.2/hysteria-linux-amd64"; const resolved = selectLatestStableRelease([ release("app/v2.12.2", { - assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: encoded }] + assets: [ + { name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: encoded }, + { + name: HYSTERIA_HASHES_ASSET, + browser_download_url: assetUrl("app/v2.12.2", HYSTERIA_HASHES_ASSET) + } + ] }) ]); expect(resolved.artifactUrl).toBe(encoded); }); + + test("резолв отдаёт и url ассета с контрольными суммами", () => { + const resolved = selectLatestStableRelease(UPSTREAM_FIXTURE); + expect(resolved.hashesUrl).toBe( + "https://github.com/HyNetworks/hysteria/releases/download/app/v2.12.2/hashes.txt" + ); + }); + + // Без hashes.txt проверять происхождение артефакта нечем, поэтому такой + // релиз для production-сборки непригоден. + test("релиз без hashes.txt отклоняется", () => { + const noHashes = release("app/v2.12.2", { + assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: assetUrl("app/v2.12.2") }] + }); + expect(() => selectLatestStableRelease([noHashes])).toThrow(/has no hashes\.txt asset/); + }); +}); + +describe("selectHashesAsset", () => { + test("находит ассет контрольных сумм", () => { + expect(selectHashesAsset(release("app/v2.12.2")).name).toBe(HYSTERIA_HASHES_ASSET); + }); + + test("дублирующийся hashes.txt — ошибка", () => { + const ambiguous = release("app/v2.12.2", { + assets: [ + ...defaultAssets("app/v2.12.2"), + { name: HYSTERIA_HASHES_ASSET, browser_download_url: "https://example.com/evil" } + ] + }); + expect(() => selectHashesAsset(ambiguous)).toThrow(/ambiguous/); + }); +}); + +describe("parseUpstreamHashes", () => { + // Реальный формат релиза app/v2.12.2. + const UPSTREAM_HASHES = [ + "6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 build/hysteria-linux-amd64", + "f24f63bece90b91b4e47d392f0afa4d8fe6d38785cca0589c1d121fd53b8e189 build/hysteria-linux-amd64-avx", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa build/hysteria-linux-arm64" + ].join("\n"); + + test("достаёт сумму нужного артефакта", () => { + expect(parseUpstreamHashes(UPSTREAM_HASHES, HYSTERIA_LINUX_AMD64_ASSET)).toBe( + "6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94" + ); + }); + + // Ключевой момент: -avx не должен совпасть по префиксу с базовым артефактом. + test("не путает hysteria-linux-amd64 с hysteria-linux-amd64-avx", () => { + expect(parseUpstreamHashes(UPSTREAM_HASHES, HYSTERIA_LINUX_AMD64_ASSET)).not.toBe( + "f24f63bece90b91b4e47d392f0afa4d8fe6d38785cca0589c1d121fd53b8e189" + ); + expect(parseUpstreamHashes(UPSTREAM_HASHES, "hysteria-linux-amd64-avx")).toBe( + "f24f63bece90b91b4e47d392f0afa4d8fe6d38785cca0589c1d121fd53b8e189" + ); + }); + + test("работает без префикса каталога", () => { + const flat = `6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 ${HYSTERIA_LINUX_AMD64_ASSET}`; + expect(parseUpstreamHashes(flat, HYSTERIA_LINUX_AMD64_ASSET)).toBe( + "6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94" + ); + }); + + test("понимает форму sha256: и бинарный маркер '*'", () => { + const alternative = [ + `sha256:6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 *${HYSTERIA_LINUX_AMD64_ASSET}` + ].join("\n"); + expect(parseUpstreamHashes(alternative, HYSTERIA_LINUX_AMD64_ASSET)).toBe( + "6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94" + ); + }); + + test("верхний регистр нормализуется", () => { + const upper = `6493DFFFD55B5883F64C76C63880ECC32988F0C568C9CA9014907877B4D55F94 ${HYSTERIA_LINUX_AMD64_ASSET}`; + expect(parseUpstreamHashes(upper, HYSTERIA_LINUX_AMD64_ASSET)).toBe( + "6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94" + ); + }); + + test("комментарии и пустые строки игнорируются", () => { + const noisy = [ + "# hysteria release checksums", + "", + `6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 build/${HYSTERIA_LINUX_AMD64_ASSET}` + ].join("\n"); + expect(parseUpstreamHashes(noisy, HYSTERIA_LINUX_AMD64_ASSET)).toBe( + "6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94" + ); + }); + + test("отсутствие нужной строки — ошибка, а не тихий пропуск проверки", () => { + expect(() => parseUpstreamHashes(UPSTREAM_HASHES, "hysteria-linux-riscv64")).toThrow( + /no SHA-256 entry for hysteria-linux-riscv64/ + ); + expect(() => parseUpstreamHashes("", HYSTERIA_LINUX_AMD64_ASSET)).toThrow(/no SHA-256 entry/); + }); + + test("противоречивые записи — ошибка, а не выбор первой", () => { + const conflicting = [ + `6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 build/${HYSTERIA_LINUX_AMD64_ASSET}`, + `bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb dist/${HYSTERIA_LINUX_AMD64_ASSET}` + ].join("\n"); + expect(() => parseUpstreamHashes(conflicting, HYSTERIA_LINUX_AMD64_ASSET)).toThrow(/conflicting/); + }); + + test("повторяющаяся одинаковая запись ошибкой не считается", () => { + const duplicated = [ + `6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 build/${HYSTERIA_LINUX_AMD64_ASSET}`, + `6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 ${HYSTERIA_LINUX_AMD64_ASSET}` + ].join("\n"); + expect(parseUpstreamHashes(duplicated, HYSTERIA_LINUX_AMD64_ASSET)).toBe( + "6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94" + ); + }); + + test("строка с мусором вместо digest игнорируется", () => { + const garbage = `not-a-sha build/${HYSTERIA_LINUX_AMD64_ASSET}`; + expect(() => parseUpstreamHashes(garbage, HYSTERIA_LINUX_AMD64_ASSET)).toThrow(/no SHA-256 entry/); + }); }); describe("selectLinuxAmd64Asset", () => { diff --git a/orchestrator/test/release-rollover.test.ts b/orchestrator/test/release-rollover.test.ts index 01ffea4..46518f0 100644 --- a/orchestrator/test/release-rollover.test.ts +++ b/orchestrator/test/release-rollover.test.ts @@ -2,11 +2,16 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { + HYSTERIA_HASHES_ASSET, HYSTERIA_LINUX_AMD64_ASSET, selectLatestStableRelease, type GithubRelease } from "../src/build/hysteriaRelease"; +function assetUrl(tag: string, name: string): string { + return `https://github.com/HyNetworks/hysteria/releases/download/${tag}/${name}`; +} + function release(tag: string): GithubRelease { return { tag_name: tag, @@ -16,7 +21,11 @@ function release(tag: string): GithubRelease { assets: [ { name: HYSTERIA_LINUX_AMD64_ASSET, - browser_download_url: `https://github.com/HyNetworks/hysteria/releases/download/${tag}/${HYSTERIA_LINUX_AMD64_ASSET}` + browser_download_url: assetUrl(tag, HYSTERIA_LINUX_AMD64_ASSET) + }, + { + name: HYSTERIA_HASHES_ASSET, + browser_download_url: assetUrl(tag, HYSTERIA_HASHES_ASSET) } ] }; diff --git a/orchestrator/tools/parse-upstream-hashes.ts b/orchestrator/tools/parse-upstream-hashes.ts new file mode 100644 index 0000000..82a5a13 --- /dev/null +++ b/orchestrator/tools/parse-upstream-hashes.ts @@ -0,0 +1,54 @@ +/** + * Build-time CLI: достаёт ожидаемый SHA-256 артефакта из upstream hashes.txt. + * + * Вынесено в отдельную команду, чтобы разбор формата жил в одном месте и был + * покрыт юнит-тестами, а не повторялся awk-выражением в bash. + * + * bun run tools/parse-upstream-hashes.ts --file hashes.txt --asset hysteria-linux-amd64 + */ + +import { parseUpstreamHashes } from "../src/build/hysteriaRelease"; + +function fail(message: string): never { + console.error(`[hy2xs-build] ERROR: ${message}`); + process.exit(1); +} + +function parseArgs(argv: string[]): { file: string; asset: string } { + let file = ""; + let asset = ""; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const value = argv[i + 1]; + if (arg === "--file") { + file = value ?? ""; + i += 1; + continue; + } + if (arg === "--asset") { + asset = value ?? ""; + i += 1; + continue; + } + fail(`unknown argument: ${arg}`); + } + + if (!file) { + fail("missing --file"); + } + if (!asset) { + fail("missing --asset"); + } + + return { file, asset }; +} + +const { file, asset } = parseArgs(Bun.argv.slice(2)); + +try { + const text = await Bun.file(file).text(); + process.stdout.write(`${parseUpstreamHashes(text, asset)}\n`); +} catch (error) { + fail(error instanceof Error ? error.message : String(error)); +} diff --git a/orchestrator/tools/resolve-hysteria.ts b/orchestrator/tools/resolve-hysteria.ts index 271675e..04df27b 100644 --- a/orchestrator/tools/resolve-hysteria.ts +++ b/orchestrator/tools/resolve-hysteria.ts @@ -102,6 +102,7 @@ function emit(resolved: ResolvedHysteriaRelease, resolution: string): void { console.log(`HYSTERIA_VERSION=${resolved.version}`); console.log(`HYSTERIA_TAG=${resolved.tag}`); console.log(`HYSTERIA_ARTIFACT_URL=${resolved.artifactUrl}`); + console.log(`HYSTERIA_HASHES_URL=${resolved.hashesUrl}`); console.log(`HYSTERIA_PUBLISHED_AT=${resolved.publishedAt}`); console.log(`HYSTERIA_RESOLUTION=${resolution}`); } diff --git a/tools/build/hysteria-lock.env b/tools/build/hysteria-lock.env index 40b08f6..1a23731 100644 --- a/tools/build/hysteria-lock.env +++ b/tools/build/hysteria-lock.env @@ -3,5 +3,6 @@ HYSTERIA_VERSION=v2.12.2 HYSTERIA_ARTIFACT_URL=https://github.com/HyNetworks/hysteria/releases/download/app/v2.12.2/hysteria-linux-amd64 HYSTERIA_ARTIFACT_SHA256=6493dfffd55b5883f64c76c63880ecc32988f0c568c9ca9014907877b4d55f94 +HYSTERIA_SHA_SOURCE=upstream-hashes HYSTERIA_RESOLUTION=latest-stable HYSTERIA_RESOLVED_AT=2026-08-27T00:00:00Z diff --git a/tools/build/lib/hysteria.sh b/tools/build/lib/hysteria.sh index 56f0526..cfe0fbe 100644 --- a/tools/build/lib/hysteria.sh +++ b/tools/build/lib/hysteria.sh @@ -18,6 +18,11 @@ HYSTERIA_VERSION_OVERRIDE="${HYSTERIA_VERSION_OVERRIDE:-}" 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}" @@ -55,8 +60,12 @@ load_pinned_hysteria_lock() { 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 HYSTERIA_RESOLUTION HYSTERIA_RESOLVED_AT + export HYSTERIA_VERSION HYSTERIA_ARTIFACT_URL HYSTERIA_ARTIFACT_SHA256 + export HYSTERIA_RESOLUTION HYSTERIA_RESOLVED_AT HYSTERIA_SHA_SOURCE } resolve_hysteria_from_upstream() { @@ -77,6 +86,7 @@ resolve_hysteria_from_upstream() { HYSTERIA_VERSION="" HYSTERIA_TAG="" HYSTERIA_ARTIFACT_URL="" + HYSTERIA_HASHES_URL="" HYSTERIA_PUBLISHED_AT="" HYSTERIA_RESOLUTION="" @@ -88,6 +98,7 @@ resolve_hysteria_from_upstream() { 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" ;; @@ -96,14 +107,50 @@ resolve_hysteria_from_upstream() { [ -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_PUBLISHED_AT - export HYSTERIA_RESOLUTION HYSTERIA_RESOLVED_AT + 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() { @@ -123,13 +170,17 @@ download_hysteria_artifact() { 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, got $actual_sha" + || 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" - log_info "Computed Hysteria artifact sha256: $HYSTERIA_ARTIFACT_SHA256" + HYSTERIA_SHA_SOURCE="local-download" + log_info "Computed Hysteria artifact sha256 without upstream verification: $HYSTERIA_ARTIFACT_SHA256" fi - export HYSTERIA_ARTIFACT_SHA256 HYSTERIA_BINARY + export HYSTERIA_ARTIFACT_SHA256 HYSTERIA_BINARY HYSTERIA_SHA_SOURCE chmod 0755 "$HYSTERIA_BINARY" @@ -255,6 +306,7 @@ write_hysteria_lock_file() { HYSTERIA_VERSION=$HYSTERIA_VERSION HYSTERIA_ARTIFACT_URL=$HYSTERIA_ARTIFACT_URL HYSTERIA_ARTIFACT_SHA256=$HYSTERIA_ARTIFACT_SHA256 +HYSTERIA_SHA_SOURCE=${HYSTERIA_SHA_SOURCE:-unknown} HYSTERIA_RESOLUTION=$HYSTERIA_RESOLUTION HYSTERIA_RESOLVED_AT=$HYSTERIA_RESOLVED_AT EOF @@ -264,8 +316,11 @@ resolve_and_verify_hysteria() { case "$HYSTERIA_CHANNEL" in stable) resolve_hysteria_from_upstream - # sha256 считается локально от скачанного артефакта + # Ожидаемая сумма приходит из upstream hashes.txt, а не из локального + # файла: проверяется происхождение артефакта, а не только целостность. HYSTERIA_ARTIFACT_SHA256="" + HYSTERIA_SHA_SOURCE="" + resolve_expected_sha_from_upstream ;; pinned) log_info "Using pinned Hysteria lock file (offline): $HYSTERIA_LOCK_FILE"