diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..6db74fe963 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,32 @@ +#** +# Line-ending policy. +# +# Git for Windows defaults to `core.autocrlf=true`, which rewrites every text +# file to CRLF on checkout. Two BitFun paths break silently when that happens: +# +# 1. `src/apps/relay-server/*.sh` is embedded into the Desktop binary with +# `include_str!` and uploaded verbatim to the relay host. A CR turns each +# blank line into `$'\r': command not found`, and `set -e` aborts the +# one-click deploy on the first one. +# 2. Rust raw string literals (`r#"..."#`) that hold remote bash carry the +# checkout's CRLF into the generated scripts the same way. +# +# Force LF for everything that has to run on a POSIX host, regardless of where +# it was checked out. Windows-only scripts (`*.ps1`) are left alone — PowerShell +# reads LF fine and pinning them would churn every non-Windows working tree. +#** + +*.sh text eol=lf +*.bash text eol=lf +*.rs text eol=lf +*.py text eol=lf +*.mjs text eol=lf +*.cjs text eol=lf + +Dockerfile text eol=lf +Dockerfile.* text eol=lf +*.Dockerfile text eol=lf +.dockerignore text eol=lf +docker-compose.yml text eol=lf +docker-compose.*.yml text eol=lf +Caddyfile text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48748dca4c..a2a5963c32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,38 @@ permissions: contents: read jobs: + # ── Shell deploy assets: LF endings + syntax ─────────────────────── + # Relay one-click deploy embeds these scripts into the Desktop binary and + # uploads them verbatim to a Linux host, where a single CR aborts the deploy + # with `$'\r': command not found`. .gitattributes pins LF; this is the guard. + shell-scripts: + name: Shell Deploy Scripts + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v5 + + - name: Reject CRLF in shell and deploy assets + run: | + bad=$(git ls-files -z \ + '*.sh' '*.bash' 'Dockerfile' 'Dockerfile.*' '*.Dockerfile' 'Caddyfile' \ + 'docker-compose.yml' 'docker-compose.*.yml' \ + | xargs -0 -r grep -lU $'\r' || true) + if [ -n "$bad" ]; then + echo "::error::CRLF line endings found; these must stay LF (see .gitattributes):" + echo "$bad" + exit 1 + fi + echo "All shell and deploy assets are LF-only." + + - name: bash -n every tracked shell script + run: | + rc=0 + while IFS= read -r -d '' f; do + bash -n "$f" || { echo "::error file=$f::bash syntax error"; rc=1; } + done < <(git ls-files -z '*.sh' '*.bash') + exit "$rc" + # ── CLI: independent tests ───────────────────────────────────────── cli-test: name: CLI Tests (${{ matrix.os }}) diff --git a/scripts/relay/release-download-harness.sh b/scripts/relay/release-download-harness.sh index bc08738814..a46de00ec6 100755 --- a/scripts/relay/release-download-harness.sh +++ b/scripts/relay/release-download-harness.sh @@ -149,7 +149,16 @@ run_case() { fi } -GITHUB_URL="https://github.com/GCWing/BitFun/releases/download/v0.2.13/${RELAY_ASSET}" +# 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. diff --git a/src/apps/relay-server/README.md b/src/apps/relay-server/README.md index 00c615e8b7..2e7fb73a21 100644 --- a/src/apps/relay-server/README.md +++ b/src/apps/relay-server/README.md @@ -147,6 +147,20 @@ 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**. +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 +first blank line: + +``` +deploy.sh: line 37: $'\r': command not found +``` + +If that happens, strip the CR and re-run: + +```bash +sed -i 's/\r$//' *.sh && bash deploy.sh +``` + After a successful start, the script runs `relay-admin list-users`. If the database has **no accounts**, it prints the exact `add-user` command to run next (account login will not work until you create at least one user). diff --git a/src/apps/relay-server/common.sh b/src/apps/relay-server/common.sh index fcb84b91f9..15892bacf2 100755 --- a/src/apps/relay-server/common.sh +++ b/src/apps/relay-server/common.sh @@ -33,6 +33,17 @@ resolve_compose() { exit 1 } +# POSIX single-quote each argument. `sg -c` takes a single string that the shell +# re-parses, so an unquoted "$*" loses argument boundaries — a path with a space +# or a `-f '{{.State.Running}}'` format string arrives mangled. +shell_join() { + local out="" arg + for arg in "$@"; do + out="$out'$(printf '%s' "$arg" | sed "s/'/'\\\\''/g")' " + done + printf '%s' "$out" +} + compose() { if [ "${#COMPOSE[@]}" -eq 0 ]; then resolve_compose @@ -47,7 +58,7 @@ compose() { ;; sg) if sg docker -c 'docker compose version' >/dev/null 2>&1; then - sg docker -c "docker compose $*" + sg docker -c "$(shell_join docker compose "$@")" return fi ;; @@ -57,10 +68,41 @@ compose() { # Resolve how to talk to the Docker daemon for the current shell. # Sets BITFUN_DOCKER_MODE to: direct | sg | sudo -resolve_docker_access() { - check_command docker +# Make DOCKER_CONFIG usable by the current user. +# +# A root-run Docker install (or an earlier `sudo -E docker`) can leave +# ~/.bitfun/docker-config and its config.json owned by root, and every later +# unprivileged docker call then reports +# WARNING: Error loading config file: .../config.json: permission denied +# before misbehaving. Repair it, or fall back to a per-uid dir we can read. +fix_docker_config() { export DOCKER_CONFIG="${DOCKER_CONFIG:-$HOME/.bitfun/docker-config}" mkdir -p "$DOCKER_CONFIG" 2>/dev/null || true + docker_config_usable() { + [ -r "$DOCKER_CONFIG" ] && [ -w "$DOCKER_CONFIG" ] && + { [ ! -e "$DOCKER_CONFIG/config.json" ] || [ -r "$DOCKER_CONFIG/config.json" ]; } + } + if [ "$(id -u)" = "0" ] || docker_config_usable; then + chmod 700 "$DOCKER_CONFIG" 2>/dev/null || true + return 0 + fi + echo "Warning: $DOCKER_CONFIG is not usable by $(id -un) (root-owned by an earlier install)." + if [ "$(id -u)" != "0" ]; then + sudo -n chown -R "$(id -un):$(id -gn)" "$DOCKER_CONFIG" 2>/dev/null || + sudo chown -R "$(id -un):$(id -gn)" "$DOCKER_CONFIG" 2>/dev/null || true + fi + if ! docker_config_usable; then + DOCKER_CONFIG="$HOME/.bitfun/docker-config-$(id -u)" + export DOCKER_CONFIG + mkdir -p "$DOCKER_CONFIG" + echo " Using DOCKER_CONFIG=$DOCKER_CONFIG instead." + fi + chmod 700 "$DOCKER_CONFIG" 2>/dev/null || true +} + +resolve_docker_access() { + check_command docker + fix_docker_config if [ -e "$HOME/.docker" ] && [ ! -w "$HOME/.docker" ]; then echo "Warning: $HOME/.docker is not writable (often root-owned after sudo docker)." @@ -102,7 +144,7 @@ resolve_docker_access() { docker_cmd() { case "${BITFUN_DOCKER_MODE:-direct}" in - sg) sg docker -c "docker $*" ;; + sg) sg docker -c "$(shell_join docker "$@")" ;; sudo) sudo docker "$@" ;; *) docker "$@" ;; esac diff --git a/src/apps/relay-server/release-download.sh b/src/apps/relay-server/release-download.sh index 7dc64aa861..468aeff3c5 100644 --- a/src/apps/relay-server/release-download.sh +++ b/src/apps/relay-server/release-download.sh @@ -44,10 +44,22 @@ 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 "docker $*" ;; + 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 ;; @@ -100,6 +112,65 @@ bitfun_canonical_checksum_url() { 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 + + # 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 -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 @@ -340,7 +411,7 @@ DOCKERFILE image="bitfun-relay:release-${BITFUN_RELEASE_TAG}" echo ">>> Building lightweight Relay runtime image (no Rust/Cargo compilation)..." - if ! bitfun_docker build -t "$image" "$context"; then + if ! bitfun_build_runtime_image "$image" "$context"; then echo ">>> Published binary image build failed; falling back to source build." return 1 fi 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 7741908474..6e23c4a391 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 @@ -97,6 +97,10 @@ const DEPLOY_STATE_DIR: &str = ".bitfun/relay-deploy"; 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 +/// before the task counts as dead. Covers PTY startup and the shell prompt; an +/// alive driver (an open sudo password prompt, say) is never bounded by this. +const PREPARE_GRACE_SECONDS: u64 = 90; fn release_tag_for_version(version: &str) -> String { if version.contains("-nightly.") { @@ -303,7 +307,7 @@ echo "port_owned=$PORT_OWNED" "#, port = port, ); - let (stdout, _stderr, code) = manager.execute_command(connection_id, &script).await?; + let (stdout, _stderr, code) = exec_script(manager, connection_id, &script).await?; if code != 0 { return Err(anyhow!("preflight probe failed (exit {code})")); } @@ -469,6 +473,9 @@ pub async fn start_task( let body_path = format!("{dir}/{stem}-body.sh"); let script_path = format!("{dir}/{stem}.sh"); let port_path = format!("{dir}/relay.port"); + // Upload as LF-only: bash on the relay host runs a stray CR as a command. + let body = to_unix_script(&body); + let driver = to_unix_script(&driver); manager .sftp_write(connection_id, &body_path, body.as_bytes()) .await?; @@ -481,21 +488,22 @@ pub async fn start_task( .await?; } // Seed preparing flag before the PTY runs the driver so early polls do not - // race into "failed" (no pid / no flag yet). + // race into "failed" (no pid / no flag yet). Clear any driver pid from a + // previous attempt so a recycled pid cannot read as "still preparing". let prepare_flag = format!("{dir}/{stem}.preparing"); let log_path = format!("{dir}/{stem}.log"); let pid_path = format!("{dir}/{stem}.pid"); + let driver_pid_path = format!("{dir}/{stem}.driver.pid"); exec_ok( manager, connection_id, - &format!( - "chmod 700 {} {} && rm -f {} {} && : > {} && touch {}", - shell_quote_posix(&body_path), - shell_quote_posix(&script_path), - shell_quote_posix(&pid_path), - shell_quote_posix(&log_path), - shell_quote_posix(&log_path), - shell_quote_posix(&prepare_flag), + &stage_scripts_command( + &body_path, + &script_path, + &pid_path, + &driver_pid_path, + &log_path, + &prepare_flag, ), ) .await?; @@ -503,6 +511,47 @@ pub async fn start_task( Ok(RelayTaskStart { script_path }) } +/// Strip trailing CR from a file already on the relay host, in place. +/// +/// POSIX `sed` (no `-i`, whose syntax differs between GNU and BSD userlands). +/// The rewrite drops the file's mode, so callers must `chmod` afterwards. +fn strip_cr_command(path: &str) -> String { + let src = shell_quote_posix(path); + let tmp = shell_quote_posix(&format!("{path}.lf")); + format!("sed 's/\r$//' {src} > {tmp} && mv {tmp} {src}") +} + +/// Prepare uploaded scripts for the PTY: normalize line endings, make them +/// executable, and seed the liveness files `poll_task` reads. +/// +/// The CR strip runs **on the relay host, after upload and before execution**, +/// so a CR-free script on disk does not depend on the uploader having called +/// `to_unix_script` (which it does — this is the second line of defence, and +/// the one that still holds if a future upload path forgets). +fn stage_scripts_command( + body_path: &str, + script_path: &str, + pid_path: &str, + driver_pid_path: &str, + log_path: &str, + prepare_flag: &str, +) -> String { + format!( + "{strip_body} && {strip_driver} \ + && chmod 700 {body} {script} \ + && rm -f {pid} {driver_pid} {log} \ + && : > {log} && touch {flag}", + strip_body = strip_cr_command(body_path), + strip_driver = strip_cr_command(script_path), + body = shell_quote_posix(body_path), + script = shell_quote_posix(script_path), + pid = shell_quote_posix(pid_path), + driver_pid = shell_quote_posix(driver_pid_path), + log = shell_quote_posix(log_path), + flag = shell_quote_posix(prepare_flag), + ) +} + /// Poll a detached task: incremental log output plus liveness/completion status. pub async fn poll_task( manager: &SSHConnectionManager, @@ -516,12 +565,33 @@ pub async fn poll_task( D="$HOME/{DEPLOY_STATE_DIR}" LOG="$D/{stem}.log" PIDF="$D/{stem}.pid" +DRVF="$D/{stem}.driver.pid" PREPF="$D/{stem}.preparing" running=0 if [ -f "$PIDF" ] && kill -0 "$(cat "$PIDF" 2>/dev/null)" 2>/dev/null; then running=1; fi -# Interactive prepare phase (sudo prompts) before nohup starts. +# Interactive prepare phase (sudo prompts) before nohup starts. The prompt can +# sit for minutes, so an alive driver keeps "preparing" regardless of age. preparing=0 -if [ -f "$PREPF" ]; then preparing=1; fi +driver_gone=0 +if [ -f "$PREPF" ]; then + preparing=1 + if [ ! -f "$DRVF" ] || ! kill -0 "$(cat "$DRVF" 2>/dev/null)" 2>/dev/null; then + # No driver process. Either the PTY has not started it yet (normal for the + # first few seconds) or it died before installing its cleanup trap — a bad + # script upload, for instance. Without this bound the flag start_task seeded + # would never clear and the wizard would report "running" forever. + prep_age=-1 + prep_now="$(date +%s 2>/dev/null || echo '')" + prep_mtime="$(stat -c %Y "$PREPF" 2>/dev/null || stat -f %m "$PREPF" 2>/dev/null || echo '')" + if [ -n "$prep_now" ] && [ -n "$prep_mtime" ]; then + prep_age=$((prep_now - prep_mtime)) + fi + if [ "$prep_age" -ge {prepare_grace_seconds} ]; then + preparing=0 + driver_gone=1 + fi + fi +fi log_exists=0 size=0 if [ -f "$LOG" ]; then log_exists=1; size=$(wc -c < "$LOG" | tr -d ' '); fi @@ -531,6 +601,7 @@ if [ -f "$LOG" ] && grep -q {TASK_DONE_MARKER} "$LOG"; then marker=1; fi # briefly looks gone; treat a growing log without a marker as running. echo "running=$running" echo "preparing=$preparing" +echo "driver_gone=$driver_gone" echo "log_exists=$log_exists" echo "size=$size" echo "marker=$marker" @@ -538,8 +609,9 @@ echo "---" if [ -f "$LOG" ]; then tail -c +{from} "$LOG"; fi "#, from = cursor.saturating_add(1), + prepare_grace_seconds = PREPARE_GRACE_SECONDS, ); - let (stdout, _stderr, code) = manager.execute_command(connection_id, &script).await?; + let (stdout, _stderr, code) = exec_script(manager, connection_id, &script).await?; if code != 0 { return Err(anyhow!("poll failed (exit {code})")); } @@ -553,6 +625,7 @@ if [ -f "$LOG" ]; then tail -c +{from} "$LOG"; fi }; let running = get("running") == "1"; let preparing = get("preparing") == "1"; + let driver_gone = get("driver_gone") == "1"; let log_exists = get("log_exists") == "1"; let marker = get("marker") == "1"; let size: u64 = get("size").parse().unwrap_or(cursor); @@ -560,14 +633,24 @@ if [ -f "$LOG" ]; then tail -c +{from} "$LOG"; fi marker, running, preparing, + driver_gone, log_exists, size, cursor, !output.is_empty(), ); + // The driver writes its errors to the PTY, not the log, so a prepare-phase + // death leaves the wizard's log pane empty. Say where to look. + let mut output = output.to_string(); + if status == RelayTaskStatus::Failed && driver_gone && size == 0 { + output.push_str( + "\n>>> The prepare step exited before starting the task. \ + See the terminal above for the error.\n", + ); + } Ok(RelayTaskPoll { cursor: size, - output: output.to_string(), + output, status, }) } @@ -626,11 +709,12 @@ STEM="{stem}" LOG="$D/$STEM.log" PIDF="$D/$STEM.pid" PREPF="$D/$STEM.preparing" +DRVF="$D/$STEM.driver.pid" BODY="$D/$STEM-body.sh" mkdir -p "$D" 2>/dev/null was_active=0 [ -f "$PREPF" ] && was_active=1 -rm -f "$PREPF" +rm -f "$PREPF" "$DRVF" kill_tree() {{ local p="$1" local sig="$2" @@ -671,7 +755,7 @@ exit 0 stem = stem, compose_teardown = compose_teardown, ); - let (_stdout, stderr, code) = manager.execute_command(connection_id, &script).await?; + let (_stdout, stderr, code) = exec_script(manager, connection_id, &script).await?; if code != 0 { return Err(anyhow!("cancel failed (exit {code}): {stderr}")); } @@ -682,10 +766,12 @@ exit 0 /// /// Pending (PTY not started yet) and active prepare/build must not look like /// failure — the wizard polls immediately after staging scripts. +#[allow(clippy::too_many_arguments)] fn decide_task_status( marker: bool, running: bool, preparing: bool, + driver_gone: bool, log_exists: bool, size: u64, cursor: u64, @@ -694,7 +780,16 @@ fn decide_task_status( if marker { return RelayTaskStatus::Succeeded; } - if running || preparing || !log_exists || size == 0 { + if running || preparing { + return RelayTaskStatus::Running; + } + // The prepare step is definitively dead and never handed off to the body. + // Checked before the empty-log case, which would otherwise read as "still + // starting up" forever. + if driver_gone { + return RelayTaskStatus::Failed; + } + if !log_exists || size == 0 { return RelayTaskStatus::Running; } // Log still growing since last poll — keep running even if pid check flaked. @@ -778,7 +873,7 @@ pub async fn import_account( name = RELAY_CONTAINER_NAME, db = RELAY_CONTAINER_DB, ); - let (stdout, stderr, code) = manager.execute_command(connection_id, &cmd).await?; + let (stdout, stderr, code) = exec_script(manager, connection_id, &cmd).await?; if code != 0 { let detail = relay_admin_error(&stdout, &stderr); return Err(anyhow!(detail)); @@ -829,8 +924,31 @@ async fn resolve_home(manager: &SSHConnectionManager, connection_id: &str) -> Re Ok(home.to_string()) } +/// Strip CR from anything sent to the relay host as bash. +/// +/// Git for Windows checks out with CRLF by default, so both `include_str!` +/// (mirror.sh / release-download.sh) and this file's own `r#"..."#` remote +/// scripts can carry CRLF into the generated script. Remote bash then executes +/// the CR on the first blank line, prints `line N: $'\r': command not found` +/// and — under `set -euo pipefail` — aborts the deploy right there. `.gitattributes` +/// pins LF for fresh checkouts; this keeps existing CRLF working trees safe too. +fn to_unix_script(script: &str) -> String { + script.replace("\r\n", "\n") +} + +/// `execute_command` for remote bash, with line endings normalized first. +async fn exec_script( + manager: &SSHConnectionManager, + connection_id: &str, + script: &str, +) -> Result<(String, String, i32)> { + manager + .execute_command(connection_id, &to_unix_script(script)) + .await +} + async fn exec_ok(manager: &SSHConnectionManager, connection_id: &str, command: &str) -> Result<()> { - let (stdout, stderr, code) = manager.execute_command(connection_id, command).await?; + let (stdout, stderr, code) = exec_script(manager, connection_id, command).await?; if code != 0 { return Err(anyhow!( "remote command failed (exit {code}): {}", @@ -917,10 +1035,47 @@ bitfun_ensure_tools() { fi } -bitfun_fix_docker_home() { +# Owner of $HOME — the SSH user even when this script runs elevated with their +# HOME preserved (BITFUN_KEEP_HOME). +bitfun_home_owner() { + stat -c '%U:%G' "$HOME" 2>/dev/null || stat -f '%Su:%Sg' "$HOME" 2>/dev/null || true +} + +# Make DOCKER_CONFIG usable by whoever is running now. +# +# The Docker-install task runs as root but keeps the SSH user's HOME, so it used +# 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 +# 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}" - mkdir -p "$DOCKER_CONFIG" + mkdir -p "$DOCKER_CONFIG" 2>/dev/null || true + if [ "$(id -u)" = "0" ]; then + # Hand the tree back to the SSH user; root reads it either way. + local owner + owner="$(bitfun_home_owner)" + if [ -n "$owner" ] && [ "$owner" != "root:root" ]; then + chown -R "$owner" "$DOCKER_CONFIG" 2>/dev/null || true + fi + elif [ ! -r "$DOCKER_CONFIG" ] || [ ! -w "$DOCKER_CONFIG" ] \ + || { [ -e "$DOCKER_CONFIG/config.json" ] && [ ! -r "$DOCKER_CONFIG/config.json" ]; }; then + echo ">>> $DOCKER_CONFIG is not usable by $(id -un) (left root-owned by an earlier install)." + bitfun_priv chown -R "$(id -un):$(id -gn)" "$DOCKER_CONFIG" 2>/dev/null || true + if [ ! -r "$DOCKER_CONFIG" ] || [ ! -w "$DOCKER_CONFIG" ] \ + || { [ -e "$DOCKER_CONFIG/config.json" ] && [ ! -r "$DOCKER_CONFIG/config.json" ]; }; then + DOCKER_CONFIG="$HOME/.bitfun/docker-config-$(id -u)" + export DOCKER_CONFIG + mkdir -p "$DOCKER_CONFIG" + echo ">>> Could not repair it; using DOCKER_CONFIG=$DOCKER_CONFIG instead." + fi + fi chmod 700 "$DOCKER_CONFIG" 2>/dev/null || true +} + +bitfun_fix_docker_home() { + bitfun_fix_docker_config if [ -e "$HOME/.docker" ] && [ ! -w "$HOME/.docker" ]; then echo ">>> $HOME/.docker is not writable (often root-owned buildx lock)." echo ">>> Fixing ownership..." @@ -987,9 +1142,20 @@ bitfun_resolve_docker_mode() { return 1 } +# POSIX single-quote each argument so `sg -c` cannot re-split or glob them. +# `sg docker -c "docker $*"` loses argument boundaries: a context path with a +# space, or a `-f '{{.State.Running}}'` format string, arrives mangled. +bitfun_shell_join() { + local out="" arg + for arg in "$@"; do + out="$out'$(printf '%s' "$arg" | sed "s/'/'\\\\''/g")' " + done + printf '%s' "$out" +} + bitfun_docker() { case "${BITFUN_DOCKER_MODE:-direct}" in - sg) sg docker -c "docker $*" ;; + 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 ;; @@ -1003,12 +1169,15 @@ bitfun_run_deploy_sh() { # Prefer already-resolved mirror mode so deploy.sh does not re-probe. local mirror_mode="${BITFUN_MIRROR:-${BITFUN_MIRROR_MODE:-auto}}" # 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 \ - DOCKER_CONFIG="${DOCKER_CONFIG:-}" \ BITFUN_MIRROR="$mirror_mode" \ BITFUN_USE_CN_MIRROR="${BITFUN_USE_CN_MIRROR:-0}" \ BITFUN_APT_MIRROR="${BITFUN_APT_MIRROR:-}" \ @@ -1019,7 +1188,6 @@ bitfun_run_deploy_sh() { 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 \ - DOCKER_CONFIG="${DOCKER_CONFIG:-}" \ BITFUN_MIRROR="$mirror_mode" \ BITFUN_USE_CN_MIRROR="${BITFUN_USE_CN_MIRROR:-0}" \ BITFUN_APT_MIRROR="${BITFUN_APT_MIRROR:-}" \ @@ -1061,6 +1229,10 @@ PIDF="$D/$STEM.pid" BODY="$D/$STEM-body.sh" mkdir -p "$D" chmod 700 "$D" +# Claim the prepare phase before anything that can fail: poll_task treats a +# missing/dead driver pid as "prepare died" once its grace window elapses. +DRIVER_PIDF="$D/$STEM.driver.pid" +echo $$ >"$DRIVER_PIDF" {helpers} echo ">>> BitFun relay {kind}: interactive prepare" @@ -1078,16 +1250,19 @@ if [ -n "${{BITFUN_KEEP_HOME:-}}" ]; then LOG="$D/$STEM.log" PIDF="$D/$STEM.pid" BODY="$D/$STEM-body.sh" - PREPARE_FLAG="$D/$STEM.preparing" + DRIVER_PIDF="$D/$STEM.driver.pid" fi PREPARE_FLAG="$D/$STEM.preparing" +# Re-claim the prepare phase: an elevated re-exec is a different process, and D +# may have moved with HOME. +echo $$ >"$DRIVER_PIDF" # Keep/refresh the preparing flag seeded by start_task — do not clear it first # or early polls can race into "failed". rm -f "$PIDF" : >"$LOG" touch "$PREPARE_FLAG" echo ">>> prepare starting (uid=$(id -u) home=$HOME)" | tee -a "$LOG" -cleanup_prepare() {{ rm -f "$PREPARE_FLAG"; }} +cleanup_prepare() {{ rm -f "$PREPARE_FLAG" "$DRIVER_PIDF"; }} trap cleanup_prepare EXIT # Region/mirrors before apt tool install and Docker/GitHub downloads. export BITFUN_REPO_GIT_URL="{REPO_GIT_URL}" @@ -1095,7 +1270,9 @@ export BITFUN_REPO_TARBALL_URL="{REPO_TARBALL_URL}" bitfun_mirror_init bitfun_ensure_tools export DOCKER_CONFIG="${{DOCKER_CONFIG:-$HOME/.bitfun/docker-config}}" -mkdir -p "$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. if [ "{kind}" = "install" ]; then @@ -1139,7 +1316,7 @@ if [ "{kind}" = "install" ]; then fi code=${{PIPESTATUS[0]}} set -e - rm -f "$PREPARE_FLAG" "$PIDF" + rm -f "$PREPARE_FLAG" "$PIDF" "$DRIVER_PIDF" trap - EXIT if [ "$code" -ne 0 ]; then echo "ERROR: Docker install failed (exit $code)" | tee -a "$LOG" @@ -1164,7 +1341,9 @@ nohup env BITFUN_DOCKER_MODE="$BITFUN_DOCKER_MODE" DOCKER_CONFIG="$DOCKER_CONFIG BITFUN_REPO_TARBALL_URL="${{BITFUN_REPO_TARBALL_URL:-}}" \ "${{RUNNER[@]}}" "$BODY" >"$LOG" 2>&1 < /dev/null & echo $! >"$PIDF" -rm -f "$PREPARE_FLAG" +# The body pid now drives liveness; `exec tail` below would leave a stale driver +# pid behind, so retire it here rather than in the (never-reached) EXIT trap. +rm -f "$PREPARE_FLAG" "$DRIVER_PIDF" trap - EXIT echo ">>> Following log..." exec tail -n +1 -f "$LOG" @@ -1188,7 +1367,7 @@ set -euo pipefail # Prefer the original SSH user's home (set by elevated driver). 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" +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 @@ -1233,6 +1412,15 @@ 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 \ @@ -1546,6 +1734,10 @@ 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 +# skipped below, and the docker CLI then fails on an unreadable config.json. +bitfun_fix_docker_config if [ "$BITFUN_DOCKER_MODE" = "direct" ] && ! docker info >/dev/null 2>&1; then bitfun_resolve_docker_mode fi @@ -1617,11 +1809,12 @@ echo {TASK_DONE_MARKER} mod tests { use super::{ classify_docker_access, decide_task_status, deploy_body_script_with_checksums, - parse_preflight, + install_docker_body_script, interactive_driver_script, parse_preflight, prepare_helpers_bash, release_binary_deploy_bash, release_tag_for_version, - split_poll_stdout, sync_source_bash, verified_checksum_exports, + split_poll_stdout, stage_scripts_command, sync_source_bash, to_unix_script, + verified_checksum_exports, verified_release_checksums, verify_minisign, DockerAccessMode, RelayTaskStatus, - RELAY_MIRROR_SH, RELEASE_PUBKEY, + RELAY_MIRROR_SH, RELAY_RELEASE_DOWNLOAD_SH, RELEASE_PUBKEY, }; #[test] @@ -1665,6 +1858,225 @@ mod tests { ); } + /// A CRLF checkout (Git for Windows' `core.autocrlf=true` default) used to + /// ship CRLF straight into the uploaded scripts, and the relay host failed + /// with `line 37: $'\r': command not found` — line 37 being the first blank + /// line of the embedded mirror.sh. + #[test] + fn embedded_scripts_are_lf_only() { + for (name, script) in [ + ("mirror.sh", RELAY_MIRROR_SH), + ("release-download.sh", RELAY_RELEASE_DOWNLOAD_SH), + ] { + assert!( + !script.contains('\r'), + "{name} must be checked out LF-only (see .gitattributes)" + ); + } + } + + /// The remote-side half of the CR guarantee: whatever bytes reached the host, + /// the staged scripts are LF before the PTY runs them. Runs the real command + /// against real CRLF files rather than asserting on its text. + #[cfg(unix)] + #[test] + fn staging_strips_cr_on_the_host_before_execution() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("temp dir"); + let p = |name: &str| dir.path().join(name).to_string_lossy().into_owned(); + let (body_path, script_path) = (p("deploy-body.sh"), p("deploy.sh")); + let (pid_path, driver_pid_path) = (p("deploy.pid"), p("deploy.driver.pid")); + let (log_path, prepare_flag) = (p("deploy.log"), p("deploy.preparing")); + + // Simulate an uploader that skipped normalization. + let crlf = "#!/usr/bin/env bash\r\nset -euo pipefail\r\n\r\necho hi\r\n"; + std::fs::write(&body_path, crlf).expect("write body"); + std::fs::write(&script_path, crlf).expect("write driver"); + // Stale files from a previous attempt that staging must clear. + std::fs::write(&pid_path, "1234").expect("write pid"); + std::fs::write(&driver_pid_path, "5678").expect("write driver pid"); + + let command = stage_scripts_command( + &body_path, + &script_path, + &pid_path, + &driver_pid_path, + &log_path, + &prepare_flag, + ); + let output = std::process::Command::new("bash") + .args(["-c", &command]) + .output() + .expect("run staging command"); + assert!( + output.status.success(), + "staging command failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + for path in [&body_path, &script_path] { + let staged = std::fs::read_to_string(path).expect("read staged script"); + assert_eq!( + staged, "#!/usr/bin/env bash\nset -euo pipefail\n\necho hi\n", + "{path} must be LF-only on the host" + ); + let mode = std::fs::metadata(path).expect("stat").permissions().mode(); + assert_eq!(mode & 0o777, 0o700, "{path} must stay owner-only executable"); + } + // The rewrite must not leave its scratch file behind. + assert!(!std::path::Path::new(&format!("{script_path}.lf")).exists()); + assert!(!std::path::Path::new(&pid_path).exists(), "stale pid cleared"); + assert!( + !std::path::Path::new(&driver_pid_path).exists(), + "stale driver pid cleared" + ); + assert!(std::path::Path::new(&prepare_flag).exists(), "flag seeded"); + assert_eq!( + std::fs::read_to_string(&log_path).expect("read log"), + "", + "log must be truncated for the incremental cursor" + ); + } + + #[test] + fn uploaded_scripts_are_normalized_to_lf() { + // Simulate a CRLF working tree: every generated script must still leave + // this crate as LF-only bash. + let crlf = "#!/usr/bin/env bash\r\nset -euo pipefail\r\n\r\necho hi\r\n"; + assert_eq!( + to_unix_script(crlf), + "#!/usr/bin/env bash\nset -euo pipefail\n\necho hi\n" + ); + + for (name, script) in [ + ("deploy driver", interactive_driver_script("deploy", "deploy")), + ( + "install driver", + interactive_driver_script("install-docker", "install"), + ), + ("deploy body", deploy_body_script_with_checksums(9700, "")), + ("install body", install_docker_body_script()), + ] { + assert!( + !to_unix_script(&script).contains('\r'), + "{name} must reach the relay host without CR" + ); + } + } + + /// `deploy.sh` on the relay host is this driver, not the repo script — it + /// was previously the only generated script with no syntax coverage. + #[cfg(unix)] + #[test] + fn generated_driver_scripts_are_valid_bash() { + for (stem, kind) in [("deploy", "deploy"), ("install-docker", "install")] { + let script = to_unix_script(&interactive_driver_script(stem, kind)); + let output = std::process::Command::new("bash") + .args(["-n", "-c", &script]) + .output() + .expect("parse generated driver script"); + assert!( + output.status.success(), + "generated {kind} driver is invalid:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + } + } + + #[cfg(unix)] + #[test] + fn generated_install_body_is_valid_bash() { + let script = to_unix_script(&install_docker_body_script()); + let output = std::process::Command::new("bash") + .args(["-n", "-c", &script]) + .output() + .expect("parse generated install script"); + assert!( + output.status.success(), + "generated install body is invalid:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + } + + /// 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. + #[test] + fn docker_config_ownership_is_repaired_across_privilege_levels() { + let helpers = prepare_helpers_bash(); + assert!( + helpers.contains("bitfun_fix_docker_config"), + "helpers must expose a DOCKER_CONFIG repair" + ); + + let install = install_docker_body_script(); + assert!( + install.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 repair = body + .find("bitfun_fix_docker_config") + .expect("deploy body must repair DOCKER_CONFIG"); + let mode_check = body + .find(r#"if [ "$BITFUN_DOCKER_MODE" = "direct" ]"#) + .expect("deploy body must keep the direct-mode probe"); + assert!( + repair < mode_check, + "DOCKER_CONFIG must be repaired before any docker call, not only in direct mode" + ); + } + + /// `sg docker -c "docker $*"` re-parsed its arguments through a second + /// shell, losing every boundary — paths with spaces and `-f '{{...}}'` + /// format strings arrived mangled. + #[test] + fn sg_docker_preserves_argument_boundaries() { + let helpers = prepare_helpers_bash(); + // Match the dispatch line, not the comment above it that quotes the + // old form for context. + assert!( + !helpers.contains(r#"sg) sg docker -c "docker $*""#), + "sg path must not re-split arguments through an unquoted $*" + ); + assert!( + helpers.contains("bitfun_shell_join"), + "sg path must quote each argument" + ); + } + + #[cfg(unix)] + #[test] + fn shell_join_round_trips_through_a_second_shell() { + let helpers = to_unix_script(&prepare_helpers_bash()); + let script = format!( + r#"{helpers} +sh -c "$(bitfun_shell_join printf '%s\n' 'a b' "it's" '{{{{.State.Running}}}}' '*')" +"# + ); + let output = std::process::Command::new("bash") + .arg("-c") + .arg(&script) + .output() + .expect("run shell join round trip"); + assert!( + output.status.success(), + "shell join failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "a b\nit's\n{{.State.Running}}\n*\n", + "each argument must survive the second shell verbatim" + ); + } + #[test] fn sync_source_uses_mirror_url_env_with_upstream_fallback() { let sync = sync_source_bash(); @@ -1879,12 +2291,14 @@ PY #[test] fn decide_status_pending_before_pty_is_running() { + // preparing, no log yet. assert_eq!( - decide_task_status(false, false, true, true, 0, 0, false), + decide_task_status(false, false, true, false, true, 0, 0, false), RelayTaskStatus::Running ); + // Nothing staged yet at all. assert_eq!( - decide_task_status(false, false, false, false, 0, 0, false), + decide_task_status(false, false, false, false, false, 0, 0, false), RelayTaskStatus::Running ); } @@ -1892,7 +2306,7 @@ PY #[test] fn decide_status_growing_log_without_pid_is_running() { assert_eq!( - decide_task_status(false, false, false, true, 1000, 100, true), + decide_task_status(false, false, false, false, true, 1000, 100, true), RelayTaskStatus::Running ); } @@ -1900,9 +2314,31 @@ PY #[test] fn decide_status_dead_pid_stale_log_is_failed() { assert_eq!( - decide_task_status(false, false, false, true, 1000, 1000, false), + decide_task_status(false, false, false, false, true, 1000, 1000, false), + RelayTaskStatus::Failed + ); + } + + /// A driver that died before installing its cleanup trap (bad script upload, + /// syntax error) leaves the seeded `preparing` flag and an empty log. That + /// used to read as "running" forever; the wizard never surfaced the failure. + #[test] + fn decide_status_dead_driver_with_empty_log_is_failed() { + assert_eq!( + decide_task_status(false, false, false, true, true, 0, 0, false), RelayTaskStatus::Failed ); + // An alive driver still outranks the grace window — a sudo password + // prompt can legitimately sit for minutes. + assert_eq!( + decide_task_status(false, false, true, false, true, 0, 0, false), + RelayTaskStatus::Running + ); + // Success wins even if the prepare flag was left behind. + assert_eq!( + decide_task_status(true, false, false, true, true, 10, 0, true), + RelayTaskStatus::Succeeded + ); } #[test] diff --git a/src/web-ui/src/features/relay-deploy/README.md b/src/web-ui/src/features/relay-deploy/README.md index e3277ddf94..cdc8e3134c 100644 --- a/src/web-ui/src/features/relay-deploy/README.md +++ b/src/web-ui/src/features/relay-deploy/README.md @@ -98,6 +98,42 @@ Desktop Tauri surface: `src/apps/desktop/src/api/relay_deploy_api.rs` `daemon.json`; host Cargo config must remain untouched; global mode rolls back only BitFun-managed apt and Docker entries. +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. + +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. + ## Related docs - Relay runtime / admin: [`src/apps/relay-server/README.md`](../../../apps/relay-server/README.md)