From a388ed1014f8e8174c3dac91afdd2a7ed000cbff Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 24 Jul 2026 20:27:15 -0700 Subject: [PATCH 1/4] perf(release): rank download sources by speed and resume transfers A slow link, not an unreachable one, was the failure mode. Both download paths only failed over on error, and both used a wall-clock ceiling as their sole give-up condition, so success required speed > size / ceiling: - Relay deploy used `--max-time 900` with no resume, and re-`rm`ed the partial file on every retry. A ~20MB archive needed >23 KB/s; below that each attempt timed out mid-transfer and restarted from zero, so it could never finish. With `--retry 3` and no `--retry-max-time` (curl resets --max-time per retry) that burned up to 60 minutes per source while showing no progress, because `-s` swallowed the output. - CLI self-update set reqwest's whole-request `.timeout(120)`, which includes the body, so a ~30MB archive needed >250 KB/s. Both sources shared the cap, so a slow link failed both rather than degrading. Now, on both paths: candidates are probed with a 10s ranged request and ranked by measured throughput (>=128 KB/s counts as healthy; if nothing clears the bar the fastest is still used, since a slow download beats a 20-minute source rebuild); transfers resume rather than restart, including across a source switch, with only a checksum mismatch discarding the partial; and a throughput floor abandons a dead link in 30s instead of waiting out a ceiling. Relay deploy also emits a progress heartbeat so a slow transfer is distinguishable from a hang. Sync-window fixes: - Relay deploy takes the mirror URL from the mirror's own manifest instead of a pinned // path, which 404ed for every Desktop build outside the two releases the mirror retained. KEEP_VERSIONS 2 -> 6. - The mirror now publishes the four small Linux archives before the ~700MB of Desktop packages, so the window where it advertises a release it cannot serve is minutes rather than however long the Desktop mirror takes. - CLI self-update reads both manifests concurrently and installs whichever advertises the newer version, instead of silently reporting "up to date" when it fell back to a mirror that still had the previous release. - Optional post-publish webhook lets the mirror sync immediately; cron remains the source of truth and an unset secret keeps today's behaviour. Startup no longer downloads: the automatic check fetches only the manifest (10s budget) and hands the archive to a detached child, serialised by an update.lock. Removes the AUTO_UPDATE_BUDGET workaround. Also fixes two defects found by executing the generated script rather than parsing it: `kill` on a shell blocked in a long `sleep` is not delivered until that sleep returns, which stalled every download a full tick past completion; and an unreachable mirror manifest made a command substitution fail, which only avoided aborting the caller because `set -e` happens to be suspended inside an `if` condition. scripts/relay/release-download-harness.sh drives the generated bash against a stubbed curl; self_update.rs tests run against real throttled sockets. --- .github/workflows/desktop-package.yml | 22 + scripts/linux-binaries-manifest.test.mjs | 34 + scripts/openbitfun-release-sync.sh | 183 +++-- scripts/relay/release-download-harness.sh | 178 +++++ src/apps/cli/Cargo.toml | 1 + src/apps/cli/README.md | 16 +- src/apps/cli/src/self_update.rs | 667 +++++++++++++++--- .../src/remote_ssh/relay_deploy.rs | 192 ++++- .../src/features/relay-deploy/README.md | 44 +- 9 files changed, 1142 insertions(+), 195 deletions(-) create mode 100755 scripts/relay/release-download-harness.sh diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 5d6c77a56..736596e31 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -352,3 +352,25 @@ jobs: "https://github.com/GCWing/BitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/linux-binaries.json" \ -o linux-binaries.published.json test "$(jq -r '.version' linux-binaries.published.json)" = "${{ needs.prepare.outputs.version }}" + + # Nudge the openbitfun.com mirror to sync now instead of on its next + # 10-minute cron tick. Until the mirror has these bytes, CN clients have + # only the GitHub origin to fall back to. Best effort: the cron run is + # still the source of truth, so a failed or unconfigured ping never fails + # the release. Receiver setup: scripts/openbitfun-release-sync.sh. + - name: Request openbitfun mirror sync + continue-on-error: true + env: + SYNC_WEBHOOK_URL: ${{ secrets.OPENBITFUN_SYNC_WEBHOOK_URL }} + RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }} + run: | + set -euo pipefail + if [[ -z "${SYNC_WEBHOOK_URL:-}" ]]; then + echo "OPENBITFUN_SYNC_WEBHOOK_URL is not configured; the mirror will pick this up on its next cron run." + exit 0 + fi + curl -fsSL -X POST --retry 3 --retry-delay 5 --max-time 30 \ + -H 'Content-Type: application/json' \ + -d "{\"tag\":\"${RELEASE_TAG}\"}" \ + "${SYNC_WEBHOOK_URL}" >/dev/null + echo "Mirror sync requested for ${RELEASE_TAG}." diff --git a/scripts/linux-binaries-manifest.test.mjs b/scripts/linux-binaries-manifest.test.mjs index 254b6864f..03dcacc2c 100644 --- a/scripts/linux-binaries-manifest.test.mjs +++ b/scripts/linux-binaries-manifest.test.mjs @@ -109,3 +109,37 @@ test('openbitfun sync mirrors both products and their checksums', () => { assert.match(syncScript, /OPENBITFUN_BASE_URL/); assert.match(syncScript, /WEBSITE_RELEASE_DIR.*linux-binaries\.json/); }); + +test('Linux archives are mirrored before the much larger Desktop packages', () => { + const syncScript = fs.readFileSync( + path.join(repoRoot, 'scripts/openbitfun-release-sync.sh'), + 'utf8' + ); + + const linuxCall = syncScript.indexOf('\n mirror_linux_binaries\n'); + const desktopLoop = syncScript.indexOf('Mirroring Desktop asset'); + assert.ok(linuxCall > 0, 'mirror_linux_binaries must be called from main'); + assert.ok(desktopLoop > 0, 'Desktop asset mirroring must still exist'); + assert.ok( + linuxCall < desktopLoop, + 'CLI/Relay archives must be mirrored first: Desktop packages are ~700MB per ' + + 'release, and until the Linux assets land the mirror advertises a version ' + + 'whose CLI/Relay bytes it cannot serve' + ); +}); + +test('the mirror retains enough releases for older Desktop builds', () => { + const syncScript = fs.readFileSync( + path.join(repoRoot, 'scripts/openbitfun-release-sync.sh'), + 'utf8' + ); + const keep = /^KEEP_VERSIONS=(\d+)$/m.exec(syncScript); + assert.ok(keep, 'KEEP_VERSIONS must be set'); + // One-click Relay deploy asks the mirror for the version baked into the + // running Desktop binary; too few kept versions sends older installs into a + // 20-minute source rebuild. + assert.ok( + Number(keep[1]) >= 4, + `KEEP_VERSIONS must retain several releases, got ${keep[1]}` + ); +}); diff --git a/scripts/openbitfun-release-sync.sh b/scripts/openbitfun-release-sync.sh index 23c266bb7..99d137c1c 100755 --- a/scripts/openbitfun-release-sync.sh +++ b/scripts/openbitfun-release-sync.sh @@ -4,11 +4,13 @@ # # Flow: # 1. Fetch latest.json from GitHub (follows /releases/latest/download/ redirect) -# 2. Download every Desktop updater package into release/{version}/ -# 3. If present, download linux-binaries.json plus its CLI/Relay assets +# 2. If present, mirror linux-binaries.json plus its CLI/Relay assets FIRST +# (small, and both the CLI updater and one-click Relay deploy fall back +# to them, so they must not queue behind ~700 MB of Desktop packages) +# 3. Download every Desktop updater package into release/{version}/ # 4. Rewrite all mirrored URLs to point at openbitfun.com # 5. Publish versioned and root manifests -# 6. Remove old version dirs, keeping only the two most recent +# 6. Remove old version dirs, keeping only the most recent KEEP_VERSIONS # # The published release/latest.json is the Tauri updater fallback endpoint. # When GitHub is unreachable, the desktop client automatically falls through @@ -18,6 +20,22 @@ # */10 * * * * /root/repos/BitFun-AutoUpdate/openbitfun-release-sync.sh \ # >> /root/repos/BitFun-AutoUpdate/sync.log 2>&1 # +# Optional immediate trigger. The release workflow POSTs to +# ${OPENBITFUN_SYNC_WEBHOOK_URL} once assets are published (see the "Request +# openbitfun mirror sync" step in .github/workflows/desktop-package.yml) so a +# new release reaches the mirror in a couple of minutes rather than up to ten. +# Point that secret at any receiver that runs this script, for example: +# +# while true; do +# nc -l -p 8787 -q 1 >/dev/null \ +# && /root/repos/BitFun-AutoUpdate/openbitfun-release-sync.sh \ +# >> /root/repos/BitFun-AutoUpdate/sync.log 2>&1 +# done +# +# Cron stays the source of truth: this script is idempotent and holds a flock, +# so a webhook run and a cron run cannot collide and a missed webhook only costs +# latency. Leaving the secret unset keeps the cron-only behaviour. +# set -euo pipefail # ── Configuration ────────────────────────────────────────────── @@ -26,7 +44,11 @@ GITHUB_LINUX_BINARIES_URL="https://github.com/GCWing/BitFun/releases/latest/down OPENBITFUN_BASE_URL="https://openbitfun.com/release" WEBSITE_RELEASE_DIR="/root/repos/BitFun-Website/dist/release" LOCK_FILE="/root/repos/BitFun-AutoUpdate/sync.lock" -KEEP_VERSIONS=2 +# Keep enough releases that the mirror still serves a Desktop build a few +# versions behind. One-click Relay deploy asks the mirror for the version baked +# into the running Desktop binary, so retaining too few sends older installs +# into a 20-minute source rebuild. +KEEP_VERSIONS=6 CONNECT_TIMEOUT=30 MAX_TIME=1800 # per-request ceiling (30 min; installer packages can be large) MAX_RETRIES=3 @@ -97,6 +119,82 @@ fetch_linux_manifest() { return 0 } +# Mirror the CLI + Relay manifest and every asset it references. +# +# Runs BEFORE the Desktop packages on purpose. Desktop assets are ~700 MB per +# release; mirroring those first meant the window in which openbitfun advertised +# a release whose CLI/Relay bytes it could not yet serve was however long that +# took, not the 10-minute cron interval. These four archives are small, so +# publishing them first collapses that window to a couple of minutes. +mirror_linux_binaries() { + LINUX_MANIFEST_TMP="${VERSION_DIR}/linux-binaries.github.json.part" + LINUX_MANIFEST_STATE="missing" + fetch_linux_manifest + if [ "$LINUX_MANIFEST_STATE" = "ok" ]; then + LINUX_VERSION=$("$PYTHON" -c \ + "import json,sys;print(json.load(open(sys.argv[1], encoding='utf-8'))['version'])" \ + "$LINUX_MANIFEST_TMP") + if [ "$LINUX_VERSION" != "$VERSION" ]; then + log "ERROR: Linux manifest version $LINUX_VERSION does not match Desktop version $VERSION" + rm -f "$LINUX_MANIFEST_TMP" + exit 1 + fi + + LINUX_ASSET_LIST=$("$PYTHON" - "$LINUX_MANIFEST_TMP" <<'PY' +import json, sys +with open(sys.argv[1], encoding="utf-8") as f: + data = json.load(f) +seen = set() +for platform in data.get("platforms", {}).values(): + for product in ("cli", "relay"): + entry = platform.get(product, {}) + for key in ("url", "sha256Url"): + url = entry.get(key) + if not url: + continue + filename = url.rsplit("/", 1)[-1] + if filename not in seen: + seen.add(filename) + print(f"{url}\t{filename}") +PY +) + while IFS=$'\t' read -r url filename; do + [ -z "$url" ] && continue + log " Mirroring Linux binary asset: $filename" + download_asset "$url" "${VERSION_DIR}/${filename}" || exit 1 + done <<< "$LINUX_ASSET_LIST" + + "$PYTHON" - "$LINUX_MANIFEST_TMP" "${VERSION_DIR}/linux-binaries.json" \ + "$OPENBITFUN_BASE_URL" <<'PY' +import json, sys +source, dest, base = sys.argv[1:] +with open(source, encoding="utf-8") as f: + data = json.load(f) +version_base = f"{base}/{data['version']}" +for platform in data.get("platforms", {}).values(): + for product in ("cli", "relay"): + entry = platform.get(product, {}) + for key in ("url", "sha256Url"): + if entry.get(key): + entry[key] = f"{version_base}/{entry[key].rsplit('/', 1)[-1]}" +with open(dest, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + f.write("\n") +PY + rm -f "$LINUX_MANIFEST_TMP" + cp "${VERSION_DIR}/linux-binaries.json" "${WEBSITE_RELEASE_DIR}/linux-binaries.json" + log "Updated ${WEBSITE_RELEASE_DIR}/linux-binaries.json" + elif [ "$LINUX_MANIFEST_STATE" = "missing" ]; then + rm -f "${WEBSITE_RELEASE_DIR}/linux-binaries.json" + log "Linux binaries manifest is not present in the latest release yet; Desktop mirror only." + else + # Transient failure. Keep whatever is already published: CLI self-update and + # one-click Relay deploy both fall back to this file, so a single flaky run + # must not take it offline for the next 10 minutes. + log "WARN: Linux binaries manifest unreachable this run; keeping the published mirror." + fi +} + # ── Main ─────────────────────────────────────────────────────── main() { mkdir -p "$(dirname "$LOCK_FILE")" @@ -132,7 +230,10 @@ main() { VERSION_DIR="${WEBSITE_RELEASE_DIR}/${VERSION}" mkdir -p "$VERSION_DIR" - # 4. Download all platform installer packages + # 4. Mirror the small Linux CLI/Relay archives first (see function comment). + mirror_linux_binaries + + # 5. Download all platform installer packages # Extract "\t" pairs, then curl each one. ASSET_LIST=$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c " import sys, json @@ -152,7 +253,7 @@ for p, info in data.get('platforms', {}).items(): download_asset "$url" "${VERSION_DIR}/${filename}" || exit 1 done <<< "$ASSET_LIST" - # 5. Rewrite URLs in latest.json to point at openbitfun.com + # 6. Rewrite URLs in latest.json to point at openbitfun.com printf '%s' "$LATEST_JSON" | "$PYTHON" -c " import sys, json data = json.load(sys.stdin) @@ -165,78 +266,10 @@ print(json.dumps(data, indent=2)) " > "${VERSION_DIR}/latest.json" log "Saved ${VERSION_DIR}/latest.json" - # 6. Publish root latest.json (Tauri fallback endpoint) + # 7. Publish root latest.json (Tauri fallback endpoint) cp "${VERSION_DIR}/latest.json" "${WEBSITE_RELEASE_DIR}/latest.json" log "Updated ${WEBSITE_RELEASE_DIR}/latest.json" - # 7. Mirror the CLI + Relay release manifest and every asset it references. - LINUX_MANIFEST_TMP="${VERSION_DIR}/linux-binaries.github.json.part" - LINUX_MANIFEST_STATE="missing" - fetch_linux_manifest - if [ "$LINUX_MANIFEST_STATE" = "ok" ]; then - LINUX_VERSION=$("$PYTHON" -c \ - "import json,sys;print(json.load(open(sys.argv[1], encoding='utf-8'))['version'])" \ - "$LINUX_MANIFEST_TMP") - if [ "$LINUX_VERSION" != "$VERSION" ]; then - log "ERROR: Linux manifest version $LINUX_VERSION does not match Desktop version $VERSION" - rm -f "$LINUX_MANIFEST_TMP" - exit 1 - fi - - LINUX_ASSET_LIST=$("$PYTHON" - "$LINUX_MANIFEST_TMP" <<'PY' -import json, sys -with open(sys.argv[1], encoding="utf-8") as f: - data = json.load(f) -seen = set() -for platform in data.get("platforms", {}).values(): - for product in ("cli", "relay"): - entry = platform.get(product, {}) - for key in ("url", "sha256Url"): - url = entry.get(key) - if not url: - continue - filename = url.rsplit("/", 1)[-1] - if filename not in seen: - seen.add(filename) - print(f"{url}\t{filename}") -PY -) - while IFS=$'\t' read -r url filename; do - [ -z "$url" ] && continue - log " Mirroring Linux binary asset: $filename" - download_asset "$url" "${VERSION_DIR}/${filename}" || exit 1 - done <<< "$LINUX_ASSET_LIST" - - "$PYTHON" - "$LINUX_MANIFEST_TMP" "${VERSION_DIR}/linux-binaries.json" \ - "$OPENBITFUN_BASE_URL" <<'PY' -import json, sys -source, dest, base = sys.argv[1:] -with open(source, encoding="utf-8") as f: - data = json.load(f) -version_base = f"{base}/{data['version']}" -for platform in data.get("platforms", {}).values(): - for product in ("cli", "relay"): - entry = platform.get(product, {}) - for key in ("url", "sha256Url"): - if entry.get(key): - entry[key] = f"{version_base}/{entry[key].rsplit('/', 1)[-1]}" -with open(dest, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) - f.write("\n") -PY - rm -f "$LINUX_MANIFEST_TMP" - cp "${VERSION_DIR}/linux-binaries.json" "${WEBSITE_RELEASE_DIR}/linux-binaries.json" - log "Updated ${WEBSITE_RELEASE_DIR}/linux-binaries.json" - elif [ "$LINUX_MANIFEST_STATE" = "missing" ]; then - rm -f "${WEBSITE_RELEASE_DIR}/linux-binaries.json" - log "Linux binaries manifest is not present in the latest release yet; Desktop mirror only." - else - # Transient failure. Keep whatever is already published: CLI self-update and - # one-click Relay deploy both fall back to this file, so a single flaky run - # must not take it offline for the next 10 minutes. - log "WARN: Linux binaries manifest unreachable this run; keeping the published mirror." - fi - # 8. Clean up old versions — keep only the latest KEEP_VERSIONS dirs ALL_DIRS=() while IFS= read -r d; do diff --git a/scripts/relay/release-download-harness.sh b/scripts/relay/release-download-harness.sh new file mode 100755 index 000000000..05267389d --- /dev/null +++ b/scripts/relay/release-download-harness.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# +# Executes the source-ranking and download loop that +# `relay_deploy.rs::release_binary_deploy_bash` generates, against a stubbed +# curl. `bash -n` only proves the script parses; these scenarios prove it picks +# the fast source, survives a link that is slow rather than broken, and still +# reaches the source-build fallback when every source is dead. +# +# Usage: release-download-harness.sh +# +# Scenario plan lines are `url-substring:mode:speed-bytes-per-sec`, where mode is +# one of ok | stall | corrupt | dead. + +set -uo pipefail + +SCRIPT_UNDER_TEST="${1:?usage: release-download-harness.sh }" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +STUB="$WORK/bin" +mkdir -p "$STUB" "$WORK/home" + +cat >"$STUB/curl" <<'CURL' +#!/usr/bin/env bash +url=""; out=""; writeout=""; range="" +args=("$@") +for ((i=0;i<${#args[@]};i++)); do + case "${args[$i]}" in + -o) out="${args[$((i+1))]}" ;; + -w) writeout="${args[$((i+1))]}" ;; + -r) range="${args[$((i+1))]}" ;; + http*) url="${args[$i]}" ;; + esac +done +mode="dead"; speed="0" +while IFS=: read -r pat m s; do + [ -n "$pat" ] || continue + case "$url" in *"$pat"*) mode="$m"; speed="$s" ;; esac +done < "$PLAN" +echo "${mode} ${url}" >>"$TRACE" +[ "$mode" = dead ] && exit 7 + +case "$url" in + *linux-binaries.json) cat "$WORKDIR/manifest.json"; exit 0 ;; +esac + +if [ -n "$range" ]; then # throughput probe + [ -n "$writeout" ] && printf '%s' "$speed" + exit 0 +fi + +case "$url" in + *.sha256) + printf '%s %s\n' "$(cat "$WORKDIR/expected_sha")" "$(basename "${url%.sha256}")" >"$out" + exit 0 ;; +esac + +case "$mode" in + stall) exit 28 ;; # curl's speed-limit abort + corrupt) printf 'CORRUPT' >"$out"; exit 0 ;; + ok) cat "$WORKDIR/payload" >"$out"; exit 0 ;; +esac +exit 7 +CURL + +cat >"$STUB/tar" <<'TAR' +#!/usr/bin/env bash +echo "DOWNLOAD-OK-reached-tar" >>"$TRACE" +exit 1 +TAR + +cat >"$STUB/uname" <<'U' +#!/usr/bin/env bash +if [ "${1:-}" = "-m" ]; then echo x86_64; else echo Linux; fi +U + +chmod +x "$STUB/curl" "$STUB/tar" "$STUB/uname" + +RELAY_ASSET="bitfun-relay-server-x86_64-unknown-linux-gnu.tar.gz" +MIRROR_ASSET_URL="https://openbitfun.com/release/0.2.13/${RELAY_ASSET}" +cat >"$WORK/manifest.json" <"$WORK/payload" +if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$WORK/payload" | awk '{print $1}' >"$WORK/expected_sha" +else + shasum -a 256 "$WORK/payload" | awk '{print $1}' >"$WORK/expected_sha" +fi + +pass=0 +fail=0 + +run_case() { + local name="$1" expect="$2" + shift 2 + printf '%s\n' "$@" >"$WORK/plan" + : >"$WORK/trace" + rm -rf "$WORK/home/.bitfun" + + local output got + output="$( + export PATH="$STUB:$PATH" HOME="$WORK/home" WORKDIR="$WORK" \ + PLAN="$WORK/plan" TRACE="$WORK/trace" RELAY_PORT=9700 \ + BITFUN_MIRROR_MODE=cn BITFUN_GITHUB_PROXY=https://ghfast.top/ + # Production calls this as an if-condition under `set -euo pipefail`. + bash -c ' + set -euo pipefail + source "$1" + if bitfun_try_release_deploy; then echo RESULT=deployed; else echo RESULT=fallback; fi + ' _ "$SCRIPT_UNDER_TEST" 2>&1 + )" + + if grep -q "DOWNLOAD-OK-reached-tar" "$WORK/trace"; then got=download-ok; else got=no-download; fi + + if [ "$got" = "$expect" ]; then + echo "PASS $name" + pass=$((pass + 1)) + else + echo "FAIL $name (expected $expect, got $got)" + printf '%s\n' "$output" | sed 's/^/ /' + echo " curl trace:" + sed 's/^/ /' "$WORK/trace" + fail=$((fail + 1)) + fi + + # The chosen source must be the fastest one that works. + if [ -n "${EXPECT_SOURCE:-}" ] \ + && ! printf '%s\n' "$output" | grep -q "Downloading published Relay binary: ${EXPECT_SOURCE}"; then + echo "FAIL $name (expected to download from ${EXPECT_SOURCE})" + printf '%s\n' "$output" | sed 's/^/ /' + fail=$((fail + 1)) + fi +} + +GITHUB_URL="https://github.com/GCWing/BitFun/releases/download/v0.2.13/${RELAY_ASSET}" + +# The reported case: GitHub is reachable but crawling, the mirror is fast. +# Ranking must send the download to the mirror instead of crawling for an hour. +EXPECT_SOURCE="$MIRROR_ASSET_URL" \ + run_case "slow GitHub loses to fast mirror" download-ok \ + "ghfast.top:ok:20480" "//github.com:ok:20480" \ + "linux-binaries.json:ok:999999" "release/0.2.13:ok:2097152" + +# Nothing clears the healthy bar: still download, because the alternative is a +# 20-minute source rebuild. +EXPECT_SOURCE="$MIRROR_ASSET_URL" \ + run_case "all sources under the healthy bar still download" download-ok \ + "ghfast.top:ok:20480" "//github.com:ok:30720" \ + "linux-binaries.json:ok:999999" "release/0.2.13:ok:40960" + +# A source that dies mid-transfer must hand off rather than retry forever. +EXPECT_SOURCE="$GITHUB_URL" \ + run_case "stalled fastest source fails over" download-ok \ + "ghfast.top:stall:2097152" "//github.com:ok:100000" \ + "linux-binaries.json:ok:999999" "release/0.2.13:ok:50000" + +# Bad bytes must not be resumed on top of from the next source. +EXPECT_SOURCE="$GITHUB_URL" \ + run_case "checksum mismatch discards the partial file" download-ok \ + "ghfast.top:corrupt:2097152" "//github.com:ok:100000" \ + "linux-binaries.json:ok:999999" "release/0.2.13:ok:50000" + +# An unreachable mirror manifest must not abort the caller under `set -e`. +EXPECT_SOURCE="$GITHUB_URL" \ + run_case "unreachable mirror manifest leaves GitHub usable" download-ok \ + "ghfast.top:dead:0" "//github.com:ok:150000" "linux-binaries.json:dead:0" + +EXPECT_SOURCE="" \ + run_case "every source dead reaches the source-build fallback" no-download \ + "ghfast.top:dead:0" "//github.com:dead:0" "linux-binaries.json:dead:0" + +echo "----" +echo "pass=$pass fail=$fail" +[ "$fail" -eq 0 ] diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 91b3f2766..8fa879412 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -78,6 +78,7 @@ anyhow = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } flate2 = { workspace = true } +futures-util = { workspace = true } reqwest = { workspace = true } sha2 = { workspace = true } tar = { workspace = true } diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 396480d78..364bd0153 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -25,12 +25,22 @@ bitfun update --check # report only; do not install ``` Official Linux archive installations check for updates before interactive TUI -startup at most once every six hours. The check uses GitHub first, then -`https://openbitfun.com/release/linux-binaries.json` when GitHub or its asset -download fails. Set `behavior.auto_update = false` in the CLI config or export +startup at most once every six hours. That check only fetches the release +manifest — a few KB, bounded at ten seconds — and hands the actual archive to a +detached background process, so launch never waits on a download. Set +`behavior.auto_update = false` in the CLI config or export `BITFUN_CLI_DISABLE_AUTO_UPDATE=1` to disable automatic checks. Development and nightly binaries are never replaced by the stable automatic updater. +Both GitHub and `https://openbitfun.com/release/linux-binaries.json` are read, +and whichever advertises the newer version decides what gets installed. When +both carry it, each is probed with a short ranged request and the archive is +downloaded from whichever is measurably faster — a source that is merely slow +rather than broken no longer holds the update hostage. Downloads resume rather +than restart, including across a switch of source, and the checksum is verified +before anything is replaced. Concurrent updates are serialised by +`update.lock` in the CLI config directory. + `bitfun-cli` is a deprecated compatibility entrypoint. It writes `Warning: \`bitfun-cli\` is deprecated; use \`bitfun\` instead.` to stderr; new scripts and integrations must use `bitfun`. Official installers and archives ship both commands as a pair; a diff --git a/src/apps/cli/src/self_update.rs b/src/apps/cli/src/self_update.rs index ae4f0dff0..32080f848 100644 --- a/src/apps/cli/src/self_update.rs +++ b/src/apps/cli/src/self_update.rs @@ -1,5 +1,6 @@ use anyhow::{anyhow, Context, Result}; use flate2::read::GzDecoder; +use futures_util::StreamExt; use reqwest::Client; use serde::Deserialize; use sha2::{Digest, Sha256}; @@ -7,17 +8,39 @@ use std::fs; use std::io::Cursor; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use std::time::{Duration, SystemTime}; +use std::time::{Duration, Instant, SystemTime}; use tar::Archive; const GITHUB_MANIFEST: &str = "https://github.com/GCWing/BitFun/releases/latest/download/linux-binaries.json"; const OPENBITFUN_MANIFEST: &str = "https://openbitfun.com/release/linux-binaries.json"; const AUTO_CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); -/// Hard ceiling on how long an automatic check may delay interactive startup. -const AUTO_UPDATE_BUDGET: Duration = Duration::from_secs(90); const DEPRECATION_WARNING: &str = "Warning: `bitfun-cli` is deprecated; use `bitfun` instead."; +/// Ranked-source download tuning. Mirrors the relay deploy path in +/// `src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs`, +/// which solves the same problem on the server side; keep the two in step. +/// +/// A fixed-length ranged request measures throughput: bytes delivered inside +/// the window *is* the speed estimate, so one probe per source ranks them all. +const PROBE_WINDOW: Duration = Duration::from_secs(10); +const PROBE_BYTES: u64 = 4 * 1024 * 1024; +/// A source at or above this is used without hesitation. +const HEALTHY_THROUGHPUT: u64 = 128 * 1024; +/// Sustained below this counts as a dead link and we fail over. Deliberately +/// far under the healthy bar: a genuinely slow but only available source must +/// still be allowed to finish rather than loop forever. +const STALL_THROUGHPUT: u64 = 8 * 1024; +const STALL_WINDOW: Duration = Duration::from_secs(30); +/// Per-chunk read ceiling. Replaces a whole-request timeout, which made success +/// depend on archive size over link speed: a 30 MB archive under a 120 s total +/// timeout simply could not be fetched below ~250 KB/s, from any source. +const READ_TIMEOUT: Duration = Duration::from_secs(30); +/// Manifests are a few KB, so they may carry a total ceiling; archives may not. +const MANIFEST_TIMEOUT: Duration = Duration::from_secs(20); +/// Ceiling for the whole startup-path check, which precedes the first paint. +const AUTO_CHECK_BUDGET: Duration = Duration::from_secs(10); + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct LinuxBinariesManifest { @@ -50,6 +73,11 @@ pub(crate) enum UpdateOutcome { } pub(crate) async fn run_manual(check_only: bool) -> Result { + let _lock = if check_only { + None + } else { + Some(InstallLock::acquire()?) + }; let outcome = update_from_configured_sources(check_only).await?; match outcome { UpdateOutcome::Current => { @@ -67,23 +95,114 @@ pub(crate) async fn run_manual(check_only: bool) -> Result { Ok(outcome) } +/// Startup-path update check. +/// +/// This only fetches the manifest — a few KB, fast even on a crawling link — +/// and hands the multi-megabyte archive to a detached child. Interactive launch +/// must never sit behind a transfer whose duration is set by the user's +/// bandwidth; the previous inline download could hold the TUI for minutes. pub(crate) async fn maybe_run_automatic() { if !automatic_update_is_eligible() || !automatic_check_is_due() { return; } mark_automatic_check(); - // Interactive startup must never wait on the network longer than this, no - // matter how slowly a mirror trickles the archive out. - match tokio::time::timeout(AUTO_UPDATE_BUDGET, update_from_configured_sources(false)).await { - Ok(Ok(UpdateOutcome::Updated)) => eprintln!( - "BitFun CLI updated in the background. The new version will be used next time." - ), - Ok(Ok(_)) => {} - Ok(Err(error)) => tracing::debug!("Automatic CLI update check failed: {error}"), - Err(_) => tracing::debug!( - "Automatic CLI update check exceeded {}s; continuing startup.", - AUTO_UPDATE_BUDGET.as_secs() + + let client = match build_client() { + Ok(client) => client, + Err(error) => { + tracing::debug!("Automatic CLI update check skipped: {error}"); + return; + } + }; + // Even a manifest fetch gets a tight leash here: this runs before the TUI + // paints, and a check that is 6 hours overdue can wait for the next launch. + let Ok((manifests, errors)) = + tokio::time::timeout(AUTO_CHECK_BUDGET, fetch_manifests(&client)).await + else { + tracing::debug!("Automatic CLI update check timed out; continuing startup."); + return; + }; + if manifests.is_empty() { + tracing::debug!("Automatic CLI update check failed: {}", errors.join("; ")); + return; + } + let newest = newest_version(&manifests); + if !is_newer_version(&newest, env!("CARGO_PKG_VERSION")) { + return; + } + + match spawn_detached_install() { + Ok(true) => eprintln!( + "BitFun CLI {newest} is downloading in the background; it will be used next launch." ), + Ok(false) => tracing::debug!("A CLI update is already in progress; skipping."), + Err(error) => tracing::debug!("Could not start background CLI update: {error}"), + } +} + +/// Run `bitfun update` detached so it outlives this process. Returns false when +/// another install already holds the lock. +fn spawn_detached_install() -> Result { + if InstallLock::is_held() { + return Ok(false); + } + let exe = std::env::current_exe().context("resolve current BitFun CLI executable")?; + Command::new(exe) + .arg("update") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("spawn background CLI update")?; + Ok(true) +} + +/// Guards against two `bitfun update` runs swapping the binaries at once. +struct InstallLock { + path: PathBuf, +} + +impl InstallLock { + fn path() -> Option { + crate::config::CliConfig::config_dir() + .ok() + .map(|dir| dir.join("update.lock")) + } + + /// A lock older than this is treated as abandoned by a killed process. + const STALE_AFTER: Duration = Duration::from_secs(60 * 60); + + fn is_held() -> bool { + let Some(path) = Self::path() else { + return false; + }; + let Ok(modified) = fs::metadata(&path).and_then(|meta| meta.modified()) else { + return false; + }; + SystemTime::now() + .duration_since(modified) + .is_ok_and(|age| age < Self::STALE_AFTER) + } + + fn acquire() -> Result { + let path = + Self::path().ok_or_else(|| anyhow!("cannot resolve the CLI update lock location"))?; + if Self::is_held() { + return Err(anyhow!( + "another BitFun CLI update is already running ({})", + path.display() + )); + } + let _ = fs::remove_file(&path); + fs::write(&path, std::process::id().to_string()) + .with_context(|| format!("create {}", path.display()))?; + Ok(Self { path }) + } +} + +impl Drop for InstallLock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); } } @@ -96,76 +215,176 @@ async fn update_from_configured_sources(check_only: bool) -> Result return Ok(outcome), - Ok(None) => {} - Err(error) => errors.push(format!("{source}: {error:#}")), + let newest = newest_version(&manifests); + if !is_newer_version(&newest, env!("CARGO_PKG_VERSION")) { + return Ok(UpdateOutcome::Current); + } + if check_only { + let from = manifests + .iter() + .filter(|(_, manifest)| manifest.version == newest) + .map(|(source, _)| *source) + .collect::>() + .join(", "); + println!( + "BitFun CLI {} is available from {} (current {}).", + newest, + from, + env!("CARGO_PKG_VERSION") + ); + return Ok(UpdateOutcome::Available); + } + + // Only sources that actually carry the newest version are candidates. During + // the mirror's sync window openbitfun still advertises the previous release, + // so it is simply not offering these bytes yet. + let mut candidates = Vec::new(); + let mut skipped = Vec::new(); + for (source, manifest) in &manifests { + if manifest.version != newest { + continue; + } + match platform_asset(manifest, platform_key) { + Ok(asset) => candidates.push(AssetCandidate { + url: asset.url.clone(), + sha256_url: asset.sha256_url.clone(), + filename: asset.filename.clone(), + }), + Err(error) => skipped.push(format!("{source}: {error:#}")), + } + } + if candidates.is_empty() { + return Err(anyhow!( + "no source offers a usable {platform_key} CLI asset for {newest}: {}", + skipped.join("; ") + )); + } + + let ranked = rank_sources(&client, candidates).await; + if let Some(fastest) = ranked.first() { + if fastest.1 < HEALTHY_THROUGHPUT { + eprintln!( + "Fastest update source is {} KB/s, under the {} KB/s bar; the download will take a while.", + fastest.1 / 1024, + HEALTHY_THROUGHPUT / 1024 + ); } } + // Partial progress carries across sources: every source serves the same + // artifact and the checksum catches a bad resume. + let mut buffer = Vec::new(); + let mut failures = Vec::new(); + for (candidate, _) in &ranked { + if let Err(error) = download_resumable(&client, &candidate.url, &mut buffer).await { + failures.push(format!("{}: {error:#}", candidate.url)); + continue; + } + let checksum_text = match download_text(&client, &candidate.sha256_url).await { + Ok(text) => text, + Err(error) => { + failures.push(format!("{}: {error:#}", candidate.sha256_url)); + continue; + } + }; + if let Err(error) = verify_sha256(&buffer, &checksum_text, &candidate.filename) { + // Bad bytes, not a bad link: discard so the next source does not + // resume on top of them. + buffer.clear(); + failures.push(format!("{}: {error:#}", candidate.url)); + continue; + } + install_archive(&buffer, ¤t_exe)?; + restart_managed_daemon(); + println!("Updated to {newest} from {}", candidate.url); + return Ok(UpdateOutcome::Updated); + } + Err(anyhow!( - "CLI update failed from both configured sources: {}", - errors.join("; ") + "CLI update could not be downloaded from any source: {}", + failures.join("; ") )) } -async fn try_source( +/// One source's copy of the same archive. +#[derive(Debug, Clone)] +struct AssetCandidate { + url: String, + sha256_url: String, + filename: String, +} + +async fn fetch_manifests( client: &Client, - source: &str, - manifest_url: &str, - platform_key: &str, - current_exe: &Path, - check_only: bool, -) -> Result> { - let manifest = client - .get(manifest_url) - .send() - .await - .with_context(|| format!("request {manifest_url}"))? - .error_for_status() - .with_context(|| format!("fetch {manifest_url}"))? - .json::() - .await - .with_context(|| format!("parse {manifest_url}"))?; +) -> (Vec<(&'static str, LinuxBinariesManifest)>, Vec) { + // Concurrently: one unreachable source must not add its whole ceiling to + // the other's, which matters most on the startup path. + let (github, mirror) = tokio::join!( + fetch_manifest(client, GITHUB_MANIFEST), + fetch_manifest(client, OPENBITFUN_MANIFEST), + ); + + let mut manifests = Vec::new(); + let mut errors = Vec::new(); + for (source, result) in [("GitHub", github), ("openbitfun.com", mirror)] { + match result { + Ok(manifest) => manifests.push((source, manifest)), + Err(error) => errors.push(format!("{source}: {error:#}")), + } + } + (manifests, errors) +} + +async fn fetch_manifest(client: &Client, manifest_url: &str) -> Result { + // Manifests are a few KB, so a short total ceiling is safe here even though + // archive downloads deliberately have none. + let manifest = tokio::time::timeout(MANIFEST_TIMEOUT, async { + client + .get(manifest_url) + .send() + .await + .with_context(|| format!("request {manifest_url}"))? + .error_for_status() + .with_context(|| format!("fetch {manifest_url}"))? + .json::() + .await + .with_context(|| format!("parse {manifest_url}")) + }) + .await + .map_err(|_| anyhow!("{manifest_url} did not answer within {MANIFEST_TIMEOUT:?}"))??; + if manifest.schema_version != 1 { return Err(anyhow!( "unsupported Linux binaries manifest schema {}", manifest.schema_version )); } - if !is_newer_version(&manifest.version, env!("CARGO_PKG_VERSION")) { - return Ok(Some(UpdateOutcome::Current)); - } - if check_only { - println!( - "BitFun CLI {} is available from {} (current {}).", - manifest.version, - source, - env!("CARGO_PKG_VERSION") - ); - return Ok(Some(UpdateOutcome::Available)); + Ok(manifest) +} + +fn newest_version(manifests: &[(&'static str, LinuxBinariesManifest)]) -> String { + let mut newest = manifests[0].1.version.clone(); + for (_, manifest) in manifests { + if is_newer_version(&manifest.version, &newest) { + newest = manifest.version.clone(); + } } + newest +} +fn platform_asset<'a>( + manifest: &'a LinuxBinariesManifest, + platform_key: &str, +) -> Result<&'a ReleaseAsset> { let platform = manifest .platforms .get(platform_key) @@ -185,30 +404,139 @@ async fn try_source( if !platform.cli.filename.ends_with(".tar.gz") { return Err(anyhow!("CLI release asset is not a tar.gz archive")); } + Ok(&platform.cli) +} - let archive = download_bytes(client, &platform.cli.url).await?; - let checksum_text = download_text(client, &platform.cli.sha256_url).await?; - verify_sha256(&archive, &checksum_text, &platform.cli.filename)?; - install_archive(&archive, current_exe)?; - restart_managed_daemon(); - println!("Updated from {source}: {}", manifest.version); - Ok(Some(UpdateOutcome::Updated)) +fn build_client() -> Result { + Client::builder() + .connect_timeout(Duration::from_secs(8)) + // Deliberately no `.timeout()`: a whole-request ceiling turns "slow" into + // "impossible" for any archive larger than ceiling x link speed. Stalls + // are caught by READ_TIMEOUT plus the throughput floor below. + .read_timeout(READ_TIMEOUT) + .build() + .context("build CLI updater HTTP client") } -async fn download_bytes(client: &Client, url: &str) -> Result> { - Ok(client +/// Bytes a source delivers inside [`PROBE_WINDOW`], i.e. its throughput. +/// A source that errors or answers nothing scores 0 but is still attempted +/// later: some CDNs refuse ranged requests while serving full ones fine. +async fn probe_throughput(client: &Client, url: &str, window: Duration) -> u64 { + let started = Instant::now(); + let request = client .get(url) + .header(reqwest::header::RANGE, format!("bytes=0-{}", PROBE_BYTES - 1)) + .send(); + let Ok(Ok(response)) = tokio::time::timeout(window, request).await else { + return 0; + }; + if !response.status().is_success() { + return 0; + } + + let mut received: u64 = 0; + let mut stream = response.bytes_stream(); + loop { + let remaining = match window.checked_sub(started.elapsed()) { + Some(left) if !left.is_zero() => left, + _ => break, + }; + match tokio::time::timeout(remaining, stream.next()).await { + Ok(Some(Ok(chunk))) => received += chunk.len() as u64, + // Timed out (window closed) or the body ended early. + _ => break, + } + if received >= PROBE_BYTES { + break; + } + } + + let elapsed = started.elapsed().as_secs_f64().max(0.001); + (received as f64 / elapsed) as u64 +} + +/// Order candidates fastest-first. Every source serves the same artifact, so +/// this only decides who to ask first, never what is acceptable. +async fn rank_sources( + client: &Client, + candidates: Vec, +) -> Vec<(AssetCandidate, u64)> { + rank_sources_with_window(client, candidates, PROBE_WINDOW).await +} + +async fn rank_sources_with_window( + client: &Client, + candidates: Vec, + window: Duration, +) -> Vec<(AssetCandidate, u64)> { + let mut ranked = Vec::with_capacity(candidates.len()); + for candidate in candidates { + let speed = probe_throughput(client, &candidate.url, window).await; + tracing::debug!("CLI update source probe: {speed} B/s from {}", candidate.url); + ranked.push((candidate, speed)); + } + ranked.sort_by(|left, right| right.1.cmp(&left.1)); + ranked +} + +/// Stream a body, appending to `buffer`, aborting if throughput stays under +/// [`STALL_THROUGHPUT`] across a [`STALL_WINDOW`] slice. +async fn stream_with_stall_guard( + response: reqwest::Response, + buffer: &mut Vec, + url: &str, +) -> Result<()> { + let mut stream = response.bytes_stream(); + let mut window_start = Instant::now(); + let mut window_bytes: u64 = 0; + + while let Some(chunk) = stream.next().await { + let chunk = chunk.with_context(|| format!("read {url}"))?; + buffer.extend_from_slice(&chunk); + window_bytes += chunk.len() as u64; + + let elapsed = window_start.elapsed(); + if elapsed >= STALL_WINDOW { + let rate = window_bytes / elapsed.as_secs().max(1); + if rate < STALL_THROUGHPUT { + return Err(anyhow!( + "source stalled at {} KB/s (need {} KB/s)", + rate / 1024, + STALL_THROUGHPUT / 1024 + )); + } + window_start = Instant::now(); + window_bytes = 0; + } + } + Ok(()) +} + +/// Download `url`, resuming with a Range request when a previous source left a +/// partial body. Every source serves an identical artifact, so partial progress +/// carries across sources; a bad resume is caught by the checksum. +async fn download_resumable(client: &Client, url: &str, buffer: &mut Vec) -> Result<()> { + let mut request = client.get(url); + if !buffer.is_empty() { + request = request.header(reqwest::header::RANGE, format!("bytes={}-", buffer.len())); + } + let response = request .send() .await - .with_context(|| format!("request {url}"))? + .with_context(|| format!("request {url}"))?; + + // A server that ignores Range answers 200 with the whole body; restart + // cleanly rather than concatenating a duplicate prefix onto the partial. + if !buffer.is_empty() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT { + buffer.clear(); + } + let response = response .error_for_status() - .with_context(|| format!("download {url}"))? - .bytes() - .await - .with_context(|| format!("read {url}"))? - .to_vec()) + .with_context(|| format!("download {url}"))?; + stream_with_stall_guard(response, buffer, url).await } + async fn download_text(client: &Client, url: &str) -> Result { client .get(url) @@ -433,8 +761,181 @@ fn restart_managed_daemon() { #[cfg(test)] mod tests { - use super::{is_newer_version, verify_sha256}; + use super::*; use sha2::{Digest, Sha256}; + use std::sync::Arc; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + /// Minimal HTTP/1.1 origin that serves `body` at a fixed rate, honours + /// `Range: bytes=-` and can be told to hang up mid-response. Enough to + /// exercise the parts of the updater that only misbehave on a slow link. + struct StubOrigin { + url: String, + } + + impl StubOrigin { + async fn spawn(body: Arc>, chunk: usize, delay: Duration, truncate: bool) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let url = format!("http://{}/asset.tar.gz", listener.local_addr().unwrap()); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + let body = Arc::clone(&body); + tokio::spawn(async move { + let mut request = vec![0u8; 2048]; + let read = socket.read(&mut request).await.unwrap_or(0); + let text = String::from_utf8_lossy(&request[..read]).to_string(); + + let start = text + .lines() + .find_map(|line| { + let rest = line.strip_prefix("range: bytes=").or_else(|| { + line.to_ascii_lowercase() + .starts_with("range: bytes=") + .then(|| &line["range: bytes=".len()..]) + })?; + rest.split('-').next()?.trim().parse::().ok() + }) + .unwrap_or(0) + .min(body.len()); + + let slice = &body[start..]; + let status = if start > 0 { + "206 Partial Content" + } else { + "200 OK" + }; + let header = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nAccept-Ranges: bytes\r\n\r\n", + slice.len() + ); + if socket.write_all(header.as_bytes()).await.is_err() { + return; + } + + let stop = if truncate { slice.len() / 3 } else { slice.len() }; + let mut sent = 0usize; + while sent < stop { + let end = (sent + chunk).min(stop); + if socket.write_all(&slice[sent..end]).await.is_err() { + return; + } + let _ = socket.flush().await; + sent = end; + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + } + }); + } + }); + Self { url } + } + + fn candidate(&self, filename: &str) -> AssetCandidate { + AssetCandidate { + url: self.url.clone(), + sha256_url: format!("{}.sha256", self.url), + filename: filename.to_string(), + } + } + } + + fn payload(size: usize) -> Arc> { + Arc::new((0..size).map(|index| (index % 251) as u8).collect()) + } + + #[tokio::test] + async fn ranking_prefers_the_faster_source() { + let body = payload(256 * 1024); + // One origin trickles 2 KB every 20 ms (~100 KB/s); the other is unthrottled. + let slow = StubOrigin::spawn(Arc::clone(&body), 2048, Duration::from_millis(20), false).await; + let fast = StubOrigin::spawn(Arc::clone(&body), 64 * 1024, Duration::ZERO, false).await; + let client = build_client().expect("client"); + + let ranked = rank_sources_with_window( + &client, + vec![slow.candidate("a.tar.gz"), fast.candidate("a.tar.gz")], + Duration::from_millis(400), + ) + .await; + + assert_eq!( + ranked[0].0.url, fast.url, + "the faster origin must be attempted first, got {ranked:?}" + ); + assert!(ranked[0].1 > ranked[1].1, "speeds must be ordered: {ranked:?}"); + } + + /// The regression that made slow links fail outright: a whole-request + /// timeout meant success depended on archive size over link speed. This + /// body takes far longer than the old 120 s ceiling would have allowed to + /// be proportionally, and must still complete. + #[tokio::test] + async fn slow_but_alive_source_completes() { + let body = payload(128 * 1024); + let slow = StubOrigin::spawn(Arc::clone(&body), 4096, Duration::from_millis(15), false).await; + let client = build_client().expect("client"); + + let mut buffer = Vec::new(); + download_resumable(&client, &slow.url, &mut buffer) + .await + .expect("slow source must still finish"); + assert_eq!(buffer, *body); + } + + #[tokio::test] + async fn partial_download_resumes_across_sources() { + let body = payload(96 * 1024); + let truncating = + StubOrigin::spawn(Arc::clone(&body), 8192, Duration::ZERO, true).await; + let complete = StubOrigin::spawn(Arc::clone(&body), 8192, Duration::ZERO, false).await; + let client = build_client().expect("client"); + + let mut buffer = Vec::new(); + // First source hangs up early, leaving a partial body behind. + let _ = download_resumable(&client, &truncating.url, &mut buffer).await; + let partial = buffer.len(); + assert!(partial > 0 && partial < body.len(), "expected a partial body"); + + // Second source must continue from there, not restart. + download_resumable(&client, &complete.url, &mut buffer) + .await + .expect("resume"); + assert_eq!(buffer, *body, "resumed body must match the original"); + } + + #[tokio::test] + async fn unreachable_source_scores_zero_without_hanging() { + let client = build_client().expect("client"); + // Port 1 on loopback refuses immediately. + let speed = probe_throughput(&client, "http://127.0.0.1:1/asset", PROBE_WINDOW).await; + assert_eq!(speed, 0); + } + + #[test] + fn newest_version_wins_across_manifests() { + let manifest = |version: &str| LinuxBinariesManifest { + schema_version: 1, + version: version.to_string(), + platforms: std::collections::HashMap::new(), + }; + // Mirror lags GitHub during its sync window; the newer one must win. + let manifests = vec![ + ("GitHub", manifest("0.2.14")), + ("openbitfun.com", manifest("0.2.13")), + ]; + assert_eq!(newest_version(&manifests), "0.2.14"); + + let reversed = vec![ + ("GitHub", manifest("0.2.13")), + ("openbitfun.com", manifest("0.2.14")), + ]; + assert_eq!(newest_version(&reversed), "0.2.14"); + } #[test] fn version_comparison_ignores_release_metadata() { diff --git a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs index 496bf1d3f..0fc31b79f 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs @@ -54,6 +54,19 @@ const REPO_TARBALL_URL: &str = "https://github.com/GCWing/BitFun/archive/refs/he const RELEASE_DOWNLOAD_BASE: &str = "https://github.com/GCWing/BitFun/releases/download"; const OPENBITFUN_RELEASE_BASE: &str = "https://openbitfun.com/release"; const RELEASE_VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Ranked-source download tuning, shared with the CLI self-updater +/// (`src/apps/cli/src/self_update.rs`) so both paths behave the same on a slow +/// link. Each candidate gets a fixed-length ranged request; bytes delivered in +/// that window is the throughput estimate used to rank sources. +const SOURCE_PROBE_SECONDS: u64 = 10; +const SOURCE_PROBE_BYTES: u64 = 4 * 1024 * 1024; +/// A source at or above this is used without hesitation. +const HEALTHY_THROUGHPUT_BYTES_PER_SEC: u64 = 128 * 1024; +/// Below this for `STALL_WINDOW_SECONDS` the source counts as dead and we fail +/// over. Deliberately far under the healthy bar: a genuinely slow but only +/// available link must still be allowed to finish rather than loop forever. +const STALL_THROUGHPUT_BYTES_PER_SEC: u64 = 8 * 1024; +const STALL_WINDOW_SECONDS: u64 = 30; /// Canonical China-mirror helper (shared with `src/apps/relay-server/deploy.sh`). /// Embedded so Desktop orchestration can apply mirrors before the git clone. const RELAY_MIRROR_SH: &str = include_str!(concat!( @@ -1355,7 +1368,7 @@ fn release_binary_deploy_bash() -> String { r#" bitfun_try_release_deploy() {{ local release_dir="$HOME/.bitfun/relay-release" - local target archive upstream_url openbitfun_url proxy_url download_dir extracted context image + local target archive upstream_url download_dir extracted context image case "$(uname -m 2>/dev/null)" in x86_64|amd64) target="x86_64-unknown-linux-gnu" ;; aarch64|arm64) target="aarch64-unknown-linux-gnu" ;; @@ -1367,7 +1380,6 @@ bitfun_try_release_deploy() {{ archive="bitfun-relay-server-${{target}}.tar.gz" upstream_url="{RELEASE_DOWNLOAD_BASE}/{release_tag}/${{archive}}" - openbitfun_url="{OPENBITFUN_RELEASE_BASE}/{release_version}/${{archive}}" mkdir -p "$release_dir" chmod 700 "$release_dir" 2>/dev/null || true download_dir="$(mktemp -d "$release_dir/download.XXXXXX")" @@ -1386,29 +1398,126 @@ bitfun_try_release_deploy() {{ ) }} + # Measure a candidate by how many bytes it delivers inside a fixed window. + # Bytes-in-fixed-time is throughput, so one ranged request yields the ranking + # without downloading the whole archive from a source we may not use. + bitfun_probe_source() {{ + local url="$1" speed + speed="$(curl -sSL --connect-timeout 5 --max-time {probe_seconds} \ + -r 0-{probe_bytes} -o /dev/null -w '%{{speed_download}}' "$url" 2>/dev/null || true)" + speed="${{speed%%.*}}" + case "$speed" in + ''|*[!0-9]*) echo 0 ;; + *) echo "$speed" ;; + esac + }} + + # Resume-capable download. `-C -` continues a partial file, so a link that + # cannot finish inside one attempt still converges instead of restarting from + # zero. --speed-limit/--speed-time abandons a source that has gone dead + # without waiting out a wall-clock ceiling. bitfun_download_release_pair() {{ local url="$1" - rm -f "$download_dir/$archive" "$download_dir/${{archive}}.sha256" + local watcher="" status=0 + local done_marker="$download_dir/.download-active" echo ">>> Downloading published Relay binary: $url" - curl -fsSL --retry 3 --connect-timeout 15 --max-time 900 \ - -o "$download_dir/$archive" "$url" \ - && curl -fsSL --retry 3 --connect-timeout 15 --max-time 60 \ - -o "$download_dir/${{archive}}.sha256" "${{url}}.sha256" \ - && bitfun_verify_release_archive + # The wizard polls this log; without a heartbeat a slow link is + # indistinguishable from a hang. Poll a sentinel on a short interval rather + # than sleeping long: `kill` on a shell blocked in a long `sleep` is not + # delivered until that sleep returns, which would stall every download by a + # full tick after curl already finished. + : >"$done_marker" + ( + local ticks=0 + while [ -f "$done_marker" ]; do + sleep 2 + ticks=$(( ticks + 1 )) + [ "$(( ticks % 10 ))" -eq 0 ] || continue + [ -f "$download_dir/$archive" ] || continue + echo ">>> ... $(du -h "$download_dir/$archive" 2>/dev/null | cut -f1) downloaded" + done + ) & + watcher=$! + curl -fsSL -C - \ + --retry 3 --retry-delay 3 --retry-max-time {retry_max_time} \ + --connect-timeout 15 --max-time {max_time} \ + --speed-limit {stall_floor} --speed-time {stall_seconds} \ + -o "$download_dir/$archive" "$url" || status=$? + rm -f "$done_marker" + wait "$watcher" >/dev/null 2>&1 || true + if [ "$status" -ne 0 ]; then + echo ">>> Source failed or stalled below $(( {stall_floor} / 1024 )) KB/s (curl $status); trying the next source." + return 1 + fi + rm -f "$download_dir/${{archive}}.sha256" + curl -fsSL --retry 3 --connect-timeout 15 --max-time 60 \ + -o "$download_dir/${{archive}}.sha256" "${{url}}.sha256" || return 1 + if bitfun_verify_release_archive; then + return 0 + fi + # Bad bytes, not a bad link: mark the partial file poisoned so the caller + # discards it instead of resuming on top of it from the next source. + : >"$download_dir/${{archive}}.verify-failed" + return 1 }} - proxy_url="" + # Candidate sources, one per line. Files rather than arrays: this script runs + # on whatever bash the user's server has, and `"${{empty[@]}}"` under `set -u` + # aborts on bash 4.2 (CentOS 7). + local sources="$download_dir/sources.tsv" mirror_url="" probe speed best_speed + : >"$sources.in" if [ "${{BITFUN_MIRROR_MODE:-global}}" = "cn" ] && [ -n "${{BITFUN_GITHUB_PROXY:-}}" ]; then - proxy_url="${{BITFUN_GITHUB_PROXY%/}}/${{upstream_url}}" + printf '%s\n' "${{BITFUN_GITHUB_PROXY%/}}/${{upstream_url}}" >>"$sources.in" fi - if [ -n "$proxy_url" ] && bitfun_download_release_pair "$proxy_url"; then - : - elif bitfun_download_release_pair "$upstream_url"; then - : - elif bitfun_download_release_pair "$openbitfun_url"; then - : - else - echo ">>> Published Relay binary unavailable from GitHub and openbitfun.com; falling back to source build." + printf '%s\n' "$upstream_url" >>"$sources.in" + # Take the mirror URL from the mirror's own manifest rather than building a + # // path: openbitfun keeps only the most recent releases, so a + # pinned version 404s for every Desktop build that is not one of them. + # `|| true`: an unreachable mirror or a non-matching manifest must leave + # mirror_url empty, never abort the caller under `set -e`. + mirror_url="$(curl -fsSL --connect-timeout 10 --max-time 30 \ + "{OPENBITFUN_RELEASE_BASE}/linux-binaries.json" 2>/dev/null \ + | tr ',' '\n' | grep -F '"url"' | grep -F "$archive" \ + | head -n 1 | sed -e 's/.*"url"[[:space:]]*:[[:space:]]*"//' -e 's/".*//' || true)" + if [ -n "$mirror_url" ]; then + printf '%s\n' "$mirror_url" >>"$sources.in" + fi + + : >"$sources" + while IFS= read -r probe; do + [ -n "$probe" ] || continue + speed="$(bitfun_probe_source "$probe")" + echo ">>> Source probe: $(( speed / 1024 )) KB/s — $probe" + printf '%s\t%s\n' "$speed" "$probe" >>"$sources" + done <"$sources.in" + + if [ ! -s "$sources" ]; then + echo ">>> No Relay binary source responded; falling back to source build." + rm -rf "$download_dir" + return 1 + fi + sort -rn -k1,1 -o "$sources" "$sources" + best_speed="$(head -n 1 "$sources" | cut -f1)" + if [ "${{best_speed:-0}}" -lt {healthy_floor} ]; then + echo ">>> Fastest source is $(( ${{best_speed:-0}} / 1024 )) KB/s, under the $(( {healthy_floor} / 1024 )) KB/s bar; continuing anyway — a slow download still beats a source rebuild." + fi + + # Try fastest first. Every source serves the identical artifact, so a partial + # file is reused across sources too (`-C -`); only a checksum mismatch, which + # means the bytes really are bad, wipes it and starts the next source clean. + local ok=0 + while IFS=$'\t' read -r speed probe; do + [ -n "$probe" ] || continue + if bitfun_download_release_pair "$probe"; then + ok=1 + break + fi + if [ -f "$download_dir/${{archive}}.verify-failed" ]; then + rm -f "$download_dir/$archive" "$download_dir/${{archive}}.verify-failed" + fi + done <"$sources" + if [ "$ok" -ne 1 ]; then + echo ">>> Published Relay binary unavailable from every source; falling back to source build." rm -rf "$download_dir" return 1 fi @@ -1547,7 +1656,13 @@ DOCKERFILE RELEASE_DOWNLOAD_BASE = RELEASE_DOWNLOAD_BASE, OPENBITFUN_RELEASE_BASE = OPENBITFUN_RELEASE_BASE, release_tag = release_tag, - release_version = RELEASE_VERSION, + probe_seconds = SOURCE_PROBE_SECONDS, + probe_bytes = SOURCE_PROBE_BYTES - 1, + healthy_floor = HEALTHY_THROUGHPUT_BYTES_PER_SEC, + stall_floor = STALL_THROUGHPUT_BYTES_PER_SEC, + stall_seconds = STALL_WINDOW_SECONDS, + retry_max_time = 300, + max_time = 3600, ) } @@ -1704,6 +1819,45 @@ mod tests { assert!(script.contains("name=^bitfun-relay-before-release-")); // Port publishing keeps compose's configurable bind address. assert!(script.contains("${RELAY_HOST_BIND_IP:-0.0.0.0}:${RELAY_PORT}:${RELAY_PORT}")); + + // Slow-link contract. A wall-clock ceiling alone made a 20 KB/s link + // fail forever: each attempt timed out mid-archive and restarted from + // zero. Throughput floor + resume + ranking replace that. + assert!(script.contains("--speed-limit")); + assert!(script.contains("--speed-time")); + assert!(script.contains("-C -")); + assert!(script.contains("--retry-max-time")); + assert!(script.contains("bitfun_probe_source")); + assert!(script.contains("sort -rn -k1,1")); + // The mirror URL must come from the mirror's manifest, never a pinned + // // path that 404s for older Desktop builds. + assert!(script.contains("linux-binaries.json")); + assert!(!script.contains("release/0.2")); + } + + /// `bash -n` only proves the generated script parses. This runs its source + /// ranking and download loop against a stubbed curl so the slow-link + /// behaviour is actually exercised. + #[cfg(unix)] + #[test] + fn release_download_picks_the_fastest_working_source() { + let dir = tempfile::tempdir().expect("temp dir"); + let script = dir.path().join("release.sh"); + std::fs::write(&script, release_binary_deploy_bash()).expect("write script"); + + let harness = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../../scripts/relay/release-download-harness.sh"); + let output = std::process::Command::new("bash") + .arg(&harness) + .arg(&script) + .output() + .expect("run release download harness"); + assert!( + output.status.success(), + "release download harness failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); } #[cfg(unix)] diff --git a/src/web-ui/src/features/relay-deploy/README.md b/src/web-ui/src/features/relay-deploy/README.md index d8b3e6bfd..43c6c15f4 100644 --- a/src/web-ui/src/features/relay-deploy/README.md +++ b/src/web-ui/src/features/relay-deploy/README.md @@ -17,44 +17,58 @@ Desktop Tauri surface: `src/apps/desktop/src/api/relay_deploy_api.rs` ## Invariants (do not regress) 1. **Published binary first, source fallback.** Download the matching stable - `v` asset or the `nightly` asset for nightly Desktop builds, - trying GitHub before the versioned openbitfun.com mirror. Verify its `.sha256` - and preserve the existing `bitfun-relay` container, volumes, ports, and - `/app/relay-admin` contract. A download, checksum, runtime-image, startup, or - health failure restores the previous container before falling back to source. - -2. **Fallback source path is `~/.bitfun/relay-src`**, never `$HOME/BitFun` / + `v` asset or the `nightly` asset for nightly Desktop builds. + Verify its `.sha256` and preserve the existing `bitfun-relay` container, + volumes, ports, and `/app/relay-admin` contract. A download, checksum, + runtime-image, startup, or health failure restores the previous container + before falling back to source. + +2. **Rank sources by measured speed; never by fixed priority.** The CN proxy, + GitHub, and the openbitfun.com mirror each get a short ranged probe and the + download goes to the fastest. A source that is slow rather than broken must + not hold the deploy: `--speed-limit`/`--speed-time` abandons a dead link + quickly, `-C -` resumes instead of restarting (a wall-clock ceiling alone + made a 20 KB/s link retry from zero forever), and every source is tried + fastest-first before giving up. If nothing clears the healthy-throughput + bar, still download — a slow transfer beats a 20-minute source rebuild. + +3. **Take the mirror URL from the mirror's own manifest** + (`openbitfun.com/release/linux-binaries.json`), never a constructed + `//` path. The mirror retains only the most recent releases, so a + pinned version 404s for every older Desktop build. + +4. **Fallback source path is `~/.bitfun/relay-src`**, never `$HOME/BitFun` / `$HOME/bitfun`. Sync always passes an explicit clone destination. Destructive replace is only safe under `~/.bitfun/`. -3. **Git first, tarball fallback.** When `.git` already exists, deploy must +5. **Git first, tarball fallback.** When `.git` already exists, deploy must `fetch` + checkout, not re-clone from scratch (preserves BuildKit layers and Cargo cache mounts for registry/git/`target`). -4. **Close wizard = cancel remote task.** Do not leave nohup builds running +6. **Close wizard = cancel remote task.** Do not leave nohup builds running after the modal closes; cancel must kill the pid tree and best-effort stop compose/buildx workers. -5. **Account password never leaves this device.** Provision locally, then +7. **Account password never leaves this device.** Provision locally, then `relay-admin import-user` over the SSH session. Do not send plaintext passwords to the remote as env/script args. -6. **“Already deployed” is container-aware, not only selected-port health.** +8. **“Already deployed” is container-aware, not only selected-port health.** Changing the listen port must not hide a running `bitfun-relay`. Use `container_running` / `existing_relay_port` / `relay_healthy` (health on selected **or** existing port). “Create account” must hit the running port. -7. **Port conflict ≠ our relay.** `port_busy && !port_owned_by_relay` blocks +9. **Port conflict ≠ our relay.** `port_busy && !port_owned_by_relay` blocks deploy; busy-because-bitfun-relay does not. -8. **Privilege / Docker install.** Do not call `sudo -v` unconditionally. +10. **Privilege / Docker install.** Do not call `sudo -v` unconditionally. Detect root / passwordless sudo / interactive elevate. Docker install must not require a working daemon *before* install. -9. **Scripts are embedded Rust templates** staged via SFTP. Do not rely on a +11. **Scripts are embedded Rust templates** staged via SFTP. Do not rely on a static repo `.sh` alone on the server until the desktop binary re-stages. -10. **China mirrors before overseas downloads.** Desktop orchestration embeds +12. **China mirrors before overseas downloads.** Desktop orchestration embeds `src/apps/relay-server/mirror.sh` and runs `bitfun_mirror_init` before apt tool install, Docker Engine install, and GitHub sync. `deploy.sh` sources the same file so manual and one-click paths stay aligned. Force with From 69ab33e2cec80e64745f80ea6dd5d4d5b45f6f6b Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 24 Jul 2026 21:05:11 -0700 Subject: [PATCH 2/4] fix(release): harden the update and deploy download paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up implementing every finding from the review of the two flows. Desktop updater had the same defect the CLI and relay just had, on the largest artifact. Tauri walks `endpoints`, stops at the first usable manifest, then downloads from the URL inside it. latest.json is ~2KB, so a reachable-but- crawling GitHub always won the race to answer and then pinned an 80-160MB download to itself; the mirror was reached only when GitHub errored outright. Endpoints are now ordered by probing the package each origin actually advertises (probing the manifest would measure latency, not throughput). Deliberately still routed through Update::download: minisign verification lives inside it, so hand-rolling the fetch and calling Update::install would silently skip signature checking. Supply chain: CLI and relay archives were verified only against a .sha256 from the same host that served them, which proves the transfer was intact but nothing about the source — and the CN relay path prefers a third-party GitHub proxy by design. Two changes: checksums are now fetched from the canonical GitHub URL whatever origin the bytes came from (derivable from any candidate, including the mirror's versioned path), and the four Linux archives are signed in CI with the same minisign key the Desktop updater trusts. Official CLI builds embed that public key and refuse an unsigned release; builds without it fall back to checksum-only, so forks still work. The signing step self-verifies before publishing, because a signature nobody checked is worse than none. One implementation, two callers: the ranked/resumable download plus runtime image moved to src/apps/relay-server/release-download.sh, sourced by deploy.sh and embedded by relay_deploy.rs the way mirror.sh already is. The manual path consequently stops compiling Rust on the user's VPS for ~20 minutes; a new --build-from-source restores the old behaviour. Also: preflight reports free disk under $HOME and on Docker's data root, and the source-build fallback refuses to start without ~6GB rather than dying as an opaque compiler error; CLI downloads stage to disk so a killed background installer resumes instead of restarting. Verified: the harness gained a scenario proving a mirror download is checked against GitHub's checksum (the stub gives every non-GitHub origin a wrong one); signature tests run against a fixture produced with the real minisign CLI, and the sign/verify invocation used in CI was validated locally against a generated key. Raised the CLI crate's recursion_limit — the added async layers tipped trait resolution over the default depth. --- .github/workflows/desktop-package.yml | 5 + .github/workflows/linux-binaries.yml | 57 +++ .github/workflows/nightly.yml | 5 + scripts/generate-linux-binaries-manifest.mjs | 10 +- scripts/linux-binaries-manifest.test.mjs | 46 +- scripts/openbitfun-release-sync.sh | 6 +- scripts/relay/release-download-harness.sh | 38 +- src/apps/cli/Cargo.toml | 2 + src/apps/cli/README.md | 17 +- src/apps/cli/src/main.rs | 4 + src/apps/cli/src/self_update.rs | 261 ++++++++++- src/apps/desktop/src/api/system_api.rs | 184 +++++++- src/apps/relay-server/deploy.sh | 24 ++ src/apps/relay-server/release-download.sh | 404 ++++++++++++++++++ .../src/remote_ssh/relay_deploy.rs | 372 ++++------------ .../src/features/relay-deploy/README.md | 31 +- 16 files changed, 1130 insertions(+), 336 deletions(-) create mode 100644 src/apps/relay-server/release-download.sh diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 736596e31..f45e1985d 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -243,6 +243,10 @@ jobs: name: Linux CLI and Relay Server needs: prepare uses: ./.github/workflows/linux-binaries.yml + secrets: + release_signing_key: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + release_signing_password: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + release_pubkey: ${{ secrets.TAURI_UPDATER_PUBKEY }} with: checkout_ref: ${{ needs.prepare.outputs.checkout_ref }} version: ${{ needs.prepare.outputs.version }} @@ -332,6 +336,7 @@ jobs: linux-release-assets/bitfun-cli-*.tar.gz.sha256 linux-release-assets/bitfun-relay-server-*.tar.gz linux-release-assets/bitfun-relay-server-*.tar.gz.sha256 + linux-release-assets/*.tar.gz.sig linux-release-assets/linux-binaries.json fail_on_unmatched_files: true diff --git a/.github/workflows/linux-binaries.yml b/.github/workflows/linux-binaries.yml index cd39e3d65..ba1448893 100644 --- a/.github/workflows/linux-binaries.yml +++ b/.github/workflows/linux-binaries.yml @@ -15,6 +15,16 @@ on: description: "Stable prefix that isolates artifacts in the caller run." required: true type: string + secrets: + release_signing_key: + description: "Tauri/minisign private key, base64. Absent on forks: archives ship unsigned." + required: false + release_signing_password: + description: "Password for release_signing_key." + required: false + release_pubkey: + description: "Tauri/minisign public key, base64. Compiled into the CLI as its trust root." + required: false permissions: contents: read @@ -91,6 +101,11 @@ jobs: - name: Build CLI and Relay Server shell: bash + env: + # Compiled into the CLI; its presence makes signature verification + # mandatory for self-update. Unset on forks, which then fall back to + # checksum-only. + BITFUN_RELEASE_PUBKEY: ${{ secrets.release_pubkey }} run: | cargo build --release \ --target ${{ matrix.platform.target }} \ @@ -118,6 +133,47 @@ jobs: TARGET: ${{ matrix.platform.target }} run: bash scripts/relay/package-unix.sh "$ASSET_VERSION" "$TARGET" + - name: Sign release archives + id: sign + shell: bash + env: + SIGNING_KEY: ${{ secrets.release_signing_key }} + SIGNING_PASSWORD: ${{ secrets.release_signing_password }} + SIGNING_PUBKEY: ${{ secrets.release_pubkey }} + CLI_ARCHIVE: ${{ steps.cli-stage.outputs.archive }} + RELAY_ARCHIVE: ${{ steps.relay-stage.outputs.archive }} + run: | + set -euo pipefail + if [[ -z "${SIGNING_KEY:-}" ]]; then + echo "No release signing key configured; archives ship with checksums only." + echo "signed=false" >>"$GITHUB_OUTPUT" + exit 0 + fi + + sudo apt-get install -y --no-install-recommends minisign + + # Tauri stores the minisign secret key base64-wrapped; unwrap it to the + # on-disk format minisign expects. Same key that signs Desktop updates, + # so the CLI and the Desktop updater share one trust root. + umask 077 + printf '%s' "$SIGNING_KEY" | base64 -d >"$RUNNER_TEMP/release.key" + printf '%s' "$SIGNING_PUBKEY" | base64 -d >"$RUNNER_TEMP/release.pub" + + for archive in "$CLI_ARCHIVE" "$RELAY_ARCHIVE"; do + printf '%s\n' "$SIGNING_PASSWORD" | + minisign -S -s "$RUNNER_TEMP/release.key" -m "$archive" -x "${archive}.minisig" + # Verify before publishing: a signature nobody checked is worse than + # none, because the client is about to treat it as proof. + minisign -Vm "$archive" -p "$RUNNER_TEMP/release.pub" -x "${archive}.minisig" + # Tauri's wire format is base64 of the whole signature file. + base64 -w0 <"${archive}.minisig" >"${archive}.sig" + rm -f "${archive}.minisig" + echo "Signed and verified: ${archive}.sig" + done + + rm -f "$RUNNER_TEMP/release.key" "$RUNNER_TEMP/release.pub" + echo "signed=true" >>"$GITHUB_OUTPUT" + - name: Upload Linux binary artifacts uses: actions/upload-artifact@v6 with: @@ -129,3 +185,4 @@ jobs: ${{ steps.cli-stage.outputs.checksum }} ${{ steps.relay-stage.outputs.archive }} ${{ steps.relay-stage.outputs.checksum }} + *.tar.gz.sig diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index f192b3dd4..63613eafc 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -217,6 +217,10 @@ jobs: needs: check-changes if: needs.check-changes.outputs.should_build == 'true' uses: ./.github/workflows/linux-binaries.yml + secrets: + release_signing_key: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + release_signing_password: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + release_pubkey: ${{ secrets.TAURI_UPDATER_PUBKEY }} with: checkout_ref: ${{ github.sha }} version: ${{ needs.check-changes.outputs.nightly_version }} @@ -296,4 +300,5 @@ jobs: linux-release-assets/bitfun-cli-*.tar.gz.sha256 linux-release-assets/bitfun-relay-server-*.tar.gz linux-release-assets/bitfun-relay-server-*.tar.gz.sha256 + linux-release-assets/*.tar.gz.sig linux-release-assets/linux-binaries.json diff --git a/scripts/generate-linux-binaries-manifest.mjs b/scripts/generate-linux-binaries-manifest.mjs index ca2a51ef1..fe1b294ca 100644 --- a/scripts/generate-linux-binaries-manifest.mjs +++ b/scripts/generate-linux-binaries-manifest.mjs @@ -50,11 +50,19 @@ function asset(filename) { if (!fs.existsSync(checksumPath)) { throw new Error(`Required Linux checksum was not found: ${checksumPath}`); } - return { + const entry = { filename, url: `${releaseBase}/${filename}`, sha256Url: `${releaseBase}/${checksum}`, }; + // Signature is optional: forks build without the release key. A checksum + // proves only that the transfer was intact, since whoever serves the archive + // serves the checksum too; the signature is what a mirror cannot forge. + const signature = `${filename}.sig`; + if (fs.existsSync(path.join(assetsDir, signature))) { + entry.sigUrl = `${releaseBase}/${signature}`; + } + return entry; } const manifest = { diff --git a/scripts/linux-binaries-manifest.test.mjs b/scripts/linux-binaries-manifest.test.mjs index 03dcacc2c..a8ff50bb9 100644 --- a/scripts/linux-binaries-manifest.test.mjs +++ b/scripts/linux-binaries-manifest.test.mjs @@ -58,6 +58,50 @@ test('generates GitHub URLs for both Linux CLI and Relay architectures', () => { ); }); +test('publishes sigUrl when a signature is present, omits it otherwise', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-linux-manifest-sig-')); + const assets = path.join(temp, 'assets'); + const out = path.join(temp, 'linux-binaries.json'); + fs.mkdirSync(assets); + + for (const target of ['x86_64-unknown-linux-gnu', 'aarch64-unknown-linux-gnu']) { + for (const filename of [ + `bitfun-cli-1.2.3-${target}.tar.gz`, + `bitfun-relay-server-${target}.tar.gz`, + ]) { + fs.writeFileSync(path.join(assets, filename), ''); + fs.writeFileSync(path.join(assets, `${filename}.sha256`), ''); + } + } + // Sign only the x86_64 CLI, so both branches are covered in one run. + fs.writeFileSync( + path.join(assets, 'bitfun-cli-1.2.3-x86_64-unknown-linux-gnu.tar.gz.sig'), + '' + ); + + const result = spawnSync( + process.execPath, + [ + 'scripts/generate-linux-binaries-manifest.mjs', + '--assets-dir', assets, + '--version', '1.2.3', + '--tag', 'v1.2.3', + '--repo', 'GCWing/BitFun', + '--out', out, + ], + { cwd: repoRoot, encoding: 'utf8' } + ); + assert.equal(result.status, 0, result.stderr); + + const manifest = JSON.parse(fs.readFileSync(out, 'utf8')); + assert.match( + manifest.platforms['linux-x86_64'].cli.sigUrl, + /bitfun-cli-1\.2\.3-x86_64-unknown-linux-gnu\.tar\.gz\.sig$/ + ); + assert.equal(manifest.platforms['linux-x86_64'].relay.sigUrl, undefined); + assert.equal(manifest.platforms['linux-aarch64'].cli.sigUrl, undefined); +}); + test('rejects versions whose build metadata GitHub would rewrite in asset names', () => { const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-linux-manifest-meta-')); const assets = path.join(temp, 'assets'); @@ -105,7 +149,7 @@ test('openbitfun sync mirrors both products and their checksums', () => { assert.match(syncScript, /linux-binaries\.json/); assert.match(syncScript, /for product in \("cli", "relay"\)/); - assert.match(syncScript, /for key in \("url", "sha256Url"\)/); + assert.match(syncScript, /for key in \("url", "sha256Url", "sigUrl"\)/); assert.match(syncScript, /OPENBITFUN_BASE_URL/); assert.match(syncScript, /WEBSITE_RELEASE_DIR.*linux-binaries\.json/); }); diff --git a/scripts/openbitfun-release-sync.sh b/scripts/openbitfun-release-sync.sh index 99d137c1c..da4bd680a 100755 --- a/scripts/openbitfun-release-sync.sh +++ b/scripts/openbitfun-release-sync.sh @@ -148,7 +148,9 @@ seen = set() for platform in data.get("platforms", {}).values(): for product in ("cli", "relay"): entry = platform.get(product, {}) - for key in ("url", "sha256Url"): + # sigUrl too: without the signature the mirrored copy cannot be verified + # as anything stronger than "not corrupted in transit". + for key in ("url", "sha256Url", "sigUrl"): url = entry.get(key) if not url: continue @@ -174,7 +176,7 @@ version_base = f"{base}/{data['version']}" for platform in data.get("platforms", {}).values(): for product in ("cli", "relay"): entry = platform.get(product, {}) - for key in ("url", "sha256Url"): + for key in ("url", "sha256Url", "sigUrl"): if entry.get(key): entry[key] = f"{version_base}/{entry[key].rsplit('/', 1)[-1]}" with open(dest, "w", encoding="utf-8") as f: diff --git a/scripts/relay/release-download-harness.sh b/scripts/relay/release-download-harness.sh index 05267389d..165bc064a 100755 --- a/scripts/relay/release-download-harness.sh +++ b/scripts/relay/release-download-harness.sh @@ -50,7 +50,13 @@ fi case "$url" in *.sha256) - printf '%s %s\n' "$(cat "$WORKDIR/expected_sha")" "$(basename "${url%.sha256}")" >"$out" + # Only canonical github.com serves the true checksum. Every other origin + # serves a wrong one, so a successful verify proves the canonical URL was + # used rather than the download origin's own sidecar. + case "$url" in + https://github.com/*) printf '%s %s\n' "$(cat "$WORKDIR/expected_sha")" "$(basename "${url%.sha256}")" >"$out" ;; + *) printf '%s %s\n' "$(printf 'f%.0s' $(seq 64))" "$(basename "${url%.sha256}")" >"$out" ;; + esac exit 0 ;; esac @@ -116,24 +122,25 @@ run_case() { if grep -q "DOWNLOAD-OK-reached-tar" "$WORK/trace"; then got=download-ok; else got=no-download; fi - if [ "$got" = "$expect" ]; then + local problem="" + if [ "$got" != "$expect" ]; then + problem="expected $expect, got $got" + elif [ -n "${EXPECT_SOURCE:-}" ] && + ! printf '%s\n' "$output" | grep -qF "Downloading published Relay binary: ${EXPECT_SOURCE}"; then + # The chosen source must be the fastest one that actually works. + problem="expected the download to come from ${EXPECT_SOURCE}" + fi + + if [ -z "$problem" ]; then echo "PASS $name" pass=$((pass + 1)) else - echo "FAIL $name (expected $expect, got $got)" + echo "FAIL $name ($problem)" printf '%s\n' "$output" | sed 's/^/ /' echo " curl trace:" sed 's/^/ /' "$WORK/trace" fail=$((fail + 1)) fi - - # The chosen source must be the fastest one that works. - if [ -n "${EXPECT_SOURCE:-}" ] \ - && ! printf '%s\n' "$output" | grep -q "Downloading published Relay binary: ${EXPECT_SOURCE}"; then - echo "FAIL $name (expected to download from ${EXPECT_SOURCE})" - printf '%s\n' "$output" | sed 's/^/ /' - fail=$((fail + 1)) - fi } GITHUB_URL="https://github.com/GCWing/BitFun/releases/download/v0.2.13/${RELAY_ASSET}" @@ -173,6 +180,15 @@ EXPECT_SOURCE="" \ run_case "every source dead reaches the source-build fallback" no-download \ "ghfast.top:dead:0" "//github.com:dead:0" "linux-binaries.json:dead:0" +# Security property: bytes from a mirror must be checked against the checksum +# GitHub serves, not the one the mirror serves. The stub gives every non-GitHub +# origin a wrong checksum, so downloading from the mirror can only succeed if +# the canonical URL was used. +EXPECT_SOURCE="$MIRROR_ASSET_URL" \ + run_case "mirror download verifies against the canonical GitHub checksum" download-ok \ + "ghfast.top:ok:1024" "//github.com:ok:1024" \ + "linux-binaries.json:ok:999999" "release/0.2.13:ok:2097152" + echo "----" echo "pass=$pass fail=$fail" [ "$fail" -eq 0 ] diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 8fa879412..558339ce5 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -79,6 +79,8 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } flate2 = { workspace = true } futures-util = { workspace = true } +base64 = { workspace = true } +minisign-verify = "0.2" reqwest = { workspace = true } sha2 = { workspace = true } tar = { workspace = true } diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 364bd0153..f6ae32d69 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -37,10 +37,23 @@ and whichever advertises the newer version decides what gets installed. When both carry it, each is probed with a short ranged request and the archive is downloaded from whichever is measurably faster — a source that is merely slow rather than broken no longer holds the update hostage. Downloads resume rather -than restart, including across a switch of source, and the checksum is verified -before anything is replaced. Concurrent updates are serialised by +than restart, across a switch of source and across runs (a background install +killed at 90% picks up where it stopped). Concurrent updates are serialised by `update.lock` in the CLI config directory. +Two checks run before anything is replaced: + +- **Checksum**, fetched from the canonical GitHub URL even when the archive came + from a mirror. A `.sha256` served by the same host as the archive only proves + the transfer was intact — whoever serves one serves the other. Falling back to + the origin's own checksum happens only when GitHub is wholly unreachable, and + says so. +- **Signature** (`.sig`, minisign), verified against the release key compiled + into official builds — the same trust root the Desktop updater uses. This is + the check a mirror or third-party proxy cannot forge. Official builds refuse + to install an unsigned release; builds without the key (local, forks) fall + back to checksum-only. + `bitfun-cli` is a deprecated compatibility entrypoint. It writes `Warning: \`bitfun-cli\` is deprecated; use \`bitfun\` instead.` to stderr; new scripts and integrations must use `bitfun`. Official installers and archives ship both commands as a pair; a diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 88985d7ae..53ca6bf93 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -1,3 +1,7 @@ +// Trait resolution for this crate's async call graph (reqwest/h2 futures behind +// several layers of `async fn`) exceeds the default depth. +#![recursion_limit = "256"] + /// BitFun CLI /// /// Command-line interface version, supports: diff --git a/src/apps/cli/src/self_update.rs b/src/apps/cli/src/self_update.rs index 32080f848..3788d158b 100644 --- a/src/apps/cli/src/self_update.rs +++ b/src/apps/cli/src/self_update.rs @@ -61,6 +61,9 @@ struct ReleaseAsset { filename: String, url: String, sha256_url: String, + /// Present once the release is signed; absent on older manifests. + #[serde(default)] + sig_url: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -249,16 +252,30 @@ async fn update_from_configured_sources(check_only: bool) -> Result candidates.push(AssetCandidate { - url: asset.url.clone(), - sha256_url: asset.sha256_url.clone(), - filename: asset.filename.clone(), - }), + Ok(asset) => { + if *source == "GitHub" { + canonical_sha256_url = Some(asset.sha256_url.clone()); + canonical_sig_url = asset.sig_url.clone(); + } + candidates.push(AssetCandidate { + url: asset.url.clone(), + sha256_url: asset.sha256_url.clone(), + sig_url: asset.sig_url.clone(), + filename: asset.filename.clone(), + }); + } Err(error) => skipped.push(format!("{source}: {error:#}")), } } @@ -269,6 +286,8 @@ async fn update_from_configured_sources(check_only: bool) -> Result Result text, + // Falling back to the origin's own checksum is materially weaker, so + // only do it when the canonical copy is genuinely unreachable. + Err(error) if checksum_url != candidate.sha256_url => { + eprintln!( + "Warning: canonical checksum at {checksum_url} is unreachable ({error:#}); \ + falling back to the one served by the download origin, which only \ + detects corruption, not a tampered mirror." + ); + match download_text(&client, &candidate.sha256_url).await { + Ok(text) => text, + Err(error) => { + failures.push(format!("{}: {error:#}", candidate.sha256_url)); + continue; + } + } + } Err(error) => { - failures.push(format!("{}: {error:#}", candidate.sha256_url)); + failures.push(format!("{checksum_url}: {error:#}")); continue; } }; @@ -300,10 +349,30 @@ async fn update_from_configured_sources(check_only: bool) -> Result Result, +} + +impl PartialDownload { + fn open(version: &str, filename: &str) -> Self { + match crate::config::CliConfig::config_dir() { + Ok(dir) => Self::open_in(&dir, version, filename), + Err(_) => Self { path: None }, + } + } + + /// Directory-injected form. Keeps the tests off `$HOME`, which is + /// process-global and would make every other test in this binary flaky. + fn open_in(dir: &Path, version: &str, filename: &str) -> Self { + let key: String = format!("{version}-{filename}") + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' || c == '-' { + c + } else { + '_' + } + }) + .collect(); + let path = dir.join(format!("update-partial-{key}")); + + // A partial from a different version or asset is dead weight, and + // resuming one into this archive would only be caught by the checksum + // after the whole transfer. + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let stale = entry.path(); + let is_partial = stale + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("update-partial-")); + if is_partial && stale != path { + let _ = fs::remove_file(stale); + } + } + } + Self { path: Some(path) } + } + + fn resume(&self) -> Vec { + self.path + .as_ref() + .and_then(|path| fs::read(path).ok()) + .unwrap_or_default() + } + + fn save(&self, buffer: &[u8]) { + if buffer.is_empty() { + return; + } + if let Some(path) = &self.path { + let _ = fs::write(path, buffer); + } + } + + fn discard(&self) { + if let Some(path) = &self.path { + let _ = fs::remove_file(path); + } + } +} + /// One source's copy of the same archive. #[derive(Debug, Clone)] struct AssetCandidate { url: String, sha256_url: String, + sig_url: Option, filename: String, } @@ -550,6 +694,45 @@ async fn download_text(client: &Client, url: &str) -> Result { .with_context(|| format!("read {url}")) } +/// Ed25519 (minisign) public key for official release archives, injected at +/// build time from the same `TAURI_UPDATER_PUBKEY` the Desktop updater trusts. +/// +/// Absent in local and fork builds; those fall back to checksum-only, which is +/// why `signature_required` gates on it rather than assuming. +const RELEASE_PUBKEY: Option<&str> = option_env!("BITFUN_RELEASE_PUBKEY"); + +/// The trust root this binary was built with, if any. `Some` means an official +/// release build, and signature verification is then mandatory. +fn release_pubkey() -> Option<&'static str> { + RELEASE_PUBKEY.filter(|key| !key.trim().is_empty()) +} + +/// Verify a Tauri-format `.sig` (base64 of a minisign signature file) over the +/// archive, using the base64-wrapped public key. +/// +/// A checksum only proves the transfer was not corrupted: whoever serves the +/// archive can serve a matching `.sha256`. A signature proves the bytes came +/// from whoever holds the release key, which is what actually protects the +/// third-party GitHub proxy and mirror paths. +fn verify_signature(archive: &[u8], signature_b64: &str, pubkey_b64: &str) -> Result<()> { + use base64::Engine as _; + let decode = |value: &str, what: &str| -> Result { + let bytes = base64::engine::general_purpose::STANDARD + .decode(value.trim().as_bytes()) + .with_context(|| format!("decode {what}"))?; + String::from_utf8(bytes).with_context(|| format!("decode {what} as UTF-8")) + }; + + let public_key = minisign_verify::PublicKey::decode(&decode(pubkey_b64, "release public key")?) + .map_err(|error| anyhow!("invalid release public key: {error}"))?; + let signature = + minisign_verify::Signature::decode(&decode(signature_b64, "release signature")?) + .map_err(|error| anyhow!("invalid release signature: {error}"))?; + public_key + .verify(archive, &signature, false) + .map_err(|error| anyhow!("release signature does not match the archive: {error}")) +} + fn verify_sha256(archive: &[u8], checksum_text: &str, filename: &str) -> Result<()> { let expected = checksum_text .split_whitespace() @@ -839,6 +1022,7 @@ mod tests { AssetCandidate { url: self.url.clone(), sha256_url: format!("{}.sha256", self.url), + sig_url: None, filename: filename.to_string(), } } @@ -916,6 +1100,41 @@ mod tests { assert_eq!(speed, 0); } + /// A background installer killed mid-transfer must resume, not restart — + /// on a slow link restarting means never finishing. A partial from a + /// different release must never be resumed into. + #[test] + fn staged_partial_resumes_and_evicts_other_versions() { + let dir = tempfile::tempdir().expect("temp dir"); + let stage = |version: &str, filename: &str| { + PartialDownload::open_in(dir.path(), version, filename) + }; + + let first = stage("0.2.14", "bitfun-cli-0.2.14-x86_64.tar.gz"); + assert!(first.resume().is_empty(), "nothing staged yet"); + first.save(b"partial-bytes"); + assert_eq!( + stage("0.2.14", "bitfun-cli-0.2.14-x86_64.tar.gz").resume(), + b"partial-bytes", + "same version and asset must resume" + ); + + // Opening a different version evicts the stale partial rather than + // resuming a mismatched archive into the new one. + let newer = stage("0.2.15", "bitfun-cli-0.2.15-x86_64.tar.gz"); + assert!(newer.resume().is_empty()); + assert!( + stage("0.2.14", "bitfun-cli-0.2.14-x86_64.tar.gz") + .resume() + .is_empty(), + "the superseded partial must be gone" + ); + + newer.save(b"abc"); + newer.discard(); + assert!(newer.resume().is_empty(), "discard clears the staging file"); + } + #[test] fn newest_version_wins_across_manifests() { let manifest = |version: &str| LinuxBinariesManifest { @@ -944,6 +1163,28 @@ mod tests { assert!(!is_newer_version("0.2.12", "0.2.13")); } + /// Fixture produced with the real `minisign` CLI, then wrapped the way + /// Tauri wraps keys and signatures (base64 of the whole file), so this pins + /// the exact on-disk format CI must emit. + const FIXTURE_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgRTNFMDg3NENFQzFDMjJDMwpSV1RESWh6c1RJZmc0MXcyR3dpZWkwek5ES2FMWW05ZFFWcEVXTlEvVWxweXQybWJTMkpFMVUyTQo="; + const FIXTURE_SIGNATURE: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVUREloenNUSWZnNDBMTitwb25aT3RCVy9VYmJtNWhkR1poM0lCb3IwUDBKaVZmZmM1cFJaNlZSNUpaSzNUUm1yWWpYMXFLQ2svWTdZUDhHdkRZT3YvanVoZlpnZmhyWEFRPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg0OTUxOTM1CWZpbGU6YXJjaGl2ZS50YXIuZ3oJaGFzaGVkCjhWL21EUVAwZGdlZXVNU1lxWlpsOWdFSGUwOTJQTk9yRG1BMUV6ZHNQOUlEYkcyT1dneTFsQ1puUDBJaFIwQnJpMFBCeENRcUdDR2dpb0l0UGtSMUN3PT0K"; + const FIXTURE_DATA: &[u8] = b"hello-bitfun\n"; + + #[test] + fn release_signature_accepts_the_tauri_wire_format() { + verify_signature(FIXTURE_DATA, FIXTURE_SIGNATURE, FIXTURE_PUBKEY) + .expect("minisign signature in Tauri's base64 wrapper must verify"); + } + + #[test] + fn release_signature_rejects_tampered_bytes() { + // The whole point: a mirror that alters the archive cannot also forge + // this, unlike the checksum it serves alongside it. + let tampered = b"hello-bitfun-tampered\n"; + assert!(verify_signature(tampered, FIXTURE_SIGNATURE, FIXTURE_PUBKEY).is_err()); + assert!(verify_signature(FIXTURE_DATA, "bm90LWEtc2lnbmF0dXJl", FIXTURE_PUBKEY).is_err()); + } + #[test] fn checksum_contract_accepts_standard_sha_file() { let data = b"bitfun"; diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index 9d3d589a6..2f2d157d6 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -14,6 +14,167 @@ use tauri_plugin_updater::UpdaterExt; /// Emitted during `install_update` download; matches `installUpdateWithProgress` / frontend listener. const UPDATE_PROGRESS_EVENT: &str = "bitfun-update-progress"; +/// Updater origins, in configured (fallback) order. Kept in step with +/// `scripts/desktop-tauri-build.mjs`, which bakes the same pair into the bundle. +const GITHUB_UPDATER_ENDPOINT: &str = + "https://github.com/GCWing/BitFun/releases/latest/download/latest.json"; +const OPENBITFUN_UPDATER_ENDPOINT: &str = "https://openbitfun.com/release/latest.json"; + +/// Throughput probe settings, matching the CLI updater and the relay deploy +/// script (`src/apps/cli/src/self_update.rs`, +/// `src/apps/relay-server/release-download.sh`). +const PROBE_WINDOW: std::time::Duration = std::time::Duration::from_secs(10); +const PROBE_BYTES: u64 = 4 * 1024 * 1024; +const HEALTHY_THROUGHPUT: u64 = 128 * 1024; + +/// Order the updater endpoints fastest-first. +/// +/// Tauri walks `endpoints` and stops at the first that returns a usable +/// manifest, then downloads from the URL *inside that manifest*. `latest.json` +/// is ~2 KB, so a reachable-but-crawling GitHub always wins the race to answer +/// and then pins an 80-160 MB download to itself — the mirror is only ever tried +/// when GitHub errors outright. Probing the actual package each origin would +/// serve, and ordering endpoints by that, makes the slow-link case fall to +/// whichever origin can actually deliver. +/// +/// Deliberately still routed through `Update::download`: minisign verification +/// lives inside it, so fetching bytes by hand and calling `Update::install` +/// would silently skip signature checking. +async fn endpoints_fastest_first() -> Vec { + let client = match reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(5)) + .build() + { + Ok(client) => client, + Err(_) => return default_endpoints(), + }; + + // Probe the package each origin would actually serve, not its manifest. + // `latest.json` is ~2 KB, so probing it measures round-trip latency and + // tells us nothing about an 80-160 MB transfer. Each manifest names its own + // download URL, which is exactly the thing worth measuring. + let platform = updater_platform_key(); + let mut ranked = Vec::new(); + for endpoint in [GITHUB_UPDATER_ENDPOINT, OPENBITFUN_UPDATER_ENDPOINT] { + let Ok(url) = endpoint.parse::() else { + continue; + }; + let speed = match manifest_package_url(&client, endpoint, &platform).await { + Some(package) => probe_endpoint_throughput(&client, &package).await, + // Unreachable or no build for this platform: rank last, but still + // offer it — Tauri may succeed where a ranged probe did not. + None => 0, + }; + log::debug!("Desktop updater probe: {} B/s via {}", speed, endpoint); + ranked.push((url, speed)); + } + if ranked.is_empty() { + return default_endpoints(); + } + ranked.sort_by(|left, right| right.1.cmp(&left.1)); + if ranked[0].1 < HEALTHY_THROUGHPUT { + log::info!( + "Fastest updater origin is {} KB/s, under the {} KB/s bar; the download will be slow.", + ranked[0].1 / 1024, + HEALTHY_THROUGHPUT / 1024 + ); + } + ranked.into_iter().map(|(url, _)| url).collect() +} + +/// Tauri's `latest.json` platform key for this host, e.g. `darwin-aarch64`. +/// Mirrors `scripts/generate-tauri-latest-json.mjs`. +fn updater_platform_key() -> String { + let os = match std::env::consts::OS { + "macos" => "darwin", + other => other, + }; + format!("{os}-{}", std::env::consts::ARCH) +} + +/// Read one updater manifest and return the download URL it advertises for this +/// platform. Cheap: the manifest is a couple of kilobytes. +async fn manifest_package_url( + client: &reqwest::Client, + endpoint: &str, + platform: &str, +) -> Option { + let manifest = tokio::time::timeout( + std::time::Duration::from_secs(10), + client.get(endpoint).send(), + ) + .await + .ok()? + .ok()? + .error_for_status() + .ok()? + .json::() + .await + .ok()?; + manifest + .get("platforms")? + .get(platform)? + .get("url")? + .as_str() + .map(str::to_owned) +} + +fn default_endpoints() -> Vec { + [GITHUB_UPDATER_ENDPOINT, OPENBITFUN_UPDATER_ENDPOINT] + .iter() + .filter_map(|endpoint| endpoint.parse().ok()) + .collect() +} + +/// Bytes an origin delivers inside [`PROBE_WINDOW`], i.e. its throughput. +async fn probe_endpoint_throughput(client: &reqwest::Client, url: &str) -> u64 { + use futures::StreamExt; + + let started = std::time::Instant::now(); + let request = client + .get(url) + .header(reqwest::header::RANGE, format!("bytes=0-{}", PROBE_BYTES - 1)) + .send(); + let Ok(Ok(response)) = tokio::time::timeout(PROBE_WINDOW, request).await else { + return 0; + }; + if !response.status().is_success() { + return 0; + } + + let mut received: u64 = 0; + let mut stream = response.bytes_stream(); + loop { + let remaining = match PROBE_WINDOW.checked_sub(started.elapsed()) { + Some(left) if !left.is_zero() => left, + _ => break, + }; + match tokio::time::timeout(remaining, stream.next()).await { + Ok(Some(Ok(chunk))) => received += chunk.len() as u64, + _ => break, + } + if received >= PROBE_BYTES { + break; + } + } + (received as f64 / started.elapsed().as_secs_f64().max(0.001)) as u64 +} + +/// Build an updater whose endpoints are ordered by measured throughput. +/// Falls back to the bundled configuration if the builder rejects them. +async fn ranked_updater(app: &AppHandle) -> Result { + let endpoints = endpoints_fastest_first().await; + let builder = app.updater_builder(); + let builder = match builder.endpoints(endpoints) { + Ok(builder) => builder, + Err(error) => { + log::warn!("Updater endpoint ranking rejected, using bundled order: {}", error); + app.updater_builder() + } + }; + builder.build().map_err(|error| error.to_string()) +} + #[derive(Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] struct UpdateProgressPayload { @@ -75,7 +236,7 @@ pub async fn check_for_updates( request: CheckForUpdatesRequest, ) -> Result { let _ = request; - let updater = app.updater().map_err(|e| e.to_string())?; + let updater = ranked_updater(&app).await?; let update = updater.check().await.map_err(|e| e.to_string())?; match update { Some(u) => Ok(CheckForUpdatesResponse { @@ -103,7 +264,7 @@ pub struct InstallUpdateRequest {} #[tauri::command] pub async fn install_update(app: AppHandle, request: InstallUpdateRequest) -> Result<(), String> { let _ = request; - let updater = app.updater().map_err(|e| e.to_string())?; + let updater = ranked_updater(&app).await?; let update = updater.check().await.map_err(|e| e.to_string())?; let Some(update) = update else { return Err("No update available".to_string()); @@ -666,6 +827,25 @@ pub async fn send_system_notification( #[cfg(test)] mod tests { + /// The probe reads `platforms[].url` out of `latest.json`; if this key + /// stops matching what scripts/generate-tauri-latest-json.mjs emits, every + /// probe silently scores 0 and ranking degrades to the configured order. + #[test] + fn updater_platform_key_matches_latest_json_convention() { + let key = super::updater_platform_key(); + let (os, arch) = key.split_once('-').expect("os-arch shape"); + assert!( + matches!(os, "darwin" | "linux" | "windows"), + "unexpected updater os segment: {os}" + ); + assert!( + matches!(arch, "x86_64" | "aarch64"), + "unexpected updater arch segment: {arch}" + ); + #[cfg(target_os = "macos")] + assert!(key.starts_with("darwin-"), "macOS must map to darwin, got {key}"); + } + use super::*; #[test] diff --git a/src/apps/relay-server/deploy.sh b/src/apps/relay-server/deploy.sh index 4b88e9a39..7e4db8a06 100755 --- a/src/apps/relay-server/deploy.sh +++ b/src/apps/relay-server/deploy.sh @@ -22,9 +22,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/common.sh" # shellcheck source=mirror.sh source "${SCRIPT_DIR}/mirror.sh" +# shellcheck source=release-download.sh +source "${SCRIPT_DIR}/release-download.sh" SKIP_BUILD=false SKIP_HEALTH_CHECK=false +BUILD_FROM_SOURCE=false MIRROR_ARGS=() usage() { @@ -43,6 +46,7 @@ Supported architectures: Options: --skip-build Skip docker compose build, only recreate/start services + --build-from-source Skip the published binary and compile from source --skip-health-check Skip post-deploy health check --cn-mirror Force China mirrors (apt/Docker/cargo/GitHub) --global-mirror Force global upstream mirrors @@ -63,6 +67,7 @@ EOF for arg in "$@"; do case "$arg" in --skip-build) SKIP_BUILD=true ;; + --build-from-source) BUILD_FROM_SOURCE=true ;; --skip-health-check) SKIP_HEALTH_CHECK=true ;; --cn-mirror|--global-mirror|--no-cn-mirror|--skip-mirror-apply) MIRROR_ARGS+=("$arg") @@ -111,6 +116,25 @@ fi echo "BITFUN_CARGO_SPARSE_URL=${BITFUN_CARGO_SPARSE_URL:-sparse+https://rsproxy.cn/index/}" } >>.env +# Prefer the published binary: a runtime image around a prebuilt archive takes +# under a minute, while compiling the relay from source on a small VPS takes +# ~20 minutes and needs ~2GB RAM. Identical code to the Desktop one-click path +# (release-download.sh); it restores any previous container on failure and +# returns non-zero to hand back to the source build below. +if [ "$BUILD_FROM_SOURCE" = true ]; then + echo "[1/2] Skipping the published binary (--build-from-source)" +elif [ "$SKIP_BUILD" = true ]; then + echo "[1/2] Skipping the published binary (--skip-build)" +elif bitfun_try_release_deploy; then + RELAY_PORT="${RELAY_PORT:-9700}" + echo "" + echo "=== Deploy complete (published binary) ===" + echo "Relay server running on port ${RELAY_PORT} (host arch: ${HOST_ARCH})" + echo "" + check_relay_accounts_or_remind + exit 0 +fi + # Build first so a compile failure does not take down a running relay. if [ "$SKIP_BUILD" = true ]; then echo "[1/2] Skipping Docker build (--skip-build)" diff --git a/src/apps/relay-server/release-download.sh b/src/apps/relay-server/release-download.sh new file mode 100644 index 000000000..80df4a256 --- /dev/null +++ b/src/apps/relay-server/release-download.sh @@ -0,0 +1,404 @@ +#!/usr/bin/env bash +# BitFun Relay Server — published-binary download and runtime deploy. +# +# Single implementation shared by both deployment paths, the same way mirror.sh +# is shared: +# - src/apps/relay-server/deploy.sh sources this file +# - remote_ssh/relay_deploy.rs embeds it with include_str! +# +# Defines: +# bitfun_try_release_deploy Download the published archive for this host's +# architecture, verify it, build a small runtime +# image around it and start the relay. Returns 1 +# (without disturbing a running relay) whenever the +# caller should fall back to a source build. +# +# Configuration — all optional, defaults target the official release: +# BITFUN_RELEASE_TAG v0.2.13 | nightly | latest (default latest) +# BITFUN_GITHUB_RELEASE_BASE https://github.com/GCWing/BitFun/releases +# BITFUN_OPENBITFUN_RELEASE_BASE https://openbitfun.com/release +# BITFUN_GITHUB_PROXY prefix-style proxy, set by mirror.sh in CN +# BITFUN_MIRROR_MODE cn | global, set by mirror.sh +# RELAY_PORT published port (default 9700) +# RELAY_HOST_BIND_IP bind address (default 0.0.0.0, as compose) +# +# Throughput tuning. A wall-clock ceiling alone is the wrong give-up condition: +# it makes success depend on archive size over link speed, so a link that is +# merely slow can never finish and retries from zero forever. Rank sources by +# measured throughput instead, and treat only a sustained floor breach as death. +# BITFUN_PROBE_SECONDS probe window per candidate (default 10) +# BITFUN_PROBE_BYTES ranged probe size (default 4MiB) +# BITFUN_HEALTHY_BPS a source at/above this is used freely (default 128KiB/s) +# BITFUN_STALL_BPS sustained below this counts as dead (default 8KiB/s) +# BITFUN_STALL_SECONDS window for the floor above (default 30) + +BITFUN_RELEASE_TAG="${BITFUN_RELEASE_TAG:-latest}" +BITFUN_GITHUB_RELEASE_BASE="${BITFUN_GITHUB_RELEASE_BASE:-https://github.com/GCWing/BitFun/releases}" +BITFUN_OPENBITFUN_RELEASE_BASE="${BITFUN_OPENBITFUN_RELEASE_BASE:-https://openbitfun.com/release}" +BITFUN_PROBE_SECONDS="${BITFUN_PROBE_SECONDS:-10}" +BITFUN_PROBE_BYTES="${BITFUN_PROBE_BYTES:-4194304}" +BITFUN_HEALTHY_BPS="${BITFUN_HEALTHY_BPS:-131072}" +BITFUN_STALL_BPS="${BITFUN_STALL_BPS:-8192}" +BITFUN_STALL_SECONDS="${BITFUN_STALL_SECONDS:-30}" + +# Docker invocation. relay_deploy.rs and common.sh each define their own +# privilege-aware wrapper before sourcing this file; fall back to a compatible +# one so the file also works standalone. +if ! declare -F bitfun_docker >/dev/null 2>&1; then + bitfun_docker() { + case "${BITFUN_DOCKER_MODE:-direct}" in + sg) sg docker -c "docker $*" ;; + sudo) + if sudo -n true >/dev/null 2>&1; then sudo -n docker "$@"; else sudo docker "$@"; fi + ;; + *) docker "$@" ;; + esac + } +fi + +# Build the release asset URL for a tag. `latest` uses GitHub's redirecting +# /releases/latest/download/ path, which is what the manual deploy wants; a +# pinned tag is what Desktop wants so the relay matches the app it ships with. +bitfun_release_asset_url() { + local tag="$1" asset="$2" + if [ "$tag" = "latest" ]; then + printf '%s/latest/download/%s\n' "$BITFUN_GITHUB_RELEASE_BASE" "$asset" + else + printf '%s/download/%s/%s\n' "$BITFUN_GITHUB_RELEASE_BASE" "$tag" "$asset" + fi +} + +# Map any candidate download URL back to the checksum GitHub itself serves for +# those exact bytes. +# +# Verifying an archive against a `.sha256` fetched from the same host proves +# only that the transfer was not corrupted; a hostile or compromised mirror +# serves both and passes. Binding to a checksum from a different origin means +# one compromised mirror is not enough. This matters because the CN path +# deliberately prefers a third-party GitHub proxy. +# +# The mirror encodes its version in the path (release//), so the +# matching canonical tag is recoverable even when the mirror lags behind latest. +bitfun_canonical_checksum_url() { + local url="$1" asset version + asset="${url##*/}" + case "$url" in + "$BITFUN_OPENBITFUN_RELEASE_BASE"/*) + version="${url#"$BITFUN_OPENBITFUN_RELEASE_BASE"/}" + version="${version%%/*}" + if [ -n "$version" ] && [ "$version" != "$asset" ]; then + printf '%s/download/v%s/%s.sha256\n' "$BITFUN_GITHUB_RELEASE_BASE" "$version" "$asset" + return 0 + fi + ;; + *"$BITFUN_GITHUB_RELEASE_BASE"/*) + # Plain GitHub, or a prefix-style proxy in front of it. Strip the prefix. + printf '%s.sha256\n' "${BITFUN_GITHUB_RELEASE_BASE}${url#*"$BITFUN_GITHUB_RELEASE_BASE"}" + return 0 + ;; + esac + printf '%s.sha256\n' "$url" +} + +bitfun_try_release_deploy() { + local release_dir="$HOME/.bitfun/relay-release" + local target archive upstream_url download_dir extracted context image + case "$(uname -m 2>/dev/null)" in + x86_64 | amd64) target="x86_64-unknown-linux-gnu" ;; + aarch64 | arm64) target="aarch64-unknown-linux-gnu" ;; + *) + echo ">>> No published Relay binary for architecture $(uname -m); using source build." + return 1 + ;; + esac + + archive="bitfun-relay-server-${target}.tar.gz" + upstream_url="$(bitfun_release_asset_url "$BITFUN_RELEASE_TAG" "$archive")" + mkdir -p "$release_dir" + chmod 700 "$release_dir" 2>/dev/null || true + download_dir="$(mktemp -d "$release_dir/download.XXXXXX")" + + bitfun_verify_release_archive() { + ( + cd "$download_dir" || return 1 + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c "${archive}.sha256" + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 -c "${archive}.sha256" + else + echo "ERROR: sha256sum or shasum is required to verify the Relay release." >&2 + return 1 + fi + ) + } + + # Fetch the checksum, preferring the canonical GitHub copy over the one the + # download origin offers. Falls back to same-origin only when GitHub cannot be + # reached at all, and says so — that is a materially weaker guarantee. + bitfun_fetch_release_checksum() { + local url="$1" canonical + canonical="$(bitfun_canonical_checksum_url "$url")" + rm -f "$download_dir/${archive}.sha256" + if curl -fsSL --retry 3 --connect-timeout 15 --max-time 60 \ + -o "$download_dir/${archive}.sha256" "$canonical"; then + return 0 + fi + if [ "$canonical" = "${url}.sha256" ]; then + return 1 + fi + echo ">>> WARNING: could not reach the canonical checksum at ${canonical}." + echo ">>> Falling back to the checksum served by the download origin," + echo ">>> which only detects corruption, not a tampered mirror." + curl -fsSL --retry 3 --connect-timeout 15 --max-time 60 \ + -o "$download_dir/${archive}.sha256" "${url}.sha256" + } + + # Measure a candidate by how many bytes it delivers inside a fixed window. + # Bytes-in-fixed-time is throughput, so one ranged request ranks a source + # without downloading the whole archive from one we may not use. + bitfun_probe_source() { + local url="$1" speed + speed="$(curl -sSL --connect-timeout 5 --max-time "$BITFUN_PROBE_SECONDS" \ + -r "0-$((BITFUN_PROBE_BYTES - 1))" -o /dev/null \ + -w '%{speed_download}' "$url" 2>/dev/null || true)" + speed="${speed%%.*}" + case "$speed" in + '' | *[!0-9]*) echo 0 ;; + *) echo "$speed" ;; + esac + } + + bitfun_download_release_pair() { + local url="$1" + local watcher="" status=0 + local done_marker="$download_dir/.download-active" + echo ">>> Downloading published Relay binary: $url" + # Callers poll this log; without a heartbeat a slow link is + # indistinguishable from a hang. Poll a sentinel on a short interval rather + # than sleeping long: `kill` on a shell blocked in a long `sleep` is not + # delivered until that sleep returns, which would stall every download by a + # full tick after curl already finished. + : >"$done_marker" + ( + local ticks=0 + while [ -f "$done_marker" ]; do + sleep 2 + ticks=$((ticks + 1)) + [ "$((ticks % 10))" -eq 0 ] || continue + [ -f "$download_dir/$archive" ] || continue + echo ">>> ... $(du -h "$download_dir/$archive" 2>/dev/null | cut -f1) downloaded" + done + ) & + watcher=$! + curl -fsSL -C - \ + --retry 3 --retry-delay 3 --retry-max-time 300 \ + --connect-timeout 15 --max-time 3600 \ + --speed-limit "$BITFUN_STALL_BPS" --speed-time "$BITFUN_STALL_SECONDS" \ + -o "$download_dir/$archive" "$url" || status=$? + rm -f "$done_marker" + wait "$watcher" >/dev/null 2>&1 || true + if [ "$status" -ne 0 ]; then + echo ">>> Source failed or stalled below $((BITFUN_STALL_BPS / 1024)) KB/s (curl $status); trying the next source." + return 1 + fi + bitfun_fetch_release_checksum "$url" || return 1 + if bitfun_verify_release_archive; then + return 0 + fi + # Bad bytes, not a bad link: mark the partial file poisoned so the caller + # discards it instead of resuming on top of it from the next source. + : >"$download_dir/${archive}.verify-failed" + return 1 + } + + # Candidate sources, one per line. Files rather than arrays: this runs on + # whatever bash the target server has, and `"${empty[@]}"` under `set -u` + # aborts on bash 4.2 (CentOS 7). + local sources="$download_dir/sources.tsv" mirror_url="" probe speed best_speed + : >"$sources.in" + if [ "${BITFUN_MIRROR_MODE:-global}" = "cn" ] && [ -n "${BITFUN_GITHUB_PROXY:-}" ]; then + printf '%s\n' "${BITFUN_GITHUB_PROXY%/}/${upstream_url}" >>"$sources.in" + fi + printf '%s\n' "$upstream_url" >>"$sources.in" + # Take the mirror URL from the mirror's own manifest rather than building a + # // path: openbitfun keeps only the most recent releases, so a + # pinned version 404s for every Desktop build that is not one of them. + # `|| true`: an unreachable mirror or a non-matching manifest must leave this + # empty, never abort the caller under `set -e`. + mirror_url="$(curl -fsSL --connect-timeout 10 --max-time 30 \ + "${BITFUN_OPENBITFUN_RELEASE_BASE}/linux-binaries.json" 2>/dev/null | + tr ',' '\n' | grep -F '"url"' | grep -F "$archive" | + head -n 1 | sed -e 's/.*"url"[[:space:]]*:[[:space:]]*"//' -e 's/".*//' || true)" + if [ -n "$mirror_url" ]; then + printf '%s\n' "$mirror_url" >>"$sources.in" + fi + + : >"$sources" + while IFS= read -r probe; do + [ -n "$probe" ] || continue + speed="$(bitfun_probe_source "$probe")" + echo ">>> Source probe: $((speed / 1024)) KB/s — $probe" + printf '%s\t%s\n' "$speed" "$probe" >>"$sources" + done <"$sources.in" + + if [ ! -s "$sources" ]; then + echo ">>> No Relay binary source responded; falling back to source build." + rm -rf "$download_dir" + return 1 + fi + sort -rn -k1,1 -o "$sources" "$sources" + best_speed="$(head -n 1 "$sources" | cut -f1)" + if [ "${best_speed:-0}" -lt "$BITFUN_HEALTHY_BPS" ]; then + echo ">>> Fastest source is $((${best_speed:-0} / 1024)) KB/s, under the $((BITFUN_HEALTHY_BPS / 1024)) KB/s bar; continuing anyway — a slow download still beats a source rebuild." + fi + + # Try fastest first. Every source serves the identical artifact, so a partial + # file is reused across sources too (`-C -`); only a checksum mismatch, which + # means the bytes really are bad, wipes it and starts the next source clean. + local ok=0 + while IFS=$'\t' read -r speed probe; do + [ -n "$probe" ] || continue + if bitfun_download_release_pair "$probe"; then + ok=1 + break + fi + if [ -f "$download_dir/${archive}.verify-failed" ]; then + rm -f "$download_dir/$archive" "$download_dir/${archive}.verify-failed" + fi + done <"$sources" + if [ "$ok" -ne 1 ]; then + echo ">>> Published Relay binary unavailable from every source; falling back to source build." + rm -rf "$download_dir" + return 1 + fi + + mkdir -p "$download_dir/extracted" + if ! tar xzf "$download_dir/$archive" -C "$download_dir/extracted"; then + echo ">>> Published Relay archive could not be extracted; falling back to source build." + rm -rf "$download_dir" + return 1 + fi + extracted="$(find "$download_dir/extracted" -mindepth 1 -maxdepth 1 -type d \ + -name 'bitfun-relay-server-*' | head -n 1)" + if [ -z "$extracted" ] || + [ ! -x "$extracted/bitfun-relay-server" ] || + [ ! -x "$extracted/relay-admin" ] || + [ ! -f "$extracted/static/index.html" ]; then + echo ">>> Published Relay archive layout is invalid; falling back to source build." + rm -rf "$download_dir" + return 1 + fi + + context="$release_dir/runtime" + rm -rf "$context.new" + mkdir -p "$context.new" + cp "$extracted/bitfun-relay-server" "$extracted/relay-admin" "$context.new/" + cp -R "$extracted/static" "$context.new/static" + cat >"$context.new/Dockerfile" <<'DOCKERFILE' +FROM debian:bookworm-slim +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY bitfun-relay-server relay-admin /app/ +COPY static /app/static +RUN chmod 755 /app/bitfun-relay-server /app/relay-admin \ + && mkdir -p /app/data /app/room-web +HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \ + CMD curl -fsS "http://127.0.0.1:${RELAY_PORT:-9700}/health" || exit 1 +CMD ["/app/bitfun-relay-server"] +DOCKERFILE + rm -rf "$context" + mv "$context.new" "$context" + rm -rf "$download_dir" + + image="bitfun-relay:release-${BITFUN_RELEASE_TAG}" + echo ">>> Building lightweight Relay runtime image (no Rust/Cargo compilation)..." + if ! bitfun_docker build -t "$image" "$context"; then + echo ">>> Published binary image build failed; falling back to source build." + return 1 + fi + + bitfun_docker volume create relay-server_relay-db >/dev/null + bitfun_docker volume create relay-server_room-web >/dev/null + + local backup_container="" + if bitfun_docker container inspect bitfun-relay >/dev/null 2>&1; then + backup_container="bitfun-relay-before-release-$$" + bitfun_docker stop bitfun-relay >/dev/null 2>&1 || true + if ! bitfun_docker rename bitfun-relay "$backup_container"; then + echo ">>> Could not stage the existing Relay container; falling back to source build." + bitfun_docker start bitfun-relay >/dev/null 2>&1 || true + return 1 + fi + fi + + bitfun_restore_previous_relay() { + bitfun_docker rm -f bitfun-relay >/dev/null 2>&1 || true + if [ -n "$backup_container" ]; then + bitfun_docker rename "$backup_container" bitfun-relay >/dev/null 2>&1 || true + bitfun_docker start bitfun-relay >/dev/null 2>&1 || true + fi + } + + # A cancelled wizard sends TERM/INT. Without a trap the user's relay would + # stay stopped under its backup name and disappear from the "already + # deployed" probe, so always put the previous container back. + trap 'bitfun_restore_previous_relay; trap - INT TERM; exit 1' INT TERM + + echo ">>> Starting published Relay binary on port ${RELAY_PORT:-9700}..." + if ! bitfun_docker run -d \ + --name bitfun-relay \ + --restart unless-stopped \ + --label com.docker.compose.project=relay-server \ + --label com.docker.compose.service=relay-server \ + -p "${RELAY_HOST_BIND_IP:-0.0.0.0}:${RELAY_PORT:-9700}:${RELAY_PORT:-9700}" \ + -e "RELAY_PORT=${RELAY_PORT:-9700}" \ + -e RELAY_STATIC_DIR=/app/static \ + -e RELAY_ROOM_WEB_DIR=/app/room-web \ + -e RELAY_ROOM_TTL=300 \ + -e RELAY_ASSET_STORE_MAX_BYTES=1073741824 \ + -e RELAY_DB_PATH=/app/data/bitfun_relay.db \ + -v relay-server_room-web:/app/room-web \ + -v relay-server_relay-db:/app/data \ + "$image" >/dev/null; then + echo ">>> Published Relay binary could not start; restoring previous container." + bitfun_restore_previous_relay + trap - INT TERM + return 1 + fi + + # Probe the address the container is actually published on; a wildcard bind + # is reachable through loopback. + local attempt stale probe_host="${RELAY_HOST_BIND_IP:-0.0.0.0}" + if [ "$probe_host" = "0.0.0.0" ] || [ "$probe_host" = "::" ]; then + probe_host="127.0.0.1" + fi + for attempt in $(seq 1 20); do + if curl -fsS --max-time 3 "http://${probe_host}:${RELAY_PORT:-9700}/health" >/dev/null 2>&1; then + trap - INT TERM + if [ -n "$backup_container" ]; then + bitfun_docker rm "$backup_container" >/dev/null 2>&1 || true + fi + # Sweep backups orphaned by an earlier interrupted release deploy. + for stale in $(bitfun_docker ps -aq \ + --filter 'name=^bitfun-relay-before-release-' 2>/dev/null); do + bitfun_docker rm -f "$stale" >/dev/null 2>&1 || true + done + echo ">>> Published Relay binary is healthy." + return 0 + fi + if ! bitfun_docker inspect -f '{{.State.Running}}' bitfun-relay 2>/dev/null | + grep -qx true; then + break + fi + sleep 2 + done + + echo ">>> Published Relay binary failed its health check; restoring previous container." + bitfun_docker logs --tail 40 bitfun-relay 2>/dev/null || true + bitfun_restore_previous_relay + trap - INT TERM + return 1 +} diff --git a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs index 0fc31b79f..0255ba4ec 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs @@ -51,7 +51,7 @@ const REPO_GIT_BRANCH: &str = "main"; const REPO_TARBALL_URL: &str = "https://github.com/GCWing/BitFun/archive/refs/heads/main.tar.gz"; /// Release asset base. Asset names are stable across tags so the embedded /// Desktop version can address its matching server build without a GitHub API call. -const RELEASE_DOWNLOAD_BASE: &str = "https://github.com/GCWing/BitFun/releases/download"; +const RELEASE_BASE: &str = "https://github.com/GCWing/BitFun/releases"; const OPENBITFUN_RELEASE_BASE: &str = "https://openbitfun.com/release"; const RELEASE_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Ranked-source download tuning, shared with the CLI self-updater @@ -67,12 +67,22 @@ const HEALTHY_THROUGHPUT_BYTES_PER_SEC: u64 = 128 * 1024; /// available link must still be allowed to finish rather than loop forever. const STALL_THROUGHPUT_BYTES_PER_SEC: u64 = 8 * 1024; const STALL_WINDOW_SECONDS: u64 = 30; +/// Free space the source-build fallback needs under `$HOME` (Cargo registry, +/// target dir and Docker layers). Checked before the build rather than +/// discovered as an opaque compiler failure part-way through. +const SOURCE_BUILD_FREE_KB: u64 = 6 * 1024 * 1024; /// Canonical China-mirror helper (shared with `src/apps/relay-server/deploy.sh`). /// Embedded so Desktop orchestration can apply mirrors before the git clone. const RELAY_MIRROR_SH: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../../apps/relay-server/mirror.sh" )); +/// Published-binary download + runtime deploy (shared with `deploy.sh`, so the +/// manual and one-click paths run the same code). +const RELAY_RELEASE_DOWNLOAD_SH: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../apps/relay-server/release-download.sh" +)); /// Remote directory (relative to the SSH user's home) holding deploy state. const DEPLOY_STATE_DIR: &str = ".bitfun/relay-deploy"; /// BitFun-managed source checkout (relative to home). Must stay under `.bitfun/` @@ -146,6 +156,10 @@ pub struct RelayPreflight { /// `sudo` exists but `sudo -n` fails (password required). pub sudo_needs_password: bool, pub mem_total_mb: u64, + /// Free space under `$HOME` in MB (archive staging + source checkout). + pub home_free_mb: u64, + /// Free space on Docker's data root in MB (images and layers). + pub docker_free_mb: u64, /// Selected listen port already bound by another process. pub port_busy: bool, /// Port that was probed (`port_busy` / selected-port health). @@ -228,6 +242,13 @@ if [ ! -e "$HOME/.docker" ]; then echo "docker_home_writable=1" elif [ -w "$HOME/.docker" ] && {{ [ ! -e "$HOME/.docker/buildx" ] || [ -w "$HOME/.docker/buildx" ]; }}; then echo "docker_home_writable=1" else echo "docker_home_writable=0"; fi echo "mem_kb=$(awk '/MemTotal/ {{print $2}}' /proc/meminfo 2>/dev/null || echo 0)" +# Free space where the work actually lands: ~/.bitfun holds the downloaded +# archive and the source checkout, Docker's data root holds images and layers. +echo "home_free_kb=$(df -Pk "$HOME" 2>/dev/null | awk 'NR==2 {{print $4}}' || echo 0)" +DOCKER_ROOT=$(docker info -f '{{{{.DockerRootDir}}}}' 2>/dev/null \ + || sudo -n docker info -f '{{{{.DockerRootDir}}}}' 2>/dev/null || echo /var/lib/docker) +[ -d "$DOCKER_ROOT" ] || DOCKER_ROOT=/var +echo "docker_free_kb=$(df -Pk "$DOCKER_ROOT" 2>/dev/null | awk 'NR==2 {{print $4}}' || echo 0)" if command -v ss >/dev/null 2>&1; then PORTS=$(ss -ltn 2>/dev/null); else PORTS=$(netstat -ltn 2>/dev/null); fi if printf '%s\n' "$PORTS" | awk '{{print $4}}' | grep -q ":${{PORT}}$"; then echo "port_busy=1"; else echo "port_busy=0"; fi # Prefer a docker CLI that can talk to the daemon (plain or passwordless sudo). @@ -296,6 +317,8 @@ fn parse_preflight(out: &str, fallback_port: u16) -> RelayPreflight { let arch_supported = os == "Linux" && (arch == "x86_64" || arch == "amd64" || arch == "aarch64" || arch == "arm64"); let mem_kb: u64 = get("mem_kb").parse().unwrap_or(0); + let home_free_kb: u64 = get("home_free_kb").parse().unwrap_or(0); + let docker_free_kb: u64 = get("docker_free_kb").parse().unwrap_or(0); let docker_installed = get("docker") == "1"; let active_has_docker_group = get("active_docker_group") == "1"; let in_docker_group_file = get("in_docker_group_file") == "1"; @@ -342,6 +365,8 @@ fn parse_preflight(out: &str, fallback_port: u16) -> RelayPreflight { sudo_available, sudo_needs_password, mem_total_mb: mem_kb / 1024, + home_free_mb: home_free_kb / 1024, + docker_free_mb: docker_free_kb / 1024, port_busy: get("port_busy") == "1", probed_port, port_owned_by_relay: get("port_owned") == "1", @@ -1359,310 +1384,38 @@ bitfun_sync_source() {{ ) } -/// Download the matching published Relay archive and build only a small runtime -/// image around it. The existing container name, volumes, ports, and relay-admin -/// path stay identical to the source-compose deployment. +/// Preamble + shared script that downloads the matching published Relay archive +/// and builds a small runtime image around it. The container name, volumes, +/// ports and relay-admin path stay identical to the source-compose deployment. +/// +/// The body lives in `src/apps/relay-server/release-download.sh` so the manual +/// `deploy.sh` path runs exactly the same code, the same way `mirror.sh` is +/// shared. Only the configuration differs: Desktop pins the release tag to its +/// own version, the manual path tracks `latest`. fn release_binary_deploy_bash() -> String { - let release_tag = release_tag_for_version(RELEASE_VERSION); format!( r#" -bitfun_try_release_deploy() {{ - local release_dir="$HOME/.bitfun/relay-release" - local target archive upstream_url download_dir extracted context image - case "$(uname -m 2>/dev/null)" in - x86_64|amd64) target="x86_64-unknown-linux-gnu" ;; - aarch64|arm64) target="aarch64-unknown-linux-gnu" ;; - *) - echo ">>> No published Relay binary for architecture $(uname -m); using source build." - return 1 - ;; - esac - - archive="bitfun-relay-server-${{target}}.tar.gz" - upstream_url="{RELEASE_DOWNLOAD_BASE}/{release_tag}/${{archive}}" - mkdir -p "$release_dir" - chmod 700 "$release_dir" 2>/dev/null || true - download_dir="$(mktemp -d "$release_dir/download.XXXXXX")" - - bitfun_verify_release_archive() {{ - ( - cd "$download_dir" - if command -v sha256sum >/dev/null 2>&1; then - sha256sum -c "${{archive}}.sha256" - elif command -v shasum >/dev/null 2>&1; then - shasum -a 256 -c "${{archive}}.sha256" - else - echo "ERROR: sha256sum or shasum is required to verify the Relay release." >&2 - return 1 - fi - ) - }} - - # Measure a candidate by how many bytes it delivers inside a fixed window. - # Bytes-in-fixed-time is throughput, so one ranged request yields the ranking - # without downloading the whole archive from a source we may not use. - bitfun_probe_source() {{ - local url="$1" speed - speed="$(curl -sSL --connect-timeout 5 --max-time {probe_seconds} \ - -r 0-{probe_bytes} -o /dev/null -w '%{{speed_download}}' "$url" 2>/dev/null || true)" - speed="${{speed%%.*}}" - case "$speed" in - ''|*[!0-9]*) echo 0 ;; - *) echo "$speed" ;; - esac - }} - - # Resume-capable download. `-C -` continues a partial file, so a link that - # cannot finish inside one attempt still converges instead of restarting from - # zero. --speed-limit/--speed-time abandons a source that has gone dead - # without waiting out a wall-clock ceiling. - bitfun_download_release_pair() {{ - local url="$1" - local watcher="" status=0 - local done_marker="$download_dir/.download-active" - echo ">>> Downloading published Relay binary: $url" - # The wizard polls this log; without a heartbeat a slow link is - # indistinguishable from a hang. Poll a sentinel on a short interval rather - # than sleeping long: `kill` on a shell blocked in a long `sleep` is not - # delivered until that sleep returns, which would stall every download by a - # full tick after curl already finished. - : >"$done_marker" - ( - local ticks=0 - while [ -f "$done_marker" ]; do - sleep 2 - ticks=$(( ticks + 1 )) - [ "$(( ticks % 10 ))" -eq 0 ] || continue - [ -f "$download_dir/$archive" ] || continue - echo ">>> ... $(du -h "$download_dir/$archive" 2>/dev/null | cut -f1) downloaded" - done - ) & - watcher=$! - curl -fsSL -C - \ - --retry 3 --retry-delay 3 --retry-max-time {retry_max_time} \ - --connect-timeout 15 --max-time {max_time} \ - --speed-limit {stall_floor} --speed-time {stall_seconds} \ - -o "$download_dir/$archive" "$url" || status=$? - rm -f "$done_marker" - wait "$watcher" >/dev/null 2>&1 || true - if [ "$status" -ne 0 ]; then - echo ">>> Source failed or stalled below $(( {stall_floor} / 1024 )) KB/s (curl $status); trying the next source." - return 1 - fi - rm -f "$download_dir/${{archive}}.sha256" - curl -fsSL --retry 3 --connect-timeout 15 --max-time 60 \ - -o "$download_dir/${{archive}}.sha256" "${{url}}.sha256" || return 1 - if bitfun_verify_release_archive; then - return 0 - fi - # Bad bytes, not a bad link: mark the partial file poisoned so the caller - # discards it instead of resuming on top of it from the next source. - : >"$download_dir/${{archive}}.verify-failed" - return 1 - }} - - # Candidate sources, one per line. Files rather than arrays: this script runs - # on whatever bash the user's server has, and `"${{empty[@]}}"` under `set -u` - # aborts on bash 4.2 (CentOS 7). - local sources="$download_dir/sources.tsv" mirror_url="" probe speed best_speed - : >"$sources.in" - if [ "${{BITFUN_MIRROR_MODE:-global}}" = "cn" ] && [ -n "${{BITFUN_GITHUB_PROXY:-}}" ]; then - printf '%s\n' "${{BITFUN_GITHUB_PROXY%/}}/${{upstream_url}}" >>"$sources.in" - fi - printf '%s\n' "$upstream_url" >>"$sources.in" - # Take the mirror URL from the mirror's own manifest rather than building a - # // path: openbitfun keeps only the most recent releases, so a - # pinned version 404s for every Desktop build that is not one of them. - # `|| true`: an unreachable mirror or a non-matching manifest must leave - # mirror_url empty, never abort the caller under `set -e`. - mirror_url="$(curl -fsSL --connect-timeout 10 --max-time 30 \ - "{OPENBITFUN_RELEASE_BASE}/linux-binaries.json" 2>/dev/null \ - | tr ',' '\n' | grep -F '"url"' | grep -F "$archive" \ - | head -n 1 | sed -e 's/.*"url"[[:space:]]*:[[:space:]]*"//' -e 's/".*//' || true)" - if [ -n "$mirror_url" ]; then - printf '%s\n' "$mirror_url" >>"$sources.in" - fi - - : >"$sources" - while IFS= read -r probe; do - [ -n "$probe" ] || continue - speed="$(bitfun_probe_source "$probe")" - echo ">>> Source probe: $(( speed / 1024 )) KB/s — $probe" - printf '%s\t%s\n' "$speed" "$probe" >>"$sources" - done <"$sources.in" - - if [ ! -s "$sources" ]; then - echo ">>> No Relay binary source responded; falling back to source build." - rm -rf "$download_dir" - return 1 - fi - sort -rn -k1,1 -o "$sources" "$sources" - best_speed="$(head -n 1 "$sources" | cut -f1)" - if [ "${{best_speed:-0}}" -lt {healthy_floor} ]; then - echo ">>> Fastest source is $(( ${{best_speed:-0}} / 1024 )) KB/s, under the $(( {healthy_floor} / 1024 )) KB/s bar; continuing anyway — a slow download still beats a source rebuild." - fi - - # Try fastest first. Every source serves the identical artifact, so a partial - # file is reused across sources too (`-C -`); only a checksum mismatch, which - # means the bytes really are bad, wipes it and starts the next source clean. - local ok=0 - while IFS=$'\t' read -r speed probe; do - [ -n "$probe" ] || continue - if bitfun_download_release_pair "$probe"; then - ok=1 - break - fi - if [ -f "$download_dir/${{archive}}.verify-failed" ]; then - rm -f "$download_dir/$archive" "$download_dir/${{archive}}.verify-failed" - fi - done <"$sources" - if [ "$ok" -ne 1 ]; then - echo ">>> Published Relay binary unavailable from every source; falling back to source build." - rm -rf "$download_dir" - return 1 - fi - - mkdir -p "$download_dir/extracted" - if ! tar xzf "$download_dir/$archive" -C "$download_dir/extracted"; then - echo ">>> Published Relay archive could not be extracted; falling back to source build." - rm -rf "$download_dir" - return 1 - fi - extracted="$(find "$download_dir/extracted" -mindepth 1 -maxdepth 1 -type d \ - -name 'bitfun-relay-server-*' | head -n 1)" - if [ -z "$extracted" ] \ - || [ ! -x "$extracted/bitfun-relay-server" ] \ - || [ ! -x "$extracted/relay-admin" ] \ - || [ ! -f "$extracted/static/index.html" ]; then - echo ">>> Published Relay archive layout is invalid; falling back to source build." - rm -rf "$download_dir" - return 1 - fi - - context="$release_dir/runtime" - rm -rf "$context.new" - mkdir -p "$context.new" - cp "$extracted/bitfun-relay-server" "$extracted/relay-admin" "$context.new/" - cp -R "$extracted/static" "$context.new/static" - cat >"$context.new/Dockerfile" <<'DOCKERFILE' -FROM debian:bookworm-slim -ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl \ - && rm -rf /var/lib/apt/lists/* -WORKDIR /app -COPY bitfun-relay-server relay-admin /app/ -COPY static /app/static -RUN chmod 755 /app/bitfun-relay-server /app/relay-admin \ - && mkdir -p /app/data /app/room-web -HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \ - CMD curl -fsS "http://127.0.0.1:${{RELAY_PORT:-9700}}/health" || exit 1 -CMD ["/app/bitfun-relay-server"] -DOCKERFILE - rm -rf "$context" - mv "$context.new" "$context" - rm -rf "$download_dir" - - image="bitfun-relay:release-{release_tag}" - echo ">>> Building lightweight Relay runtime image (no Rust/Cargo compilation)..." - if ! bitfun_docker build -t "$image" "$context"; then - echo ">>> Published binary image build failed; falling back to source build." - return 1 - fi - - bitfun_docker volume create relay-server_relay-db >/dev/null - bitfun_docker volume create relay-server_room-web >/dev/null - - local backup_container="" - if bitfun_docker container inspect bitfun-relay >/dev/null 2>&1; then - backup_container="bitfun-relay-before-release-$$" - bitfun_docker stop bitfun-relay >/dev/null 2>&1 || true - if ! bitfun_docker rename bitfun-relay "$backup_container"; then - echo ">>> Could not stage the existing Relay container; falling back to source build." - bitfun_docker start bitfun-relay >/dev/null 2>&1 || true - return 1 - fi - fi - - bitfun_restore_previous_relay() {{ - bitfun_docker rm -f bitfun-relay >/dev/null 2>&1 || true - if [ -n "$backup_container" ]; then - bitfun_docker rename "$backup_container" bitfun-relay >/dev/null 2>&1 || true - bitfun_docker start bitfun-relay >/dev/null 2>&1 || true - fi - }} - - # Wizard-close cancels this script with TERM/INT. Without a trap the user's - # relay would stay stopped under its backup name and disappear from the - # "already deployed" probe, so always put the previous container back. - trap 'bitfun_restore_previous_relay; trap - INT TERM; exit 1' INT TERM - - echo ">>> Starting published Relay binary on port $RELAY_PORT..." - if ! bitfun_docker run -d \ - --name bitfun-relay \ - --restart unless-stopped \ - --label com.docker.compose.project=relay-server \ - --label com.docker.compose.service=relay-server \ - -p "${{RELAY_HOST_BIND_IP:-0.0.0.0}}:${{RELAY_PORT}}:${{RELAY_PORT}}" \ - -e "RELAY_PORT=${{RELAY_PORT}}" \ - -e RELAY_STATIC_DIR=/app/static \ - -e RELAY_ROOM_WEB_DIR=/app/room-web \ - -e RELAY_ROOM_TTL=300 \ - -e RELAY_ASSET_STORE_MAX_BYTES=1073741824 \ - -e RELAY_DB_PATH=/app/data/bitfun_relay.db \ - -v relay-server_room-web:/app/room-web \ - -v relay-server_relay-db:/app/data \ - "$image" >/dev/null; then - echo ">>> Published Relay binary could not start; restoring previous container." - bitfun_restore_previous_relay - trap - INT TERM - return 1 - fi - - # Probe the address the container is actually published on; a wildcard bind - # is reachable through loopback. - local attempt stale probe_host="${{RELAY_HOST_BIND_IP:-0.0.0.0}}" - if [ "$probe_host" = "0.0.0.0" ] || [ "$probe_host" = "::" ]; then - probe_host="127.0.0.1" - fi - for attempt in $(seq 1 20); do - if curl -fsS --max-time 3 "http://${{probe_host}}:${{RELAY_PORT}}/health" >/dev/null 2>&1; then - trap - INT TERM - if [ -n "$backup_container" ]; then - bitfun_docker rm "$backup_container" >/dev/null 2>&1 || true - fi - # Sweep backups orphaned by an earlier interrupted release deploy. - for stale in $(bitfun_docker ps -aq \ - --filter 'name=^bitfun-relay-before-release-' 2>/dev/null); do - bitfun_docker rm -f "$stale" >/dev/null 2>&1 || true - done - echo ">>> Published Relay binary is healthy." - return 0 - fi - if ! bitfun_docker inspect -f '{{{{.State.Running}}}}' bitfun-relay 2>/dev/null \ - | grep -qx true; then - break - fi - sleep 2 - done - - echo ">>> Published Relay binary failed its health check; restoring previous container." - bitfun_docker logs --tail 40 bitfun-relay 2>/dev/null || true - bitfun_restore_previous_relay - trap - INT TERM - return 1 -}} +export BITFUN_RELEASE_TAG="{release_tag}" +export BITFUN_GITHUB_RELEASE_BASE="{RELEASE_BASE}" +export BITFUN_OPENBITFUN_RELEASE_BASE="{OPENBITFUN_RELEASE_BASE}" +export BITFUN_PROBE_SECONDS="{probe_seconds}" +export BITFUN_PROBE_BYTES="{probe_bytes}" +export BITFUN_HEALTHY_BPS="{healthy_floor}" +export BITFUN_STALL_BPS="{stall_floor}" +export BITFUN_STALL_SECONDS="{stall_seconds}" +# --- begin BitFun relay release-download.sh --- +{release_download} +# --- end BitFun relay release-download.sh --- "#, - RELEASE_DOWNLOAD_BASE = RELEASE_DOWNLOAD_BASE, + release_tag = release_tag_for_version(RELEASE_VERSION), + RELEASE_BASE = RELEASE_BASE, OPENBITFUN_RELEASE_BASE = OPENBITFUN_RELEASE_BASE, - release_tag = release_tag, probe_seconds = SOURCE_PROBE_SECONDS, - probe_bytes = SOURCE_PROBE_BYTES - 1, + probe_bytes = SOURCE_PROBE_BYTES, healthy_floor = HEALTHY_THROUGHPUT_BYTES_PER_SEC, stall_floor = STALL_THROUGHPUT_BYTES_PER_SEC, stall_seconds = STALL_WINDOW_SECONDS, - retry_max_time = 300, - max_time = 3600, + release_download = RELAY_RELEASE_DOWNLOAD_SH, ) } @@ -1701,6 +1454,16 @@ if bitfun_try_release_deploy; then exit 0 fi echo ">>> Release binary path did not complete; starting source-build fallback." +# Compiling the relay pulls a Cargo registry, a target dir and Docker layers. +# Running out of disk halfway through surfaces as an opaque compiler or BuildKit +# error, so refuse up front with something the user can act on. +SRC_FREE_KB=$(df -Pk "$HOME" 2>/dev/null | awk 'NR==2 {{print $4}}' || echo 0) +if [ "${{SRC_FREE_KB:-0}}" -lt {source_build_free_kb} ]; then + echo ">>> ERROR: the source build needs about {source_build_free_gb} GB free under $HOME," + echo ">>> but only $(( SRC_FREE_KB / 1024 )) MB is available." + echo ">>> Free up space, or install a published Relay binary manually." + exit 1 +fi SRC="$HOME/{SOURCE_DIR}" bitfun_sync_source "$SRC" cd "$SRC/src/apps/relay-server" @@ -1733,6 +1496,8 @@ echo {TASK_DONE_MARKER} TASK_DONE_MARKER = TASK_DONE_MARKER, REPO_GIT_URL = REPO_GIT_URL, REPO_TARBALL_URL = REPO_TARBALL_URL, + source_build_free_kb = SOURCE_BUILD_FREE_KB, + source_build_free_gb = SOURCE_BUILD_FREE_KB / 1024 / 1024, ) } @@ -1807,7 +1572,7 @@ mod tests { let script = release_binary_deploy_bash(); assert!(script.contains("bitfun-relay-server-${target}.tar.gz")); assert!(script.contains("sha256sum -c")); - assert!(script.contains("https://openbitfun.com/release/")); + assert!(script.contains("https://openbitfun.com/release")); assert!(script.contains("no Rust/Cargo compilation")); assert!(script.contains("--name bitfun-relay")); assert!(script.contains("relay-server_relay-db:/app/data")); @@ -1818,7 +1583,8 @@ mod tests { assert!(script.contains("trap 'bitfun_restore_previous_relay")); assert!(script.contains("name=^bitfun-relay-before-release-")); // Port publishing keeps compose's configurable bind address. - assert!(script.contains("${RELAY_HOST_BIND_IP:-0.0.0.0}:${RELAY_PORT}:${RELAY_PORT}")); + assert!(script + .contains("${RELAY_HOST_BIND_IP:-0.0.0.0}:${RELAY_PORT:-9700}:${RELAY_PORT:-9700}")); // Slow-link contract. A wall-clock ceiling alone made a 20 KB/s link // fail forever: each attempt timed out mid-archive and restarted from @@ -1833,6 +1599,12 @@ mod tests { // // path that 404s for older Desktop builds. assert!(script.contains("linux-binaries.json")); assert!(!script.contains("release/0.2")); + // Checksums bind to a canonical GitHub URL, so a compromised mirror or + // third-party proxy cannot serve matching bytes and checksum together. + assert!(script.contains("bitfun_canonical_checksum_url")); + // The shared file backs both this path and deploy.sh. + assert!(script.contains("release-download.sh")); + assert!(script.contains("export BITFUN_RELEASE_TAG=\"v0.2")); } /// `bash -n` only proves the generated script parses. This runs its source @@ -2030,6 +1802,8 @@ active_docker_group=0 in_docker_group_file=1 docker_home_writable=0 mem_kb=2097152 +home_free_kb=12582912 +docker_free_kb=8388608 port_busy=0 container=1 container_running=1 @@ -2050,5 +1824,7 @@ port_owned=0 assert_eq!(pf.existing_relay_port, 9700); assert!(pf.relay_healthy); assert!(!pf.port_owned_by_relay); + assert_eq!(pf.home_free_mb, 12288); + assert_eq!(pf.docker_free_mb, 8192); } } diff --git a/src/web-ui/src/features/relay-deploy/README.md b/src/web-ui/src/features/relay-deploy/README.md index 43c6c15f4..3896ca20c 100644 --- a/src/web-ui/src/features/relay-deploy/README.md +++ b/src/web-ui/src/features/relay-deploy/README.md @@ -37,38 +37,51 @@ Desktop Tauri surface: `src/apps/desktop/src/api/relay_deploy_api.rs` `//` path. The mirror retains only the most recent releases, so a pinned version 404s for every older Desktop build. -4. **Fallback source path is `~/.bitfun/relay-src`**, never `$HOME/BitFun` / +4. **Verify against a checksum from a different origin than the bytes.** A + `.sha256` served by whoever served the archive only detects corruption; the + CN path deliberately prefers a third-party GitHub proxy, so the checksum is + always fetched from the canonical GitHub URL (derivable from any candidate + URL, including the mirror's versioned path). Same-origin fallback is allowed + only when GitHub is unreachable, and must say so in the log. + +5. **One implementation, two callers.** The download, verification and runtime + image live in `src/apps/relay-server/release-download.sh`; `deploy.sh` + sources it and `relay_deploy.rs` embeds it with `include_str!`, exactly as + `mirror.sh` is shared. Do not fork this logic back into the Rust template — + manual and one-click deploys must not drift. + +6. **Fallback source path is `~/.bitfun/relay-src`**, never `$HOME/BitFun` / `$HOME/bitfun`. Sync always passes an explicit clone destination. Destructive replace is only safe under `~/.bitfun/`. -5. **Git first, tarball fallback.** When `.git` already exists, deploy must +7. **Git first, tarball fallback.** When `.git` already exists, deploy must `fetch` + checkout, not re-clone from scratch (preserves BuildKit layers and Cargo cache mounts for registry/git/`target`). -6. **Close wizard = cancel remote task.** Do not leave nohup builds running +8. **Close wizard = cancel remote task.** Do not leave nohup builds running after the modal closes; cancel must kill the pid tree and best-effort stop compose/buildx workers. -7. **Account password never leaves this device.** Provision locally, then +9. **Account password never leaves this device.** Provision locally, then `relay-admin import-user` over the SSH session. Do not send plaintext passwords to the remote as env/script args. -8. **“Already deployed” is container-aware, not only selected-port health.** +10. **“Already deployed” is container-aware, not only selected-port health.** Changing the listen port must not hide a running `bitfun-relay`. Use `container_running` / `existing_relay_port` / `relay_healthy` (health on selected **or** existing port). “Create account” must hit the running port. -9. **Port conflict ≠ our relay.** `port_busy && !port_owned_by_relay` blocks +11. **Port conflict ≠ our relay.** `port_busy && !port_owned_by_relay` blocks deploy; busy-because-bitfun-relay does not. -10. **Privilege / Docker install.** Do not call `sudo -v` unconditionally. +12. **Privilege / Docker install.** Do not call `sudo -v` unconditionally. Detect root / passwordless sudo / interactive elevate. Docker install must not require a working daemon *before* install. -11. **Scripts are embedded Rust templates** staged via SFTP. Do not rely on a +13. **Scripts are embedded Rust templates** staged via SFTP. Do not rely on a static repo `.sh` alone on the server until the desktop binary re-stages. -12. **China mirrors before overseas downloads.** Desktop orchestration embeds +14. **China mirrors before overseas downloads.** Desktop orchestration embeds `src/apps/relay-server/mirror.sh` and runs `bitfun_mirror_init` before apt tool install, Docker Engine install, and GitHub sync. `deploy.sh` sources the same file so manual and one-click paths stay aligned. Force with From c7c38c893bf3cce6a5ee10e992e73ba7d25e8232 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 24 Jul 2026 21:30:07 -0700 Subject: [PATCH 3/4] fix(release): sign every asset, verify relay checksums client-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, the first from a good catch about the timeouts. Removing the residual wall-clock cliff. --max-time 3600 was the original bug moved one order of magnitude out: at the 8 KB/s floor a 30 MB archive needs ~3750 s, so the cap would have killed a transfer that was progressing fine, and --retry-max-time 300 had already stopped allowing retries 55 minutes earlier. The throughput floor is the give-up condition — it aborts a dead or hung link within --speed-time, and --connect-timeout covers setup — so a ceiling on the transfer adds nothing but a cliff for slow users. Removed; the harness now asserts the archive fetch carries no --max-time while still requiring a floor (scoped to the resumable transfer, since the probe must stay time-boxed). Signing every published asset. Only the five updater artifacts were signed, by the Tauri bundler during `tauri build`. The nine installers people actually download by hand from the release page — dmg, deb, rpm, the Windows installer and the direct AppImages — shipped with no signature at all. All of them are now signed with the same key via a shared scripts/sign-release-assets.sh, which skips anything the bundler already signed so updater signatures stay intact, and self-verifies before publishing. minisign.pub ships with every release, because a signature nobody can fetch a key for is not verifiable. This is not OS-level code signing; Gatekeeper and SmartScreen still need Apple/Authenticode certs the project does not hold, and the docs say so. Verifying relay archives without minisign on the server. A relay host is an arbitrary user machine with no trust root, so it cannot check a signature — and the signature covers the archive, which only the server downloads. Resolved by signing the .sha256 files too: Desktop verifies that signature locally (a couple of hundred bytes), then exports the proven hash into the generated script as BITFUN_EXPECTED_SHA256_. The remote needs nothing but sha256sum, and no origin can override the hash. Falls back to the cross-origin checksum when the Desktop build carries no pubkey or GitHub is unreachable. The sign/verify mechanics were validated locally against a generated key, including the exact command the relay README now documents. --- .github/workflows/desktop-package.yml | 35 ++++ .github/workflows/linux-binaries.yml | 44 ++--- .github/workflows/nightly.yml | 34 ++++ scripts/relay/release-download-harness.sh | 25 +++ scripts/sign-release-assets.sh | 86 +++++++++ src/apps/relay-server/README.md | 24 +++ src/apps/relay-server/release-download.sh | 31 +++- .../services/services-integrations/Cargo.toml | 3 + .../src/remote_ssh/relay_deploy.rs | 169 +++++++++++++++++- .../src/features/relay-deploy/README.md | 43 +++-- 10 files changed, 435 insertions(+), 59 deletions(-) create mode 100755 scripts/sign-release-assets.sh diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index f45e1985d..86b643e6f 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -84,6 +84,10 @@ jobs: BITFUN_ENABLE_UPDATER_ARTIFACTS: ${{ needs.prepare.outputs.upload_to_release }} TAURI_UPDATER_ENDPOINT: https://github.com/GCWing/BitFun/releases/latest/download/latest.json TAURI_UPDATER_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + # Same trust root, compiled into the Desktop binary so one-click relay + # deploy can verify the signed checksum locally and hand the remote host + # a hash it does not have to trust the mirror for. + BITFUN_RELEASE_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} @@ -320,6 +324,35 @@ jobs: --repo "GCWing/BitFun" \ --out linux-release-assets/linux-binaries.json + # The Tauri bundler signs the five updater artifacts during `tauri build`, + # but the installers people download by hand from the release page — dmg, + # deb, rpm, the Windows installer and the direct AppImages — shipped with + # no signature at all. Sign them with the same key so every published + # artifact is verifiable. (This is not OS-level code signing: Gatekeeper + # and SmartScreen still need Apple/Authenticode certificates.) + - name: Sign installer packages + shell: bash + env: + BITFUN_SIGNING_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + BITFUN_SIGNING_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + BITFUN_SIGNING_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + run: | + set -euo pipefail + mapfile -t assets < <( + find release-assets -type f \ + \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \ + -o -name '*.dmg' -o -name '*bitfun-installer.exe' \) | sort + ) + if [[ "${#assets[@]}" -eq 0 ]]; then + echo "No installer packages found to sign." + exit 0 + fi + bash scripts/sign-release-assets.sh "${assets[@]}" + + # Publish the public key alongside the signatures: a signature nobody + # can fetch a key for is not verifiable. + printf '%s' "${BITFUN_SIGNING_PUBKEY}" | base64 -d >release-assets/minisign.pub + - name: Upload to release uses: softprops/action-gh-release@v3 with: @@ -332,6 +365,8 @@ jobs: release-assets/**/*.dmg release-assets/**/*.rpm release-assets/**/*bitfun-installer.exe + release-assets/**/*.sig + release-assets/minisign.pub linux-release-assets/bitfun-cli-*.tar.gz linux-release-assets/bitfun-cli-*.tar.gz.sha256 linux-release-assets/bitfun-relay-server-*.tar.gz diff --git a/.github/workflows/linux-binaries.yml b/.github/workflows/linux-binaries.yml index ba1448893..5fee1c4fe 100644 --- a/.github/workflows/linux-binaries.yml +++ b/.github/workflows/linux-binaries.yml @@ -134,45 +134,22 @@ jobs: run: bash scripts/relay/package-unix.sh "$ASSET_VERSION" "$TARGET" - name: Sign release archives - id: sign shell: bash env: - SIGNING_KEY: ${{ secrets.release_signing_key }} - SIGNING_PASSWORD: ${{ secrets.release_signing_password }} - SIGNING_PUBKEY: ${{ secrets.release_pubkey }} + BITFUN_SIGNING_KEY: ${{ secrets.release_signing_key }} + BITFUN_SIGNING_PASSWORD: ${{ secrets.release_signing_password }} + BITFUN_SIGNING_PUBKEY: ${{ secrets.release_pubkey }} CLI_ARCHIVE: ${{ steps.cli-stage.outputs.archive }} RELAY_ARCHIVE: ${{ steps.relay-stage.outputs.archive }} run: | set -euo pipefail - if [[ -z "${SIGNING_KEY:-}" ]]; then - echo "No release signing key configured; archives ship with checksums only." - echo "signed=false" >>"$GITHUB_OUTPUT" - exit 0 - fi - - sudo apt-get install -y --no-install-recommends minisign - - # Tauri stores the minisign secret key base64-wrapped; unwrap it to the - # on-disk format minisign expects. Same key that signs Desktop updates, - # so the CLI and the Desktop updater share one trust root. - umask 077 - printf '%s' "$SIGNING_KEY" | base64 -d >"$RUNNER_TEMP/release.key" - printf '%s' "$SIGNING_PUBKEY" | base64 -d >"$RUNNER_TEMP/release.pub" - - for archive in "$CLI_ARCHIVE" "$RELAY_ARCHIVE"; do - printf '%s\n' "$SIGNING_PASSWORD" | - minisign -S -s "$RUNNER_TEMP/release.key" -m "$archive" -x "${archive}.minisig" - # Verify before publishing: a signature nobody checked is worse than - # none, because the client is about to treat it as proof. - minisign -Vm "$archive" -p "$RUNNER_TEMP/release.pub" -x "${archive}.minisig" - # Tauri's wire format is base64 of the whole signature file. - base64 -w0 <"${archive}.minisig" >"${archive}.sig" - rm -f "${archive}.minisig" - echo "Signed and verified: ${archive}.sig" - done - - rm -f "$RUNNER_TEMP/release.key" "$RUNNER_TEMP/release.pub" - echo "signed=true" >>"$GITHUB_OUTPUT" + # The .sha256 files are signed too: remote relay hosts have no + # minisign, so Desktop verifies the signature over the tiny checksum + # file locally and hands the trusted hash to the server, which then + # only needs sha256sum. + bash scripts/sign-release-assets.sh \ + "$CLI_ARCHIVE" "${CLI_ARCHIVE}.sha256" \ + "$RELAY_ARCHIVE" "${RELAY_ARCHIVE}.sha256" - name: Upload Linux binary artifacts uses: actions/upload-artifact@v6 @@ -186,3 +163,4 @@ jobs: ${{ steps.relay-stage.outputs.archive }} ${{ steps.relay-stage.outputs.checksum }} *.tar.gz.sig + *.tar.gz.sha256.sig diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 63613eafc..bdc93d1c4 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -67,6 +67,9 @@ jobs: if: needs.check-changes.outputs.should_build == 'true' env: NODE_OPTIONS: --max-old-space-size=6144 + # Nightly relay archives are signed too (linux-binaries.yml receives the + # key), so nightly Desktop needs the same trust root to verify them. + BITFUN_RELEASE_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} strategy: fail-fast: false @@ -276,6 +279,35 @@ jobs: --repo "GCWing/BitFun" \ --out linux-release-assets/linux-binaries.json + # The Tauri bundler signs the five updater artifacts during `tauri build`, + # but the installers people download by hand from the release page — dmg, + # deb, rpm, the Windows installer and the direct AppImages — shipped with + # no signature at all. Sign them with the same key so every published + # artifact is verifiable. (This is not OS-level code signing: Gatekeeper + # and SmartScreen still need Apple/Authenticode certificates.) + - name: Sign installer packages + shell: bash + env: + BITFUN_SIGNING_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + BITFUN_SIGNING_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + BITFUN_SIGNING_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + run: | + set -euo pipefail + mapfile -t assets < <( + find release-assets -type f \ + \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \ + -o -name '*.dmg' -o -name '*bitfun-installer.exe' \) | sort + ) + if [[ "${#assets[@]}" -eq 0 ]]; then + echo "No installer packages found to sign." + exit 0 + fi + bash scripts/sign-release-assets.sh "${assets[@]}" + + # Publish the public key alongside the signatures: a signature nobody + # can fetch a key for is not verifiable. + printf '%s' "${BITFUN_SIGNING_PUBKEY}" | base64 -d >release-assets/minisign.pub + - name: Create nightly release uses: softprops/action-gh-release@v3 with: @@ -296,6 +328,8 @@ jobs: release-assets/**/*.dmg release-assets/**/*.rpm release-assets/**/*bitfun-installer.exe + release-assets/**/*.sig + release-assets/minisign.pub linux-release-assets/bitfun-cli-*.tar.gz linux-release-assets/bitfun-cli-*.tar.gz.sha256 linux-release-assets/bitfun-relay-server-*.tar.gz diff --git a/scripts/relay/release-download-harness.sh b/scripts/relay/release-download-harness.sh index 165bc064a..bc0873881 100755 --- a/scripts/relay/release-download-harness.sh +++ b/scripts/relay/release-download-harness.sh @@ -37,6 +37,11 @@ while IFS=: read -r pat m s; do case "$url" in *"$pat"*) mode="$m"; speed="$s" ;; esac done < "$PLAN" echo "${mode} ${url}" >>"$TRACE" +# Record only the resumable archive transfer (-C), not the ranged probe (-r): +# the probe is deliberately time-boxed, the transfer must not be. +case " $* " in + *" -C "*) printf '%s\n' "$*" >>"$WORKDIR/archive-flags" ;; +esac [ "$mode" = dead ] && exit 7 case "$url" in @@ -105,6 +110,7 @@ run_case() { shift 2 printf '%s\n' "$@" >"$WORK/plan" : >"$WORK/trace" + : >"$WORK/archive-flags" rm -rf "$WORK/home/.bitfun" local output got @@ -189,6 +195,25 @@ EXPECT_SOURCE="$MIRROR_ASSET_URL" \ "ghfast.top:ok:1024" "//github.com:ok:1024" \ "linux-binaries.json:ok:999999" "release/0.2.13:ok:2097152" +# A wall-clock ceiling on the archive transfer is the original bug in disguise: +# it kills a link that is slow but progressing. The throughput floor must be the +# only give-up condition. +if grep -q -- '--max-time' "$WORK/archive-flags"; then + echo "FAIL archive download must not carry a wall-clock ceiling" + grep -- '--max-time' "$WORK/archive-flags" | sed 's/^/ /' + fail=$((fail + 1)) +else + echo "PASS archive download has no wall-clock ceiling" + pass=$((pass + 1)) +fi +if grep -q -- '--speed-limit' "$WORK/archive-flags"; then + echo "PASS archive download gives up on a throughput floor" + pass=$((pass + 1)) +else + echo "FAIL archive download has no throughput floor" + fail=$((fail + 1)) +fi + echo "----" echo "pass=$pass fail=$fail" [ "$fail" -eq 0 ] diff --git a/scripts/sign-release-assets.sh b/scripts/sign-release-assets.sh new file mode 100755 index 000000000..24adb2a11 --- /dev/null +++ b/scripts/sign-release-assets.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# +# Sign release assets with the project's minisign key — the same key the Tauri +# updater already uses, so every artifact shares one trust root. +# +# Usage: sign-release-assets.sh [...] +# +# Environment: +# BITFUN_SIGNING_KEY minisign secret key, base64 (Tauri's wrapper format) +# BITFUN_SIGNING_PASSWORD password for that key +# BITFUN_SIGNING_PUBKEY minisign public key, base64; used to self-verify +# +# With no signing key configured this is a no-op, so forks keep building. +# +# Produces `.sig` containing base64 of the whole minisign signature file, +# matching what the Tauri bundler emits for updater artifacts and what +# `minisign_verify` expects after one base64 decode. +# +# Files that already carry a `.sig` are left alone: the Tauri bundler signs the +# updater artifacts during `tauri build`, and re-signing them here would replace +# a signature the updater manifest already references. + +set -euo pipefail + +if [ "$#" -lt 1 ]; then + echo "usage: sign-release-assets.sh [...]" >&2 + exit 2 +fi + +if [ -z "${BITFUN_SIGNING_KEY:-}" ]; then + echo "[sign] No signing key configured; assets ship with checksums only." + exit 0 +fi + +if ! command -v minisign >/dev/null 2>&1; then + echo "[sign] Installing minisign..." + sudo apt-get install -y --no-install-recommends minisign >/dev/null +fi + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +umask 077 + +# Tauri stores the minisign secret key base64-wrapped; unwrap it to the on-disk +# format minisign expects. +printf '%s' "$BITFUN_SIGNING_KEY" | base64 -d >"$WORK/release.key" +printf '%s' "${BITFUN_SIGNING_PUBKEY:-}" | base64 -d >"$WORK/release.pub" 2>/dev/null || true + +signed=0 +skipped=0 +for target in "$@"; do + if [ ! -f "$target" ]; then + continue + fi + case "$target" in + *.sig | *.minisig) + continue + ;; + esac + if [ -f "${target}.sig" ]; then + echo "[sign] Already signed upstream, leaving alone: $(basename "$target")" + skipped=$((skipped + 1)) + continue + fi + + printf '%s\n' "${BITFUN_SIGNING_PASSWORD:-}" | + minisign -S -s "$WORK/release.key" -m "$target" -x "${target}.minisig" >/dev/null + + # Verify before publishing. A signature nobody checked is worse than none, + # because clients are about to treat it as proof. + if [ -s "$WORK/release.pub" ]; then + minisign -Vm "$target" -p "$WORK/release.pub" -x "${target}.minisig" >/dev/null + else + echo "[sign] ERROR: BITFUN_SIGNING_PUBKEY is required to self-verify signatures." >&2 + exit 1 + fi + + # `tr -d` rather than `base64 -w0`: the latter is GNU-only and this script is + # also run by hand on macOS. + base64 <"${target}.minisig" | tr -d '\n' >"${target}.sig" + rm -f "${target}.minisig" + echo "[sign] Signed and verified: $(basename "$target").sig" + signed=$((signed + 1)) +done + +echo "[sign] ${signed} signed, ${skipped} already signed upstream." diff --git a/src/apps/relay-server/README.md b/src/apps/relay-server/README.md index c3564ba4a..00c615e8b 100644 --- a/src/apps/relay-server/README.md +++ b/src/apps/relay-server/README.md @@ -112,6 +112,30 @@ checkout is always `~/.bitfun/relay-src` (never `$HOME/BitFun`). Closing the wizard cancels the remote task. Account passwords are provisioned locally and imported via `relay-admin import-user`. +### Release artifact verification + +Every published archive carries a `.sha256` and a `.sig` (minisign, base64 of the +signature file — the same key and format the Desktop updater uses). The `.sha256` +files are signed as well, which is what lets the one-click deploy verify a +signature on the user's own machine and hand the server a trusted hash: a relay +host has no minisign and no trust root of its own. + +Verifying an archive by hand: + +```bash +BASE=https://github.com/GCWing/BitFun/releases/latest/download +ASSET=bitfun-relay-server-x86_64-unknown-linux-gnu.tar.gz +curl -fsSLO "$BASE/$ASSET" -O "$BASE/$ASSET.sig" -O "$BASE/minisign.pub" +base64 -d <"$ASSET.sig" >"$ASSET.minisig" +minisign -Vm "$ASSET" -p minisign.pub -x "$ASSET.minisig" +``` + +`minisign.pub` is published with every release and is the same key the Desktop +updater trusts, so it can also be pinned out-of-band once and reused. + +Note this is not OS-level code signing: macOS Gatekeeper and Windows SmartScreen +need Apple/Authenticode certificates, which the project does not currently hold. + ### 1. Deploy the relay (manual / server shell) ```bash diff --git a/src/apps/relay-server/release-download.sh b/src/apps/relay-server/release-download.sh index 80df4a256..7dc64aa86 100644 --- a/src/apps/relay-server/release-download.sh +++ b/src/apps/relay-server/release-download.sh @@ -102,7 +102,7 @@ bitfun_canonical_checksum_url() { bitfun_try_release_deploy() { local release_dir="$HOME/.bitfun/relay-release" - local target archive upstream_url download_dir extracted context image + local target archive upstream_url download_dir extracted context image expected_hash case "$(uname -m 2>/dev/null)" in x86_64 | amd64) target="x86_64-unknown-linux-gnu" ;; aarch64 | arm64) target="aarch64-unknown-linux-gnu" ;; @@ -114,6 +114,16 @@ bitfun_try_release_deploy() { archive="bitfun-relay-server-${target}.tar.gz" upstream_url="$(bitfun_release_asset_url "$BITFUN_RELEASE_TAG" "$archive")" + case "$target" in + x86_64-unknown-linux-gnu) expected_hash="${BITFUN_EXPECTED_SHA256_X86_64_UNKNOWN_LINUX_GNU:-}" ;; + aarch64-unknown-linux-gnu) expected_hash="${BITFUN_EXPECTED_SHA256_AARCH64_UNKNOWN_LINUX_GNU:-}" ;; + *) expected_hash="" ;; + esac + if [ -n "$expected_hash" ]; then + echo ">>> Using a signature-verified checksum supplied by the client." + else + echo ">>> No signature-verified checksum available; falling back to the canonical GitHub checksum." + fi mkdir -p "$release_dir" chmod 700 "$release_dir" 2>/dev/null || true download_dir="$(mktemp -d "$release_dir/download.XXXXXX")" @@ -137,6 +147,14 @@ bitfun_try_release_deploy() { # reached at all, and says so — that is a materially weaker guarantee. bitfun_fetch_release_checksum() { local url="$1" canonical + # Strongest case: the caller already verified a signature over the checksum + # and passed the hash down. Nothing fetched from any origin can override it, + # so a hostile mirror is out of the picture — and this host needs no + # minisign, only sha256sum. + if [ -n "$expected_hash" ]; then + printf '%s %s\n' "$expected_hash" "$archive" >"$download_dir/${archive}.sha256" + return 0 + fi canonical="$(bitfun_canonical_checksum_url "$url")" rm -f "$download_dir/${archive}.sha256" if curl -fsSL --retry 3 --connect-timeout 15 --max-time 60 \ @@ -190,9 +208,16 @@ bitfun_try_release_deploy() { done ) & watcher=$! + # No --max-time on purpose. Any wall-clock ceiling reintroduces the original + # bug one order of magnitude out: at the 8 KB/s floor a 30 MB archive needs + # ~3750 s, so a 3600 s cap would kill a transfer that was progressing fine, + # and --retry-max-time would already have refused to retry it. The + # throughput floor is the give-up condition — it aborts a dead or hung link + # within --speed-time, and --connect-timeout covers setup — so a ceiling + # adds nothing but a cliff for slow users. curl -fsSL -C - \ - --retry 3 --retry-delay 3 --retry-max-time 300 \ - --connect-timeout 15 --max-time 3600 \ + --retry 3 --retry-delay 3 --retry-max-time 0 \ + --connect-timeout 15 \ --speed-limit "$BITFUN_STALL_BPS" --speed-time "$BITFUN_STALL_SECONDS" \ -o "$download_dir/$archive" "$url" || status=$? rm -f "$done_marker" diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index 08e8dd03b..8aef730c7 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -37,6 +37,7 @@ git2 = { workspace = true, optional = true } hostname = { workspace = true, optional = true } image = { workspace = true, optional = true } hex = { workspace = true, optional = true } +minisign-verify = { version = "0.2", optional = true } libc = { workspace = true, optional = true } local-ip-address = { workspace = true, optional = true } mac_address = { workspace = true, optional = true } @@ -188,7 +189,9 @@ remote-ssh-concrete = [ "bitfun-runtime-ports", "chrono", "dirs", + "minisign-verify", "rand", + "reqwest", "russh", "russh-sftp", "russh-keys", diff --git a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs index 0255ba4ec..774190847 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs @@ -24,6 +24,7 @@ use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; +use std::time::Duration; use super::manager::SSHConnectionManager; use super::remote_git::shell_quote_posix; @@ -71,6 +72,12 @@ const STALL_WINDOW_SECONDS: u64 = 30; /// target dir and Docker layers). Checked before the build rather than /// discovered as an opaque compiler failure part-way through. const SOURCE_BUILD_FREE_KB: u64 = 6 * 1024 * 1024; +/// Trust root for published relay archives, injected at build time from the +/// same `TAURI_UPDATER_PUBKEY` the Desktop updater uses. Absent in local and +/// fork builds, which then fall back to the cross-origin checksum. +const RELEASE_PUBKEY: Option<&str> = option_env!("BITFUN_RELEASE_PUBKEY"); +/// Targets a published relay archive exists for. +const RELEASE_TARGETS: [&str; 2] = ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]; /// Canonical China-mirror helper (shared with `src/apps/relay-server/deploy.sh`). /// Embedded so Desktop orchestration can apply mirrors before the git clone. const RELAY_MIRROR_SH: &str = include_str!(concat!( @@ -446,7 +453,13 @@ pub async fn start_task( let body = match task { RelayDeployTask::InstallDocker => install_docker_body_script(), - RelayDeployTask::Deploy => deploy_body_script(port), + RelayDeployTask::Deploy => { + // Verify the signed checksums here, where a trust root exists; the + // relay host has none. + let verified = + verified_release_checksums(&release_tag_for_version(RELEASE_VERSION)).await; + deploy_body_script_with_checksums(port, &verified_checksum_exports(&verified)) + } }; let driver = match task { RelayDeployTask::InstallDocker => interactive_driver_script(stem, "install"), @@ -1419,8 +1432,106 @@ export BITFUN_STALL_SECONDS="{stall_seconds}" ) } +/// Checksums for the published relay archives, each proven by a signature this +/// machine verified. +/// +/// A relay host is an arbitrary user server with no minisign and no trust root, +/// so it cannot check a signature itself. It does not have to: the signature +/// covers the `.sha256` file, which is a couple of hundred bytes, so Desktop +/// verifies that here and sends the resulting hash down with the deploy script. +/// The server then needs nothing but `sha256sum`. +/// +/// Best effort by design — an empty map simply leaves the remote on the +/// cross-origin checksum path. +async fn verified_release_checksums(release_tag: &str) -> std::collections::HashMap { + let mut verified = std::collections::HashMap::new(); + let Some(pubkey) = RELEASE_PUBKEY.filter(|key| !key.trim().is_empty()) else { + return verified; + }; + let Ok(client) = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(8)) + .timeout(Duration::from_secs(30)) + .build() + else { + return verified; + }; + + for target in RELEASE_TARGETS { + let checksum_url = + format!("{RELEASE_BASE}/download/{release_tag}/bitfun-relay-server-{target}.tar.gz.sha256"); + let Some(checksum) = fetch_text(&client, &checksum_url).await else { + continue; + }; + let Some(signature) = fetch_text(&client, &format!("{checksum_url}.sig")).await else { + continue; + }; + if let Err(error) = verify_minisign(checksum.as_bytes(), &signature, pubkey) { + log::warn!("Relay checksum signature for {target} did not verify: {error}"); + continue; + } + let Some(hash) = checksum + .split_whitespace() + .next() + .filter(|value| value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())) + else { + continue; + }; + verified.insert(target.to_string(), hash.to_ascii_lowercase()); + } + verified +} + +async fn fetch_text(client: &reqwest::Client, url: &str) -> Option { + client + .get(url) + .send() + .await + .ok()? + .error_for_status() + .ok()? + .text() + .await + .ok() +} + +/// Verify a Tauri-format `.sig` (base64 of a minisign signature file) over +/// `data`, using the base64-wrapped public key. +fn verify_minisign(data: &[u8], signature_b64: &str, pubkey_b64: &str) -> Result<()> { + use base64::Engine as _; + let decode = |value: &str, what: &str| -> Result { + let bytes = base64::engine::general_purpose::STANDARD + .decode(value.trim().as_bytes()) + .map_err(|error| anyhow!("decode {what}: {error}"))?; + String::from_utf8(bytes).map_err(|error| anyhow!("decode {what} as UTF-8: {error}")) + }; + let public_key = minisign_verify::PublicKey::decode(&decode(pubkey_b64, "public key")?) + .map_err(|error| anyhow!("invalid release public key: {error}"))?; + let signature = minisign_verify::Signature::decode(&decode(signature_b64, "signature")?) + .map_err(|error| anyhow!("invalid release signature: {error}"))?; + public_key + .verify(data, &signature, false) + .map_err(|error| anyhow!("signature does not match: {error}")) +} + +/// Shell assignments exporting the verified hashes the remote script consumes. +fn verified_checksum_exports(verified: &std::collections::HashMap) -> String { + let mut exports = String::new(); + for target in RELEASE_TARGETS { + if let Some(hash) = verified.get(target) { + exports.push_str(&format!( + "export BITFUN_EXPECTED_SHA256_{}=\"{hash}\"\n", + target.replace(['-', '.'], "_").to_uppercase() + )); + } + } + exports +} + /// Non-interactive body for deploy (runs under nohup after prepare). -fn deploy_body_script(port: u16) -> String { +/// +/// `verified_checksums` carries hashes this device proved by signature; empty +/// leaves the remote on the cross-origin checksum path. +fn deploy_body_script_with_checksums(port: u16, verified_checksums: &str) -> String { let helpers = prepare_helpers_bash(); let sync = sync_source_bash(); let release_binary_deploy = release_binary_deploy_bash(); @@ -1429,7 +1540,7 @@ fn deploy_body_script(port: u16) -> String { set -euo pipefail {helpers} {sync} -{release_binary_deploy} +{verified_checksums}{release_binary_deploy} export DOCKER_CONFIG="${{DOCKER_CONFIG:-$HOME/.bitfun/docker-config}}" export DOCKER_BUILDKIT=1 export COMPOSE_DOCKER_CLI_BUILD=1 @@ -1489,6 +1600,7 @@ echo {TASK_DONE_MARKER} "#, helpers = helpers, sync = sync, + verified_checksums = verified_checksums, release_binary_deploy = release_binary_deploy, DEPLOY_STATE_DIR = DEPLOY_STATE_DIR, SOURCE_DIR = SOURCE_DIR, @@ -1504,9 +1616,12 @@ echo {TASK_DONE_MARKER} #[cfg(test)] mod tests { use super::{ - classify_docker_access, decide_task_status, deploy_body_script, parse_preflight, + classify_docker_access, decide_task_status, deploy_body_script_with_checksums, + parse_preflight, prepare_helpers_bash, release_binary_deploy_bash, release_tag_for_version, - split_poll_stdout, sync_source_bash, DockerAccessMode, RelayTaskStatus, RELAY_MIRROR_SH, + split_poll_stdout, sync_source_bash, verified_checksum_exports, + verified_release_checksums, verify_minisign, DockerAccessMode, RelayTaskStatus, + RELAY_MIRROR_SH, RELEASE_PUBKEY, }; #[test] @@ -1610,6 +1725,48 @@ mod tests { /// `bash -n` only proves the generated script parses. This runs its source /// ranking and download loop against a stubbed curl so the slow-link /// behaviour is actually exercised. + /// Same fixture as the CLI updater, produced with the real `minisign` CLI. + /// A relay host cannot check a signature itself, so this is the check that + /// stands between a hostile mirror and the user's server. + const FIXTURE_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgRTNFMDg3NENFQzFDMjJDMwpSV1RESWh6c1RJZmc0MXcyR3dpZWkwek5ES2FMWW05ZFFWcEVXTlEvVWxweXQybWJTMkpFMVUyTQo="; + const FIXTURE_SIGNATURE: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVUREloenNUSWZnNDBMTitwb25aT3RCVy9VYmJtNWhkR1poM0lCb3IwUDBKaVZmZmM1cFJaNlZSNUpaSzNUUm1yWWpYMXFLQ2svWTdZUDhHdkRZT3YvanVoZlpnZmhyWEFRPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg0OTUxOTM1CWZpbGU6YXJjaGl2ZS50YXIuZ3oJaGFzaGVkCjhWL21EUVAwZGdlZXVNU1lxWlpsOWdFSGUwOTJQTk9yRG1BMUV6ZHNQOUlEYkcyT1dneTFsQ1puUDBJaFIwQnJpMFBCeENRcUdDR2dpb0l0UGtSMUN3PT0K"; + const FIXTURE_DATA: &[u8] = b"hello-bitfun\n"; + + #[test] + fn checksum_signature_verifies_and_rejects_tampering() { + verify_minisign(FIXTURE_DATA, FIXTURE_SIGNATURE, FIXTURE_PUBKEY) + .expect("minisign signature in Tauri's base64 wrapper must verify"); + assert!(verify_minisign(b"tampered\n", FIXTURE_SIGNATURE, FIXTURE_PUBKEY).is_err()); + assert!(verify_minisign(FIXTURE_DATA, "bm90LWEtc2ln", FIXTURE_PUBKEY).is_err()); + } + + #[test] + fn verified_checksums_reach_the_remote_script_as_exports() { + let mut verified = std::collections::HashMap::new(); + verified.insert("x86_64-unknown-linux-gnu".to_string(), "a".repeat(64)); + let exports = verified_checksum_exports(&verified); + assert!(exports.contains(&format!( + "export BITFUN_EXPECTED_SHA256_X86_64_UNKNOWN_LINUX_GNU=\"{}\"", + "a".repeat(64) + ))); + // No entry for a target we could not verify: the remote must fall back + // rather than trust an unverified hash. + assert!(!exports.contains("AARCH64")); + + // The generated script must consume exactly those names. + let script = deploy_body_script_with_checksums(9700, &exports); + assert!(script.contains("BITFUN_EXPECTED_SHA256_X86_64_UNKNOWN_LINUX_GNU")); + assert!(script.contains("BITFUN_EXPECTED_SHA256_AARCH64_UNKNOWN_LINUX_GNU")); + } + + /// Without a trust root there is nothing to verify against, so no hash may + /// be asserted to the remote. + #[tokio::test] + async fn unsigned_builds_supply_no_checksums() { + assert!(RELEASE_PUBKEY.is_none() || RELEASE_PUBKEY == Some("")); + assert!(verified_release_checksums("v0.0.0").await.is_empty()); + } + #[cfg(unix)] #[test] fn release_download_picks_the_fastest_working_source() { @@ -1635,7 +1792,7 @@ mod tests { #[cfg(unix)] #[test] fn generated_deploy_script_is_valid_bash() { - let script = deploy_body_script(9700); + let script = deploy_body_script_with_checksums(9700, ""); let output = std::process::Command::new("bash") .args(["-n", "-c", &script]) .output() diff --git a/src/web-ui/src/features/relay-deploy/README.md b/src/web-ui/src/features/relay-deploy/README.md index 3896ca20c..e3277ddf9 100644 --- a/src/web-ui/src/features/relay-deploy/README.md +++ b/src/web-ui/src/features/relay-deploy/README.md @@ -37,51 +37,60 @@ Desktop Tauri surface: `src/apps/desktop/src/api/relay_deploy_api.rs` `//` path. The mirror retains only the most recent releases, so a pinned version 404s for every older Desktop build. -4. **Verify against a checksum from a different origin than the bytes.** A - `.sha256` served by whoever served the archive only detects corruption; the - CN path deliberately prefers a third-party GitHub proxy, so the checksum is - always fetched from the canonical GitHub URL (derivable from any candidate - URL, including the mirror's versioned path). Same-origin fallback is allowed - only when GitHub is unreachable, and must say so in the log. - -5. **One implementation, two callers.** The download, verification and runtime +4. **Verify the checksum's signature on this device, not the server.** A relay + host is an arbitrary user machine with no minisign and no trust root, so it + cannot check a signature. It does not need to: the release signs the + `.sha256` file too, Desktop verifies that signature locally (a couple of + hundred bytes) and exports the resulting hash into the generated script as + `BITFUN_EXPECTED_SHA256_`. The remote then needs only `sha256sum`, + and no origin can override that hash. Requires `BITFUN_RELEASE_PUBKEY` at + Desktop build time. + +5. **Without a verified hash, bind to a checksum from a different origin than + the bytes.** A `.sha256` served by whoever served the archive only detects + corruption; the CN path deliberately prefers a third-party GitHub proxy, so + the checksum is fetched from the canonical GitHub URL (derivable from any + candidate URL, including the mirror's versioned path). Same-origin fallback + is allowed only when GitHub is unreachable, and must say so in the log. + +6. **One implementation, two callers.** The download, verification and runtime image live in `src/apps/relay-server/release-download.sh`; `deploy.sh` sources it and `relay_deploy.rs` embeds it with `include_str!`, exactly as `mirror.sh` is shared. Do not fork this logic back into the Rust template — manual and one-click deploys must not drift. -6. **Fallback source path is `~/.bitfun/relay-src`**, never `$HOME/BitFun` / +7. **Fallback source path is `~/.bitfun/relay-src`**, never `$HOME/BitFun` / `$HOME/bitfun`. Sync always passes an explicit clone destination. Destructive replace is only safe under `~/.bitfun/`. -7. **Git first, tarball fallback.** When `.git` already exists, deploy must +8. **Git first, tarball fallback.** When `.git` already exists, deploy must `fetch` + checkout, not re-clone from scratch (preserves BuildKit layers and Cargo cache mounts for registry/git/`target`). -8. **Close wizard = cancel remote task.** Do not leave nohup builds running +9. **Close wizard = cancel remote task.** Do not leave nohup builds running after the modal closes; cancel must kill the pid tree and best-effort stop compose/buildx workers. -9. **Account password never leaves this device.** Provision locally, then +10. **Account password never leaves this device.** Provision locally, then `relay-admin import-user` over the SSH session. Do not send plaintext passwords to the remote as env/script args. -10. **“Already deployed” is container-aware, not only selected-port health.** +11. **“Already deployed” is container-aware, not only selected-port health.** Changing the listen port must not hide a running `bitfun-relay`. Use `container_running` / `existing_relay_port` / `relay_healthy` (health on selected **or** existing port). “Create account” must hit the running port. -11. **Port conflict ≠ our relay.** `port_busy && !port_owned_by_relay` blocks +12. **Port conflict ≠ our relay.** `port_busy && !port_owned_by_relay` blocks deploy; busy-because-bitfun-relay does not. -12. **Privilege / Docker install.** Do not call `sudo -v` unconditionally. +13. **Privilege / Docker install.** Do not call `sudo -v` unconditionally. Detect root / passwordless sudo / interactive elevate. Docker install must not require a working daemon *before* install. -13. **Scripts are embedded Rust templates** staged via SFTP. Do not rely on a +14. **Scripts are embedded Rust templates** staged via SFTP. Do not rely on a static repo `.sh` alone on the server until the desktop binary re-stages. -14. **China mirrors before overseas downloads.** Desktop orchestration embeds +15. **China mirrors before overseas downloads.** Desktop orchestration embeds `src/apps/relay-server/mirror.sh` and runs `bitfun_mirror_init` before apt tool install, Docker Engine install, and GitHub sync. `deploy.sh` sources the same file so manual and one-click paths stay aligned. Force with From d5a6a594339c83b0fd982ed1d9c4e29fa8f03b75 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 24 Jul 2026 21:38:20 -0700 Subject: [PATCH 4/4] fix(ci): declare the reqwest edge remote-ssh-concrete now needs Core boundary check failed: services-integrations keeps every optional runtime dependency owned by an explicit integration feature, and one-click relay deploy started using reqwest to fetch the signed release checksum without registering that edge. Declared rather than worked around. The alternative was to move the fetch into the desktop crate (its only caller) and pass hashes into start_task, but that puts security-critical verification in an API-surface file, away from the contract it protects, and leaks the env-var naming out of the module that consumes it. aes-gcm and rand already list remote-ssh-concrete as an owner in this same rule, so this is the registration path the file establishes, not an exception invented for this change. --- scripts/core-boundaries/rules/feature-rules.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 5397094a6..329653b60 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -94,7 +94,10 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'oxc', ownerFeatures: ['canvas-runtime'] }, { depName: 'qrcode', ownerFeatures: ['remote-connect'] }, { depName: 'rand', ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh-concrete'] }, - { depName: 'reqwest', ownerFeatures: ['announcement', 'browser-control', 'debug-log', 'mcp', 'miniapp-runtime', 'remote-connect', 'review-platform', 'speech', 'web-tools'] }, + // remote-ssh-concrete: one-click relay deploy fetches the signed release + // checksum over HTTPS and verifies it on this device, because the target + // server has no minisign and no trust root of its own. + { depName: 'reqwest', ownerFeatures: ['announcement', 'browser-control', 'debug-log', 'mcp', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools'] }, { depName: 'rmcp', ownerFeatures: ['mcp'] }, { depName: 'russh', ownerFeatures: ['remote-ssh-concrete'] }, { depName: 'russh-keys', ownerFeatures: ['remote-ssh-concrete'] },