diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 5d6c77a56..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 }} @@ -243,6 +247,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 }} @@ -316,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: @@ -328,10 +365,13 @@ 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 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 @@ -352,3 +392,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/.github/workflows/linux-binaries.yml b/.github/workflows/linux-binaries.yml index cd39e3d65..5fee1c4fe 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,24 @@ jobs: TARGET: ${{ matrix.platform.target }} run: bash scripts/relay/package-unix.sh "$ASSET_VERSION" "$TARGET" + - name: Sign release archives + shell: bash + env: + 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 + # 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 with: @@ -129,3 +162,5 @@ jobs: ${{ steps.cli-stage.outputs.checksum }} ${{ 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 f192b3dd4..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 @@ -217,6 +220,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 }} @@ -272,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: @@ -292,8 +328,11 @@ 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 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/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'] }, 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 254b6864f..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,41 @@ 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/); }); + +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..da4bd680a 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,84 @@ 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, {}) + # 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 + 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", "sigUrl"): + 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 +232,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 +255,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 +268,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..bc0873881 --- /dev/null +++ b/scripts/relay/release-download-harness.sh @@ -0,0 +1,219 @@ +#!/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" +# 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 + *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) + # 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 + +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" + : >"$WORK/archive-flags" + 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 + + 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 ($problem)" + printf '%s\n' "$output" | sed 's/^/ /' + echo " curl trace:" + sed 's/^/ /' "$WORK/trace" + 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" + +# 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" + +# 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/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 91b3f2766..558339ce5 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -78,6 +78,9 @@ anyhow = { workspace = true } 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 396480d78..f6ae32d69 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -25,12 +25,35 @@ 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, 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 ae4f0dff0..3788d158b 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 { @@ -38,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)] @@ -50,6 +76,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 +98,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 +218,317 @@ 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(); + // Checksum served by GitHub itself for this exact version. Verifying an + // archive against a `.sha256` from the same host it came from proves only + // that the transfer was not corrupted — a hostile mirror serves both and + // passes. Binding to a different origin means one compromised mirror is not + // enough. + let mut canonical_sha256_url = None; + let mut canonical_sig_url = None; + for (source, manifest) in &manifests { + if manifest.version != newest { + continue; + } + match platform_asset(manifest, platform_key) { + 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:#}")), + } + } + if candidates.is_empty() { + return Err(anyhow!( + "no source offers a usable {platform_key} CLI asset for {newest}: {}", + skipped.join("; ") + )); + } + + // Every candidate is the same asset, so any one names the staging file. + let asset_filename = candidates[0].filename.clone(); + 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. It also carries across + // *runs* — the automatic path installs from a detached child, and a child + // killed at 90% should not start over on the next launch. + let staging = PartialDownload::open(&newest, &asset_filename); + let mut buffer = staging.resume(); + if !buffer.is_empty() { + eprintln!( + "Resuming a previous BitFun CLI download at {} MB.", + buffer.len() / (1024 * 1024) + ); + } + let mut failures = Vec::new(); + for (candidate, _) in &ranked { + let outcome = download_resumable(&client, &candidate.url, &mut buffer).await; + staging.save(&buffer); + if let Err(error) = outcome { + failures.push(format!("{}: {error:#}", candidate.url)); + continue; + } + let checksum_url = canonical_sha256_url + .as_deref() + .unwrap_or(candidate.sha256_url.as_str()); + let checksum_text = match download_text(&client, checksum_url).await { + Ok(text) => 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!("{checksum_url}: {error:#}")); + 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(); + staging.discard(); + failures.push(format!("{}: {error:#}", candidate.url)); + continue; + } + // Checksum passed, so the bytes are intact. Signature proves who made + // them — the part a mirror or proxy cannot forge. + if let Some(pubkey) = release_pubkey() { + let sig_url = canonical_sig_url + .as_deref() + .or(candidate.sig_url.as_deref()); + let Some(sig_url) = sig_url else { + return Err(anyhow!( + "this build requires signed releases but {newest} publishes no signature; \ + refusing to install" + )); + }; + let signature = download_text(&client, sig_url) + .await + .with_context(|| format!("fetch release signature {sig_url}"))?; + verify_signature(&buffer, &signature, pubkey)?; + } + + install_archive(&buffer, ¤t_exe)?; + staging.discard(); + 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( +/// Download staging that survives the process. +/// +/// The automatic path installs from a detached child; without this a child +/// killed near the end of a slow transfer would restart from zero on the next +/// launch, which on a 20 KB/s link means never finishing. Keyed by version and +/// filename so a stale partial from an older release is never resumed into. +struct PartialDownload { + path: Option, +} + +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, +} + +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 +548,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) @@ -222,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() @@ -433,8 +944,217 @@ 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), + sig_url: None, + 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); + } + + /// 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 { + 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() { @@ -443,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/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/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..7dc64aa86 --- /dev/null +++ b/src/apps/relay-server/release-download.sh @@ -0,0 +1,429 @@ +#!/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 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" ;; + *) + 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")" + 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")" + + 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 + # 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 \ + -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=$! + # 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 0 \ + --connect-timeout 15 \ + --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/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 496bf1d3f..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; @@ -51,15 +52,44 @@ 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 +/// (`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; +/// 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; +/// 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!( 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/` @@ -133,6 +163,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). @@ -215,6 +249,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). @@ -283,6 +324,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"; @@ -329,6 +372,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", @@ -408,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"), @@ -1346,213 +1397,141 @@ 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 openbitfun_url proxy_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}}" - 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")" - - 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 +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_tag = release_tag_for_version(RELEASE_VERSION), + RELEASE_BASE = RELEASE_BASE, + OPENBITFUN_RELEASE_BASE = OPENBITFUN_RELEASE_BASE, + probe_seconds = SOURCE_PROBE_SECONDS, + probe_bytes = SOURCE_PROBE_BYTES, + healthy_floor = HEALTHY_THROUGHPUT_BYTES_PER_SEC, + stall_floor = STALL_THROUGHPUT_BYTES_PER_SEC, + stall_seconds = STALL_WINDOW_SECONDS, + release_download = RELAY_RELEASE_DOWNLOAD_SH, ) - }} - - bitfun_download_release_pair() {{ - local url="$1" - rm -f "$download_dir/$archive" "$download_dir/${{archive}}.sha256" - 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 - }} - - proxy_url="" - if [ "${{BITFUN_MIRROR_MODE:-global}}" = "cn" ] && [ -n "${{BITFUN_GITHUB_PROXY:-}}" ]; then - proxy_url="${{BITFUN_GITHUB_PROXY%/}}/${{upstream_url}}" - 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." - 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 +/// 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; + }; - 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 - }} + 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 +} - # 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 +async fn fetch_text(client: &reqwest::Client, url: &str) -> Option { + client + .get(url) + .send() + .await + .ok()? + .error_for_status() + .ok()? + .text() + .await + .ok() +} - # 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 +/// 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}")) +} - 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 -}} -"#, - RELEASE_DOWNLOAD_BASE = RELEASE_DOWNLOAD_BASE, - OPENBITFUN_RELEASE_BASE = OPENBITFUN_RELEASE_BASE, - release_tag = release_tag, - release_version = RELEASE_VERSION, - ) +/// 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(); @@ -1561,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 @@ -1586,6 +1565,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" @@ -1611,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, @@ -1618,15 +1608,20 @@ 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, ) } #[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] @@ -1692,7 +1687,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")); @@ -1703,13 +1698,101 @@ 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 + // 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")); + // 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 + /// 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() { + 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)] #[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() @@ -1876,6 +1959,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 @@ -1896,5 +1981,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 d8b3e6bfd..e3277ddf9 100644 --- a/src/web-ui/src/features/relay-deploy/README.md +++ b/src/web-ui/src/features/relay-deploy/README.md @@ -17,44 +17,80 @@ 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. **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. + +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/`. -3. **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`). -4. **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. -5. **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. -6. **“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. -7. **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. -8. **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. -9. **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. -10. **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