From 2a48e51adfb16ddc0230ee7d53845451ef2f6394 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 8 Sep 2026 17:42:51 +0800 Subject: [PATCH] fix(relay): restore one-click source fallback and release integrity --- .dockerignore | 2 + .github/workflows/ci.yml | 3 + .github/workflows/desktop-package.yml | 15 +- docs/development/releasing.md | 10 + scripts/check-github-config.test.mjs | 47 +++ scripts/linux-binaries-manifest.test.mjs | 37 ++ scripts/openbitfun-release-sync.sh | 9 +- scripts/relay/package-contract.test.mjs | 9 + scripts/relay/source-build.test.mjs | 133 +++++++ src/apps/relay-server/Dockerfile | 1 + src/apps/relay-server/README.md | 11 +- src/apps/relay-server/release-download.sh | 10 +- src/apps/relay-server/source-build.sh | 81 +++++ .../services/services-integrations/AGENTS.md | 1 + .../src/remote_ssh/relay_deploy.rs | 336 +++++++++++++++--- .../src/features/relay-deploy/README.md | 45 ++- .../relay-deploy/RelayDeployWizard.tsx | 37 +- src/web-ui/src/locales/en-US/common.json | 2 +- src/web-ui/src/locales/zh-CN/common.json | 2 +- src/web-ui/src/locales/zh-TW/common.json | 2 +- 20 files changed, 698 insertions(+), 95 deletions(-) create mode 100644 scripts/relay/source-build.test.mjs create mode 100644 src/apps/relay-server/source-build.sh diff --git a/.dockerignore b/.dockerignore index 7e46665a23..e692376038 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,8 @@ !src/apps/relay-server/Cargo.toml !src/apps/relay-server/src/ !src/apps/relay-server/src/** +!src/apps/relay-server/static/ +!src/apps/relay-server/static/** !src/crates/ !src/crates/services/ !src/crates/services/relay-service/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06d7c578c0..a68730e266 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,9 @@ jobs: - name: Verify release and version-generation contracts run: node --test scripts/release-channel.test.mjs scripts/tauri-release-manifest.test.mjs scripts/linux-binaries-manifest.test.mjs scripts/version-generation.test.mjs + - name: Validate Relay deployment and packaging contracts + run: node --test scripts/relay/*.test.mjs + - name: Verify minisign download fallback run: | set -euo pipefail diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 44e90b0601..20cf1aba18 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -88,11 +88,10 @@ jobs: TAG="${INPUT_TAG_NAME}" VERSION="${TAG#v}" CHANNEL="${INPUT_RELEASE_CHANNEL:-stable}" - # 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}" - elif [[ -n "${INPUT_CHECKOUT_REF}" ]]; then + # Image-only rebuilds use the same immutable source tag as their + # archives. Using the workflow branch breaks the tag/SHA guard and + # can combine a new Dockerfile with unrelated release binaries. + if [[ -n "${INPUT_CHECKOUT_REF}" ]]; then CHECKOUT_REF="${INPUT_CHECKOUT_REF}" else CHECKOUT_REF="${TAG}" @@ -751,7 +750,11 @@ jobs: 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/${{ github.repository }}/releases/download/${{ needs.prepare.outputs.release_tag }}/relay-image.json.sig" \ - -o /dev/null + -o relay-image.published.json.sig + # These exact bytes were signed in the image job. Detect a stale or + # crossed upload pair before advertising a release as ready. + cmp relay-image-assets/relay-image.json relay-image.published.json + cmp relay-image-assets/relay-image.json.sig relay-image.published.json.sig - name: Resolve beta channel promotion id: beta-channel diff --git a/docs/development/releasing.md b/docs/development/releasing.md index 4afebec5e0..043db2969f 100644 --- a/docs/development/releasing.md +++ b/docs/development/releasing.md @@ -44,6 +44,9 @@ pass. With `upload_to_release` disabled, the workflow keeps CLI/Relay archives in Actions artifacts and validates the runtime image build without pushing it. The explicit `relay_image_only` backfill mode remains a publishing operation. +It resolves the same immutable tag as the existing archives, rather than the +current workflow commit. Releases predating the current OpenBitFun artifact +layout are not image-rebuild inputs. Install Beta CLI archives manually and deploy the Relay with an explicit Beta image tag or its signed descriptor's digest. The default CLI install/update and @@ -51,6 +54,9 @@ Relay one-click deployment paths stay on stable; Beta CLI builds do not run stable-feed automatic update checks. This does not add a runtime channel switch or a Beta option to one-click deployment. The stable CLI/Relay mirror manifests and the Desktop-only `channel-beta/latest.json` pointer remain unchanged. +When no usable current stable Relay image exists, one-click deployment builds +current source on the target host automatically. It does not deploy a differently +named product image or silently promote a Beta image to stable. The selected ref must resolve to a commit in the protected `main` history. The workflow pins that SHA before dispatching platform jobs and rejects an existing @@ -82,6 +88,10 @@ stable-only CLI and Relay floating manifests. Production cron must run this in-repo script from the OpenBitFun checkout. Do not create a detached copy. Host paths, Nginx, and the rest of the origin restore steps live in [`deploy/openbitfun-host/README.md`](../../deploy/openbitfun-host/README.md). +The sync resolves the exact release directory from the updater manifest once; +Relay and Linux metadata use that same directory to avoid mixed-version reads +when GitHub's latest-release pointer changes. Publication also compares the +downloaded Relay descriptor and signature to the image job's signed bytes. ## Focused packaging checks diff --git a/scripts/check-github-config.test.mjs b/scripts/check-github-config.test.mjs index c624225654..7d129a6ba7 100644 --- a/scripts/check-github-config.test.mjs +++ b/scripts/check-github-config.test.mjs @@ -1295,6 +1295,53 @@ test('stable and beta publication require every producer, while artifact-only ru } }); +test('Relay image rebuild resolves the release tag instead of the newer workflow commit', (t) => { + const { jobs } = yaml.parse(readFileSync( + path.join(repoRoot, '.github/workflows/desktop-package.yml'), 'utf8', + )); + const step = jobs.prepare.steps.find((entry) => entry.name === 'Resolve version metadata'); + const verification = jobs['upload-release-assets'].steps.find((entry) => entry.name === 'Verify published Relay image descriptor'); + assert.match(verification.run, /cmp relay-image-assets\/relay-image.json relay-image.published.json/); + assert.match(verification.run, /cmp relay-image-assets\/relay-image.json.sig relay-image.published.json.sig/); + const root = mkdtempSync(path.join(tmpdir(), 'openbitfun-relay-rebuild-ref-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const tagSha = 'a'.repeat(40); + const headSha = 'b'.repeat(40); + for (const checkoutRef of ['', 'newer-branch']) { + const output = path.join(root, `output-${checkoutRef || 'tag'}`); + const result = spawnSync('bash', ['-c', ` + git() { + case "$1" in + fetch|merge-base) return 0 ;; + rev-parse) + case "\${!#}" in + 'v1.0.0^{commit}') echo "$TAG_SHA" ;; + *) echo "$GITHUB_SHA" ;; + esac ;; + esac + } + ${step.run.replaceAll('${{ github.event.release.prerelease }}', 'false')} + `], { + cwd: repoRoot, encoding: 'utf8', windowsHide: true, + env: { + ...process.env, + GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_SHA: headSha, + GITHUB_REPOSITORY: 'GCWing/OpenBitFun', GITHUB_OUTPUT: output, + INPUT_TAG_NAME: 'v1.0.0', INPUT_CHECKOUT_REF: checkoutRef, + INPUT_RELEASE_CHANNEL: 'stable', INPUT_UPLOAD_TO_RELEASE: 'true', + INPUT_RELAY_IMAGE_ONLY: 'true', TAG_SHA: tagSha, + }, + }); + if (checkoutRef) { + assert.notEqual(result.status, 0); + assert.match(result.stderr, /not requested commit/); + } else { + assert.equal(result.status, 0, result.stderr); + assert.match(readFileSync(output, 'utf8'), new RegExp(`checkout_ref=${tagSha}`)); + } + } +}); + test('Relay image tag selection keeps Beta and old stable backfills away from latest', { skip: process.platform === 'win32', }, (t) => { diff --git a/scripts/linux-binaries-manifest.test.mjs b/scripts/linux-binaries-manifest.test.mjs index 56600ec054..98f94d922b 100644 --- a/scripts/linux-binaries-manifest.test.mjs +++ b/scripts/linux-binaries-manifest.test.mjs @@ -169,6 +169,43 @@ test('openbitfun sync mirrors both products and their checksums', () => { assert.match(syncScript, /WEBSITE_RELEASE_DIR.*relay-image\.json/); }); +test('release sync pins Relay and Linux metadata to the updater release during latest rotation', (t) => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'openbitfun-release-metadata-')); + t.after(() => fs.rmSync(temp, { recursive: true, force: true })); + const urls = path.join(temp, 'urls'); + const releaseBase = 'https://github.com/GCWing/OpenBitFun/releases/download/v1.0.0'; + const result = spawnSync('bash', ['-c', ` + source "$SYNC_SCRIPT" + flock() { return 0; } + curl() { printf '%s' "$TEST_LATEST_JSON"; } + mirror_relay_image_descriptor() { printf '%s\\n' "$GITHUB_RELAY_IMAGE_URL" >> "$TEST_URLS"; } + mirror_linux_binaries() { printf '%s\\n' "$GITHUB_LINUX_BINARIES_URL" >> "$TEST_URLS"; } + mirror_dispatch_macos_cli_archives() { :; } + download_asset() { :; } + mirror_windows_installer() { :; } + write_website_download_manifest() { :; } + publish_file_atomically() { :; } + main + `], { + cwd: repoRoot, encoding: 'utf8', windowsHide: true, + env: { + ...process.env, + SYNC_SCRIPT: path.join(repoRoot, 'scripts/openbitfun-release-sync.sh'), + OPENBITFUN_RELEASE_CHANNEL: 'stable', + WEBSITE_RELEASE_DIR: path.join(temp, 'release'), + OPENBITFUN_RELEASE_SYNC_LOCK: path.join(temp, 'sync.lock'), + TEST_URLS: urls, + TEST_LATEST_JSON: JSON.stringify({ version: '1.0.0', platforms: { + 'linux-x86_64': { url: `${releaseBase}/OpenBitFun_1.0.0_linux-x86_64.AppImage` }, + } }), + }, + }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(fs.readFileSync(urls, 'utf8').trim().split('\n'), [ + `${releaseBase}/relay-image.json`, `${releaseBase}/linux-binaries.json`, + ]); +}); + test('openbitfun sync mirrors the website installer from the exact updater release', () => { const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'openbitfun-windows-installer-mirror-')); const versionDir = path.join(temp, 'release', '1.2.3'); diff --git a/scripts/openbitfun-release-sync.sh b/scripts/openbitfun-release-sync.sh index bfd120d4f4..3a15cfbc36 100755 --- a/scripts/openbitfun-release-sync.sh +++ b/scripts/openbitfun-release-sync.sh @@ -541,10 +541,8 @@ main() { } log "Latest version: $VERSION" - # Resolve the exact tagged release directory from the updater URLs. Using -# this base for the standalone installer avoids a latest-release race where -# latest.json and the manual installer could otherwise resolve to different - # versions during publication. + # Resolve one immutable release directory for every artifact. Independent + # latest/download requests can cross versions while a release is published. RELEASE_ASSET_BASE_URL=$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c " import json, sys data = json.load(sys.stdin) @@ -557,6 +555,9 @@ print(bases.pop()) exit 1 } + GITHUB_RELAY_IMAGE_URL="${RELEASE_ASSET_BASE_URL}/relay-image.json" + GITHUB_LINUX_BINARIES_URL="${RELEASE_ASSET_BASE_URL}/linux-binaries.json" + INSTALLER_METADATA=$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c " import json, sys data = json.load(sys.stdin) diff --git a/scripts/relay/package-contract.test.mjs b/scripts/relay/package-contract.test.mjs index 6f74686501..14a83b3ad2 100644 --- a/scripts/relay/package-contract.test.mjs +++ b/scripts/relay/package-contract.test.mjs @@ -17,6 +17,15 @@ test('relay archive contains the runtime and admin binaries plus static assets', assert.match(packageScript, /\.sha256/); }); +test('source image is self-contained without the manual Compose static mount', () => { + const sourceImage = read('src/apps/relay-server/Dockerfile'); + const context = read('.dockerignore'); + assert.match(sourceImage, /COPY src\/apps\/relay-server\/static\/ \/app\/static\//); + assert.match(context, /^!src\/apps\/relay-server\/static\/$/m); + assert.match(context, /^!src\/apps\/relay-server\/static\/\*\*$/m); + assert.ok(read('src/apps/relay-server/static/index.html').length > 0); +}); + test('formal and nightly releases gate publication on Linux binaries', () => { const desktop = read('.github/workflows/desktop-package.yml'); const nightly = read('.github/workflows/nightly.yml'); diff --git a/scripts/relay/source-build.test.mjs b/scripts/relay/source-build.test.mjs new file mode 100644 index 0000000000..1170717444 --- /dev/null +++ b/scripts/relay/source-build.test.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +const repoRoot = path.resolve(import.meta.dirname, '../..'); +const shell = ` +set -euo pipefail +source "$RELEASE_SCRIPT" +source "$SOURCE_SCRIPT" +openbitfun_relay_native_platform() { echo linux/amd64; } +openbitfun_image_docker_with_timeout() { shift; openbitfun_image_docker "$@"; } +openbitfun_image_docker() { + printf '%s\\n' "$*" >> "$CALLS" + case "$1" in + pull) [[ "$SCENARIO" = image_* ]] ;; + build) + if [ "$SCENARIO" = build_failed ]; then return 1; fi + if [ "$SCENARIO" = cancelled ]; then kill -TERM "$(sh -c 'echo "$PPID"')"; return 1; fi + while [ "$#" -gt 0 ]; do + if [ "$1" = --iidfile ]; then printf 'sha256:%064d' 1 > "$2"; break; fi + shift + done + ;; + image) echo amd64 ;; + container) return 0 ;; # An existing, healthy Relay must be preserved. + exec) + [ "$SCENARIO" != health_failed ] || return 1 + [ "$SCENARIO" != image_health_failed ] || [[ "$OPENBITFUN_RELAY_IMAGE" = source:* ]] + ;; + run) + [ "$SCENARIO" != image_start_failed ] || [[ "$OPENBITFUN_RELAY_IMAGE" = source:* ]] + ;; + inspect) echo false ;; + ps) return 0 ;; + *) return 0 ;; + esac +} +openbitfun_deploy_with_source_fallback "$MODE" "$SOURCE_ROOT" +echo RELAY_TASK_DONE +`; + +function runScenario(t, scenario, mode = 'image') { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'openbitfun-source-build-')); + t.after(() => fs.rmSync(temp, { recursive: true, force: true })); + const upstream = path.join(temp, 'upstream'); + fs.mkdirSync(path.join(upstream, 'src/apps/relay-server'), { recursive: true }); + fs.writeFileSync(path.join(upstream, 'src/apps/relay-server/Dockerfile'), 'FROM scratch\n'); + for (const args of [ + ['init', '-q', '-b', 'main'], ['add', '.'], + ['-c', 'user.name=Relay Test', '-c', 'user.email=relay@example.invalid', 'commit', '-qm', 'fixture'], + ]) { + const git = spawnSync('git', args, { cwd: upstream, encoding: 'utf8', windowsHide: true }); + assert.equal(git.status, 0, git.stderr); + } + const revision = spawnSync('git', ['rev-parse', 'HEAD'], { + cwd: upstream, encoding: 'utf8', windowsHide: true, + }).stdout.trim(); + const sourceRoot = path.join(temp, 'source with spaces'); + fs.mkdirSync(sourceRoot); + fs.writeFileSync(path.join(sourceRoot, 'user-file'), 'keep me'); + const calls = path.join(temp, 'calls'); + fs.writeFileSync(calls, ''); + const result = spawnSync('bash', ['-c', shell], { + encoding: 'utf8', windowsHide: true, timeout: 20_000, + env: { + ...process.env, + RELEASE_SCRIPT: path.join(repoRoot, 'src/apps/relay-server/release-download.sh'), + SOURCE_SCRIPT: path.join(repoRoot, 'src/apps/relay-server/source-build.sh'), + SOURCE_ROOT: sourceRoot, + OPENBITFUN_REPO_GIT_URL: scenario === 'download_failed' ? path.join(temp, 'missing') : upstream, + // Exercise route fallback without making a network request. + OPENBITFUN_GITHUB_GIT_URL: path.join(temp, 'unreachable-mirror'), + OPENBITFUN_MIRROR_REQUESTED_MODE: 'global', + OPENBITFUN_RELAY_IMAGE_DIGEST: `sha256:${'a'.repeat(64)}`, + OPENBITFUN_REQUIRE_IMAGE_DIGEST: '1', + MODE: mode, SCENARIO: scenario, CALLS: calls, + }, + }); + assert.deepEqual(fs.readdirSync(sourceRoot), ['user-file'], 'clean only the task checkout'); + return { ...result, calls: fs.readFileSync(calls, 'utf8'), revision }; +} + +test('available image succeeds without fetching or building source', (t) => { + const result = runScenario(t, 'image_ok'); + assert.equal(result.status, 0, result.stderr); + assert.doesNotMatch(result.calls, /^build /m); + assert.doesNotMatch(result.stdout, /Fetching current source/); + assert.match(result.stdout, /RELAY_TASK_DONE/); +}); + +for (const mode of ['source', 'image']) { + test(`${mode}: missing artifact or failed pull builds before replacing the existing Relay`, (t) => { + const result = runScenario(t, 'source_ok', mode); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /falling back to a source build/); + assert.ok(result.calls.indexOf('build ') < result.calls.indexOf('stop openbitfun-relay')); + assert.match(result.calls, new RegExp(`--build-arg RELAY_GIT_COMMIT=${result.revision}`)); + assert.match(result.calls, /--build-arg CARGO_BUILD_JOBS=1/); + assert.match(result.calls, /-v relay-server_relay-db:\/app\/data/); + assert.match(result.calls, /run .*sha256:0{63}1\n/); + assert.match(result.stdout, /RELAY_TASK_DONE/); + }); +} + +for (const scenario of ['build_failed', 'download_failed', 'cancelled']) { + test(`${scenario}: fail visibly without stopping the existing Relay`, (t) => { + const result = runScenario(t, scenario, 'source'); + assert.notEqual(result.status, 0); + assert.doesNotMatch(result.calls, /^(stop|rename|run|rm) /m); + assert.doesNotMatch(result.stdout, /RELAY_TASK_DONE/); + }); +} + +test('source health failure restores the previous container and reports failure', (t) => { + const result = runScenario(t, 'health_failed', 'source'); + assert.notEqual(result.status, 0); + assert.match(result.calls, /rename openbitfun-relay-before-image-\d+ openbitfun-relay/); + assert.match(result.calls, /start openbitfun-relay/); + assert.doesNotMatch(result.stdout, /RELAY_TASK_DONE/); +}); + +for (const scenario of ['image_start_failed', 'image_health_failed']) { + test(`${scenario}: restore service before attempting the source fallback`, (t) => { + const result = runScenario(t, scenario); + assert.equal(result.status, 0, result.stderr); + const restoration = result.calls.indexOf('start openbitfun-relay'); + assert.ok(restoration >= 0 && restoration < result.calls.indexOf('build ')); + assert.match(result.stdout, /RELAY_TASK_DONE/); + }); +} diff --git a/src/apps/relay-server/Dockerfile b/src/apps/relay-server/Dockerfile index e885877cbd..4b0b931b82 100644 --- a/src/apps/relay-server/Dockerfile +++ b/src/apps/relay-server/Dockerfile @@ -161,6 +161,7 @@ RUN set -eux; \ WORKDIR /app COPY --from=builder /out/openbitfun-relay-server /app/openbitfun-relay-server COPY --from=builder /out/relay-admin /app/relay-admin +COPY src/apps/relay-server/static/ /app/static/ RUN mkdir -p /app/static /app/data /app/room-web ENV RELAY_PORT=9700 diff --git a/src/apps/relay-server/README.md b/src/apps/relay-server/README.md index d4ad0281f4..74f778ec54 100644 --- a/src/apps/relay-server/README.md +++ b/src/apps/relay-server/README.md @@ -83,8 +83,9 @@ Use this checklist on a machine you control (VPS, LAN server, or localhost). OpenBitFun 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 latest amd64/arm64 image through the selected network 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 +starts it by immutable digest. If no usable published image exists, it automatically +builds the current OpenBitFun source in Docker and shows that fallback in the +terminal. Invalid release signatures remain an error. Pull or build 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. @@ -92,8 +93,10 @@ 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` -Task state lives under `~/.openbitfun/relay-deploy`; no repository checkout is -created. Closing the wizard cancels the remote task and restores a staged +Task state lives under `~/.openbitfun/relay-deploy`; temporary source checkouts +live under `~/.openbitfun/relay-src` and are cleaned after the build. The wizard +prepares Git and Docker Buildx if needed; host Rust and Compose are not required. +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`. diff --git a/src/apps/relay-server/release-download.sh b/src/apps/relay-server/release-download.sh index cd4a220a30..bbb6584724 100644 --- a/src/apps/relay-server/release-download.sh +++ b/src/apps/relay-server/release-download.sh @@ -222,8 +222,8 @@ openbitfun_restore_previous_relay() { openbitfun_run_relay_image() { local image_ref="$1" platform="$2" attempt stale - openbitfun_image_docker volume create relay-server_relay-db >/dev/null - openbitfun_image_docker volume create relay-server_room-web >/dev/null + openbitfun_image_docker volume create relay-server_relay-db >/dev/null || return 1 + openbitfun_image_docker volume create relay-server_room-web >/dev/null || return 1 OPENBITFUN_RELAY_BACKUP_CONTAINER="" if openbitfun_image_docker container inspect openbitfun-relay >/dev/null 2>&1; then @@ -260,7 +260,7 @@ openbitfun_run_relay_image() { -v relay-server_room-web:/app/room-web \ -v relay-server_relay-db:/app/data \ "$image_ref" >/dev/null; then - echo ">>> ERROR: the published Relay image could not start; restoring the previous container." >&2 + echo ">>> ERROR: the Relay image could not start; restoring the previous container." >&2 openbitfun_restore_previous_relay trap - INT TERM return 1 @@ -280,7 +280,7 @@ openbitfun_run_relay_image() { --filter 'name=^openbitfun-relay-before-release-' 2>/dev/null); do openbitfun_image_docker rm -f "$stale" >/dev/null 2>&1 || true done - echo ">>> Published Relay image is healthy." + echo ">>> Relay image is healthy." return 0 fi if ! openbitfun_image_docker inspect -f '{{.State.Running}}' openbitfun-relay 2>/dev/null | grep -qx true; then @@ -289,7 +289,7 @@ openbitfun_run_relay_image() { sleep 2 done - echo ">>> ERROR: published Relay image failed its health check; restoring the previous container." >&2 + echo ">>> ERROR: Relay image failed its health check; restoring the previous container." >&2 echo ">>> Container state: $(openbitfun_image_docker inspect \ -f 'running={{.State.Running}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} err={{.State.Error}}' \ openbitfun-relay 2>&1 || true)" diff --git a/src/apps/relay-server/source-build.sh b/src/apps/relay-server/source-build.sh new file mode 100644 index 0000000000..5872cfbce4 --- /dev/null +++ b/src/apps/relay-server/source-build.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Source fallback for the SSH deployment wizard. Requires mirror.sh and +# release-download.sh; container start/health/rollback remain shared with images. + +openbitfun_build_relay_from_source() ( + set -euo pipefail + local source_root="$1" platform="$2" checkout revision image_id repository + command -v git >/dev/null 2>&1 || { + echo ">>> ERROR: source fallback needs Git; install Git on this host and retry." >&2 + return 1 + } + mkdir -p "$source_root" + checkout="$(mktemp -d "$source_root/build-XXXXXXXX")" + # Freeze the path into the trap: Bash may unwind function locals before EXIT + # when errexit fires. Never let cleanup depend on a vanished local variable. + trap "rm -rf -- $(printf '%q' "$checkout")" EXIT + trap 'exit 1' INT TERM + + echo ">>> Building current OpenBitFun Relay from source. This can take several minutes." + echo ">>> The existing Relay will keep running until the build completes." + export GIT_TERMINAL_PROMPT=0 + git init -q "$checkout" + # Regional routing is shared with Docker installation. Each attempt fetches + # into a fresh task-owned checkout, never resetting a user's source tree. + local fetched=0 + for repository in "${OPENBITFUN_GITHUB_GIT_URL:-$OPENBITFUN_REPO_GIT_URL}" "$OPENBITFUN_REPO_GIT_URL"; do + echo ">>> Fetching current source from $repository" + if git -C "$checkout" -c core.hooksPath=/dev/null \ + -c http.lowSpeedLimit=1024 -c http.lowSpeedTime=60 \ + fetch --depth 1 "$repository" refs/heads/main; then + fetched=1 + break + fi + done + if [ "$fetched" != 1 ]; then + echo ">>> ERROR: source download failed on every route." >&2 + return 1 + fi + git -C "$checkout" -c core.hooksPath=/dev/null checkout -q --detach FETCH_HEAD + revision="$(git -C "$checkout" rev-parse HEAD)" + echo ">>> Building Relay source commit $revision for $platform" + test -f "$checkout/src/apps/relay-server/Dockerfile" + + export DOCKER_BUILDKIT=1 DOCKER_DEFAULT_PLATFORM="$platform" + openbitfun_image_docker build --progress plain --platform "$platform" \ + --iidfile "$checkout/relay-image.id" \ + --build-arg "CARGO_BUILD_JOBS=${RELAY_CARGO_BUILD_JOBS:-1}" \ + --build-arg "RELAY_GIT_COMMIT=$revision" \ + --build-arg "OPENBITFUN_USE_CN_MIRROR=${OPENBITFUN_USE_CN_MIRROR:-0}" \ + --build-arg "OPENBITFUN_APT_MIRROR=${OPENBITFUN_APT_MIRROR:-mirrors.aliyun.com}" \ + --build-arg "OPENBITFUN_CARGO_SPARSE_URL=${OPENBITFUN_CARGO_SPARSE_URL:-sparse+https://rsproxy.cn/index/}" \ + -f "$checkout/src/apps/relay-server/Dockerfile" "$checkout" + image_id="$(cat "$checkout/relay-image.id")" + if [[ ! "$image_id" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo ">>> ERROR: source build did not produce an immutable image ID." >&2 + return 1 + fi + local actual_arch + actual_arch="$(openbitfun_image_docker image inspect -f '{{.Architecture}}' "$image_id")" + if [ "$actual_arch" != "${platform#linux/}" ]; then + echo ">>> ERROR: source image architecture does not match $platform." >&2 + return 1 + fi + export OPENBITFUN_RELAY_IMAGE="source:$revision" + export OPENBITFUN_RELAY_IMAGE_DIGEST="$image_id" + # Starts by local image ID, with the same volumes, port, health and rollback + # contract as the published-image path. No Compose or host Rust is needed. + openbitfun_run_relay_image "$image_id" "$platform" +) + +openbitfun_deploy_with_source_fallback() { + local mode="$1" source_root="$2" platform + platform="$(openbitfun_relay_native_platform)" || return 1 + if [ "$mode" = image ]; then + if openbitfun_try_release_deploy; then return 0; fi + echo ">>> Published Relay deployment failed; falling back to a source build." + else + echo ">>> No published image is available for current OpenBitFun Relay; falling back to a source build." + fi + openbitfun_build_relay_from_source "$source_root" "$platform" +} diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 9f462d43ac..0f24fa140a 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -118,6 +118,7 @@ cargo test -p openbitfun-services-integrations --no-default-features --features cargo test -p openbitfun-services-integrations --no-default-features --features remote-ssh --test remote_ssh_contracts remote_ssh_disabled_contracts:: cargo test -p openbitfun-services-integrations --no-default-features --features remote-ssh-concrete --lib remote_ssh::manager::tests::workspace_ cargo test -p openbitfun-services-integrations --no-default-features --features remote-ssh-concrete --lib remote_ssh::wsl::tests:: +cargo test --locked -p openbitfun-services-integrations --no-default-features --features remote-ssh-concrete --lib remote_ssh::relay_deploy::tests:: cargo test --locked -p openbitfun-services-integrations --no-default-features --features remote-connect --lib remote_connect::relay_client::tests:: cargo test -p openbitfun-services-integrations --no-default-features --features file-watch --test file_watch_contracts cargo test --locked -p openbitfun-services-integrations --no-default-features --features deep-research --lib deep_research::tests:: 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 06d1bc1653..ac2a334cbe 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 @@ -12,7 +12,7 @@ //! 5. `import_account` — hand a locally-provisioned account to `relay-admin import-user`. //! //! Remote deploy state lives under the compiled product data directory. One-click deploy -//! never clones the repository or compiles on the customer server. +//! prefers published images and builds current source when no usable image is available. //! //! Product / regression invariants (wizard + entry points): //! `src/web-ui/src/features/relay-deploy/README.md`. Do not change clone destination, @@ -71,6 +71,10 @@ const RELAY_RELEASE_DOWNLOAD_SH: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../../apps/relay-server/release-download.sh" )); +const RELAY_SOURCE_BUILD_SH: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../apps/relay-server/source-build.sh" +)); /// 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 @@ -444,33 +448,18 @@ pub async fn start_task( port: u16, mirror_mode: RelayMirrorMode, ) -> Result { - let home = resolve_home(manager, connection_id).await?; - let dir = product_data_path(&home, &["relay-deploy"]); let stem = task.stem(); let port = normalize_relay_port(port)?; - - // Stop any leftover task from a previous attempt / closed wizard. - let _ = cancel_task(manager, connection_id, task).await; - - exec_ok( - manager, - connection_id, - &format!( - "mkdir -p {} && chmod 700 {}", - shell_quote_posix(&dir), - shell_quote_posix(&dir) - ), - ) - .await?; - let body = match task { RelayDeployTask::InstallDocker => install_docker_body_script(), RelayDeployTask::Deploy => { // 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_latest_relay_image_descriptor().await?; - deploy_body_script_with_image(port, &descriptor) + match verified_latest_relay_image_descriptor().await? { + Some(descriptor) => deploy_body_script_with_image(port, &descriptor), + None => deploy_body_script_from_source(port), + } } }; let driver = match task { @@ -478,6 +467,21 @@ pub async fn start_task( RelayDeployTask::Deploy => interactive_driver_script(stem, "deploy"), }; + // Resolve/authenticate release metadata before mutating remote task state. + let home = resolve_home(manager, connection_id).await?; + let dir = product_data_path(&home, &["relay-deploy"]); + let _ = cancel_task(manager, connection_id, task).await; + exec_ok( + manager, + connection_id, + &format!( + "mkdir -p {} && chmod 700 {}", + shell_quote_posix(&dir), + shell_quote_posix(&dir) + ), + ) + .await?; + let body_path = format!("{dir}/{stem}-body.sh"); let script_path = format!("{dir}/{stem}.sh"); let port_path = format!("{dir}/relay.port"); @@ -1026,6 +1030,23 @@ openbitfun_ensure_tools() { fi } +openbitfun_prepare_source_tools() { + openbitfun_ensure_tools git || return 1 + if openbitfun_docker buildx version >/dev/null 2>&1; then return 0; fi + echo ">>> Installing Docker Buildx for source fallback..." + if command -v apt-get >/dev/null 2>&1; then + openbitfun_priv apt-get update -y || return 1 + openbitfun_priv apt-get install -y docker-buildx-plugin \ + || openbitfun_priv apt-get install -y docker-buildx || return 1 + elif command -v dnf >/dev/null 2>&1; then + openbitfun_priv dnf install -y docker-buildx-plugin \ + || openbitfun_priv dnf install -y docker-buildx || return 1 + elif command -v yum >/dev/null 2>&1; then + openbitfun_priv yum install -y docker-buildx-plugin || return 1 + fi + openbitfun_docker buildx version >/dev/null 2>&1 +} + # 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 openbitfun_elevate_install_driver before calling this helper. @@ -1216,6 +1237,21 @@ openbitfun_docker() { esac } +# Long source builds may outlive sudo's timestamp. Refresh only the already +# authorized Docker command, only while this body exists; never change sudoers +# or try to prompt from the detached task. The driver's PTY owns the timestamp. +openbitfun_keep_docker_authorization() { + [ "${OPENBITFUN_DOCKER_MODE:-direct}" = sudo ] || return 0 + local owner_pid=$$ lease_pid + ( + while kill -0 "$owner_pid" 2>/dev/null && sudo -n docker version >/dev/null 2>&1; do + sleep 30 + done + ) & + lease_pid=$! + trap "kill $lease_pid 2>/dev/null || true" EXIT +} + "#; helpers .replace("__OPENBITFUN_PRODUCT_HOME__", &product_home_shell_path(&[])) @@ -1308,6 +1344,14 @@ else fi export OPENBITFUN_DOCKER_MODE +# Source fallback runs detached too. Prepare its host dependencies while +# sudo can still prompt; failure must not prevent an available image deploying. +if [ "{kind}" = "deploy" ]; then + if ! openbitfun_prepare_source_tools 2>&1 | tee -a "$LOG"; then + echo ">>> Source prerequisites could not be installed; trying the published image where available." | tee -a "$LOG" + fi +fi + # 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 @@ -1412,7 +1456,7 @@ export OPENBITFUN_OPENBITFUN_RELEASE_BASE="{OPENBITFUN_RELEASE_BASE}" /// 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_latest_relay_image_descriptor() -> Result { +async fn verified_latest_relay_image_descriptor() -> Result> { let pubkey = release_pubkey().ok_or_else(|| { anyhow!("this build has no Relay release trust root; refusing image deployment") })?; @@ -1425,40 +1469,60 @@ async fn verified_latest_relay_image_descriptor() -> Result Result> { + let mut verification_error = None; 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"); + log::warn!("Relay image descriptor unavailable: {descriptor_url}"); continue; }; let Some(signature) = fetch_text(&client, &format!("{descriptor_url}.sig")).await else { - last_error = format!("{descriptor_url}.sig was unavailable"); + log::warn!("Relay image signature unavailable: {descriptor_url}.sig"); continue; }; - 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, + match verified_relay_image_candidate(&descriptor_text, &signature, pubkey) { + Ok(Some(descriptor)) => return Ok(Some(descriptor)), + Ok(None) => { + log::info!("No current Relay image in {descriptor_url}; checking the next source") + } Err(error) => { - last_error = format!("{descriptor_url} is invalid JSON: {error}"); - continue; + let error = format!("{descriptor_url} is invalid: {error}"); + log::warn!("Relay image descriptor rejected: {error}"); + verification_error = Some(error); } - }; - if let Err(error) = validate_relay_image_descriptor(&descriptor) { - last_error = format!("{descriptor_url} is invalid: {error}"); - log::warn!("Relay image descriptor rejected: {last_error}"); - continue; } - return Ok(descriptor); } + if let Some(error) = verification_error { + return Err(anyhow!( + "could not verify the latest signed Relay image descriptor: {error}" + )); + } + log::info!("No published current Relay image is available; using a source build"); + Ok(None) +} - Err(anyhow!( - "could not verify the latest signed Relay image descriptor: {last_error}" - )) +fn verified_relay_image_candidate( + text: &str, + signature: &str, + pubkey: &str, +) -> Result> { + verify_minisign(text.as_bytes(), signature, pubkey)?; + let descriptor: RelayImageDescriptor = serde_json::from_str(text)?; + // A signed release for a different product image is not a deployable image. + // Do not rewrite or pull that repository; build the current source instead. + if descriptor.image != RELAY_IMAGE_REPOSITORY { + return Ok(None); + } + validate_relay_image_descriptor(&descriptor)?; + Ok(Some(descriptor)) } fn validate_relay_image_descriptor(descriptor: &RelayImageDescriptor) -> Result<()> { @@ -1516,6 +1580,18 @@ async fn fetch_text(client: &reqwest::Client, url: &str) -> Option { /// 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 { + deploy_body_script(port, &format!( + "export OPENBITFUN_RELAY_IMAGE={}\nexport OPENBITFUN_RELAY_IMAGE_DIGEST={}\nexport OPENBITFUN_RELEASE_TAG={}\nexport OPENBITFUN_RELEASE_VERSION={}\nexport OPENBITFUN_REQUIRE_IMAGE_DIGEST=1", + shell_quote_posix(&descriptor.image), shell_quote_posix(&descriptor.digest), + shell_quote_posix(&descriptor.tag), shell_quote_posix(&descriptor.version), + ), "image") +} + +fn deploy_body_script_from_source(port: u16) -> String { + deploy_body_script(port, "", "source") +} + +fn deploy_body_script(port: u16, image_env: &str, mode: &str) -> String { let helpers = prepare_helpers_bash(); let release_binary_deploy = release_binary_deploy_bash(); let deploy_state_dir = deploy_state_relative_dir(); @@ -1525,11 +1601,9 @@ fn deploy_body_script_with_image(port: u16, descriptor: &RelayImageDescriptor) - set -euo pipefail {helpers} {release_binary_deploy} -export OPENBITFUN_RELAY_IMAGE={image} -export OPENBITFUN_RELAY_IMAGE_DIGEST={digest} -export OPENBITFUN_RELEASE_TAG={tag} -export OPENBITFUN_RELEASE_VERSION={version} -export OPENBITFUN_REQUIRE_IMAGE_DIGEST=1 +{source_build} +{image_env} +export OPENBITFUN_REPO_GIT_URL={repo_git_url} export DOCKER_CONFIG="${{DOCKER_CONFIG:-{docker_config}}}" OPENBITFUN_DOCKER_MODE="${{OPENBITFUN_DOCKER_MODE:-direct}}" # Repair DOCKER_CONFIG unconditionally: when the driver already resolved a @@ -1539,6 +1613,7 @@ openbitfun_fix_docker_config if [ "$OPENBITFUN_DOCKER_MODE" = "direct" ] && ! docker info >/dev/null 2>&1; then openbitfun_resolve_docker_mode fi +openbitfun_keep_docker_authorization # Prefer the port staged by the desktop wizard; fall back to embedded default. PORT_FILE="$HOME/{deploy_state_dir}/relay.port" if [ -f "$PORT_FILE" ]; then @@ -1548,15 +1623,14 @@ RELAY_PORT="${{RELAY_PORT:-{port}}}" export RELAY_PORT echo ">>> Using RELAY_PORT=$RELAY_PORT" openbitfun_mirror_init -openbitfun_try_release_deploy +openbitfun_deploy_with_source_fallback {mode} "$HOME/{source_dir}" echo {TASK_DONE_MARKER} "#, helpers = helpers, release_binary_deploy = release_binary_deploy, - image = shell_quote_posix(&descriptor.image), - digest = shell_quote_posix(&descriptor.digest), - tag = shell_quote_posix(&descriptor.tag), - version = shell_quote_posix(&descriptor.version), + source_build = RELAY_SOURCE_BUILD_SH, + repo_git_url = shell_quote_posix(REPO_GIT_URL), + source_dir = product_data_relative_path(&["relay-src"]), port = port, TASK_DONE_MARKER = TASK_DONE_MARKER, ) @@ -1573,6 +1647,14 @@ mod tests { RELAY_IMAGE_REPOSITORY, RELAY_MIRROR_SH, RELAY_RELEASE_DOWNLOAD_SH, RELEASE_PUBKEY, }; + const DESCRIPTOR_TEST_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgMzA3RTRCNzVFRjdENjU5NApSV1NVWlgzdmRVdCtNTzl3cVl4SHJwQVJQMFhlakUySFY4enEwOE5UWnA4SnpTNHd4SllmVkdEZAo="; + const CURRENT_DESCRIPTOR: &str = r#"{"schema_version":1,"image":"ghcr.io/gcwing/openbitfun-relay-server","version":"1.0.0","tag":"v1.0.0","digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","platforms":["linux/amd64","linux/arm64"]} +"#; + const CURRENT_DESCRIPTOR_SIGNATURE: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVTVVpYM3ZkVXQrTUlFWmYyZ1o2ZytZemswaXlxdGkzVjR3RGRqQnAwV0NrMUg4Si9jOVpZODZJcHpXUDRheVFBUldpM0laZkJVN3hYRWxuV3NONWF3VllzODRXYVZReFFzPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4ODU5MzU5CWZpbGU6ZGVzY3JpcHRvci5qc29uCWhhc2hlZApBbmxyam9QelU4SjB4NHhrVk9pT1FJay9nbHh6dVZUZ0VsWE5JUEpKYzRIb0E1M2ZYN3FNZ0VMWVBKUlEzRlNkbWEzOFd6THBWS3BPQjFhVjBkT2hBdz09Cg=="; + const UNRELATED_DESCRIPTOR: &str = r#"{"schema_version":1,"image":"ghcr.io/example/another-product","version":"1.0.0","tag":"v1.0.0","digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","platforms":["linux/amd64","linux/arm64"]} +"#; + const UNRELATED_DESCRIPTOR_SIGNATURE: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVTVVpYM3ZkVXQrTUFtNVE1VDIyUHdFempFTGpNQ1Y3RVduMFhtUGtjeGU0aFl4bXpoejhleHErc3VBUkJPQ1ZCZnZqVnJ2dnR3dEVlZ2xzdy9yZTB4VEdINy9GZTEzYlFNPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4ODU5MzU5CWZpbGU6ZGVzY3JpcHRvci5qc29uCWhhc2hlZApjUkNkTW9hOVNSb3g1dkp1d2gzbG5ObmZCREw1UVM0T2hFbWc2d01YcENZYkc5UllmWDlMZThJRHdFN3BwNHNieFR1VEsyQkl5Und0UmY3YVlPQnlCQT09Cg=="; + fn test_image_descriptor() -> RelayImageDescriptor { RelayImageDescriptor { schema_version: 1, @@ -1584,6 +1666,156 @@ mod tests { } } + #[test] + fn source_fallback_never_accepts_a_different_image_or_invalid_signature() { + let current = super::verified_relay_image_candidate( + CURRENT_DESCRIPTOR, + CURRENT_DESCRIPTOR_SIGNATURE, + DESCRIPTOR_TEST_PUBKEY, + ) + .unwrap() + .unwrap(); + assert_eq!(current.image, RELAY_IMAGE_REPOSITORY); + assert!(super::verified_relay_image_candidate( + UNRELATED_DESCRIPTOR, + UNRELATED_DESCRIPTOR_SIGNATURE, + DESCRIPTOR_TEST_PUBKEY, + ) + .unwrap() + .is_none()); + assert!(super::verified_relay_image_candidate( + UNRELATED_DESCRIPTOR, + CURRENT_DESCRIPTOR_SIGNATURE, + DESCRIPTOR_TEST_PUBKEY, + ) + .is_err()); + assert!( + super::verified_relay_image_candidate( + std::str::from_utf8(FIXTURE_DATA).unwrap(), + FIXTURE_SIGNATURE, + FIXTURE_PUBKEY, + ) + .is_err(), + "authentic but malformed metadata must not trigger a source build" + ); + } + + #[cfg(unix)] + #[test] + fn long_builds_refresh_only_authorized_docker_commands_and_stop_refreshing_on_exit() { + let directory = tempfile::tempdir().unwrap(); + let calls = directory.path().join("calls"); + let script = format!( + r#" +set -euo pipefail +{} +export OPENBITFUN_DOCKER_MODE=sudo +sudo() {{ + printf '%s\n' "$*" >> "$TEST_CALLS" +}} +"#, + prepare_helpers_bash() + ); + let script = script + + r#" +sleep() { command sleep 0.01; } +openbitfun_keep_docker_authorization +for attempt in $(seq 1 100); do + if [ -f "$TEST_CALLS" ] && [ "$(wc -l < "$TEST_CALLS")" -ge 2 ]; then break; fi + command sleep 0.01 +done +test "$(wc -l < "$TEST_CALLS")" -ge 2 +"#; + let output = openbitfun_services_core::process_manager::create_command("bash") + .args(["-c", &script]) + .env("TEST_CALLS", &calls) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let contents = std::fs::read_to_string(&calls).unwrap(); + assert!(contents.lines().all(|line| line == "-n docker version")); + std::thread::sleep(std::time::Duration::from_millis(50)); + assert_eq!(std::fs::read_to_string(calls).unwrap(), contents); + } + + #[tokio::test] + async fn descriptor_sources_fail_over_before_selecting_source_build() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 4096]; + let count = stream.read(&mut request).await.unwrap(); + let request = String::from_utf8_lossy(&request[..count]); + let path = request.split_whitespace().nth(1).unwrap_or(""); + let is_signature = path.ends_with(".sig"); + let body = if path.starts_with("/current/") { + Some(if is_signature { + CURRENT_DESCRIPTOR_SIGNATURE + } else { + CURRENT_DESCRIPTOR + }) + } else if path.starts_with("/unrelated/") { + Some(if is_signature { + UNRELATED_DESCRIPTOR_SIGNATURE + } else { + UNRELATED_DESCRIPTOR + }) + } else if path.starts_with("/tampered/") { + Some(if is_signature { + CURRENT_DESCRIPTOR_SIGNATURE + } else { + UNRELATED_DESCRIPTOR + }) + } else if path.starts_with("/unsigned/") && !is_signature { + Some(CURRENT_DESCRIPTOR) + } else { + None + }; + let response = match body { + Some(body) => format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ), + None => { + "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + .to_string() + } + }; + stream.write_all(response.as_bytes()).await.unwrap(); + } + }); + let client = crate::reqwest_client_builder().no_proxy().build().unwrap(); + for (sources, expected) in [ + (["missing", "current"], "image"), + (["unrelated", "current"], "image"), + (["tampered", "current"], "image"), + (["missing", "unrelated"], "source"), + (["missing", "unsigned"], "source"), + (["missing", "missing"], "source"), + (["tampered", "missing"], "error"), + (["unrelated", "tampered"], "error"), + ] { + let bases = sources.map(|source| format!("{origin}/{source}")); + let result = + super::resolve_relay_image_descriptor(&client, &bases, DESCRIPTOR_TEST_PUBKEY) + .await; + let actual = match result { + Ok(Some(_)) => "image", + Ok(None) => "source", + Err(_) => "error", + }; + assert_eq!(actual, expected, "{sources:?}"); + } + server.abort(); + } + #[test] fn embedded_mirror_script_exposes_init_and_cn_defaults() { assert!( diff --git a/src/web-ui/src/features/relay-deploy/README.md b/src/web-ui/src/features/relay-deploy/README.md index a402dda413..f1b742d2ad 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, installs Docker when it -is missing, pulls the signed OpenBitFun Relay image, and starts it. Account import -remains optional. +is missing, tries the signed OpenBitFun Relay image, and builds current source +when no usable image is available. Account import remains optional. Entry points: @@ -16,14 +16,20 @@ Desktop Tauri surface: `src/apps/desktop/src/api/relay_deploy_api.rs` ## Invariants (do not regress) -1. **One click means install-if-needed, pull, start.** The deploy action must +1. **One click means install-if-needed, pull-or-build, 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. + Prepare Git and Docker Buildx while the PTY can answer sudo prompts. A failure + to prepare source dependencies must not block an available published image. + Docker Compose and host Cargo are not prerequisites. + +2. **Unavailable images fall back to current source.** Missing/unreachable + metadata, a verified descriptor for another image repository, exhausted pull + routes, or a failed image start trigger a source build with a visible terminal + message. Do not accept or rewrite another repository's image. Invalid signatures + or malformed signed metadata still fail after trying the other metadata origin. + Fetch current `main` into a fresh task-owned directory below + `~/.openbitfun/relay-src/`, record its commit, build natively with one Cargo job + by default, and clean only that temporary checkout. Never reset a user's checkout. 3. **Authenticate the latest image before touching the server.** Desktop reads the latest `relay-image.json` and `relay-image.json.sig`, verifies the @@ -33,9 +39,9 @@ Desktop Tauri surface: `src/apps/desktop/src/api/relay_deploy_api.rs` remote script. The Desktop package version does not pin Relay deployment. 4. **Always start by digest.** Tags are discovery metadata, not an execution - identity. One-click deploy sets `OPENBITFUN_REQUIRE_IMAGE_DIGEST=1`; Docker pulls + identity. Image deployment sets `OPENBITFUN_REQUIRE_IMAGE_DIGEST=1`; Docker pulls and runs `@sha256:...`, so every manifest and layer remains - content-addressed. + content-addressed. Source builds start by the immutable local image ID. 5. **Registry prefixes are transport, not trust roots.** Automatic mode keeps official GHCR first when a 10-second GitHub byte probe reaches 512 KiB/s; a @@ -54,12 +60,14 @@ Desktop Tauri surface: `src/apps/desktop/src/api/relay_deploy_api.rs` `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. + `source-build.sh` adds the wizard's source fallback and calls that same + container lifecycle code; it must not fork volumes, health checks, or rollback. 8. **Preserve the container contract.** Keep container name `openbitfun-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 +9. **Never stop a healthy Relay before the image is ready.** Pull or build 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. @@ -83,6 +91,9 @@ Desktop Tauri surface: `src/apps/desktop/src/api/relay_deploy_api.rs` 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. + Long deployments refresh an already-authorized noninteractive Docker + command while the body is alive, so sudo does not expire during compilation; + the refresh process stops when the task exits. 15. **`DOCKER_CONFIG` must remain usable by the SSH user.** Root installation keeps the user's HOME, so hand `~/.openbitfun` back before continuing. Repair or @@ -115,6 +126,16 @@ Desktop Tauri surface: `src/apps/desktop/src/api/relay_deploy_api.rs` ## Related docs +Focused checks: + +```bash +cargo test --locked -p openbitfun-services-integrations --no-default-features --features remote-ssh-concrete --lib remote_ssh::relay_deploy::tests:: +node --test scripts/relay/*.test.mjs +``` + +These use local HTTP, Git and Docker-command fixtures. They do not replace a +real Linux SSH deployment with sudo, cancellation and a running prior container. + - Relay runtime / admin: [`src/apps/relay-server/README.md`](../../../apps/relay-server/README.md) - Account login + sync choice: comments on `account_login` / `account_finalize_login` in `src/apps/desktop/src/api/remote_connect_api.rs` diff --git a/src/web-ui/src/features/relay-deploy/RelayDeployWizard.tsx b/src/web-ui/src/features/relay-deploy/RelayDeployWizard.tsx index deece4f029..7c1f04e996 100644 --- a/src/web-ui/src/features/relay-deploy/RelayDeployWizard.tsx +++ b/src/web-ui/src/features/relay-deploy/RelayDeployWizard.tsx @@ -175,6 +175,7 @@ export const RelayDeployWizard: React.FC = ({ const connectionIdRef = useRef(null); const activeTaskRef = useRef(null); const taskStatusRef = useRef(null); + const launchGenerationRef = useRef(0); // ── register ───────────────────────────────────────────────────────────── const [regUsername, setRegUsername] = useState(''); @@ -261,6 +262,7 @@ export const RelayDeployWizard: React.FC = ({ // the image script's rollback trap). Never leave a detached pull running after dismiss. useEffect(() => { if (!isOpen) { + launchGenerationRef.current += 1; stopPolling(); // Capture before any state reset so cancel still has connection/task ids. const cancelSnapshot = { @@ -306,6 +308,7 @@ export const RelayDeployWizard: React.FC = ({ // Stop polling / cancel remote task / close PTY on unmount. useEffect(() => { return () => { + launchGenerationRef.current += 1; stopPolling(); const cancelSnapshot = { connectionId: connectionIdRef.current, @@ -517,17 +520,17 @@ export const RelayDeployWizard: React.FC = ({ }, [t]); // ── status polling (PTY shows live output; poll only drives wizard state) ─ - const startTaskPolling = useCallback((task: RelayDeployTask, connId: string) => { + const startTaskPolling = useCallback((task: RelayDeployTask, connId: string, generation: number) => { stopPolling(); cursorRef.current = 0; pollFailuresRef.current = 0; pollActiveRef.current = true; const pollOnce = async (): Promise => { - if (!pollActiveRef.current) return false; + if (!pollActiveRef.current || launchGenerationRef.current !== generation) return false; try { const res = await relayDeployApi.poll(connId, task, cursorRef.current); - if (!pollActiveRef.current) return false; + if (!pollActiveRef.current || launchGenerationRef.current !== generation) return false; cursorRef.current = res.cursor; pollFailuresRef.current = 0; if (res.status !== 'running') { @@ -539,13 +542,15 @@ export const RelayDeployWizard: React.FC = ({ void closeDeployTerminal(); void runPreflight(connId); } else { - window.setTimeout(() => setStep('register'), 800); + window.setTimeout(() => { + if (launchGenerationRef.current === generation) setStep('register'); + }, 800); } } return false; } } catch (e) { - if (!pollActiveRef.current) return false; + if (!pollActiveRef.current || launchGenerationRef.current !== generation) return false; pollFailuresRef.current += 1; log.warn('task poll failed', e); if (pollFailuresRef.current >= MAX_POLL_FAILURES) { @@ -576,8 +581,10 @@ export const RelayDeployWizard: React.FC = ({ task: RelayDeployTask, connId: string, scriptPath: string, + generation: number, ) => { await closeDeployTerminal(); + if (launchGenerationRef.current !== generation) return; const session = await getTerminalService().createSession({ connectionId: connId, name: task === 'deploy' ? 'Relay Deploy' : 'Relay Docker Install', @@ -585,13 +592,22 @@ export const RelayDeployWizard: React.FC = ({ rows: 28, source: 'manual', }); + if (launchGenerationRef.current !== generation) { + await getTerminalService().closeSession(session.id, true); + return; + } terminalSessionIdRef.current = session.id; setTerminalSessionId(session.id); // Give the shell a moment to print its prompt before sending the command. await new Promise((r) => window.setTimeout(r, 400)); + if (launchGenerationRef.current !== generation) return; const quoted = `'${scriptPath.replace(/'/g, `'\\''`)}'`; await getTerminalService().sendCommand(session.id, `bash ${quoted}`); - startTaskPolling(task, connId); + if (launchGenerationRef.current !== generation) { + await relayDeployApi.cancel(connId, task); + return; + } + startTaskPolling(task, connId, generation); }, [closeDeployTerminal, startTaskPolling]); const handleStartDeploy = async () => { @@ -605,10 +621,13 @@ export const RelayDeployWizard: React.FC = ({ setStep('deploy'); setTaskStatus('running'); setActiveTask('deploy'); + const generation = ++launchGenerationRef.current; try { const started = await relayDeployApi.startDeploy(connectionId, port, mirrorMode); - await launchInteractiveTask('deploy', connectionId, started.scriptPath); + if (launchGenerationRef.current !== generation) return; + await launchInteractiveTask('deploy', connectionId, started.scriptPath, generation); } catch (e) { + if (launchGenerationRef.current !== generation) return; setTaskStatus('failed'); setError(`[start] ${errMsg(e)}`); } @@ -1220,12 +1239,12 @@ export const RelayDeployWizard: React.FC = ({ options={DEPLOY_TERMINAL_OPTIONS} /> - ) : ( + ) : taskStatus === 'running' ? (
{t('relayDeploy.openingTerminal')}
- )} + ) : null}