diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index b59bc901ce..03c2202abc 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -15,9 +15,15 @@ on: required: false default: false type: boolean + relay_image_only: + description: "Publish only the Relay Server image and signed descriptor (no Desktop packages)." + required: false + default: false + type: boolean permissions: contents: write + packages: write concurrency: group: desktop-package-${{ github.event.release.tag_name || inputs.tag_name || github.sha }} @@ -33,6 +39,7 @@ jobs: release_tag: ${{ steps.meta.outputs.release_tag }} upload_to_release: ${{ steps.meta.outputs.upload_to_release }} checkout_ref: ${{ steps.meta.outputs.checkout_ref }} + relay_image_only: ${{ steps.meta.outputs.relay_image_only }} steps: - uses: actions/checkout@v5 @@ -45,6 +52,7 @@ jobs: RELEASE_TAG_NAME: ${{ github.event.release.tag_name }} INPUT_TAG_NAME: ${{ inputs.tag_name }} INPUT_UPLOAD_TO_RELEASE: ${{ inputs.upload_to_release }} + INPUT_RELAY_IMAGE_ONLY: ${{ inputs.relay_image_only }} run: | set -euo pipefail @@ -56,7 +64,13 @@ jobs: elif [[ -n "${INPUT_TAG_NAME}" ]]; then TAG="${INPUT_TAG_NAME}" VERSION="${TAG#v}" - CHECKOUT_REF="${TAG}" + # A one-off image backfill must use the workflow branch: an older + # tag does not contain Dockerfile.release or this publishing job. + if [[ "${INPUT_RELAY_IMAGE_ONLY}" == "true" ]]; then + CHECKOUT_REF="${GITHUB_SHA}" + else + CHECKOUT_REF="${TAG}" + fi if [[ "${INPUT_UPLOAD_TO_RELEASE}" == "true" ]]; then UPLOAD="true" else @@ -73,12 +87,14 @@ jobs: echo "release_tag=$TAG" >> "$GITHUB_OUTPUT" echo "upload_to_release=$UPLOAD" >> "$GITHUB_OUTPUT" echo "checkout_ref=$CHECKOUT_REF" >> "$GITHUB_OUTPUT" + echo "relay_image_only=${INPUT_RELAY_IMAGE_ONLY:-false}" >> "$GITHUB_OUTPUT" # ── Build per platform ───────────────────────────────────────────── package: name: Package (${{ matrix.platform.name }}) runs-on: ${{ matrix.platform.os }} needs: prepare + if: needs.prepare.outputs.relay_image_only != 'true' env: NODE_OPTIONS: --max-old-space-size=6144 BITFUN_ENABLE_UPDATER_ARTIFACTS: ${{ needs.prepare.outputs.upload_to_release }} @@ -239,6 +255,7 @@ jobs: linux-binaries: name: Linux CLI and Relay Server needs: prepare + if: needs.prepare.outputs.relay_image_only != 'true' uses: ./.github/workflows/linux-binaries.yml secrets: release_signing_key: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -249,11 +266,208 @@ jobs: version: ${{ needs.prepare.outputs.version }} artifact_prefix: ${{ needs.prepare.outputs.release_tag }} + # Publish the Relay once, as a multi-platform image. User servers only pull + # this image; they no longer download an archive and build a runtime image. + publish-relay-image: + name: Publish Relay Server Image + needs: [prepare, linux-binaries] + if: >- + always() && + (needs.prepare.outputs.upload_to_release == 'true' || + needs.prepare.outputs.relay_image_only == 'true') && + (needs.prepare.outputs.relay_image_only == 'true' || + needs.linux-binaries.result == 'success') + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + env: + IMAGE: ghcr.io/gcwing/bitfun-relay-server + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ needs.prepare.outputs.checkout_ref }} + + - name: Download Relay archives from this release run + if: needs.prepare.outputs.relay_image_only != 'true' + uses: actions/download-artifact@v7 + with: + pattern: bitfun-linux-${{ needs.prepare.outputs.release_tag }}-* + path: linux-release-assets + merge-multiple: true + + - name: Download Relay archives from the existing release (image-only backfill) + if: needs.prepare.outputs.relay_image_only == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }} + run: | + set -euo pipefail + mkdir -p linux-release-assets + gh release download "${RELEASE_TAG}" \ + --repo GCWing/BitFun \ + --dir linux-release-assets \ + --pattern 'bitfun-relay-server-*.tar.gz' \ + --pattern 'bitfun-relay-server-*.tar.gz.sha256' + + - name: Verify image inputs + shell: bash + run: | + set -euo pipefail + test -f linux-release-assets/bitfun-relay-server-x86_64-unknown-linux-gnu.tar.gz + test -f linux-release-assets/bitfun-relay-server-aarch64-unknown-linux-gnu.tar.gz + for archive in linux-release-assets/bitfun-relay-server-*.tar.gz; do + (cd linux-release-assets && sha256sum --check "$(basename "${archive}").sha256") + done + cp src/apps/relay-server/Dockerfile.release linux-release-assets/Dockerfile.release + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Resolve image tags + id: image-tags + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }} + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + IMAGE_ONLY: ${{ needs.prepare.outputs.relay_image_only }} + RELEASE_PRERELEASE: ${{ github.event.release.prerelease }} + run: | + set -euo pipefail + asset_version="${RELEASE_VERSION%%+*}" + { + echo 'value<>"$GITHUB_OUTPUT" + + - name: Build and push multi-platform image + id: image + uses: docker/build-push-action@v7 + with: + context: linux-release-assets + file: linux-release-assets/Dockerfile.release + platforms: linux/amd64,linux/arm64 + push: true + provenance: false + sbom: false + build-args: | + VERSION=${{ needs.prepare.outputs.version }} + REVISION=${{ needs.prepare.outputs.release_tag }} + tags: ${{ steps.image-tags.outputs.value }} + + - name: Smoke-test published image on both platforms + shell: bash + env: + IMAGE_DIGEST: ${{ steps.image.outputs.digest }} + run: bash scripts/relay/smoke-image.sh "${IMAGE}@${IMAGE_DIGEST}" + + - name: Verify manifest and generate signed descriptor + shell: bash + env: + IMAGE_DIGEST: ${{ steps.image.outputs.digest }} + RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }} + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + 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 + [[ "${IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] + docker buildx imagetools inspect "${IMAGE}@${IMAGE_DIGEST}" --raw >relay-image-manifest.json + jq -e ' + [.manifests[].platform | .os + "/" + .architecture] as $platforms + | ($platforms | index("linux/amd64")) != null + and ($platforms | index("linux/arm64")) != null + ' relay-image-manifest.json >/dev/null + jq -n \ + --arg image "${IMAGE}" \ + --arg tag "${RELEASE_TAG}" \ + --arg version "${RELEASE_VERSION}" \ + --arg digest "${IMAGE_DIGEST}" \ + '{ + schema_version: 1, + image: $image, + tag: $tag, + version: $version, + digest: $digest, + platforms: ["linux/amd64", "linux/arm64"] + }' >relay-image.json + bash scripts/sign-release-assets.sh relay-image.json + test -s relay-image.json.sig + + - name: Upload signed image descriptor + uses: actions/upload-artifact@v6 + with: + name: bitfun-relay-image-${{ needs.prepare.outputs.release_tag }} + if-no-files-found: error + retention-days: 7 + path: | + relay-image.json + relay-image.json.sig + + # The package is private on first creation. This deliberately fails until + # its visibility is changed to public, preventing an apparently green + # release that anonymous customer servers cannot pull. + - name: Verify anonymous pull access + shell: bash + env: + IMAGE_DIGEST: ${{ steps.image.outputs.digest }} + run: | + set -euo pipefail + docker logout ghcr.io >/dev/null 2>&1 || true + clean_config="$(mktemp -d)" + trap 'rm -rf "$clean_config"' EXIT + DOCKER_CONFIG="$clean_config" docker buildx imagetools inspect \ + "${IMAGE}@${IMAGE_DIGEST}" >/dev/null + + - name: Attach descriptor to an existing release (image-only backfill) + if: needs.prepare.outputs.relay_image_only == 'true' + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ needs.prepare.outputs.release_tag }} + files: | + relay-image.json + relay-image.json.sig + fail_on_unmatched_files: true + # ── Upload assets to GitHub Release ──────────────────────────────── upload-release-assets: name: Upload Release Assets - needs: [prepare, package, linux-binaries] - if: needs.prepare.outputs.upload_to_release == 'true' + needs: [prepare, package, linux-binaries, publish-relay-image] + if: >- + always() && + needs.prepare.outputs.upload_to_release == 'true' && + needs.package.result == 'success' && + needs.linux-binaries.result == 'success' && + needs.publish-relay-image.result == 'success' runs-on: ubuntu-latest env: REQUIRED_UPDATER_PLATFORMS: windows-x86_64,darwin-x86_64,darwin-aarch64,linux-x86_64,linux-aarch64 @@ -276,12 +490,20 @@ jobs: path: linux-release-assets merge-multiple: true + - name: Download Relay image descriptor + uses: actions/download-artifact@v7 + with: + name: bitfun-relay-image-${{ needs.prepare.outputs.release_tag }} + path: relay-image-assets + - name: List release assets run: | echo "Release assets:" find release-assets -type f | sort echo "Linux CLI and Relay Server assets:" find linux-release-assets -type f | sort + echo "Relay image descriptor:" + find relay-image-assets -type f | sort - name: Collect updater assets run: | @@ -367,6 +589,8 @@ jobs: linux-release-assets/*.tar.gz.sig linux-release-assets/*.tar.gz.sha256.sig linux-release-assets/linux-binaries.json + relay-image-assets/relay-image.json + relay-image-assets/relay-image.json.sig fail_on_unmatched_files: true - name: Verify published updater manifest @@ -391,6 +615,18 @@ jobs: curl -fsSL --retry 5 --retry-delay 3 "${cli_url}.sha256.sig" -o /dev/null done < <(jq -r '.platforms[].cli.url' linux-binaries.published.json) + - name: Verify published Relay image descriptor + run: | + curl -fsSL --retry 5 --retry-delay 3 \ + "https://github.com/GCWing/BitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/relay-image.json" \ + -o relay-image.published.json + test "$(jq -r '.tag' relay-image.published.json)" = "${{ needs.prepare.outputs.release_tag }}" + test "$(jq -r '.image' relay-image.published.json)" = "ghcr.io/gcwing/bitfun-relay-server" + jq -e '.digest | test("^sha256:[0-9a-f]{64}$")' relay-image.published.json >/dev/null + curl -fsSL --retry 5 --retry-delay 3 \ + "https://github.com/GCWing/BitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/relay-image.json.sig" \ + -o /dev/null + # 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 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index a05e31a668..16473c1eea 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -8,6 +8,7 @@ on: permissions: contents: write + packages: write concurrency: group: nightly @@ -288,6 +289,109 @@ jobs: path: linux-release-assets merge-multiple: true + - name: Verify Relay image inputs + shell: bash + run: | + set -euo pipefail + test -f linux-release-assets/bitfun-relay-server-x86_64-unknown-linux-gnu.tar.gz + test -f linux-release-assets/bitfun-relay-server-aarch64-unknown-linux-gnu.tar.gz + for archive in linux-release-assets/bitfun-relay-server-*.tar.gz; do + (cd linux-release-assets && sha256sum --check "$(basename "${archive}").sha256") + done + cp src/apps/relay-server/Dockerfile.release linux-release-assets/Dockerfile.release + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Resolve nightly image metadata + id: nightly-image-meta + shell: bash + env: + NIGHTLY_VERSION: ${{ needs.check-changes.outputs.nightly_version }} + run: echo "asset_version=${NIGHTLY_VERSION%%+*}" >>"$GITHUB_OUTPUT" + + - name: Build and push multi-platform Relay image + id: relay-image + uses: docker/build-push-action@v7 + with: + context: linux-release-assets + file: linux-release-assets/Dockerfile.release + platforms: linux/amd64,linux/arm64 + push: true + provenance: false + sbom: false + build-args: | + VERSION=${{ needs.check-changes.outputs.nightly_version }} + REVISION=${{ github.sha }} + tags: | + ghcr.io/gcwing/bitfun-relay-server:${{ env.NIGHTLY_TAG }} + ghcr.io/gcwing/bitfun-relay-server:${{ steps.nightly-image-meta.outputs.asset_version }} + + - name: Smoke-test published Relay image on both platforms + shell: bash + env: + IMAGE_DIGEST: ${{ steps.relay-image.outputs.digest }} + run: bash scripts/relay/smoke-image.sh \ + "ghcr.io/gcwing/bitfun-relay-server@${IMAGE_DIGEST}" + + - name: Generate signed Relay image descriptor + shell: bash + env: + IMAGE_DIGEST: ${{ steps.relay-image.outputs.digest }} + NIGHTLY_VERSION: ${{ needs.check-changes.outputs.nightly_version }} + 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 + asset_version="${NIGHTLY_VERSION%%+*}" + [[ "${IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] + docker buildx imagetools inspect \ + "ghcr.io/gcwing/bitfun-relay-server@${IMAGE_DIGEST}" \ + --raw >relay-image-manifest.json + jq -e ' + [.manifests[].platform | .os + "/" + .architecture] as $platforms + | ($platforms | index("linux/amd64")) != null + and ($platforms | index("linux/arm64")) != null + ' relay-image-manifest.json >/dev/null + jq -n \ + --arg image "ghcr.io/gcwing/bitfun-relay-server" \ + --arg tag "${NIGHTLY_TAG}" \ + --arg version "${asset_version}" \ + --arg digest "${IMAGE_DIGEST}" \ + '{ + schema_version: 1, + image: $image, + tag: $tag, + version: $version, + digest: $digest, + platforms: ["linux/amd64", "linux/arm64"] + }' >relay-image.json + bash scripts/sign-release-assets.sh relay-image.json + test -s relay-image.json.sig + + - name: Verify anonymous Relay image access + shell: bash + env: + IMAGE_DIGEST: ${{ steps.relay-image.outputs.digest }} + run: | + set -euo pipefail + docker logout ghcr.io >/dev/null 2>&1 || true + clean_config="$(mktemp -d)" + trap 'rm -rf "$clean_config"' EXIT + DOCKER_CONFIG="$clean_config" docker buildx imagetools inspect \ + "ghcr.io/gcwing/bitfun-relay-server@${IMAGE_DIGEST}" >/dev/null + - name: List release assets run: | echo "Nightly assets:" @@ -374,6 +478,8 @@ jobs: linux-release-assets/*.tar.gz.sig linux-release-assets/*.tar.gz.sha256.sig linux-release-assets/linux-binaries.json + relay-image.json + relay-image.json.sig - name: Verify published Linux CLI signatures shell: bash @@ -384,6 +490,23 @@ jobs: curl -fsSL --retry 5 --retry-delay 3 "${cli_url}.sha256.sig" -o /dev/null done < <(jq -r '.platforms[].cli.url' linux-release-assets/linux-binaries.json) + - name: Verify published Relay image descriptor + shell: bash + env: + NIGHTLY_VERSION: ${{ needs.check-changes.outputs.nightly_version }} + run: | + set -euo pipefail + curl -fsSL --retry 5 --retry-delay 3 \ + "https://github.com/GCWing/BitFun/releases/download/${NIGHTLY_TAG}/relay-image.json" \ + -o relay-image.published.json + test "$(jq -r '.tag' relay-image.published.json)" = "${NIGHTLY_TAG}" + test "$(jq -r '.version' relay-image.published.json)" = "${NIGHTLY_VERSION%%+*}" + test "$(jq -r '.image' relay-image.published.json)" = "ghcr.io/gcwing/bitfun-relay-server" + jq -e '.digest | test("^sha256:[0-9a-f]{64}$")' relay-image.published.json >/dev/null + curl -fsSL --retry 5 --retry-delay 3 \ + "https://github.com/GCWing/BitFun/releases/download/${NIGHTLY_TAG}/relay-image.json.sig" \ + -o /dev/null + - name: Verify published macOS CLI assets shell: bash env: diff --git a/scripts/openbitfun-release-sync.sh b/scripts/openbitfun-release-sync.sh index 8c66881d8d..94aa03e797 100755 --- a/scripts/openbitfun-release-sync.sh +++ b/scripts/openbitfun-release-sync.sh @@ -4,9 +4,8 @@ # # Flow: # 1. Fetch latest.json from GitHub (follows /releases/latest/download/ redirect) -# 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) +# 2. Mirror the signed Relay image descriptor and Linux binary manifest FIRST +# (small trust metadata 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 @@ -41,13 +40,14 @@ set -euo pipefail # ── Configuration ────────────────────────────────────────────── GITHUB_LATEST_JSON_URL="https://github.com/GCWing/BitFun/releases/latest/download/latest.json" GITHUB_LINUX_BINARIES_URL="https://github.com/GCWing/BitFun/releases/latest/download/linux-binaries.json" +GITHUB_RELAY_IMAGE_URL="https://github.com/GCWing/BitFun/releases/latest/download/relay-image.json" 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 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. +# into the running Desktop binary, so retaining too few removes its descriptor +# fallback when GitHub metadata is temporarily unreachable. KEEP_VERSIONS=6 CONNECT_TIMEOUT=30 MAX_TIME=1800 # per-request ceiling (30 min; installer packages can be large) @@ -247,6 +247,61 @@ PY fi } +# Mirror the signed, digest-pinned container descriptor before large Desktop +# assets. The mirror is not trusted: Desktop verifies relay-image.json.sig with +# its compiled-in minisign key before sending the digest to a customer server. +mirror_relay_image_descriptor() { + local descriptor_tmp signature_tmp status descriptor_version + descriptor_tmp="${VERSION_DIR}/relay-image.json.part" + signature_tmp="${VERSION_DIR}/relay-image.json.sig.part" + rm -f "$descriptor_tmp" "$signature_tmp" + + status="$(curl -sSL \ + --connect-timeout "$CONNECT_TIMEOUT" --max-time "$MAX_TIME" \ + -o "$descriptor_tmp" -w '%{http_code}' "$GITHUB_RELAY_IMAGE_URL" || echo 000)" + if [ "$status" = "404" ]; then + log "Relay image descriptor is not present in the latest release yet." + rm -f "$descriptor_tmp" + return 0 + fi + if [ "$status" != "200" ]; then + log "WARN: relay-image.json unreachable (HTTP $status); keeping any existing versioned copy." + rm -f "$descriptor_tmp" + return 0 + fi + if ! curl -fsSL --retry "$MAX_RETRIES" --retry-delay "$RETRY_DELAY" \ + --connect-timeout "$CONNECT_TIMEOUT" --max-time "$MAX_TIME" \ + -o "$signature_tmp" "${GITHUB_RELAY_IMAGE_URL}.sig"; then + log "WARN: relay-image.json.sig unreachable; refusing to publish an unsigned descriptor." + rm -f "$descriptor_tmp" "$signature_tmp" + return 0 + fi + + descriptor_version="$("$PYTHON" - "$descriptor_tmp" <<'PY' +import json, re, sys +with open(sys.argv[1], encoding="utf-8") as f: + data = json.load(f) +assert data.get("schema_version") == 1 +assert data.get("image") == "ghcr.io/gcwing/bitfun-relay-server" +assert re.fullmatch(r"sha256:[0-9a-f]{64}", data.get("digest", "")) +print(data["version"]) +PY +)" || { + log "ERROR: relay-image.json failed its schema/repository/digest checks" + rm -f "$descriptor_tmp" "$signature_tmp" + return 1 + } + if [ "$descriptor_version" != "$VERSION" ]; then + log "ERROR: Relay image descriptor version $descriptor_version does not match Desktop version $VERSION" + rm -f "$descriptor_tmp" "$signature_tmp" + return 1 + fi + + mv "$descriptor_tmp" "${VERSION_DIR}/relay-image.json" + mv "$signature_tmp" "${VERSION_DIR}/relay-image.json.sig" + log "Published signed Relay image descriptor for $VERSION" +} + # ── Main ─────────────────────────────────────────────────────── main() { mkdir -p "$(dirname "$LOCK_FILE")" @@ -282,7 +337,8 @@ main() { VERSION_DIR="${WEBSITE_RELEASE_DIR}/${VERSION}" mkdir -p "$VERSION_DIR" - # 4. Mirror the small Linux CLI/Relay archives first (see function comment). + # 4. Mirror small trust metadata and Linux archives first. + mirror_relay_image_descriptor mirror_linux_binaries # 5. Download all platform installer packages diff --git a/scripts/relay/package-contract.test.mjs b/scripts/relay/package-contract.test.mjs index c2493a2f3d..6ec25e5eab 100644 --- a/scripts/relay/package-contract.test.mjs +++ b/scripts/relay/package-contract.test.mjs @@ -40,6 +40,38 @@ test('formal and nightly releases gate publication on Linux binaries', () => { assert.match(reusable, /scripts\/cli\/package-unix\.sh/); }); +test('formal and nightly releases publish signed anonymous multi-platform Relay images', () => { + const formal = read('.github/workflows/desktop-package.yml'); + const nightly = read('.github/workflows/nightly.yml'); + const dockerfile = read('src/apps/relay-server/Dockerfile.release'); + const smoke = read('scripts/relay/smoke-image.sh'); + + for (const workflow of [formal, nightly]) { + assert.match(workflow, /packages:\s*write/); + assert.match(workflow, /docker\/build-push-action@v7/); + assert.match(workflow, /platforms:\s*linux\/amd64,linux\/arm64/); + assert.match(workflow, /ghcr\.io\/gcwing\/bitfun-relay-server/); + assert.match(workflow, /relay-image\.json/); + assert.match(workflow, /scripts\/sign-release-assets\.sh relay-image\.json/); + assert.match(workflow, /scripts\/relay\/smoke-image\.sh/); + assert.match(workflow, /Verify anonymous .*image access|Verify anonymous pull access/); + assert.match(workflow, /DOCKER_CONFIG="\$clean_config" docker buildx imagetools inspect/); + } + assert.match(formal, /latest_release=.*releases\/latest/); + assert.doesNotMatch(nightly, /bitfun-relay-server:latest/); + + assert.match(smoke, /for arch in amd64 arm64/); + assert.match(smoke, /\.State\.Health/); + assert.match(smoke, /docker image rm "\$IMAGE_REF"/); + assert.match(smoke, /docker logs --tail/); + + assert.match(dockerfile, /org\.opencontainers\.image\.source="https:\/\/github\.com\/GCWing\/BitFun"/); + assert.match(dockerfile, /TARGETARCH/); + assert.match(dockerfile, /bitfun-relay-server/); + assert.match(dockerfile, /relay-admin/); + assert.match(dockerfile, /debian:trixie-slim/); +}); + test('exactly one workflow publishes the Linux CLI archives', () => { // Both cli-package.yml and desktop-package.yml run on `release: published`. // If both built Linux they would upload identical asset names concurrently, diff --git a/scripts/relay/release-download-harness.sh b/scripts/relay/release-download-harness.sh deleted file mode 100755 index a46de00ec6..0000000000 --- a/scripts/relay/release-download-harness.sh +++ /dev/null @@ -1,228 +0,0 @@ -#!/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 -} - -# Read the tag out of the script under test rather than hardcoding one: Desktop -# pins BITFUN_RELEASE_TAG to its own crate version, so a literal here silently -# rots at every release bump and every `EXPECT_SOURCE="$GITHUB_URL"` case fails. -RELEASE_TAG="$(sed -n 's/^export BITFUN_RELEASE_TAG="\(.*\)"$/\1/p' "$SCRIPT_UNDER_TEST" | head -n1)" -RELEASE_TAG="${RELEASE_TAG:-latest}" -if [ "$RELEASE_TAG" = "latest" ]; then - GITHUB_URL="https://github.com/GCWing/BitFun/releases/latest/download/${RELAY_ASSET}" -else - GITHUB_URL="https://github.com/GCWing/BitFun/releases/download/${RELEASE_TAG}/${RELAY_ASSET}" -fi - -# 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/relay/smoke-image.sh b/scripts/relay/smoke-image.sh new file mode 100755 index 0000000000..c2b6b7706d --- /dev/null +++ b/scripts/relay/smoke-image.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +# Start every published Relay platform under the runner's native/QEMU runtime +# and require the image's own HEALTHCHECK to pass before release metadata is +# signed. Usage: smoke-image.sh @sha256: + +set -euo pipefail + +IMAGE_REF="${1:?usage: smoke-image.sh @sha256:}" +if [[ ! "$IMAGE_REF" =~ @sha256:[0-9a-f]{64}$ ]]; then + echo "ERROR: Relay smoke test requires a digest-pinned image." >&2 + exit 2 +fi + +containers=() +cleanup() { + local container + for container in "${containers[@]}"; do + docker rm -fv "$container" >/dev/null 2>&1 || true + done + docker image rm "$IMAGE_REF" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +for arch in amd64 arm64; do + container="bitfun-relay-smoke-${arch}" + containers+=("$container") + echo ">>> Smoke-testing ${IMAGE_REF} on linux/${arch}..." + docker run -d \ + --name "$container" \ + --platform "linux/${arch}" \ + -e RELAY_PORT=9700 \ + -e RELAY_STATIC_DIR=/app/static \ + -e RELAY_ROOM_WEB_DIR=/app/room-web \ + -e RELAY_DB_PATH=/app/data/bitfun_relay.db \ + "$IMAGE_REF" >/dev/null + + healthy=0 + for _attempt in $(seq 1 45); do + status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container")" + if [ "$status" = "healthy" ]; then + healthy=1 + break + fi + if [ "$status" = "exited" ] || [ "$status" = "dead" ]; then + break + fi + sleep 2 + done + + if [ "$healthy" != "1" ]; then + echo "ERROR: linux/${arch} Relay image did not become healthy." >&2 + docker inspect -f 'status={{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} error={{.State.Error}}' "$container" >&2 || true + docker logs --tail 80 "$container" >&2 || true + exit 1 + fi + docker rm -fv "$container" >/dev/null + # Docker's classic image store cannot resolve one multi-platform digest to + # amd64 and then overwrite that local resolution with arm64. Drop the first + # platform before pulling the next so both runs are independent. + docker image rm "$IMAGE_REF" >/dev/null 2>&1 || true + echo ">>> linux/${arch} Relay image is healthy." +done + +trap - EXIT INT TERM diff --git a/src/apps/relay-server/Dockerfile.release b/src/apps/relay-server/Dockerfile.release new file mode 100644 index 0000000000..e6de7ab21b --- /dev/null +++ b/src/apps/relay-server/Dockerfile.release @@ -0,0 +1,64 @@ +# syntax=docker/dockerfile:1 + +# Build one multi-platform runtime image from the two signed Relay archives +# produced by linux-binaries.yml. The archives are built on Ubuntu 22.04 and +# carry an asserted glibc <= 2.35 floor; trixie is intentionally kept as the +# runtime base because older Relay releases in the wild required glibc 2.38. +FROM debian:trixie-slim AS payload + +ARG TARGETARCH +WORKDIR /payload +COPY bitfun-relay-server-*.tar.gz /tmp/relay/ + +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) target=x86_64-unknown-linux-gnu ;; \ + arm64) target=aarch64-unknown-linux-gnu ;; \ + *) echo "Unsupported Relay image architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + archive="/tmp/relay/bitfun-relay-server-${target}.tar.gz"; \ + test -f "${archive}"; \ + tar -xzf "${archive}" --strip-components=1; \ + test -x bitfun-relay-server; \ + test -x relay-admin; \ + test -f static/index.html + +FROM debian:trixie-slim + +ARG VERSION +ARG REVISION +LABEL org.opencontainers.image.title="BitFun Relay Server" \ + org.opencontainers.image.description="Self-hosted relay server for BitFun" \ + org.opencontainers.image.source="https://github.com/GCWing/BitFun" \ + org.opencontainers.image.url="https://github.com/GCWing/BitFun" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${REVISION}" \ + org.opencontainers.image.licenses="MIT" + +ENV DEBIAN_FRONTEND=noninteractive +RUN set -eux; \ + apt-get -o Acquire::Retries=3 update; \ + apt-get install -y --no-install-recommends ca-certificates curl; \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY --from=payload /payload/bitfun-relay-server /payload/relay-admin /app/ +COPY --from=payload /payload/static /app/static + +RUN set -eu; \ + chmod 755 /app/bitfun-relay-server /app/relay-admin; \ + mkdir -p /app/data /app/room-web; \ + for bin in /app/bitfun-relay-server /app/relay-admin; do \ + out="$(ldd "$bin" 2>&1)"; \ + printf '%s\n' "$out"; \ + case "$out" in \ + *"not found"*) echo "ERROR: $bin cannot be loaded by this runtime image." >&2; exit 1 ;; \ + esac; \ + done + +VOLUME ["/app/data", "/app/room-web"] +EXPOSE 9700 +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"] diff --git a/src/apps/relay-server/README.md b/src/apps/relay-server/README.md index 4ad46c7a1c..2a62afae9b 100644 --- a/src/apps/relay-server/README.md +++ b/src/apps/relay-server/README.md @@ -25,25 +25,20 @@ One-click Docker deploy (`bash deploy.sh`) targets: | Linux | **amd64** (`x86_64`) | | Linux | **arm64** (`aarch64`) | -Requirements: Docker Engine + Compose V2 (`docker compose`) **or** legacy -`docker-compose`, plus permission to talk to the Docker daemon. - -Build natively on the server (do **not** set `DOCKER_DEFAULT_PLATFORM` to a -foreign arch unless you intentionally cross-build with qemu). On small -memory VPS (common on arm64), use: - -```bash -RELAY_CARGO_BUILD_JOBS=1 bash deploy.sh -``` +The default path requires Docker Engine plus permission to talk to its daemon. +BitFun Desktop installs Docker automatically when the SSH user has root/sudo. +Docker Compose, Cargo, git, tar, and build toolchains are not required on the +customer server. Compose is used only by the explicit +`deploy.sh --build-from-source` maintenance path. ### Mainland China hosts -`deploy.sh` (and Desktop one-click deploy) auto-detects mainland China and -configures host mirrors for apt, Docker Hub, and GitHub source retrieval plus -a build-local Cargo/crates.io mirror. Docker Engine installation also uses a -mainland mirror. The Desktop wizard also offers **Auto / Mainland China / -Global** so an operator can override cloud IP geolocation without editing the -server environment. Override manual deploys when needed: +`deploy.sh` (and Desktop one-click deploy) auto-detects mainland China. Image +pulls try the verified Nanjing University GHCR accelerator, then DaoCloud, then +official GHCR, always using the same image digest. Global +mode goes directly to official GHCR. Docker Engine installation also uses a +mainland route. The Desktop wizard offers **Auto / Mainland China / Global** so +an operator can override inaccurate cloud-IP geolocation: ```bash BITFUN_MIRROR=cn bash deploy.sh # force China mirrors @@ -52,24 +47,11 @@ bash deploy.sh --cn-mirror bash deploy.sh --global-mirror ``` -Defaults (overridable via env): Aliyun apt, Docker registry mirrors -(`docker.1ms.run` / `dockerproxy.net` / `docker.m.daocloud.io`), -rsproxy Cargo sparse index, `ghfast.top` GitHub prefix, Aliyun docker-ce -for Engine install (fallback: jsDelivr docker-install). See `mirror.sh` -for the full list (`BITFUN_APT_MIRROR`, `BITFUN_DOCKER_REGISTRY_MIRRORS`, -`BITFUN_CARGO_SPARSE_URL`, `BITFUN_GITHUB_PROXY`, …). - -China mode does not modify the SSH user's global `~/.cargo/config.toml`; Cargo -mirroring is scoped to the relay image build. Switching to `global` restores -apt files disabled by BitFun and removes only Docker registry mirrors recorded -as BitFun additions. The published-binary runtime image receives the same -choice as build args: Docker daemon mirrors cover the base-image pull, while -the apt mirror separately covers packages installed inside the image. - -`deploy.sh` enables Docker BuildKit so the Dockerfile can reuse Cargo -registry/git/`target` cache mounts across redeploys. Keep BuildKit enabled -(`DOCKER_BUILDKIT=1`, the deploy default) and avoid `docker builder prune` -unless you intentionally want a cold rebuild. +Engine installation defaults to Aliyun docker-ce and mirrored get.docker.com. +The daemon's Docker Hub mirrors remain useful for explicit source builds, but +they do not accelerate GHCR; `release-download.sh` therefore uses GHCR-specific +repository prefixes. Switching to global restores only BitFun-managed host +mirror entries. See `mirror.sh` for the installer/source-build knobs. ## Two operating modes @@ -98,31 +80,34 @@ Use this checklist on a machine you control (VPS, LAN server, or localhost). ### Desktop one-click deploy (preferred for end users) -BitFun Desktop can SSH to your host and run the same Docker path without a -manual clone. It first downloads the matching checksum-verified GitHub Release -archive for Linux amd64/arm64, falls back to the versioned openbitfun.com mirror, -and builds only a small runtime image around the published binaries. If both -binary sources, checksum verification, image creation, startup, or health -validation fail, it restores the previous healthy container and automatically -falls back to the source Docker build. Entry points: Account Login → -“一键部署到自己的服务器”, or +BitFun Desktop can SSH to your host without a manual clone. One click installs +Docker when necessary, verifies the signed release image descriptor locally, +pulls the matching amd64/arm64 image through the selected regional route, and +starts it by immutable digest. It never builds on the customer server and never +silently falls back to source compilation. Pull completes before an existing +Relay is stopped; startup or health failure restores the previous container. +Entry points: Account Login → “一键部署到自己的服务器”, or Remote Connect → Network Relay → Self-Hosted → the same action. - Orchestration: `src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs` - Wizard + invariants: `src/web-ui/src/features/relay-deploy/README.md` -Release runtime state lives under `~/.bitfun/relay-release`; fallback source -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`. +Task state lives under `~/.bitfun/relay-deploy`; no repository checkout is +created. Closing the wizard cancels the remote task and restores a staged +previous container. 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. +Every release publishes `relay-image.json` plus `relay-image.json.sig` +(minisign, in the same base64-wrapped format as the Desktop updater). The +descriptor fixes the canonical GHCR repository, release tag, amd64/arm64 +platform set, and multi-platform manifest digest. Desktop verifies it on the +user's machine and sends the digest to the server; Docker then verifies every +manifest and layer while pulling, even through a third-party accelerator. + +The raw Relay archives still carry `.sha256` and `.sig` files for direct binary +use and for constructing the release image in CI. Verifying an archive by hand: @@ -149,7 +134,9 @@ bash deploy.sh ``` `deploy.sh` must run **on the target server** (it does not SSH elsewhere). -Requires Docker and Docker Compose on **linux/amd64** or **linux/arm64**. +Its default path requires Docker on **linux/amd64** or **linux/arm64** and pulls +`ghcr.io/gcwing/bitfun-relay-server:latest`; it does not compile locally. +Use `--build-from-source` only when deliberately exercising the source path. Clone on the server, as above, rather than uploading a Windows checkout. Git for Windows rewrites these scripts to CRLF by default, and bash then fails on the @@ -173,19 +160,19 @@ Verify: ```bash curl -fsS http://127.0.0.1:9700/health -docker compose ps +docker ps --filter name=bitfun-relay ``` ### 2. Confirm account database is on -Compose sets: +The published image deploy and the Compose source path both set: ```yaml RELAY_DB_PATH=/app/data/bitfun_relay.db ``` -Data lives in the `relay-db` Docker volume. If you run the binary without -Compose, export a persistent path first: +Data lives in the `relay-server_relay-db` Docker volume. If you run the binary +without Docker, export a persistent path first: ```bash export RELAY_DB_PATH=/var/lib/bitfun/bitfun_relay.db diff --git a/src/apps/relay-server/deploy.sh b/src/apps/relay-server/deploy.sh index 7e4db8a06c..c7a1de956f 100755 --- a/src/apps/relay-server/deploy.sh +++ b/src/apps/relay-server/deploy.sh @@ -1,13 +1,14 @@ #!/usr/bin/env bash # BitFun Relay Server — one-click deploy script. -# Usage: bash deploy.sh [--skip-build] [--skip-health-check] [--cn-mirror|--global-mirror] +# Usage: bash deploy.sh [--build-from-source] [--cn-mirror|--global-mirror] # # Run this script on the target server itself after SSH login. # It deploys to the current machine only; it does not SSH to a remote host. # # Supported hosts: Linux amd64 (x86_64) and arm64 (aarch64) with Docker. # -# Prerequisites: Docker + Compose V2 (`docker compose`) or legacy docker-compose +# Prerequisite: Docker. The default path pulls a published multi-platform image; +# Compose is needed only for the explicit --build-from-source escape hatch. # # Low-memory VPS tip (especially arm64): # RELAY_CARGO_BUILD_JOBS=1 bash deploy.sh @@ -45,9 +46,9 @@ Supported architectures: linux/amd64 (x86_64), linux/arm64 (aarch64) 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 + --skip-build Source mode only: skip compose build, recreate/start services + --build-from-source Explicitly compile from source instead of pulling the image + --skip-health-check Source mode only: skip post-deploy health check --cn-mirror Force China mirrors (apt/Docker/cargo/GitHub) --global-mirror Force global upstream mirrors -h, --help Show this help message @@ -95,12 +96,35 @@ assert_supported_arch # Validate the host first so unsupported machines are not modified. bitfun_mirror_init "${MIRROR_ARGS[@]+"${MIRROR_ARGS[@]}"}" require_docker_daemon -resolve_compose warn_if_forced_foreign_platform -echo "Compose: ${COMPOSE[*]}" cd "$SCRIPT_DIR" +# Default path: exactly one image pull followed by container start. It preserves +# the old container until the new one passes health checks. Source compilation +# is an explicit maintenance escape hatch, never a silent fallback. +if [ "$BUILD_FROM_SOURCE" = true ]; then + echo "[1/2] Skipping the published image (--build-from-source)" +elif [ "$SKIP_BUILD" = true ]; then + echo "[1/2] Skipping the published image (--skip-build)" +elif bitfun_try_release_deploy; then + RELAY_PORT="${RELAY_PORT:-9700}" + echo "" + echo "=== Deploy complete (published image) ===" + echo "Relay server running on port ${RELAY_PORT} (host arch: ${HOST_ARCH})" + echo "" + check_relay_accounts_or_remind + exit 0 +else + echo "ERROR: Published Relay image deployment failed." >&2 + echo " Fix the registry route and retry; use --build-from-source only for manual recovery." >&2 + exit 1 +fi + +# Everything below belongs to the explicit source/skip-build maintenance path. +resolve_compose +echo "Compose: ${COMPOSE[*]}" + # Persist compose build-args for CN builds (and subsequent restarts). touch .env chmod 600 .env 2>/dev/null || true @@ -116,25 +140,6 @@ 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 index a726111bd2..f5efcecca9 100644 --- a/src/apps/relay-server/release-download.sh +++ b/src/apps/relay-server/release-download.sh @@ -1,525 +1,174 @@ #!/usr/bin/env bash -# BitFun Relay Server — published-binary download and runtime deploy. +# BitFun Relay Server — pull and start the published multi-platform image. # -# 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! +# Shared by: +# - src/apps/relay-server/deploy.sh +# - remote_ssh/relay_deploy.rs (embedded 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. +# The Desktop path supplies BITFUN_RELAY_IMAGE_DIGEST from a minisign-verified +# release descriptor. Docker then verifies every manifest/layer against that +# immutable digest while pulling through either the official registry or a +# China acceleration prefix. The old download-archive + local image-construction +# path is intentionally gone. # -# 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 -# BITFUN_USE_CN_MIRROR 1 switches apt inside the runtime image -# BITFUN_APT_MIRROR Debian mirror host (default mirrors.aliyun.com) -# BITFUN_RUNTIME_BASE runtime base image (default debian:trixie-slim) -# 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_shell_join >/dev/null 2>&1; then - # `sg -c` re-parses a single string, so an unquoted "$*" loses argument - # boundaries. Single-quote each argument (POSIX-safe for any /bin/sh). - bitfun_shell_join() { - local out="" arg - for arg in "$@"; do - out="$out'$(printf '%s' "$arg" | sed "s/'/'\\\\''/g")' " - done - printf '%s' "$out" - } -fi - -if ! declare -F bitfun_docker >/dev/null 2>&1; then - bitfun_docker() { - case "${BITFUN_DOCKER_MODE:-direct}" in - sg) sg docker -c "$(bitfun_shell_join 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" +# Configuration: +# BITFUN_RELAY_IMAGE canonical image repository +# BITFUN_RELAY_IMAGE_DIGEST sha256:<64 lowercase hex> (required by Desktop) +# BITFUN_RELAY_IMAGE_TAG manual-script fallback tag (default release tag) +# BITFUN_IMAGE_PULL_TIMEOUT per-route pull timeout in seconds (default 900) +# BITFUN_MIRROR_MODE cn | global +# RELAY_PORT published/container port (default 9700) +# RELAY_HOST_BIND_IP host bind address (default 0.0.0.0) + +BITFUN_RELAY_IMAGE="${BITFUN_RELAY_IMAGE:-ghcr.io/gcwing/bitfun-relay-server}" +BITFUN_RELAY_IMAGE_TAG="${BITFUN_RELAY_IMAGE_TAG:-${BITFUN_RELEASE_TAG:-latest}}" +BITFUN_IMAGE_PULL_TIMEOUT="${BITFUN_IMAGE_PULL_TIMEOUT:-900}" + +# The embedded Desktop helpers call Docker through bitfun_docker; the manual +# script exposes docker_cmd. Keep this file independent of either caller. +bitfun_image_docker() { + if declare -F bitfun_docker >/dev/null 2>&1; then + bitfun_docker "$@" + elif declare -F docker_cmd >/dev/null 2>&1; then + docker_cmd "$@" else - printf '%s/download/%s/%s\n' "$BITFUN_GITHUB_RELEASE_BASE" "$tag" "$asset" + docker "$@" 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 +# POSIX-quote arguments passed through `sg docker -c`. +bitfun_image_shell_join() { + local out="" arg + for arg in "$@"; do + out="$out'$(printf '%s' "$arg" | sed "s/'/'\\\\''/g")' " + done + printf '%s' "$out" +} + +# Bound each registry route. A dead accelerator must not prevent falling back +# to the next route forever. Hosts without GNU timeout still retain Docker's +# own network timeouts and progress reporting. +bitfun_image_docker_with_timeout() { + local seconds="$1" + shift + if ! command -v timeout >/dev/null 2>&1; then + bitfun_image_docker "$@" + return + fi + case "${BITFUN_DOCKER_MODE:-direct}" in + sg) + sg docker -c "$(bitfun_image_shell_join timeout "$seconds" docker "$@")" ;; - *"$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 + sudo) + if sudo -n true >/dev/null 2>&1; then + sudo -n timeout "$seconds" docker "$@" + else + sudo timeout "$seconds" docker "$@" + fi ;; + *) timeout "$seconds" docker "$@" ;; esac - printf '%s.sha256\n' "$url" } -# Build the runtime image around the published binary. -# -# Losing this build costs ~20 minutes: the caller falls back to compiling the -# relay from source. Two failure modes are recoverable and worth retrying rather -# than surrendering to that, both observed on real hosts: -# -# - DOCKER_CONFIG holds a root-owned config.json from an earlier elevated run. -# The CLI prints `WARNING: Error loading config file: ... permission denied` -# and then mis-dispatches the build (`unknown shorthand flag: 't' in -t`). -# - BuildKit is requested through inherited DOCKER_BUILDKIT=1 but buildx is -# missing or broken. This image is `FROM debian` + `COPY`, so it needs none -# of BuildKit's cache mounts and the classic builder does just as well. -# -# Each attempt runs in a subshell so its env override cannot leak into the -# source-build path that follows. -bitfun_build_runtime_image() { - local image="$1" context="$2" rc=1 - local use_cn_mirror="${BITFUN_USE_CN_MIRROR:-0}" - local apt_mirror="${BITFUN_APT_MIRROR:-mirrors.aliyun.com}" - local runtime_base="${BITFUN_RUNTIME_BASE:-debian:trixie-slim}" +bitfun_relay_native_platform() { + case "$(uname -m 2>/dev/null)" in + x86_64 | amd64) echo linux/amd64 ;; + aarch64 | arm64) echo linux/arm64 ;; + *) return 1 ;; + esac +} - # The Docker daemon's registry-mirrors accelerate the FROM pull. These build - # args are a separate, equally necessary hop: apt runs *inside* the temporary - # runtime image and cannot see the host's /etc/apt sources. Losing these args - # made correctly detected CN hosts still contact deb.debian.org here. - local build_args=( - --build-arg "BITFUN_USE_CN_MIRROR=${use_cn_mirror}" - --build-arg "BITFUN_APT_MIRROR=${apt_mirror}" - --build-arg "BITFUN_RUNTIME_BASE=${runtime_base}" - ) - if [ "$use_cn_mirror" = "1" ]; then - echo ">>> Runtime image network route: China (apt=${apt_mirror}; Docker registry mirrors are daemon-managed)" +bitfun_relay_image_ref() { + local repository="$1" + if [ -n "${BITFUN_RELAY_IMAGE_DIGEST:-}" ]; then + printf '%s@%s' "$repository" "$BITFUN_RELAY_IMAGE_DIGEST" else - echo ">>> Runtime image network route: global (official Debian apt and image registry)" + printf '%s:%s' "$repository" "$BITFUN_RELAY_IMAGE_TAG" fi - - # A config dir this user definitely owns. Empty if it cannot be created, in - # which case the retries keep the inherited DOCKER_CONFIG. - local clean_config="$context.docker-config" - rm -rf "$clean_config" - if ! mkdir -p "$clean_config" 2>/dev/null; then - clean_config="" - fi - - local attempt - for attempt in inherited clean-config classic-builder; do - case "$attempt" in - clean-config) - if [ -z "$clean_config" ]; then continue; fi - echo ">>> Retrying the runtime image build with a clean Docker config..." - ;; - classic-builder) - echo ">>> Retrying the runtime image build with the classic builder..." - ;; - esac - # Subshell: the env overrides must not leak into the source-build path. - if ( - case "$attempt" in - clean-config) export DOCKER_CONFIG="$clean_config" ;; - classic-builder) - if [ -n "$clean_config" ]; then export DOCKER_CONFIG="$clean_config"; fi - export DOCKER_BUILDKIT=0 - ;; - esac - bitfun_docker build "${build_args[@]}" -t "$image" "$context" - ); then - rc=0 - break - fi - done - - if [ -n "$clean_config" ]; then - rm -rf "$clean_config" - fi - return "$rc" } -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 - ;; +# Pull through the fastest likely route for the selected region. The digest is +# identical across registry proxies, so changing transport does not change the +# image Desktop authenticated. +bitfun_pull_relay_image() { + local platform="$1" routes route_name repository image_ref selected="" + local pull_timeout="${BITFUN_IMAGE_PULL_TIMEOUT:-900}" + case "$pull_timeout" in + '' | *[!0-9]*) pull_timeout=900 ;; 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." + routes="$(mktemp)" + if [ "${BITFUN_MIRROR_MODE:-global}" = "cn" ]; then + printf '%s\t%s\n' \ + "NJU GHCR accelerator" "ghcr.nju.edu.cn/${BITFUN_RELAY_IMAGE#ghcr.io/}" \ + "DaoCloud GHCR accelerator" "m.daocloud.io/${BITFUN_RELAY_IMAGE}" \ + "official GHCR fallback" "$BITFUN_RELAY_IMAGE" >"$routes" else - echo ">>> No signature-verified checksum available; falling back to the canonical GitHub checksum." + printf '%s\t%s\n' "official GHCR" "$BITFUN_RELAY_IMAGE" >"$routes" 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 + while IFS=$'\t' read -r route_name repository; do + [ -n "$repository" ] || continue + image_ref="$(bitfun_relay_image_ref "$repository")" + echo ">>> Pulling Relay image via ${route_name}: ${image_ref}" >&2 + if bitfun_image_docker_with_timeout "$pull_timeout" pull --platform "$platform" "$image_ref" >&2; then + selected="$image_ref" 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 + echo ">>> ${route_name} failed or timed out; trying the next route." >&2 + done <"$routes" + rm -f "$routes" - 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" + if [ -z "$selected" ]; then + echo ">>> ERROR: Relay image pull failed on every ${BITFUN_MIRROR_MODE:-global} route." >&2 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" - # Base image glibc must be >= what the *archive being installed* was linked - # against — which is not the same as what CI builds today. - # - # arm64 releases up to and including v0.2.14 were built on ubuntu-24.04-arm and - # require GLIBC_2.38. On bookworm-slim (2.36) they could not load at all: the - # container exited instantly, the loader error went to stderr, and the deploy - # surfaced only as a failed health check followed by a 20-minute rebuild. - # - # The release matrix now pins both arches to ubuntu-22.04 (glibc 2.35, asserted - # by scripts/ci/check-glibc-floor.sh), but that does NOT make bookworm safe - # again: Desktop pins BITFUN_RELEASE_TAG to its own version, so a v0.2.14 - # client installs the v0.2.14 archive forever, and published archives keep the - # floor they were built with. This base must satisfy the highest floor across - # every release a client in the wild might still install. trixie-slim carries - # glibc 2.41 and covers both 2.38 and 2.35. - cat >"$context.new/Dockerfile" <<'DOCKERFILE' -ARG BITFUN_RUNTIME_BASE=debian:trixie-slim -FROM ${BITFUN_RUNTIME_BASE} -ARG BITFUN_USE_CN_MIRROR=0 -ARG BITFUN_APT_MIRROR=mirrors.aliyun.com -ENV DEBIAN_FRONTEND=noninteractive -RUN set -eux; \ - if [ "${BITFUN_USE_CN_MIRROR}" = "1" ]; then \ - sed -i \ - -e "s|deb.debian.org/debian|${BITFUN_APT_MIRROR}/debian|g" \ - -e "s|security.debian.org/debian-security|${BITFUN_APT_MIRROR}/debian-security|g" \ - /etc/apt/sources.list 2>/dev/null || true; \ - if [ -f /etc/apt/sources.list.d/debian.sources ]; then \ - sed -i \ - -e "s|deb.debian.org/debian|${BITFUN_APT_MIRROR}/debian|g" \ - -e "s|security.debian.org/debian-security|${BITFUN_APT_MIRROR}/debian-security|g" \ - /etc/apt/sources.list.d/debian.sources; \ - fi; \ - fi; \ - apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=20 -o Acquire::https::Timeout=20 update \ - && apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=20 -o Acquire::https::Timeout=20 \ - 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 -# Fail the build, loudly and in seconds, if either binary cannot be loaded here. -# `ldd` runs the real dynamic loader and prints the exact -# `version 'GLIBC_x.yz' not found` line — but it still exits 0, so its *output* -# is the gate, not its status. The relay binary itself is unusable as a probe: -# it has no --version flag and simply starts serving. Without this check a -# future runner bump reappears as an opaque failed health check plus a -# 20-minute source rebuild. -RUN set -eu; \ - for bin in /app/bitfun-relay-server /app/relay-admin; do \ - out="$(ldd "$bin" 2>&1)"; \ - printf '%s\n' "$out"; \ - case "$out" in \ - *"not found"*) \ - echo "ERROR: $bin cannot be loaded on this base image (see above)." >&2; \ - echo " The published binary needs a newer glibc than this base provides." >&2; \ - exit 1 ;; \ - esac; \ - done -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_build_runtime_image "$image" "$context"; then - echo ">>> Published binary image build failed; falling back to source build." + local expected_arch="${platform#linux/}" actual_arch + actual_arch="$(bitfun_image_docker image inspect -f '{{.Architecture}}' "$selected" 2>/dev/null || true)" + if [ "$actual_arch" != "$expected_arch" ]; then + echo ">>> ERROR: pulled image architecture '$actual_arch' does not match '$expected_arch'." >&2 return 1 fi + printf '%s' "$selected" +} - bitfun_docker volume create relay-server_relay-db >/dev/null - bitfun_docker volume create relay-server_room-web >/dev/null +bitfun_restore_previous_relay() { + bitfun_image_docker rm -f bitfun-relay >/dev/null 2>&1 || true + if [ -n "${BITFUN_RELAY_BACKUP_CONTAINER:-}" ]; then + bitfun_image_docker rename "$BITFUN_RELAY_BACKUP_CONTAINER" bitfun-relay >/dev/null 2>&1 || true + bitfun_image_docker start bitfun-relay >/dev/null 2>&1 || true + fi +} - 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 +bitfun_run_relay_image() { + local image_ref="$1" platform="$2" attempt stale + bitfun_image_docker volume create relay-server_relay-db >/dev/null + bitfun_image_docker volume create relay-server_room-web >/dev/null + + BITFUN_RELAY_BACKUP_CONTAINER="" + if bitfun_image_docker container inspect bitfun-relay >/dev/null 2>&1; then + BITFUN_RELAY_BACKUP_CONTAINER="bitfun-relay-before-image-$$" + bitfun_image_docker stop bitfun-relay >/dev/null 2>&1 || true + if ! bitfun_image_docker rename bitfun-relay "$BITFUN_RELAY_BACKUP_CONTAINER"; then + echo ">>> ERROR: could not stage the existing Relay container." >&2 + bitfun_image_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. + # A cancelled wizard must put the previously healthy Relay 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 \ + echo ">>> Starting Relay image on port ${RELAY_PORT:-9700}..." + if ! bitfun_image_docker run -d \ --name bitfun-relay \ + --platform "$platform" \ --restart unless-stopped \ --label com.docker.compose.project=relay-server \ --label com.docker.compose.service=relay-server \ + --label "com.bitfun.relay.image=${BITFUN_RELAY_IMAGE}" \ + --label "com.bitfun.relay.digest=${BITFUN_RELAY_IMAGE_DIGEST:-unlocked}" \ -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 \ @@ -527,55 +176,71 @@ DOCKERFILE -e RELAY_ROOM_TTL=300 \ -e RELAY_ASSET_STORE_MAX_BYTES=1073741824 \ -e RELAY_DB_PATH=/app/data/bitfun_relay.db \ + -e "RELAY_PAGE_PUBLIC_BASE_URL=${RELAY_PAGE_PUBLIC_BASE_URL:-}" \ + -e "RELAY_PAGE_AUTH_BASE_URL=${RELAY_PAGE_AUTH_BASE_URL:-}" \ -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." + "$image_ref" >/dev/null; then + echo ">>> ERROR: the published Relay image could not start; restoring the previous container." >&2 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 + # Probe inside the image. This avoids requiring curl on an otherwise ready + # Docker host and verifies the exact container that will be kept. + for attempt in $(seq 1 30); do + if bitfun_image_docker exec bitfun-relay \ + curl -fsS --max-time 3 "http://127.0.0.1:${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 + if [ -n "$BITFUN_RELAY_BACKUP_CONTAINER" ]; then + bitfun_image_docker rm "$BITFUN_RELAY_BACKUP_CONTAINER" >/dev/null 2>&1 || true fi - # Sweep backups orphaned by an earlier interrupted release deploy. - for stale in $(bitfun_docker ps -aq \ + for stale in $(bitfun_image_docker ps -aq \ + --filter 'name=^bitfun-relay-before-image-' \ --filter 'name=^bitfun-relay-before-release-' 2>/dev/null); do - bitfun_docker rm -f "$stale" >/dev/null 2>&1 || true + bitfun_image_docker rm -f "$stale" >/dev/null 2>&1 || true done - echo ">>> Published Relay binary is healthy." + echo ">>> Published Relay image is healthy." return 0 fi - if ! bitfun_docker inspect -f '{{.State.Running}}' bitfun-relay 2>/dev/null | - grep -qx true; then + if ! bitfun_image_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." - # `docker logs` relays the container's stderr on *its own* stderr, so the - # `2>/dev/null` that used to be here discarded exactly the output we need: - # the relay logs through tracing, i.e. to stderr. Keep both streams, and say - # whether the container died or was up but not answering — the two have - # completely different causes. - echo ">>> Container state: $(bitfun_docker inspect \ + echo ">>> ERROR: published Relay image failed its health check; restoring the previous container." >&2 + echo ">>> Container state: $(bitfun_image_docker inspect \ -f 'running={{.State.Running}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} err={{.State.Error}}' \ bitfun-relay 2>&1 || true)" - echo ">>> Probed http://${probe_host}:${RELAY_PORT:-9700}/health" echo ">>> Last 40 log lines from bitfun-relay:" - bitfun_docker logs --tail 40 bitfun-relay 2>&1 | sed 's/^/ /' || true + bitfun_image_docker logs --tail 40 bitfun-relay 2>&1 | sed 's/^/ /' || true bitfun_restore_previous_relay trap - INT TERM return 1 } + +bitfun_try_release_deploy() { + local platform image_ref + platform="$(bitfun_relay_native_platform)" || { + echo ">>> ERROR: no published Relay image for architecture $(uname -m)." >&2 + return 1 + } + + if [ -n "${BITFUN_RELAY_IMAGE_DIGEST:-}" ] && \ + [[ ! "$BITFUN_RELAY_IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo ">>> ERROR: invalid Relay image digest; refusing to pull executable code." >&2 + return 1 + fi + if [ -n "${BITFUN_REQUIRE_IMAGE_DIGEST:-}" ] && [ -z "${BITFUN_RELAY_IMAGE_DIGEST:-}" ]; then + echo ">>> ERROR: a signed Relay image descriptor is required for one-click deployment." >&2 + return 1 + fi + + # Ignore a user-level foreign-platform default. Relay one-click deployment is + # deliberately native on its two supported server architectures. + export DOCKER_DEFAULT_PLATFORM="$platform" + image_ref="$(bitfun_pull_relay_image "$platform")" || return 1 + bitfun_run_relay_image "$image_ref" "$platform" +} 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 926dc6da5a..e1f8903e8b 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 @@ -4,17 +4,15 @@ //! //! 1. `run_preflight` — probe OS/arch, Docker access mode, memory, port, existing installs. //! 2. `start_task` — stage an interactive driver script (run inside a remote PTY so sudo -//! passwords work) that prepares Docker access, then launches the long build via -//! `nohup` and `tail -f`s the log. +//! passwords work) that installs Docker when needed, then pulls and starts the signed +//! multi-platform image via `nohup` while `tail -f` streams its log. //! 3. `poll_task` — detect completion via marker/pid for wizard state transitions. -//! 4. `cancel_task` — stop a running task when the wizard closes (kill process tree + -//! best-effort compose teardown for in-progress deploys). +//! 4. `cancel_task` — stop a running task when the wizard closes (kill process tree; +//! the image script restores any staged previous container). //! 5. `import_account` — hand a locally-provisioned account to `relay-admin import-user`. //! -//! Remote deploy state lives under `~/.bitfun/relay-deploy/`. Published binaries -//! are staged under `~/.bitfun/relay-release/`; only the automatic fallback clones -//! source under `~/.bitfun/relay-src/` (never `$HOME/bitfun`, which may be the -//! user's own project). +//! Remote deploy state lives under `~/.bitfun/relay-deploy/`. One-click deploy +//! never clones the repository or compiles on the customer server. //! //! Product / regression invariants (wizard + entry points): //! `src/web-ui/src/features/relay-deploy/README.md`. Do not change clone destination, @@ -27,9 +25,9 @@ use serde::{Deserialize, Serialize}; use std::time::Duration; use super::manager::SSHConnectionManager; -use super::release_verify::{release_pubkey, release_tag_for_version, verify_signed_checksum}; #[cfg(test)] -use super::release_verify::{verify_minisign, RELEASE_PUBKEY}; +use super::release_verify::RELEASE_PUBKEY; +use super::release_verify::{release_pubkey, release_tag_for_version, verify_minisign}; use super::remote_git::shell_quote_posix; /// Default public relay port, matching `src/apps/relay-server/docker-compose.yml`. @@ -47,38 +45,19 @@ pub fn normalize_relay_port(port: u16) -> Result { const RELAY_CONTAINER_NAME: &str = "bitfun-relay"; /// Account DB path inside the relay container (RELAY_DB_PATH in docker-compose.yml). const RELAY_CONTAINER_DB: &str = "/app/data/bitfun_relay.db"; -/// Canonical git remote for incremental source updates on the target server. +/// Canonical repository URLs supplied to the shared regional-routing helper. const REPO_GIT_URL: &str = "https://github.com/GCWing/BitFun.git"; -/// Branch tracked by one-click deploy. -const REPO_GIT_BRANCH: &str = "main"; /// Tarball fallback when git is unavailable or clone/fetch fails. 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_BASE: &str = "https://github.com/GCWing/BitFun/releases"; const OPENBITFUN_RELEASE_BASE: &str = "https://openbitfun.com/release"; +const RELAY_IMAGE_REPOSITORY: &str = "ghcr.io/gcwing/bitfun-relay-server"; +const RELAY_IMAGE_DESCRIPTOR_ASSET: &str = "relay-image.json"; 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; -/// 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. +/// Embedded so Desktop orchestration can select Docker-install and image routes. const RELAY_MIRROR_SH: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../../apps/relay-server/mirror.sh" @@ -91,9 +70,6 @@ const RELAY_RELEASE_DOWNLOAD_SH: &str = include_str!(concat!( )); /// 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/` -/// so deploy never deletes or overwrites a user directory named `bitfun`/`BitFun`. -const SOURCE_DIR: &str = ".bitfun/relay-src"; /// Line printed by task scripts on success; polled to detect completion. const TASK_DONE_MARKER: &str = "RELAY_TASK_DONE"; /// How long the seeded `preparing` flag may sit with no live driver process @@ -109,6 +85,17 @@ pub enum RelayDeployTask { Deploy, } +/// Signed release metadata for the immutable multi-platform Relay image. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RelayImageDescriptor { + schema_version: u8, + image: String, + tag: String, + version: String, + digest: String, + platforms: Vec, +} + impl RelayDeployTask { fn stem(self) -> &'static str { match self { @@ -118,7 +105,7 @@ impl RelayDeployTask { } } -/// Network route used by relay deployment downloads and image builds. +/// Network route used by Docker installation and Relay image pulls. /// /// `Auto` keeps server-side detection as the default. The explicit variants /// are a user-facing escape hatch for cloud IPs whose geolocation or outbound @@ -182,7 +169,7 @@ 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). + /// Free space under `$HOME` in MB (task scripts and logs). pub home_free_mb: u64, /// Free space on Docker's data root in MB (images and layers). pub docker_free_mb: u64, @@ -268,8 +255,8 @@ 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. +# Free space where the work actually lands: ~/.bitfun holds task state and +# Docker's data root holds the pulled image and writable 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) @@ -443,8 +430,7 @@ fn classify_docker_access( /// Stage an interactive driver script for the task. Does **not** launch it — /// the wizard runs the script inside a remote PTY so sudo can prompt. /// -/// `port` is used for deploy (written to `relay.port` + compose `.env`); ignored -/// for Docker install. +/// `port` is used for deploy (written to `relay.port`); ignored for Docker install. pub async fn start_task( manager: &SSHConnectionManager, connection_id: &str, @@ -474,11 +460,12 @@ pub async fn start_task( let body = match task { RelayDeployTask::InstallDocker => install_docker_body_script(), 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)) + // Authenticate the registry digest here, where the compiled-in + // release trust root exists. The remote host then only needs + // Docker's normal content-addressed pull verification. + let descriptor = + verified_relay_image_descriptor(&release_tag_for_version(RELEASE_VERSION)).await?; + deploy_body_script_with_image(port, &descriptor) } }; let driver = match task { @@ -630,7 +617,7 @@ size=0 if [ -f "$LOG" ]; then log_exists=1; size=$(wc -c < "$LOG" | tr -d ' '); fi marker=0 if [ -f "$LOG" ] && grep -q {TASK_DONE_MARKER} "$LOG"; then marker=1; fi -# Build may still be progressing via docker/buildkit even if the wrapper pid +# A pull or health check may still be progressing even if the wrapper pid # briefly looks gone; treat a growing log without a marker as running. echo "running=$running" echo "preparing=$preparing" @@ -690,50 +677,15 @@ if [ -f "$LOG" ]; then tail -c +{from} "$LOG"; fi /// Cancel a running install/deploy task (wizard close / back / retry). /// -/// Kills the nohup body process tree, clears pid/preparing flags, appends a -/// cancel marker to the log, and for deploy best-effort stops an in-progress -/// compose build. Safe to call when nothing is running. +/// Kills the nohup body process tree, clears pid/preparing flags, and appends a +/// cancel marker to the log. The image deploy's TERM trap restores any previous +/// container. Safe to call when nothing is running. pub async fn cancel_task( manager: &SSHConnectionManager, connection_id: &str, task: RelayDeployTask, ) -> Result<()> { let stem = task.stem(); - // Only tear down compose when we interrupt an in-progress deploy — never when - // cancel is a no-op cleanup before start_task (would stop a healthy relay). - let compose_teardown = if matches!(task, RelayDeployTask::Deploy) { - format!( - r#" -if [ "$was_active" = "1" ]; then - SRC="$HOME/{SOURCE_DIR}/src/apps/relay-server" - stop_compose() {{ - if [ ! -d "$SRC" ]; then return 0; fi - ( - cd "$SRC" || exit 0 - "$@" compose kill >/dev/null 2>&1 || true - for id in $("$@" ps -aq --filter "label=com.docker.compose.project=relay-server" 2>/dev/null); do - # Skip the already-running production container name only when we are - # not mid-redeploy; during cancel of an active build, tear builders down. - "$@" kill -s KILL "$id" >/dev/null 2>&1 || true - done - # BuildKit workers often outlive the compose CLI — stop the default builder. - "$@" buildx stop >/dev/null 2>&1 || true - "$@" builder stop >/dev/null 2>&1 || true - ) || true - }} - if command -v docker >/dev/null 2>&1; then - stop_compose docker - fi - if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then - stop_compose sudo -n docker - fi -fi -"#, - SOURCE_DIR = SOURCE_DIR, - ) - } else { - String::new() - }; let script = format!( r#" set +e @@ -777,7 +729,6 @@ if [ -n "$BODY" ] && pgrep -f "$BODY" >/dev/null 2>&1; then sleep 1 pkill -KILL -f "$BODY" 2>/dev/null || true fi -{compose_teardown} if [ "$was_active" = "1" ]; then echo "" >>"$LOG" 2>/dev/null echo ">>> Cancelled by client (wizard closed)" >>"$LOG" 2>/dev/null @@ -786,7 +737,6 @@ exit 0 "#, DEPLOY_STATE_DIR = DEPLOY_STATE_DIR, stem = stem, - compose_teardown = compose_teardown, ); let (_stdout, stderr, code) = exec_script(manager, connection_id, &script).await?; if code != 0 { @@ -797,7 +747,7 @@ exit 0 /// Decide poll status from remote probe fields. /// -/// Pending (PTY not started yet) and active prepare/build must not look like +/// Pending (PTY not started yet) and active prepare/pull must not look like /// failure — the wizard polls immediately after staging scripts. #[allow(clippy::too_many_arguments)] fn decide_task_status( @@ -1050,9 +1000,11 @@ bitfun_elevate_install_driver() { bitfun_ensure_tools() { local pkgs=() - command -v git >/dev/null 2>&1 || pkgs+=(git) - command -v curl >/dev/null 2>&1 || pkgs+=(curl) - command -v tar >/dev/null 2>&1 || pkgs+=(tar) + if [ "$#" -eq 0 ]; then set -- git curl tar; fi + local tool + for tool in "$@"; do + command -v "$tool" >/dev/null 2>&1 || pkgs+=("$tool") + done if [ "${#pkgs[@]}" -eq 0 ]; then return 0; fi echo ">>> Installing missing tools (${pkgs[*]})..." if [ "$(id -u)" = "0" ]; then @@ -1068,6 +1020,68 @@ bitfun_ensure_tools() { fi } +# Install Docker Engine for the original SSH user. The caller must initialize +# mirror routing first and, when interactive sudo is needed, re-exec the driver +# through bitfun_elevate_install_driver before calling this helper. +bitfun_install_docker_engine() { + local deploy_user="${SUDO_USER:-}" installed=0 + if [ -z "$deploy_user" ] || [ "$deploy_user" = "root" ]; then + if [ -n "${BITFUN_KEEP_HOME:-}" ] && [ -d "${BITFUN_KEEP_HOME}" ]; then + deploy_user="$(stat -c '%U' "$BITFUN_KEEP_HOME" 2>/dev/null || true)" + fi + fi + if [ -z "$deploy_user" ] || [ "$deploy_user" = "root" ]; then + deploy_user="$(id -un)" + fi + + bitfun_ensure_tools curl + echo ">>> Installing Docker as uid=$(id -u) for user=$deploy_user (mirror_mode=${BITFUN_MIRROR_MODE:-global}) ..." + if [ "${BITFUN_MIRROR_MODE:-}" = "cn" ]; then + if bitfun_mirror_install_docker_aliyun; then + installed=1 + else + echo ">>> Aliyun docker-ce install failed; falling back to get.docker.com mirror..." + fi + fi + if [ "$installed" != "1" ]; then + bitfun_mirror_fetch_docker_install_script /tmp/bitfun-get-docker.sh \ + || curl -fsSL --retry 3 https://get.docker.com -o /tmp/bitfun-get-docker.sh + if [ "$(id -u)" = "0" ]; then + sh /tmp/bitfun-get-docker.sh + else + bitfun_priv sh /tmp/bitfun-get-docker.sh + fi + rm -f /tmp/bitfun-get-docker.sh + fi + + if [ "$(id -u)" = "0" ]; then + systemctl enable --now docker 2>/dev/null || service docker start + usermod -aG docker "$deploy_user" || true + else + bitfun_priv systemctl enable --now docker 2>/dev/null || bitfun_priv service docker start + bitfun_priv usermod -aG docker "$deploy_user" + fi + if [ "${BITFUN_MIRROR_MODE:-}" = "cn" ]; then + bitfun_mirror_apply_docker_daemon || true + fi + bitfun_fix_docker_home + if [ "$(id -u)" = "0" ] && [ -n "$deploy_user" ] && [ "$deploy_user" != "root" ] \ + && [ -d "$HOME/.bitfun" ]; then + echo ">>> Restoring ownership of $HOME/.bitfun to $deploy_user..." + chown -R "$deploy_user" "$HOME/.bitfun" 2>/dev/null || true + fi + + if docker info >/dev/null 2>&1 \ + || sg docker -c 'docker info' >/dev/null 2>&1 \ + || sudo -n docker info >/dev/null 2>&1 \ + || sudo docker info >/dev/null 2>&1; then + echo ">>> Docker installed and reachable: $(docker --version 2>/dev/null || sudo -n docker --version 2>/dev/null || true)" + return 0 + fi + echo "ERROR: Docker installed but daemon is not reachable" >&2 + return 1 +} + # Owner of $HOME — the SSH user even when this script runs elevated with their # HOME preserved (BITFUN_KEEP_HOME). bitfun_home_owner() { @@ -1080,7 +1094,7 @@ bitfun_home_owner() { # to leave ~/.bitfun/docker-config (and its config.json) owned by root:root 0700. # Every later unprivileged deploy then hit # WARNING: Error loading config file: .../config.json: permission denied -# and the docker CLI misparsed the build that followed. Repair the ownership when +# and the docker CLI misparsed the command that followed. Repair ownership when # we have the rights, and otherwise move to a config dir we can actually read. bitfun_fix_docker_config() { export DOCKER_CONFIG="${DOCKER_CONFIG:-$HOME/.bitfun/docker-config}" @@ -1196,61 +1210,6 @@ bitfun_docker() { esac } -bitfun_run_deploy_sh() { - local dir="$1" - local port="${RELAY_PORT:-9700}" - # Prefer already-resolved mirror mode so deploy.sh does not re-probe. - local mirror_mode="${BITFUN_MIRROR_MODE:-${BITFUN_MIRROR:-auto}}" - # Always --build-from-source: this function is reached ONLY after - # bitfun_try_release_deploy already failed, and deploy.sh's own first step is - # that same release-binary path. Without the flag it re-downloads, re-builds - # and re-starts the published binary that just failed — the deploy visibly - # runs twice before reaching the source build it was called for. - # DOCKER_BUILDKIT is required for Dockerfile cargo registry/git/target mounts. - # DOCKER_CONFIG is deliberately NOT forwarded to the sudo branches: root would - # write config.json into the SSH user's ~/.bitfun/docker-config and every later - # unprivileged run would then fail to read its own Docker config. Root falls - # back to /root/.docker, which it owns. - case "${BITFUN_DOCKER_MODE:-direct}" in - sudo) - if sudo -n true >/dev/null 2>&1; then - sudo -n -E env RELAY_PORT="$port" RELAY_CARGO_BUILD_JOBS="${RELAY_CARGO_BUILD_JOBS:-}" \ - DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDKIT_PROGRESS=plain \ - BITFUN_MIRROR="$mirror_mode" \ - BITFUN_USE_CN_MIRROR="${BITFUN_USE_CN_MIRROR:-0}" \ - BITFUN_APT_MIRROR="${BITFUN_APT_MIRROR:-}" \ - BITFUN_CARGO_SPARSE_URL="${BITFUN_CARGO_SPARSE_URL:-}" \ - BITFUN_DOCKER_REGISTRY_MIRRORS="${BITFUN_DOCKER_REGISTRY_MIRRORS:-}" \ - BITFUN_GITHUB_PROXY="${BITFUN_GITHUB_PROXY:-}" \ - bash "$dir/deploy.sh" --build-from-source - else - sudo -E env RELAY_PORT="$port" RELAY_CARGO_BUILD_JOBS="${RELAY_CARGO_BUILD_JOBS:-}" \ - DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDKIT_PROGRESS=plain \ - BITFUN_MIRROR="$mirror_mode" \ - BITFUN_USE_CN_MIRROR="${BITFUN_USE_CN_MIRROR:-0}" \ - BITFUN_APT_MIRROR="${BITFUN_APT_MIRROR:-}" \ - BITFUN_CARGO_SPARSE_URL="${BITFUN_CARGO_SPARSE_URL:-}" \ - BITFUN_DOCKER_REGISTRY_MIRRORS="${BITFUN_DOCKER_REGISTRY_MIRRORS:-}" \ - BITFUN_GITHUB_PROXY="${BITFUN_GITHUB_PROXY:-}" \ - bash "$dir/deploy.sh" --build-from-source - fi - ;; - sg) - sg docker -c "env RELAY_PORT='$port' RELAY_CARGO_BUILD_JOBS='${RELAY_CARGO_BUILD_JOBS:-}' DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDKIT_PROGRESS=plain DOCKER_CONFIG='${DOCKER_CONFIG:-}' BITFUN_MIRROR='$mirror_mode' BITFUN_USE_CN_MIRROR='${BITFUN_USE_CN_MIRROR:-0}' BITFUN_APT_MIRROR='${BITFUN_APT_MIRROR:-}' BITFUN_CARGO_SPARSE_URL='${BITFUN_CARGO_SPARSE_URL:-}' BITFUN_DOCKER_REGISTRY_MIRRORS='${BITFUN_DOCKER_REGISTRY_MIRRORS:-}' BITFUN_GITHUB_PROXY='${BITFUN_GITHUB_PROXY:-}' bash '$dir/deploy.sh' --build-from-source" - ;; - *) - env RELAY_PORT="$port" RELAY_CARGO_BUILD_JOBS="${RELAY_CARGO_BUILD_JOBS:-}" \ - DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDKIT_PROGRESS=plain \ - BITFUN_MIRROR="$mirror_mode" \ - BITFUN_USE_CN_MIRROR="${BITFUN_USE_CN_MIRROR:-0}" \ - BITFUN_APT_MIRROR="${BITFUN_APT_MIRROR:-}" \ - BITFUN_CARGO_SPARSE_URL="${BITFUN_CARGO_SPARSE_URL:-}" \ - BITFUN_DOCKER_REGISTRY_MIRRORS="${BITFUN_DOCKER_REGISTRY_MIRRORS:-}" \ - BITFUN_GITHUB_PROXY="${BITFUN_GITHUB_PROXY:-}" \ - bash "$dir/deploy.sh" --build-from-source - ;; - esac -} "# } @@ -1314,13 +1273,20 @@ if [ -f "$MIRROR_MODE_FILE" ]; then esac fi bitfun_mirror_init -bitfun_ensure_tools export DOCKER_CONFIG="${{DOCKER_CONFIG:-$HOME/.bitfun/docker-config}}" # May exist root-owned from an older Docker-install run; repair or relocate it # instead of letting an unwritable dir abort the run under `set -e`. bitfun_fix_docker_config -# install: Docker is not present yet — do NOT resolve daemon access here. +# Deploy is genuinely one-click: if Docker is absent, install it through +# bitfun_priv/bitfun_mirror_priv (interactive sudo is allowed), then continue as +# the original SSH user so cancellation can still signal the detached task. +if [ "{kind}" = "deploy" ] && ! command -v docker >/dev/null 2>&1; then + echo ">>> Docker is not installed; installing it before pulling Relay..." | tee -a "$LOG" + bitfun_install_docker_engine 2>&1 | tee -a "$LOG" +fi + +# Standalone install resolves nothing; deploy always needs live daemon access. if [ "{kind}" = "install" ]; then BITFUN_DOCKER_MODE=direct else @@ -1328,25 +1294,8 @@ else fi export BITFUN_DOCKER_MODE -# Ensure compose plugin when deploying -if [ "{kind}" = "deploy" ]; then - if ! docker compose version >/dev/null 2>&1 \ - && ! command -v docker-compose >/dev/null 2>&1 \ - && ! sudo -n docker compose version >/dev/null 2>&1 \ - && ! sudo docker compose version >/dev/null 2>&1; then - echo ">>> docker compose missing; attempting install..." - if [ "$(id -u)" = "0" ]; then - apt-get update -y && apt-get install -y docker-compose-plugin 2>/dev/null \ - || yum install -y docker-compose-plugin 2>/dev/null || true - else - bitfun_priv apt-get update -y && bitfun_priv apt-get install -y docker-compose-plugin 2>/dev/null \ - || bitfun_priv yum install -y docker-compose-plugin 2>/dev/null || true - fi - fi -fi - -# Docker install: run in foreground as (elevated) root when possible. -# Long deploy builds still go through nohup so the wizard can follow the log. +# Docker install runs in the foreground. The image pull/start task goes through +# nohup so the wizard can poll and follow its log. if [ "{kind}" = "install" ]; then echo ">>> Installing Docker..." | tee -a "$LOG" export BITFUN_KEEP_HOME="${{BITFUN_KEEP_HOME:-$HOME}}" @@ -1379,18 +1328,9 @@ fi if command -v stdbuf >/dev/null 2>&1; then RUNNER=(stdbuf -oL -eL bash); else RUNNER=(bash); fi echo ">>> Starting background task (log: $LOG)" | tee -a "$LOG" nohup env BITFUN_DOCKER_MODE="$BITFUN_DOCKER_MODE" DOCKER_CONFIG="$DOCKER_CONFIG" \ - RELAY_CARGO_BUILD_JOBS="${{RELAY_CARGO_BUILD_JOBS:-}}" \ - DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDKIT_PROGRESS=plain \ BITFUN_MIRROR="${{BITFUN_MIRROR:-auto}}" \ BITFUN_MIRROR_MODE="${{BITFUN_MIRROR_MODE:-}}" \ BITFUN_MIRROR_REASON="${{BITFUN_MIRROR_REASON:-}}" \ - BITFUN_USE_CN_MIRROR="${{BITFUN_USE_CN_MIRROR:-0}}" \ - BITFUN_APT_MIRROR="${{BITFUN_APT_MIRROR:-}}" \ - BITFUN_CARGO_SPARSE_URL="${{BITFUN_CARGO_SPARSE_URL:-}}" \ - BITFUN_DOCKER_REGISTRY_MIRRORS="${{BITFUN_DOCKER_REGISTRY_MIRRORS:-}}" \ - BITFUN_GITHUB_PROXY="${{BITFUN_GITHUB_PROXY:-}}" \ - BITFUN_REPO_GIT_URL="${{BITFUN_REPO_GIT_URL:-}}" \ - BITFUN_REPO_TARBALL_URL="${{BITFUN_REPO_TARBALL_URL:-}}" \ "${{RUNNER[@]}}" "$BODY" >"$LOG" 2>&1 < /dev/null & echo $! >"$PIDF" # The body pid now drives liveness; `exec tail` below would leave a stale driver @@ -1420,70 +1360,11 @@ set -euo pipefail if [ -n "${{BITFUN_KEEP_HOME:-}}" ]; then export HOME="$BITFUN_KEEP_HOME"; fi export DOCKER_CONFIG="${{DOCKER_CONFIG:-$HOME/.bitfun/docker-config}}" mkdir -p "$DOCKER_CONFIG" 2>/dev/null || true -# When elevated as root, add the original login user to the docker group. -DEPLOY_USER="${{SUDO_USER:-}}" -if [ -z "$DEPLOY_USER" ] || [ "$DEPLOY_USER" = "root" ]; then - if [ -n "${{BITFUN_KEEP_HOME:-}}" ] && [ -d "${{BITFUN_KEEP_HOME}}" ]; then - DEPLOY_USER="$(stat -c '%U' "$BITFUN_KEEP_HOME" 2>/dev/null || true)" - fi -fi -if [ -z "$DEPLOY_USER" ] || [ "$DEPLOY_USER" = "root" ]; then - DEPLOY_USER="$(id -un)" -fi export BITFUN_REPO_GIT_URL="{REPO_GIT_URL}" export BITFUN_REPO_TARBALL_URL="{REPO_TARBALL_URL}" bitfun_mirror_init -echo ">>> Installing Docker as uid=$(id -u) for user=$DEPLOY_USER (mirror_mode=${{BITFUN_MIRROR_MODE:-global}}) ..." -INSTALLED=0 -if [ "${{BITFUN_MIRROR_MODE:-}}" = "cn" ]; then - if bitfun_mirror_install_docker_aliyun; then - INSTALLED=1 - else - echo ">>> Aliyun docker-ce install failed; falling back to get.docker.com mirror..." - fi -fi -if [ "$INSTALLED" != "1" ]; then - bitfun_mirror_fetch_docker_install_script /tmp/bitfun-get-docker.sh \ - || curl -fsSL --retry 3 https://get.docker.com -o /tmp/bitfun-get-docker.sh - if [ "$(id -u)" = "0" ]; then - sh /tmp/bitfun-get-docker.sh - else - bitfun_priv sh /tmp/bitfun-get-docker.sh - fi - rm -f /tmp/bitfun-get-docker.sh -fi -if [ "$(id -u)" = "0" ]; then - systemctl enable --now docker - usermod -aG docker "$DEPLOY_USER" || true -else - bitfun_priv systemctl enable --now docker - bitfun_priv usermod -aG docker "$DEPLOY_USER" -fi -# Re-apply Docker registry mirrors after engine install (daemon.json may be new). -if [ "${{BITFUN_MIRROR_MODE:-}}" = "cn" ]; then - bitfun_mirror_apply_docker_daemon || true -fi -bitfun_fix_docker_home -# This body runs as root but with the SSH user's HOME, so anything it created -# under ~/.bitfun (notably docker-config/config.json) is root-owned. Left that -# way, the next unprivileged deploy cannot read its own Docker config and the -# build that follows fails. Hand the tree back before finishing. -if [ "$(id -u)" = "0" ] && [ -n "$DEPLOY_USER" ] && [ "$DEPLOY_USER" != "root" ] \ - && [ -d "$HOME/.bitfun" ]; then - echo ">>> Restoring ownership of $HOME/.bitfun to $DEPLOY_USER..." - chown -R "$DEPLOY_USER" "$HOME/.bitfun" 2>/dev/null || true -fi -# Verify without relying on a new login session -if docker info >/dev/null 2>&1 \ - || sg docker -c 'docker info' >/dev/null 2>&1 \ - || sudo -n docker info >/dev/null 2>&1 \ - || sudo docker info >/dev/null 2>&1; then - echo ">>> Docker installed and reachable: $(docker --version 2>/dev/null || sudo -n docker --version 2>/dev/null || true)" - echo {TASK_DONE_MARKER} -else - echo "ERROR: Docker installed but daemon is not reachable" >&2 - exit 1 -fi +bitfun_install_docker_engine +echo {TASK_DONE_MARKER} "#, helpers = helpers, REPO_GIT_URL = REPO_GIT_URL, @@ -1492,170 +1373,19 @@ fi ) } -/// Sync BitFun source: prefer shallow git update, fall back to tarball. -/// -/// `src` must be the BitFun-managed path (`~/.bitfun/relay-src`). Destructive -/// replace is safe only there — never use `$HOME/bitfun` / `$HOME/BitFun`. -fn sync_source_bash() -> String { - format!( - r#" -bitfun_sync_source() {{ - # Destination is always ~/.bitfun/relay-src (repo ROOT), never $HOME/BitFun. - # `git clone ` without a path would create ./BitFun — we always pass "$src". - # Tarball extracts BitFun-main/; we use --strip-components=1 into "$src". - local src="$1" - local git_upstream="{REPO_GIT_URL}" - local tarball_upstream="{REPO_TARBALL_URL}" - local git_url="${{BITFUN_GITHUB_GIT_URL:-$git_upstream}}" - local tarball_url="${{BITFUN_GITHUB_TARBALL_URL:-$tarball_upstream}}" - local branch="{REPO_GIT_BRANCH}" - local managed_prefix="$HOME/.bitfun/" - local relay_deploy_sh="src/apps/relay-server/deploy.sh" - - # Refuse to touch anything outside ~/.bitfun/ (protect user project dirs). - case "$src" in - "$managed_prefix"*) ;; - *) - echo "ERROR: refusing to sync source outside ~/.bitfun/: $src" >&2 - return 1 - ;; - esac - - bitfun_replace_managed_src() {{ - rm -rf "$src" - mkdir -p "$(dirname "$src")" - }} - - # Ensure "$src" is the repo root (contains src/apps/relay-server), not a - # nested BitFun/ or BitFun-main/ from a mistaken clone/extract. - bitfun_assert_source_layout() {{ - if [ -f "$src/$relay_deploy_sh" ]; then - return 0 - fi - local nested="" - if [ -f "$src/BitFun/$relay_deploy_sh" ]; then - nested="$src/BitFun" - elif [ -f "$src/BitFun-main/$relay_deploy_sh" ]; then - nested="$src/BitFun-main" - elif [ -f "$src/bitfun/$relay_deploy_sh" ]; then - nested="$src/bitfun" - fi - if [ -n "$nested" ]; then - echo ">>> Flattening nested checkout ($(basename "$nested")) into $src..." - # Move nested repo root contents up one level inside the managed dir only. - shopt -s dotglob nullglob - local tmp="$src.__flatten_$$" - mv "$nested" "$tmp" - rm -rf "$src" - mv "$tmp" "$src" - shopt -u dotglob nullglob - fi - if [ ! -f "$src/$relay_deploy_sh" ]; then - echo "ERROR: source layout invalid under $src (missing $relay_deploy_sh)" >&2 - return 1 - fi - }} - - bitfun_fetch_tarball() {{ - local url="$1" - echo ">>> Downloading BitFun source (tarball): $url" - command -v curl >/dev/null 2>&1 || bitfun_ensure_tools - command -v tar >/dev/null 2>&1 || bitfun_ensure_tools - bitfun_replace_managed_src - mkdir -p "$src" - # Archive root is BitFun-main/; strip so files land directly in "$src". - curl -fsSL --retry 3 "$url" | tar xz -C "$src" --strip-components=1 - bitfun_assert_source_layout - }} - - if ! command -v git >/dev/null 2>&1; then - bitfun_ensure_tools || true - fi - - if command -v git >/dev/null 2>&1; then - if [ -d "$src/.git" ]; then - echo ">>> Updating BitFun source (git fetch via $git_url)..." - git -C "$src" remote set-url origin "$git_url" 2>/dev/null || true - if git -C "$src" fetch --depth 1 origin "$branch" \ - && git -C "$src" checkout -f -B "$branch" "origin/$branch" \ - && git -C "$src" clean -fd \ - && bitfun_assert_source_layout; then - echo ">>> Source updated to $(git -C "$src" rev-parse --short HEAD 2>/dev/null || echo unknown)" - return 0 - fi - if [ "$git_url" != "$git_upstream" ]; then - echo ">>> git update via mirror failed; retrying upstream..." - git -C "$src" remote set-url origin "$git_upstream" 2>/dev/null || true - if git -C "$src" fetch --depth 1 origin "$branch" \ - && git -C "$src" checkout -f -B "$branch" "origin/$branch" \ - && git -C "$src" clean -fd \ - && bitfun_assert_source_layout; then - echo ">>> Source updated to $(git -C "$src" rev-parse --short HEAD 2>/dev/null || echo unknown)" - return 0 - fi - fi - echo ">>> git update failed; recloning managed source..." - bitfun_replace_managed_src - elif [ -e "$src" ]; then - echo ">>> Managed source exists but is not a git checkout; replacing..." - bitfun_replace_managed_src - fi - echo ">>> Cloning into $src via $git_url ..." - mkdir -p "$(dirname "$src")" - # Explicit destination avoids creating $PWD/BitFun from the repo name. - if git clone --depth 1 --branch "$branch" "$git_url" "$src" \ - && bitfun_assert_source_layout; then - echo ">>> Source cloned at $(git -C "$src" rev-parse --short HEAD 2>/dev/null || echo unknown)" - return 0 - fi - if [ "$git_url" != "$git_upstream" ]; then - echo ">>> git clone via mirror failed; retrying upstream $git_upstream ..." - bitfun_replace_managed_src - mkdir -p "$(dirname "$src")" - if git clone --depth 1 --branch "$branch" "$git_upstream" "$src" \ - && bitfun_assert_source_layout; then - echo ">>> Source cloned at $(git -C "$src" rev-parse --short HEAD 2>/dev/null || echo unknown)" - return 0 - fi - fi - echo ">>> git clone failed; falling back to tarball" - else - echo ">>> git unavailable; using tarball fallback" - fi - if bitfun_fetch_tarball "$tarball_url"; then - return 0 - fi - if [ "$tarball_url" != "$tarball_upstream" ]; then - echo ">>> tarball mirror failed; retrying upstream..." - bitfun_fetch_tarball "$tarball_upstream" - fi -}} -"#, - REPO_GIT_URL = REPO_GIT_URL, - REPO_GIT_BRANCH = REPO_GIT_BRANCH, - REPO_TARBALL_URL = REPO_TARBALL_URL, - ) -} - -/// 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. +/// Preamble + shared script that pulls the published multi-platform Relay image. +/// The container name, volumes, ports and relay-admin path stay identical to the +/// former 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`. +/// shared. Desktop additionally supplies a signed, immutable registry digest. fn release_binary_deploy_bash() -> String { format!( r#" 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 --- @@ -1663,66 +1393,109 @@ export BITFUN_STALL_SECONDS="{stall_seconds}" 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, ) } -/// 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() else { - return verified; - }; - let Ok(client) = reqwest::Client::builder() +/// Download and authenticate the image descriptor before any remote mutation. +/// The official release is preferred; openbitfun is a byte mirror and remains +/// safe because the same compiled-in minisign key must verify its descriptor. +async fn verified_relay_image_descriptor(release_tag: &str) -> Result { + let pubkey = release_pubkey().ok_or_else(|| { + anyhow!("this build has no Relay release trust root; refusing image deployment") + })?; + let client = reqwest::Client::builder() .connect_timeout(Duration::from_secs(8)) .timeout(Duration::from_secs(30)) - .build() - else { - return verified; - }; + .build()?; + let mut bases = vec![format!("{RELEASE_BASE}/download/{release_tag}")]; + if let Some(version) = release_tag.strip_prefix('v') { + bases.push(format!("{OPENBITFUN_RELEASE_BASE}/{version}")); + } - 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 { + let mut last_error = String::from("descriptor was unavailable from every source"); + for base in bases { + let descriptor_url = format!("{base}/{RELAY_IMAGE_DESCRIPTOR_ASSET}"); + let Some(descriptor_text) = fetch_text(&client, &descriptor_url).await else { + last_error = format!("{descriptor_url} was unavailable"); continue; }; - let Some(signature) = fetch_text(&client, &format!("{checksum_url}.sig")).await else { + let Some(signature) = fetch_text(&client, &format!("{descriptor_url}.sig")).await else { + last_error = format!("{descriptor_url}.sig was unavailable"); continue; }; - let hash = match verify_signed_checksum( - &checksum, - &signature, - pubkey, - &format!("bitfun-relay-server-{target}.tar.gz"), - ) { - Ok(hash) => hash, + if let Err(error) = verify_minisign(descriptor_text.as_bytes(), &signature, pubkey) { + last_error = format!("{descriptor_url} signature did not verify: {error}"); + log::warn!("Relay image descriptor rejected: {last_error}"); + continue; + } + let descriptor: RelayImageDescriptor = match serde_json::from_str(&descriptor_text) { + Ok(descriptor) => descriptor, Err(error) => { - log::warn!("Relay checksum signature for {target} did not verify: {error}"); + last_error = format!("{descriptor_url} is invalid JSON: {error}"); continue; } }; - verified.insert(target.to_string(), hash); + if let Err(error) = validate_relay_image_descriptor(&descriptor, release_tag) { + last_error = format!("{descriptor_url} is invalid: {error}"); + log::warn!("Relay image descriptor rejected: {last_error}"); + continue; + } + return Ok(descriptor); } - verified + + Err(anyhow!( + "could not verify the signed Relay image descriptor for {release_tag}: {last_error}" + )) +} + +fn validate_relay_image_descriptor( + descriptor: &RelayImageDescriptor, + release_tag: &str, +) -> Result<()> { + if descriptor.schema_version != 1 { + return Err(anyhow!( + "unsupported schema version {}", + descriptor.schema_version + )); + } + if descriptor.image != RELAY_IMAGE_REPOSITORY { + return Err(anyhow!("unexpected image repository")); + } + if descriptor.tag != release_tag { + return Err(anyhow!( + "descriptor tag does not match this Desktop release" + )); + } + if let Some(version) = release_tag.strip_prefix('v') { + if descriptor.version != version { + return Err(anyhow!( + "descriptor version does not match this Desktop release" + )); + } + } + let digest = descriptor.digest.as_bytes(); + if digest.len() != 71 + || !descriptor.digest.starts_with("sha256:") + || !digest[7..] + .iter() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) + { + return Err(anyhow!("image digest is not canonical lowercase SHA256")); + } + for platform in ["linux/amd64", "linux/arm64"] { + if !descriptor + .platforms + .iter() + .any(|candidate| candidate == platform) + { + return Err(anyhow!("image does not declare {platform}")); + } + } + if descriptor.version.trim().is_empty() { + return Err(anyhow!("image version is empty")); + } + Ok(()) } async fn fetch_text(client: &reqwest::Client, url: &str) -> Option { @@ -1738,38 +1511,20 @@ async fn fetch_text(client: &reqwest::Client, url: &str) -> Option { .ok() } -/// 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). -/// -/// `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 { +/// Non-interactive body for deploy (runs under nohup after prepare). It has one +/// network operation: pull the authenticated image through the selected route. +fn deploy_body_script_with_image(port: u16, descriptor: &RelayImageDescriptor) -> String { let helpers = prepare_helpers_bash(); - let sync = sync_source_bash(); let release_binary_deploy = release_binary_deploy_bash(); format!( r#"#!/usr/bin/env bash set -euo pipefail {helpers} -{sync} -{verified_checksums}{release_binary_deploy} +{release_binary_deploy} +export BITFUN_RELAY_IMAGE={image} +export BITFUN_RELAY_IMAGE_DIGEST={digest} +export BITFUN_REQUIRE_IMAGE_DIGEST=1 export DOCKER_CONFIG="${{DOCKER_CONFIG:-$HOME/.bitfun/docker-config}}" -export DOCKER_BUILDKIT=1 -export COMPOSE_DOCKER_CLI_BUILD=1 -export BUILDKIT_PROGRESS=plain BITFUN_DOCKER_MODE="${{BITFUN_DOCKER_MODE:-direct}}" # Repair DOCKER_CONFIG unconditionally: when the driver already resolved a # non-direct mode, bitfun_resolve_docker_mode (which normally does this) is @@ -1786,73 +1541,42 @@ fi RELAY_PORT="${{RELAY_PORT:-{port}}}" export RELAY_PORT echo ">>> Using RELAY_PORT=$RELAY_PORT" -export BITFUN_REPO_GIT_URL="{REPO_GIT_URL}" -export BITFUN_REPO_TARBALL_URL="{REPO_TARBALL_URL}" bitfun_mirror_init -if bitfun_try_release_deploy; then - echo {TASK_DONE_MARKER} - 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" -# Persist for compose interpolation (and subsequent start/restart scripts). -printf 'RELAY_PORT=%s\n' "$RELAY_PORT" > .env -chmod 600 .env 2>/dev/null || true -# Until main ships templated compose, rewrite hardcoded 9700 for custom ports. -if [ -f docker-compose.yml ] && ! grep -q '\${{RELAY_PORT' docker-compose.yml; then - sed -i.bak \ - -e "s/:9700:9700/:${{RELAY_PORT}}:${{RELAY_PORT}}/g" \ - -e "s/RELAY_PORT=9700/RELAY_PORT=${{RELAY_PORT}}/g" \ - -e "s|127\\.0\\.0\\.1:9700/health|127.0.0.1:${{RELAY_PORT}}/health|g" \ - docker-compose.yml 2>/dev/null || true -fi -MEM_KB=$(awk '/MemTotal/ {{print $2}}' /proc/meminfo 2>/dev/null || echo 0) -if [ "${{RELAY_CARGO_BUILD_JOBS:-}}" = "" ] && [ "$MEM_KB" -lt 2097152 ]; then - export RELAY_CARGO_BUILD_JOBS=1 - echo ">>> Low memory detected; using RELAY_CARGO_BUILD_JOBS=1" -fi -echo ">>> Building and starting the relay container on port $RELAY_PORT (this can take a while)..." -bitfun_run_deploy_sh "$(pwd)" +bitfun_try_release_deploy echo {TASK_DONE_MARKER} "#, helpers = helpers, - sync = sync, - verified_checksums = verified_checksums, release_binary_deploy = release_binary_deploy, + image = shell_quote_posix(&descriptor.image), + digest = shell_quote_posix(&descriptor.digest), DEPLOY_STATE_DIR = DEPLOY_STATE_DIR, - SOURCE_DIR = SOURCE_DIR, port = port, 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_with_checksums, + classify_docker_access, decide_task_status, deploy_body_script_with_image, install_docker_body_script, interactive_driver_script, parse_preflight, prepare_helpers_bash, release_binary_deploy_bash, release_tag_for_version, - split_poll_stdout, stage_scripts_command, sync_source_bash, to_unix_script, - verified_checksum_exports, verify_minisign, DockerAccessMode, RelayTaskStatus, - RELAY_MIRROR_SH, RELAY_RELEASE_DOWNLOAD_SH, RELEASE_PUBKEY, + split_poll_stdout, stage_scripts_command, to_unix_script, validate_relay_image_descriptor, + verify_minisign, DockerAccessMode, RelayImageDescriptor, RelayTaskStatus, + RELAY_IMAGE_REPOSITORY, RELAY_MIRROR_SH, RELAY_RELEASE_DOWNLOAD_SH, RELEASE_PUBKEY, }; + fn test_image_descriptor() -> RelayImageDescriptor { + RelayImageDescriptor { + schema_version: 1, + image: RELAY_IMAGE_REPOSITORY.to_string(), + tag: release_tag_for_version(super::RELEASE_VERSION), + version: super::RELEASE_VERSION.to_string(), + digest: format!("sha256:{}", "a".repeat(64)), + platforms: vec!["linux/amd64".into(), "linux/arm64".into()], + } + } + #[test] fn embedded_mirror_script_exposes_init_and_cn_defaults() { assert!( @@ -1893,8 +1617,8 @@ mod tests { "prepare helpers must embed mirror.sh" ); assert!( - helpers.contains("bitfun_run_deploy_sh"), - "prepare helpers must keep deploy runner" + helpers.contains("bitfun_install_docker_engine"), + "prepare helpers must support install-and-continue deployment" ); let driver = interactive_driver_script("deploy", "deploy"); assert!( @@ -2017,7 +1741,10 @@ mod tests { "install driver", interactive_driver_script("install-docker", "install"), ), - ("deploy body", deploy_body_script_with_checksums(9700, "")), + ( + "deploy body", + deploy_body_script_with_image(9700, &test_image_descriptor()), + ), ("install body", install_docker_body_script()), ] { assert!( @@ -2064,7 +1791,7 @@ mod tests { /// The Docker-install task runs as root with the SSH user's HOME, so it /// creates ~/.bitfun/docker-config root-owned. Left that way, the next /// unprivileged deploy hits `config.json: permission denied` and the docker - /// CLI mis-dispatches the runtime image build. + /// CLI can mis-dispatch the pull that follows. #[test] fn docker_config_ownership_is_repaired_across_privilege_levels() { let helpers = prepare_helpers_bash(); @@ -2075,14 +1802,18 @@ mod tests { let install = install_docker_body_script(); assert!( - install.contains(r#"chown -R "$DEPLOY_USER" "$HOME/.bitfun""#), + install.contains("bitfun_install_docker_engine"), + "standalone install must use the shared Docker installer" + ); + assert!( + helpers.contains(r#"chown -R "$deploy_user" "$HOME/.bitfun""#), "root install must hand ~/.bitfun back to the SSH user" ); // The driver exports BITFUN_DOCKER_MODE, so the body skips // bitfun_resolve_docker_mode (which is the other caller of the repair) // for every non-direct mode. It has to repair the config itself. - let body = deploy_body_script_with_checksums(9700, ""); + let body = deploy_body_script_with_image(9700, &test_image_descriptor()); let repair = body .find("bitfun_fix_docker_config") .expect("deploy body must repair DOCKER_CONFIG"); @@ -2093,6 +1824,17 @@ mod tests { repair < mode_check, "DOCKER_CONFIG must be repaired before any docker call, not only in direct mode" ); + + let driver = interactive_driver_script("deploy", "deploy"); + assert!( + driver.contains("Docker is not installed; installing it before pulling Relay") + && driver.contains("bitfun_install_docker_engine"), + "the deploy button must install a missing Docker engine and continue" + ); + assert!( + !driver.contains("docker compose missing"), + "the pull-only path must not require Docker Compose" + ); } /// `sg docker -c "docker $*"` re-parsed its arguments through a second @@ -2139,119 +1881,24 @@ sh -c "$(bitfun_shell_join printf '%s\n' 'a b' "it's" '{{{{.State.Running}}}}' ' ); } - /// `bitfun_run_deploy_sh` is reached only after `bitfun_try_release_deploy` - /// failed, and `deploy.sh`'s own first step is that same release path. - /// Without `--build-from-source` the whole published-binary attempt — - /// download, image build, container start — visibly ran a second time before - /// the source build it was called for. - #[test] - fn source_build_fallback_does_not_retry_the_release_path() { - let helpers = prepare_helpers_bash(); - let runner = helpers - .split_once("bitfun_run_deploy_sh() {") - .expect("helpers must define bitfun_run_deploy_sh") - .1; - // Check invocation sites, not a substring count: the surrounding prose - // mentions deploy.sh too. - let mut sites = 0; - for form in [r#"bash "$dir/deploy.sh""#, r#"bash '$dir/deploy.sh'"#] { - let mut rest = runner; - while let Some(i) = rest.find(form) { - let after = &rest[i + form.len()..]; - assert!( - after.starts_with(" --build-from-source"), - "a deploy.sh invocation on the fallback path would re-run the \ - release-binary step that already failed: ...{}", - &after[..after.len().min(40)] - ); - sites += 1; - rest = after; - } - } - assert_eq!(sites, 4, "expected one invocation per docker mode"); - } - - /// The published arm64 relay needs GLIBC_2.38; `debian:bookworm-slim` ships - /// 2.36, so the binary could not load at all and the deploy surfaced only as - /// a failed health check plus a 20-minute source rebuild. #[test] - fn runtime_image_base_can_load_the_published_binary() { + fn one_click_uses_digest_pinned_prebuilt_images_and_regional_routes() { let script = release_binary_deploy_bash(); - assert!( - !script.contains("FROM debian:bookworm-slim"), - "bookworm-slim (glibc 2.36) cannot load the arm64 relay (needs 2.38)" - ); - assert!( - script.contains("ARG BITFUN_RUNTIME_BASE=debian:trixie-slim") - && script.contains("FROM ${BITFUN_RUNTIME_BASE}"), - "runtime base must provide a glibc at least as new as the release matrix" - ); - // `ldd` exits 0 even when it reports an unsatisfied symbol version, so - // the gate has to inspect its output. - assert!( - script.contains(r#"*"not found"*)"#), - "the runtime image must fail its build on an unloadable binary" - ); - assert!( - script.contains("ARG BITFUN_USE_CN_MIRROR=0") - && script.contains("BITFUN_APT_MIRROR=mirrors.aliyun.com") - && script.contains("deb.debian.org/debian"), - "the generated runtime image must be able to rewrite its own apt sources" - ); - } - - /// Host apt configuration cannot affect `apt-get` inside a Docker build. - /// The release path therefore has to pass the resolved route as build args; - /// this is the exact propagation gap that made CN hosts hit deb.debian.org. - #[cfg(unix)] - #[test] - fn runtime_image_build_receives_resolved_mirror_args() { - use std::{fs, process::Command}; - - let dir = tempfile::tempdir().expect("temp dir"); - let script_path = dir.path().join("release-download.sh"); - let context = dir.path().join("runtime"); - let trace = dir.path().join("docker-args"); - fs::write(&script_path, release_binary_deploy_bash()).expect("write release script"); - fs::create_dir(&context).expect("create runtime context"); - - let output = Command::new("bash") - .arg("-c") - .arg( - r#" -set -euo pipefail -source "$1" -export TRACE="$3" -bitfun_docker() { printf '%s\n' "$@" > "$TRACE"; } -export BITFUN_USE_CN_MIRROR=1 -export BITFUN_APT_MIRROR=mirror.example -export BITFUN_RUNTIME_BASE=registry.example/library/debian:trixie-slim -bitfun_build_runtime_image bitfun-relay:test "$2" -"#, - ) - .arg("runtime-mirror-args") - .arg(&script_path) - .arg(&context) - .arg(&trace) - .output() - .expect("run runtime image build stub"); - assert!( - output.status.success(), - "runtime build stub failed:\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let args = fs::read_to_string(trace).expect("read docker args"); - for expected in [ - "BITFUN_USE_CN_MIRROR=1", - "BITFUN_APT_MIRROR=mirror.example", - "BITFUN_RUNTIME_BASE=registry.example/library/debian:trixie-slim", - ] { - assert!( - args.lines().any(|line| line == expected), - "docker build is missing {expected}:\n{args}" - ); - } + assert!(script.contains("BITFUN_RELAY_IMAGE_DIGEST")); + assert!(script.contains("m.daocloud.io/${BITFUN_RELAY_IMAGE}")); + assert!(script.contains("ghcr.nju.edu.cn/${BITFUN_RELAY_IMAGE#ghcr.io/}")); + assert!(script.contains("official GHCR fallback")); + assert!(script.contains("pull --platform")); + assert!(script.contains("bitfun_restore_previous_relay")); + assert!(script.contains("--name bitfun-relay")); + assert!(script.contains("relay-server_relay-db:/app/data")); + assert!(script.contains("RELAY_PAGE_PUBLIC_BASE_URL")); + assert!(script.contains("RELAY_PAGE_AUTH_BASE_URL")); + assert!(script.contains("trap 'bitfun_restore_previous_relay")); + assert!(script.contains("name=^bitfun-relay-before-image-")); + assert!(!script.contains("docker build")); + assert!(!script.contains("cargo build")); + assert!(!script.contains("bitfun-relay-server-${target}.tar.gz")); } /// `docker logs` relays the container's stderr on its own stderr, and the @@ -2278,14 +1925,6 @@ bitfun_build_runtime_image bitfun-relay:test "$2" ); } - #[test] - fn sync_source_uses_mirror_url_env_with_upstream_fallback() { - let sync = sync_source_bash(); - assert!(sync.contains("BITFUN_GITHUB_GIT_URL")); - assert!(sync.contains("BITFUN_GITHUB_TARBALL_URL")); - assert!(sync.contains("retrying upstream")); - } - #[test] fn release_tag_tracks_stable_and_nightly_channels() { assert_eq!(release_tag_for_version("0.2.13"), "v0.2.13"); @@ -2296,51 +1935,45 @@ bitfun_build_runtime_image bitfun-relay:test "$2" } #[test] - fn release_binary_deploy_verifies_assets_and_preserves_container_contract() { - 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("no Rust/Cargo compilation")); - assert!(script.contains("--name bitfun-relay")); - assert!(script.contains("relay-server_relay-db:/app/data")); - assert!(script.contains("/app/relay-admin")); - assert!(script.contains("falling back to source build")); - assert!(script.contains("bitfun_restore_previous_relay")); - // Wizard-close must not leave the relay stopped under its backup name. - 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:-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. + fn signed_descriptor_is_strictly_bound_to_repository_tag_digest_and_platforms() { + let descriptor = test_image_descriptor(); + validate_relay_image_descriptor(&descriptor, &descriptor.tag).unwrap(); + + for invalid in [ + RelayImageDescriptor { + image: "ghcr.io/attacker/relay".into(), + ..descriptor.clone() + }, + RelayImageDescriptor { + tag: "v9.9.9".into(), + ..descriptor.clone() + }, + RelayImageDescriptor { + digest: format!("sha256:{}", "A".repeat(64)), + ..descriptor.clone() + }, + RelayImageDescriptor { + platforms: vec!["linux/amd64".into()], + ..descriptor.clone() + }, + ] { + assert!(validate_relay_image_descriptor(&invalid, &descriptor.tag).is_err()); + } + + let body = deploy_body_script_with_image(9700, &descriptor); + assert!(body.contains(&format!("export BITFUN_RELAY_IMAGE={}", descriptor.image))); + assert!(body.contains(&format!( + "export BITFUN_RELAY_IMAGE_DIGEST={}", + descriptor.digest + ))); + assert!(body.contains("export BITFUN_REQUIRE_IMAGE_DIGEST=1")); + assert!(!body.contains("bitfun_sync_source")); + assert!(!body.contains("bitfun_run_deploy_sh")); + } + /// 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. + /// Descriptor bytes from any origin must pass this verification before a + /// digest is sent to a relay host. const FIXTURE_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgRTNFMDg3NENFQzFDMjJDMwpSV1RESWh6c1RJZmc0MXcyR3dpZWkwek5ES2FMWW05ZFFWcEVXTlEvVWxweXQybWJTMkpFMVUyTQo="; const FIXTURE_SIGNATURE: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVUREloenNUSWZnNDBMTitwb25aT3RCVy9VYmJtNWhkR1poM0lCb3IwUDBKaVZmZmM1cFJaNlZSNUpaSzNUUm1yWWpYMXFLQ2svWTdZUDhHdkRZT3YvanVoZlpnZmhyWEFRPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg0OTUxOTM1CWZpbGU6YXJjaGl2ZS50YXIuZ3oJaGFzaGVkCjhWL21EUVAwZGdlZXVNU1lxWlpsOWdFSGUwOTJQTk9yRG1BMUV6ZHNQOUlEYkcyT1dneTFsQ1puUDBJaFIwQnJpMFBCeENRcUdDR2dpb0l0UGtSMUN3PT0K"; const FIXTURE_DATA: &[u8] = b"hello-bitfun\n"; @@ -2353,28 +1986,9 @@ bitfun_build_runtime_image bitfun-relay:test "$2" 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")); - } - /// The official key is embedded as the default trust root, so even keyless - /// development builds can verify published checksums before asserting a - /// hash to the remote. + /// development builds can verify the published image descriptor before + /// asserting a digest to the remote. #[test] fn builds_always_carry_a_release_trust_root() { assert!(RELEASE_PUBKEY.is_none() || RELEASE_PUBKEY == Some("")); @@ -2383,39 +1997,69 @@ bitfun_build_runtime_image bitfun-relay:test "$2" #[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"); + fn generated_deploy_script_is_valid_bash() { + let script = deploy_body_script_with_image(9700, &test_image_descriptor()); let output = std::process::Command::new("bash") - .arg(&harness) - .arg(&script) + .args(["-n", "-c", &script]) .output() - .expect("run release download harness"); + .expect("parse generated deploy script"); assert!( output.status.success(), - "release download harness failed:\n{}\n{}", - String::from_utf8_lossy(&output.stdout), + "generated deploy script is invalid:\n{}", String::from_utf8_lossy(&output.stderr) ); } #[cfg(unix)] #[test] - fn generated_deploy_script_is_valid_bash() { - let script = deploy_body_script_with_checksums(9700, ""); + fn china_image_pull_fails_over_between_digest_pinned_routes() { + let temp = tempfile::tempdir().expect("temp dir"); + let script_path = temp.path().join("release-image.sh"); + let trace_path = temp.path().join("pulls.log"); + std::fs::write(&script_path, release_binary_deploy_bash()).expect("write image script"); + let output = std::process::Command::new("bash") - .args(["-n", "-c", &script]) + .arg("-c") + .arg( + r#" +set -euo pipefail +source "$1" +export TRACE="$2" +export BITFUN_MIRROR_MODE=cn +export BITFUN_RELAY_IMAGE_DIGEST="sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +bitfun_image_docker_with_timeout() { + shift + printf '%s\n' "$*" >>"$TRACE" + case "$*" in + *ghcr.nju.edu.cn*) return 1 ;; + *m.daocloud.io*) return 0 ;; + *) return 1 ;; + esac +} +bitfun_image_docker() { + if [ "$1 $2" = "image inspect" ]; then echo amd64; return 0; fi + return 1 +} +selected="$(bitfun_pull_relay_image linux/amd64)" +test "$selected" = "m.daocloud.io/ghcr.io/gcwing/bitfun-relay-server@$BITFUN_RELAY_IMAGE_DIGEST" +"#, + ) + .arg("image-route-failover") + .arg(&script_path) + .arg(&trace_path) .output() - .expect("parse generated deploy script"); + .expect("run image route harness"); assert!( output.status.success(), - "generated deploy script is invalid:\n{}", + "image route harness failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); + let pulls = std::fs::read_to_string(trace_path).expect("read pull trace"); + let routes: Vec<_> = pulls.lines().collect(); + assert_eq!(routes.len(), 2); + assert!(routes[0].contains("ghcr.nju.edu.cn/gcwing/bitfun-relay-server@sha256:")); + assert!(routes[1].contains("m.daocloud.io/ghcr.io/gcwing/bitfun-relay-server@sha256:")); } #[cfg(unix)] diff --git a/src/web-ui/src/features/relay-deploy/README.md b/src/web-ui/src/features/relay-deploy/README.md index f5a1b8745b..b8d33fe4e4 100644 --- a/src/web-ui/src/features/relay-deploy/README.md +++ b/src/web-ui/src/features/relay-deploy/README.md @@ -1,8 +1,8 @@ # One-click Relay Deploy -Desktop wizard that SSHes to a user-owned Linux host and deploys the matching -published Relay binary in a lightweight Docker image, with the source Docker -build retained as an automatic fallback. Account import remains optional. +Desktop wizard that SSHes to a user-owned Linux host, installs Docker when it +is missing, pulls the signed BitFun Relay image, and starts it. Account import +remains optional. Entry points: @@ -16,158 +16,98 @@ 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. - 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/`. - -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`). - -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. - -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. - -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. - -12. **Port conflict ≠ our relay.** `port_busy && !port_owned_by_relay` blocks - deploy; busy-because-bitfun-relay does not. - -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. - -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. - -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. The wizard exposes - `auto | cn | global`; the selected value is staged on the host and must reach - Docker install, published-binary deploy, and the source fallback unchanged. - Manual deploy can force the same choice with `BITFUN_MIRROR=cn|global`. - Docker daemon metadata must stay outside `daemon.json`; host Cargo config - must remain untouched; global mode rolls back only BitFun-managed apt and - Docker entries. - -15a. **A runtime-image build has two independent mirror hops.** Docker daemon - `registry-mirrors` accelerates `FROM debian:trixie-slim`; it does not affect - the `apt-get` that later runs inside that image. The generated release - Dockerfile must therefore receive `BITFUN_USE_CN_MIRROR` and - `BITFUN_APT_MIRROR` as build args, just like the source-build Dockerfile. - Keep bounded apt retries/timeouts on both paths. A log line that says - `Mirror mode: cn` followed by `deb.debian.org` is a propagation regression, - not a failed region detection. - -16. **Scripts on the relay host are LF-only, in three independent layers.** - `include_str!` and the `r#"..."#` remote templates both inherit the - checkout's line endings, and Git for Windows checks out CRLF by default. - Remote bash then runs the CR as a command and `set -euo pipefail` aborts on - the first blank line (`deploy.sh: line 37: $'\r': command not found`). - - `.gitattributes` pins LF, so the binary carries no CR. - - `to_unix_script` normalizes everything sent over SFTP or `execute_command`, - so a stale CRLF working tree still builds a working client. - - `stage_scripts_command` strips CR **on the host, after upload and before - the PTY runs the driver**. This is the layer that does not depend on the - uploader remembering anything: a new `sftp_write` that forgets - `to_unix_script` is still safe. Keep it that way — do not move the strip - back to the client only. - -17. **`sg -c` takes a single string, so quote every argument.** `sg docker -c - "docker $*"` re-parses through a second shell and loses argument boundaries. - Use `bitfun_shell_join` (`shell_join` in `common.sh`). - -18. **`DOCKER_CONFIG` must be usable by whoever runs docker.** The Docker-install - task runs as root with the SSH user's `HOME`, so it must hand `~/.bitfun` - back to that user, and no `sudo` invocation may forward the user's - `DOCKER_CONFIG` to root. A root-owned `config.json` makes the CLI warn and - then mis-dispatch the build. Deploy repairs the config unconditionally — it - cannot rely on `bitfun_resolve_docker_mode`, which is skipped when the driver - already resolved a non-direct mode. - -19. **Losing the runtime image build costs 20 minutes.** Retry it (clean Docker - config, then classic builder) before falling back to a source rebuild. - -19a. **The runtime base image's glibc must cover every archive a client might - still install — not just what CI builds today.** arm64 releases through - v0.2.14 were built on ubuntu-24.04-arm and require **GLIBC_2.38**; on - `debian:bookworm-slim` (2.36) the relay could not load at all, and the deploy - showed only a failed health check. The release matrix now pins both arches to - ubuntu-22.04 (glibc 2.35, asserted by `scripts/ci/check-glibc-floor.sh`), but - that does **not** make bookworm safe again: Desktop pins the release tag to - its own version, so a v0.2.14 client installs the 2.38 archive forever. Base - stays `debian:trixie-slim` (2.41). The image build also greps `ldd` output to - fail fast on a mismatch — `ldd` exits 0 even while reporting an unsatisfied - symbol version, so its *output* is the gate, and the relay binary is no use - as a probe because it has no `--version` and just starts serving. - -19b. **The source-build fallback must not redo the release path.** - `bitfun_run_deploy_sh` is reached only after `bitfun_try_release_deploy` - failed, and `deploy.sh` begins with that same release path — so it must be - invoked with `--build-from-source`. Otherwise the published-binary attempt - runs twice, visibly, before the source build. - -19c. **Never send container diagnostics to /dev/null.** `docker logs` relays the - container's stderr on its own stderr and the relay logs through tracing, so - `2>/dev/null` discards exactly the line that explains the failure. - -20. **Prepare-phase death must surface as failure.** The driver claims - `.driver.pid` before anything that can fail. Poll keeps reporting - `preparing` while that pid is alive — an open sudo prompt is unbounded — but - a missing/dead driver past the grace window is `failed`, not perpetual - "running". A dying driver writes to the PTY, not the log, so the log pane can - be empty. +1. **One click means install-if-needed, pull, start.** The deploy action must + continue through Docker Engine installation in the same interactive task. + Docker Compose, git, tar, Cargo, and a source checkout are not prerequisites. + +2. **Customer servers never build Relay.** The normal Desktop path contains no + archive extraction, `docker build`, repository sync, or source compilation, + and it never silently falls back to those operations. Manual + `deploy.sh --build-from-source` remains an explicit maintenance escape hatch. + +3. **Authenticate the image before touching the server.** Each release publishes + `relay-image.json` and `relay-image.json.sig`. Desktop verifies the descriptor + using its compiled-in minisign trust root, validates the exact repository, + release tag, lowercase SHA256 digest, and both supported platforms, then sends + only that trusted repository + digest to the remote script. + +4. **Always start by digest.** Tags are discovery metadata, not an execution + identity. One-click deploy sets `BITFUN_REQUIRE_IMAGE_DIGEST=1`; Docker pulls + and runs `@sha256:...`, so every manifest and layer remains + content-addressed. + +5. **Regional prefixes are transport, not trust roots.** Global mode pulls + `ghcr.io/gcwing/bitfun-relay-server` directly. China mode tries, in order, + `ghcr.nju.edu.cn/...`, `m.daocloud.io/ghcr.io/...`, then official GHCR. Every + route uses the same signed digest and has a bounded attempt before failover. + +6. **The release must be publicly pullable.** `desktop-package.yml` builds one + amd64/arm64 manifest, signs its descriptor, logs out of GHCR, and verifies an + anonymous manifest read. A private package must fail publication rather than + produce a green workflow customers cannot use. + +7. **One implementation, two callers.** Pull, route fallback, container start, + rollback, and health logic live in + `src/apps/relay-server/release-download.sh`; `deploy.sh` sources it and + `relay_deploy.rs` embeds it with `include_str!`. Do not fork that behavior + back into a Rust string template. + +8. **Preserve the container contract.** Keep container name `bitfun-relay`, + volumes `relay-server_relay-db` and `relay-server_room-web`, selected port, + `/app/data`, `/app/room-web`, and `/app/relay-admin` stable across upgrades. + +9. **Never stop a healthy Relay before the image is pulled.** Pull first, then + rename the existing container, start the replacement, and remove the backup + only after `/health` succeeds. Start, cancellation, or health failure must + restore the previous container. Keep container stderr in failure diagnostics. + +10. **Close wizard = cancel remote task.** Kill the detached body process tree. + The image script's TERM/INT trap owns restoration; cancellation must not + stop an unrelated healthy Relay or broad BuildKit/Compose processes. + +11. **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. + +12. **“Already deployed” is container-aware, not only selected-port health.** + Changing the listen port must not hide a running `bitfun-relay`. “Create + account” must use the running container's actual published port. + +13. **Port conflict ≠ our Relay.** `port_busy && !port_owned_by_relay` blocks + deploy; busy-because-bitfun-relay does not. + +14. **Privilege handling stays interactive and minimal.** Never call `sudo -v` + unconditionally. Detect root / passwordless sudo / interactive sudo. A + missing Docker engine elevates once, installs through the selected regional + route, repairs ownership, and continues without requiring a new login. + +15. **`DOCKER_CONFIG` must remain usable by the SSH user.** Root installation + keeps the user's HOME, so hand `~/.bitfun` back before continuing. Repair or + relocate an unreadable Docker config before any pull. Do not forward the + user's config into an unrelated root home. + +16. **Scripts on the Relay host are LF-only in three layers.** `.gitattributes` + pins LF; `to_unix_script` normalizes generated uploads; and + `stage_scripts_command` strips CR on the host before execution. Keep the + host-side defense even when the client already normalized bytes. + +17. **`sg -c` takes one string.** Quote every Docker argument with + `bitfun_shell_join` (`shell_join` in `common.sh`); never interpolate `$*` + directly through the second shell. + +18. **Prepare-phase death must surface as failure.** Keep reporting `preparing` + while the driver PID is alive (a sudo prompt is unbounded), but treat a dead + driver past the grace window as failed rather than running forever. + +19. **The runtime image keeps the compatibility gate.** `Dockerfile.release` + uses `debian:trixie-slim` and inspects `ldd` output for both binaries. This + covers older arm64 Relay artifacts that required GLIBC 2.38 even though the + current release builders assert a GLIBC 2.35 ceiling. + +20. **Release metadata has a China byte mirror, not a second authority.** + `openbitfun-release-sync.sh` mirrors the signed descriptor into the matching + version directory. Desktop may fetch those bytes when GitHub is unreachable, + but the same built-in minisign key must verify them. ## Related docs diff --git a/src/web-ui/src/features/relay-deploy/RelayDeployWizard.tsx b/src/web-ui/src/features/relay-deploy/RelayDeployWizard.tsx index 42708ee242..189ec70d81 100644 --- a/src/web-ui/src/features/relay-deploy/RelayDeployWizard.tsx +++ b/src/web-ui/src/features/relay-deploy/RelayDeployWizard.tsx @@ -240,7 +240,7 @@ export const RelayDeployWizard: React.FC = ({ // ── lifecycle ──────────────────────────────────────────────────────────── // Closing the wizard MUST cancel the remote task (kill pid tree / best-effort - // compose stop). Never leave a nohup Docker build running after dismiss. + // the image script's rollback trap). Never leave a detached pull running after dismiss. useEffect(() => { if (!isOpen) { stopPolling(); @@ -575,20 +575,6 @@ export const RelayDeployWizard: React.FC = ({ startTaskPolling(task, connId); }, [closeDeployTerminal, startTaskPolling]); - const handleInstallDocker = async () => { - if (!connectionId) return; - setError(null); - setTaskStatus('running'); - setActiveTask('install_docker'); - try { - const started = await relayDeployApi.installDocker(connectionId, mirrorMode); - await launchInteractiveTask('install_docker', connectionId, started.scriptPath); - } catch (e) { - setTaskStatus('failed'); - setError(`[start] ${errMsg(e)}`); - } - }; - const handleStartDeploy = async () => { if (!connectionId) return; const port = parseRelayPort(relayPortInput); @@ -718,19 +704,12 @@ export const RelayDeployWizard: React.FC = ({ const accessMode = preflight?.dockerAccessMode; const dockerRecoverable = !!preflight && preflight.dockerInstalled - && accessMode !== 'missing' - && (preflight.composeAvailable - || accessMode === 'sudo_nopass' - || accessMode === 'sudo_needs_password' - || accessMode === 'group_inactive' - || accessMode === 'broken_docker_home' - || accessMode === 'daemon_down'); + && accessMode !== 'missing'; const canInstallDocker = !!preflight && !preflight.dockerInstalled && (preflight.sudoAvailable || preflight.sudoNeedsPassword); const portValid = parseRelayPort(relayPortInput) != null; const canDeploy = !!preflight && preflight.archSupported - && preflight.curlAvailable && preflight.tarAvailable - && dockerRecoverable + && (dockerRecoverable || canInstallDocker) && portValid && (!preflight.portBusy || preflight.portOwnedByRelay); @@ -955,6 +934,7 @@ export const RelayDeployWizard: React.FC = ({ const taskFailed = activeTask === 'install_docker' && taskStatus === 'failed'; const dockerOk = pf?.dockerAccessMode === 'ok'; const dockerWarn = !!pf?.dockerInstalled && !dockerOk && pf.dockerAccessMode !== 'missing'; + const dockerWillInstall = !!pf && !pf.dockerInstalled && canInstallDocker; return (
@@ -1044,31 +1024,11 @@ export const RelayDeployWizard: React.FC = ({ : `${pf.os} / ${pf.arch} — ${t('relayDeploy.checkOsUnsupported')}`, )} {renderCheckRow( - dockerOk ? true : dockerWarn ? 'warn' : false, + dockerOk ? true : (dockerWarn || dockerWillInstall) ? 'warn' : false, t('relayDeploy.checkDocker'), - dockerAccessHint(pf.dockerAccessMode), - )} - {renderCheckRow( - !pf.dockerInstalled ? 'warn' : pf.composeAvailable, - t('relayDeploy.checkCompose'), - pf.composeAvailable ? t('relayDeploy.checkDockerOk') : t('relayDeploy.checkComposeMissing'), - )} - {renderCheckRow( - pf.curlAvailable, - 'curl', - pf.curlAvailable ? t('relayDeploy.checkDockerOk') : t('relayDeploy.checkMissing'), - )} - {renderCheckRow( - pf.tarAvailable, - 'tar', - pf.tarAvailable ? t('relayDeploy.checkDockerOk') : t('relayDeploy.checkMissing'), - )} - {renderCheckRow( - pf.memTotalMb === 0 ? 'warn' : pf.memTotalMb >= 2048 ? true : 'warn', - t('relayDeploy.checkMemory'), - pf.memTotalMb >= 2048 - ? t('relayDeploy.checkMemoryValue', { mb: pf.memTotalMb }) - : `${t('relayDeploy.checkMemoryValue', { mb: pf.memTotalMb })} — ${t('relayDeploy.checkMemoryLow')}`, + dockerWillInstall + ? t('relayDeploy.dockerAutoInstallHint') + : dockerAccessHint(pf.dockerAccessMode), )} {renderCheckRow( !pf.portBusy || pf.portOwnedByRelay, @@ -1148,12 +1108,6 @@ export const RelayDeployWizard: React.FC = ({ {t('relayDeploy.back')} - {canInstallDocker && ( - - )} {!pf.dockerInstalled && !canInstallDocker && !taskRunning && ( {t('relayDeploy.dockerManualHint')} )} diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 85e19f711e..6e4f7d8372 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -652,7 +652,7 @@ "mirrorModeAuto": "Auto-detect (recommended)", "mirrorModeCn": "Mainland China acceleration", "mirrorModeGlobal": "Global official sources", - "mirrorModeHint": "Auto mode detects the server's network region and selects matching routes for Docker, Debian, GitHub, and Cargo. Override it when cloud IP geolocation is inaccurate.", + "mirrorModeHint": "Auto mode detects the server's network region, using GHCR accelerators in mainland China and official GHCR elsewhere. Override it when cloud IP geolocation is inaccurate.", "portConflictTitle": "Port {{port}} is already in use", "portConflictDesc": "Change the port above; the check refreshes automatically. Deploy will use your selected port.", "portInvalid": "Enter a valid port between 1 and 65535", @@ -663,6 +663,7 @@ "installDocker": "Install Docker Automatically", "installingDocker": "Installing Docker… Enter sudo password in the terminal if prompted.", "dockerInstallFailed": "Docker installation failed. Check the terminal or install manually.", + "dockerAutoInstallHint": "Not installed — one-click deploy will install Docker first", "dockerManualHint": "Docker is required. Install it manually, then re-check.", "interactiveTerminalHint": "Deployment uses an interactive terminal: you can enter sudo passwords there. Closing this window stops the remote task.", "openingTerminal": "Opening remote terminal…", @@ -673,7 +674,7 @@ "redeploy": "Redeploy", "startDeploy": "Start Deployment", "deployingTitle": "Deploying relay server…", - "deployingHint": "Use the terminal below for sudo prompts and live output. The first build can take several minutes. Closing this window stops the remote deploy.", + "deployingHint": "One-click deploy installs Docker when needed, pulls the signed Relay image through the selected route, and starts it. Use the terminal for sudo prompts. Closing this window stops the remote deploy.", "waitingRemoteOutput": "Waiting for remote output…", "deploySucceeded": "Deployment succeeded", "deployFailed": "Deployment failed", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index b5cddcee5e..8bca95499e 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -652,7 +652,7 @@ "mirrorModeAuto": "自动检测(推荐)", "mirrorModeCn": "中国大陆加速", "mirrorModeGlobal": "全球官方源", - "mirrorModeHint": "自动模式会在服务器上检测网络区域,并据此为 Docker、Debian、GitHub 和 Cargo 选择相应线路。若云服务器 IP 定位不准,可在此强制选择。", + "mirrorModeHint": "自动模式会检测服务器网络区域:中国大陆优先使用 GHCR 加速线路,海外使用官方 GHCR。若云服务器 IP 定位不准,可在此强制选择。", "portConflictTitle": "端口 {{port}} 已被占用", "portConflictDesc": "请修改上方端口后会自动重新检测。部署将使用你选择的端口。", "portInvalid": "请输入 1–65535 之间的有效端口", @@ -663,6 +663,7 @@ "installDocker": "自动安装 Docker", "installingDocker": "正在安装 Docker… 如需 sudo 密码请在终端中输入。", "dockerInstallFailed": "Docker 安装失败,请查看终端或手动安装。", + "dockerAutoInstallHint": "尚未安装;一键部署会先自动安装 Docker", "dockerManualHint": "需要 Docker。请手动安装后重新检测。", "interactiveTerminalHint": "部署使用交互式终端:可在其中输入 sudo 密码。关闭本窗口会停止远端任务。", "openingTerminal": "正在打开远端终端…", @@ -673,7 +674,7 @@ "redeploy": "重新部署", "startDeploy": "开始部署", "deployingTitle": "正在部署 relay server…", - "deployingHint": "请在下方终端输入 sudo 密码并查看实时输出。首次构建可能持续几分钟。关闭本窗口会停止远端部署。", + "deployingHint": "一键部署会在需要时安装 Docker,再通过所选线路拉取已签名的 Relay 镜像并启动。若提示 sudo,请在下方终端输入密码;关闭本窗口会停止远端部署。", "waitingRemoteOutput": "正在等待远端输出…", "deploySucceeded": "部署成功", "deployFailed": "部署失败", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 66d2d87bab..6548345cba 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -652,7 +652,7 @@ "mirrorModeAuto": "自動偵測(建議)", "mirrorModeCn": "中國大陸加速", "mirrorModeGlobal": "全球官方來源", - "mirrorModeHint": "自動模式會在伺服器上偵測網路區域,並據此為 Docker、Debian、GitHub 與 Cargo 選擇相應線路。若雲端伺服器 IP 定位不準,可在此強制選擇。", + "mirrorModeHint": "自動模式會偵測伺服器網路區域:中國大陸優先使用 GHCR 加速線路,海外使用官方 GHCR。若雲端伺服器 IP 定位不準,可在此強制選擇。", "portConflictTitle": "連接埠 {{port}} 已被佔用", "portConflictDesc": "請修改上方連接埠,系統會自動重新檢測。部署將使用你選擇的連接埠。", "portInvalid": "請輸入 1–65535 之間的有效連接埠", @@ -663,6 +663,7 @@ "installDocker": "自動安裝 Docker", "installingDocker": "正在安裝 Docker… 如需 sudo 密碼請在終端機中輸入。", "dockerInstallFailed": "Docker 安裝失敗,請查看終端機或手動安裝。", + "dockerAutoInstallHint": "尚未安裝;一鍵部署會先自動安裝 Docker", "dockerManualHint": "需要 Docker。請手動安裝後重新檢測。", "interactiveTerminalHint": "部署使用互動式終端機:可在其中輸入 sudo 密碼。關閉本視窗會停止遠端任務。", "openingTerminal": "正在開啟遠端終端機…", @@ -673,7 +674,7 @@ "redeploy": "重新部署", "startDeploy": "開始部署", "deployingTitle": "正在部署 relay server…", - "deployingHint": "請在下方終端機輸入 sudo 密碼並查看即時輸出。首次建構可能持續幾分鐘。關閉本視窗會停止遠端部署。", + "deployingHint": "一鍵部署會在需要時安裝 Docker,再透過所選線路拉取已簽署的 Relay 映像並啟動。若提示 sudo,請在下方終端機輸入密碼;關閉本視窗會停止遠端部署。", "waitingRemoteOutput": "正在等待遠端輸出…", "deploySucceeded": "部署成功", "deployFailed": "部署失敗",