From 58b83fa48323ed4b606a6c3e1d0e3aadd4c3ffb0 Mon Sep 17 00:00:00 2001 From: LucaLin <78164141+LucaLin233@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:55:24 +0800 Subject: [PATCH 01/32] fix: harden Cloudflare Tunnel repository setup --- README.md | 10 +- tests/test-cloudflare-tunnel.sh | 552 ++++++++++++++++++++--------- tools/cloudflare_tunnel.sh | 608 ++++++++++++++++++++++++++++++-- 3 files changed, 972 insertions(+), 198 deletions(-) diff --git a/README.md b/README.md index 50fc8ce..27a9b88 100644 --- a/README.md +++ b/README.md @@ -292,9 +292,13 @@ scaling、SACK、DSACK、时间戳和 syncookies,但保留内核或发行版 ### Cloudflare Tunnel [`tools/cloudflare_tunnel.sh`](tools/cloudflare_tunnel.sh) 是 Cloudflare 官方 APT 安装流程的薄包装器, -只支持 Debian/Ubuntu 与 systemd。它使用官方 stable 软件源和 `cloudflared service install`, -不再下载裸二进制。安装完成后会询问是否启用受管的 APT systemd timer,默认不启用;也可稍后 -使用独立命令启用。 +只支持 Debian/Ubuntu 与 systemd。它使用 Cloudflare 官方 key/source: +`deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflare-main.gpg any main`, +并以 `cloudflared service install` 配置服务,不再下载裸二进制。keyring 会严格校验单一主公钥 +fingerprint `CC94B39C77AE7342A68B89628A682D308D4E5E73` 与 UID +`CloudFlare Software Packaging 2025 `。key/source 同一事务提交;APT probe 或安装 +失败、进程异常退出或收到 HUP/INT/TERM 时恢复旧世代并保留失败证据。安装完成后会询问是否启用 +受管的 APT systemd timer,默认不启用;也可稍后使用独立命令启用。 ```bash sudo bash <(curl -fsSL https://raw.githubusercontent.com/LucaLin233/Linux/main/tools/cloudflare_tunnel.sh) install diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index 9470627..f35ef71 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -1,182 +1,412 @@ #!/usr/bin/env bash set -euo pipefail -readonly ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) -readonly TEST_DIR=$(mktemp -d) -trap 'rm -rf "$TEST_DIR"' EXIT +if (( EUID != 0 )); then + exec sudo --preserve-env=PATH bash "$0" "$@" +fi -export CLOUDFLARED_KEYRING="$TEST_DIR/keyring.gpg" -export CLOUDFLARED_SOURCE_FILE="$TEST_DIR/cloudflared.list" -export CLOUDFLARED_STATE_DIR="$TEST_DIR/state" -export CLOUDFLARED_LEGACY_BIN="$TEST_DIR/cloudflared" -export CLOUDFLARED_APT_BIN="$TEST_DIR/usr-bin-cloudflared" -export CLOUDFLARED_LEGACY_UPDATER="$TEST_DIR/cloudflared-update" -export CLOUDFLARED_LEGACY_SERVICE="$TEST_DIR/cloudflared-updater.service" -export CLOUDFLARED_LEGACY_TIMER="$TEST_DIR/cloudflared-updater.timer" -export CLOUDFLARED_AUTO_UPDATE_SCRIPT="$TEST_DIR/cloudflared-apt-update" -export CLOUDFLARED_AUTO_UPDATE_SERVICE="$TEST_DIR/cloudflared-apt-update.service" -export CLOUDFLARED_AUTO_UPDATE_TIMER="$TEST_DIR/cloudflared-apt-update.timer" -export CLOUDFLARED_SERVICE_FILE="$TEST_DIR/cloudflared.service" -export CLOUDFLARED_BINARY_UPDATE_SERVICE="$TEST_DIR/cloudflared-update.service" -export CLOUDFLARED_BINARY_UPDATE_TIMER="$TEST_DIR/cloudflared-update.timer" -# shellcheck source=../tools/cloudflare_tunnel.sh -source "$ROOT_DIR/tools/cloudflare_tunnel.sh" +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +readonly ROOT_DIR +TEST_DIR=$(mktemp -d -p "$ROOT_DIR") +readonly TEST_DIR +trap 'rm -rf "$TEST_DIR"' EXIT +pass_count=0 fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } -pass() { printf 'PASS: %s\n' "$*"; } -SYSTEMCTL_TIMER_ENABLED=true -systemctl() { - local unit="${!#}" - if [[ "${1:-}" =~ ^(is-enabled|is-active)$ && "$unit" == *.timer ]]; then - [[ "$SYSTEMCTL_TIMER_ENABLED" == true ]] - return +pass() { pass_count=$((pass_count + 1)); printf 'PASS: %s\n' "$*"; } +assert_same() { cmp -s -- "$1" "$2" || fail "$3"; } +assert_absent() { [[ ! -e "$1" && ! -L "$1" ]] || fail "$2"; } + +make_fake_commands() { + local bin="$1" + mkdir -p "$bin" + cat > "$bin/curl" <<'FAKE' +#!/usr/bin/env bash +set -eu +printf 'curl:%s\n' "$*" >> "$FAKE_LOG" +[[ "${FAKE_CURL_FAIL:-}" != 1 ]] || exit 22 +output="" +while (( $# )); do + if [[ "$1" == -o ]]; then output="$2"; shift 2; continue; fi + shift +done +[[ -n "$output" ]] +printf 'fake-cloudflare-key\n' > "$output" +FAKE + cat > "$bin/gpg" <<'FAKE' +#!/usr/bin/env bash +set -eu +printf 'gpg:%s\n' "$*" >> "$FAKE_LOG" +[[ "${FAKE_GPG_FAIL:-}" != 1 ]] || exit 2 +case "${FAKE_GPG_CASE:-good}" in + good) + cat <<'EOF' +pub:-:4096:1:8A682D308D4E5E73:1761229688:::-:::scESC::::::23::0: +fpr:::::::::CC94B39C77AE7342A68B89628A682D308D4E5E73: +uid:-::::1761229688::HASH::CloudFlare Software Packaging 2025 ::::::::::0: +sub:-:4096:1:029E1444B7D9F50F:1761229688::::::e::::::23: +fpr:::::::::06C89DB3B80A8F4349697C76029E1444B7D9F50F: +EOF + ;; + fingerprint) + cat <<'EOF' +pub:-:4096:1:BAD:0:::-:::scESC: +fpr:::::::::0000000000000000000000000000000000000000: +uid:-::::0::HASH::CloudFlare Software Packaging 2025 ::: +EOF + ;; + uid) + cat <<'EOF' +pub:-:4096:1:8A682D308D4E5E73:0:::-:::scESC: +fpr:::::::::CC94B39C77AE7342A68B89628A682D308D4E5E73: +uid:-::::0::HASH::Attacker ::: +EOF + ;; + multi) + cat <<'EOF' +pub:-:4096:1:8A682D308D4E5E73:0:::-:::scESC: +fpr:::::::::CC94B39C77AE7342A68B89628A682D308D4E5E73: +uid:-::::0::HASH::CloudFlare Software Packaging 2025 ::: +pub:-:4096:1:BAD:0:::-:::scESC: +fpr:::::::::0000000000000000000000000000000000000000: +uid:-::::0::HASH::Other ::: +EOF + ;; +esac +FAKE + cat > "$bin/apt-get" <<'FAKE' +#!/usr/bin/env bash +set -eu +printf 'apt-get:%s\n' "$*" >> "$FAKE_LOG" +case "${1:-}" in + update) [[ "${FAKE_APT_UPDATE_FAIL:-}" != 1 ]] ;; + install) [[ "${FAKE_APT_INSTALL_FAIL:-}" != 1 ]] ;; + *) exit 0 ;; +esac +FAKE + cat > "$bin/systemctl" <<'FAKE' +#!/usr/bin/env bash +printf 'systemctl:%s\n' "$*" >> "$FAKE_LOG" +exit 0 +FAKE + chmod 0755 "$bin"/* +} + +new_case() { + CASE_DIR="$TEST_DIR/case" + rm -rf "$CASE_DIR" + mkdir -p "$CASE_DIR" + export CASE_DIR FAKE_LOG="$CASE_DIR/fake.log" + : > "$FAKE_LOG" + mkdir -p "$CASE_DIR/root/usr/share/keyrings" "$CASE_DIR/root/etc/apt/sources.list.d" \ + "$CASE_DIR/root/var/lib" + chmod 0755 "$CASE_DIR/root" "$CASE_DIR/root/usr" "$CASE_DIR/root/usr/share" \ + "$CASE_DIR/root/usr/share/keyrings" "$CASE_DIR/root/etc" "$CASE_DIR/root/etc/apt" \ + "$CASE_DIR/root/etc/apt/sources.list.d" "$CASE_DIR/root/var" "$CASE_DIR/root/var/lib" + make_fake_commands "$CASE_DIR/bin" + export PATH="$CASE_DIR/bin:$ORIGINAL_PATH" + export CLOUDFLARED_TRUST_ANCHOR="$CASE_DIR/root" + export CLOUDFLARED_KEYRING="$CASE_DIR/root/usr/share/keyrings/cloudflare-main.gpg" + export CLOUDFLARED_SOURCE_FILE="$CASE_DIR/root/etc/apt/sources.list.d/cloudflared.list" + export CLOUDFLARED_APT_SOURCE_ROOT="$CASE_DIR/root/etc/apt" + export CLOUDFLARED_STATE_DIR="$CASE_DIR/root/var/lib/cloudflared-wrapper" + export CLOUDFLARED_REPOSITORY_STATE_DIR="$CLOUDFLARED_STATE_DIR/repository" + export CLOUDFLARED_LEGACY_BIN="$CASE_DIR/legacy-cloudflared" + export CLOUDFLARED_APT_BIN="$CASE_DIR/usr-bin-cloudflared" + export CLOUDFLARED_LEGACY_UPDATER="$CASE_DIR/cloudflared-update" + export CLOUDFLARED_LEGACY_SERVICE="$CASE_DIR/cloudflared-updater.service" + export CLOUDFLARED_LEGACY_TIMER="$CASE_DIR/cloudflared-updater.timer" + export CLOUDFLARED_AUTO_UPDATE_SCRIPT="$CASE_DIR/cloudflared-apt-update" + export CLOUDFLARED_AUTO_UPDATE_SERVICE="$CASE_DIR/cloudflared-apt-update.service" + export CLOUDFLARED_AUTO_UPDATE_TIMER="$CASE_DIR/cloudflared-apt-update.timer" + export CLOUDFLARED_SERVICE_FILE="$CASE_DIR/cloudflared.service" + export CLOUDFLARED_BINARY_UPDATE_SERVICE="$CASE_DIR/cloudflared-update.service" + export CLOUDFLARED_BINARY_UPDATE_TIMER="$CASE_DIR/cloudflared-update.timer" + unset FAKE_CURL_FAIL FAKE_GPG_FAIL FAKE_GPG_CASE FAKE_APT_UPDATE_FAIL FAKE_APT_INSTALL_FAIL + unset FAIL_INSTALL_CALL FAIL_RENAME_CALL INSTALL_CALL RENAME_CALL SIGNAL_PHASE + if [[ "${SCRIPT_SOURCED:-}" != 1 ]]; then + # shellcheck source=../tools/cloudflare_tunnel.sh + source "$ROOT_DIR/tools/cloudflare_tunnel.sh" + SCRIPT_SOURCED=1 fi - return 0 } -curl() { - local output="" - while (( $# > 0 )); do - if [[ "$1" == -o ]]; then - output="$2" - break + +set_old_generation() { + printf 'old-key\n' > "$CLOUDFLARED_KEYRING" + repository_legacy_source_content > "$CLOUDFLARED_SOURCE_FILE" + chmod 0644 "$CLOUDFLARED_KEYRING" "$CLOUDFLARED_SOURCE_FILE" + cp "$CLOUDFLARED_KEYRING" "$CASE_DIR/expected-key" + cp "$CLOUDFLARED_SOURCE_FILE" "$CASE_DIR/expected-source" +} + +assert_old_generation() { + assert_same "$CLOUDFLARED_KEYRING" "$CASE_DIR/expected-key" "old key was not restored" + assert_same "$CLOUDFLARED_SOURCE_FILE" "$CASE_DIR/expected-source" "old source was not restored" +} + +run_failure_case() { + local name="$1" setup="$2" action="${3:-configure_repository}" + new_case + set_old_generation + eval "$setup" + set +e + eval "$action" >"$CASE_DIR/out" 2>"$CASE_DIR/err" + status=$? + set -e + if (( status == 0 )); then + fail "$name unexpectedly succeeded" + fi + assert_old_generation + [[ ! -d "$CLOUDFLARED_REPOSITORY_STATE_DIR/lock" ]] || fail "$name left lock" + find "$CLOUDFLARED_REPOSITORY_STATE_DIR" -maxdepth 1 -type d -name 'failure-*' -print -quit | grep -q . || + fail "$name did not preserve failure evidence" + pass "$name" +} + +ORIGINAL_PATH=$PATH + +new_case +before=$(find "$CASE_DIR/root" -mindepth 1 -printf '%P %y %m\n' | sort) +repository_source_content > "$CASE_DIR/source-output" +after=$(find "$CASE_DIR/root" -mindepth 1 -printf '%P %y %m\n' | sort) +[[ "$before" == "$after" ]] || fail "source rendering had side effects" +[[ "$(cat "$CASE_DIR/source-output")" == "deb [signed-by=$CLOUDFLARED_KEYRING] https://pkg.cloudflare.com/cloudflare-main.gpg any main" ]] || + fail "source is not exact official definition" +pass "source rendering has zero side effects and exact fields" + +new_case +mkdir -p "$CLOUDFLARED_REPOSITORY_STATE_DIR/lock" +chmod 0700 "$CLOUDFLARED_STATE_DIR" "$CLOUDFLARED_REPOSITORY_STATE_DIR" "$CLOUDFLARED_REPOSITORY_STATE_DIR/lock" +if configure_repository >/dev/null 2>&1; then fail "lock competition unexpectedly succeeded"; fi +pass "key/source lock competition" + +run_failure_case "key URL download failure" 'export FAKE_CURL_FAIL=1' +run_failure_case "gpg parser failure" 'export FAKE_GPG_FAIL=1' +run_failure_case "fingerprint mismatch" 'export FAKE_GPG_CASE=fingerprint' +run_failure_case "UID mismatch" 'export FAKE_GPG_CASE=uid' +run_failure_case "multiple primary keys" 'export FAKE_GPG_CASE=multi' + +for target in key source; do + for kind in symlink directory; do + new_case + case "$target" in key) path=$CLOUDFLARED_KEYRING ;; source) path=$CLOUDFLARED_SOURCE_FILE ;; esac + case "$kind" in + symlink) ln -s "$CASE_DIR/missing" "$path" ;; + directory) mkdir "$path" ;; + esac + if configure_repository >/dev/null 2>&1; then fail "$target $kind accepted"; fi + [[ -L "$path" || -d "$path" ]] || fail "$target $kind was changed" + pass "$target rejects $kind" + done + for bad in owner gid mode; do + new_case + case "$target" in key) path=$CLOUDFLARED_KEYRING ;; source) path=$CLOUDFLARED_SOURCE_FILE ;; esac + if [[ "$target" == source ]]; then + repository_source_content > "$path" + else + printf key > "$path" fi - shift + chmod 0644 "$path" + case "$bad" in + owner) chown 65534:0 "$path" ;; + gid) chown 0:65534 "$path" ;; + mode) chmod 0664 "$path" ;; + esac + if configure_repository >/dev/null 2>&1; then fail "$target wrong $bad accepted"; fi + pass "$target rejects wrong $bad" done - [[ -n "$output" ]] || return 1 - printf 'test-key' > "$output" -} +done +new_case +printf 'deb https://example.invalid stable main\n' > "$CASE_DIR/root/etc/apt/sources.list.d/extra.list" +chmod 0644 "$CASE_DIR/root/etc/apt/sources.list.d/extra.list" configure_repository -[[ -s "$KEYRING" ]] || fail "repository key was not installed" -grep -Fq 'https://pkg.cloudflare.com/cloudflared any main' "$SOURCE_FILE" || - fail "official repository definition was not written" -pass "configure official stable APT repository" +pass "unrelated APT source remains untouched" -cat > "$SERVICE_FILE" < "$LEGACY_BIN" <<'EOF' -#!/usr/bin/env bash -echo 'cloudflared version 2025.1.0' -EOF -chmod 0755 "$LEGACY_BIN" -migrate_legacy_binary -[[ ! -e "$LEGACY_BIN" ]] || fail "recognized legacy binary was not removed automatically" -grep -Fq "ExecStart=$APT_BIN --no-autoupdate --token-file /etc/cloudflared/token" "$SERVICE_FILE" || - fail "legacy service executable path was not migrated safely" -find "$STATE_DIR" -type f -name cloudflared.service -print -quit | grep -q . || - fail "legacy service unit was not backed up" -pass "automatically migrate legacy binary and service without replacing credentials" - -printf '#!/bin/sh\necho custom\n' > "$LEGACY_BIN" -chmod 0755 "$LEGACY_BIN" -if migrate_legacy_binary >/dev/null 2>&1; then - fail "unrecognized legacy binary unexpectedly migrated" -fi -[[ -f "$LEGACY_BIN" ]] || fail "unrecognized legacy binary was deleted" -rm -f "$LEGACY_BIN" -pass "preserve legacy binary without ownership evidence" +new_case +printf 'deb https://pkg.cloudflare.com/cloudflared any main\n' > "$CASE_DIR/root/etc/apt/sources.list.d/duplicate.list" +chmod 0644 "$CASE_DIR/root/etc/apt/sources.list.d/duplicate.list" +if configure_repository >/dev/null 2>&1; then fail "duplicate Cloudflare source accepted"; fi +assert_absent "$CLOUDFLARED_KEYRING" "duplicate source changed key" +assert_absent "$CLOUDFLARED_SOURCE_FILE" "duplicate source wrote managed source" +pass "reject duplicate or extra Cloudflare source before commit" -cat > "$APT_BIN" <<'EOF' -#!/usr/bin/env bash -echo 'cloudflared version 2026.8.2' -EOF -chmod 0755 "$APT_BIN" -dpkg-query() { - if [[ "${1:-}" == -S && "${2:-}" == "$APT_BIN" ]]; then - return 0 - fi - command dpkg-query "$@" +new_case +printf 'deb [signed-by=%s trusted=yes] https://pkg.cloudflare.com/cloudflare-main.gpg any main\n' \ + "$CLOUDFLARED_KEYRING" > "$CLOUDFLARED_SOURCE_FILE" +chmod 0644 "$CLOUDFLARED_SOURCE_FILE" +if configure_repository >/dev/null 2>&1; then fail "source injection accepted"; fi +pass "reject unknown source fields and injection content" + +( +new_case +set_old_generation +INSTALL_CALL=0 +FAIL_INSTALL_CALL=1 +repository_install_file() { + INSTALL_CALL=$((INSTALL_CALL + 1)) + (( INSTALL_CALL != FAIL_INSTALL_CALL )) || return 1 + command install -o 0 -g 0 -m "$1" -- "$2" "$3" } -ln -s "$APT_BIN" "$LEGACY_BIN" -migrate_legacy_binary -[[ -L "$LEGACY_BIN" ]] || fail "APT compatibility symlink was not preserved" -[[ "$(readlink -f "$LEGACY_BIN")" == "$APT_BIN" ]] || fail "APT compatibility symlink target changed" -pass "preserve Cloudflare APT compatibility symlink layout" -rm -f "$APT_BIN" - -write_auto_update_files -bash -n "$AUTO_UPDATE_SCRIPT" -grep -Fq 'apt-get -o DPkg::Lock::Timeout=300 update -qq' "$AUTO_UPDATE_SCRIPT" || - fail "auto updater does not refresh APT metadata" -grep -Fq "candidate=\$(LC_ALL=C apt-cache policy cloudflared | awk '/Candidate:/ {print \$2; exit}')" "$AUTO_UPDATE_SCRIPT" || - fail "auto updater candidate parsing depends on localized APT output" -grep -Fq 'dpkg --compare-versions "$candidate" gt "$installed"' "$AUTO_UPDATE_SCRIPT" || - fail "auto updater does not compare installed and candidate versions" -grep -Fq 'install -y --only-upgrade cloudflared' "$AUTO_UPDATE_SCRIPT" || - fail "auto updater does not restrict upgrade to cloudflared" -grep -Fq 'systemctl restart cloudflared.service' "$AUTO_UPDATE_SCRIPT" || - fail "auto updater does not restart an active service" -grep -Fq 'OnCalendar=daily' "$AUTO_UPDATE_TIMER" || fail "daily timer missing" -grep -Fq 'RandomizedDelaySec=6h' "$AUTO_UPDATE_TIMER" || fail "timer jitter missing" -grep -Fq 'Persistent=true' "$AUTO_UPDATE_TIMER" || fail "persistent timer missing" -pass "generate opt-in APT update timer" - -cat > "$LEGACY_UPDATER" <<'EOF' +if configure_repository >/dev/null 2>&1; then fail "key stage write failure succeeded"; fi +assert_old_generation +) +pass "formal key write failure rolls back" + +( +new_case +set_old_generation +INSTALL_CALL=0 +FAIL_INSTALL_CALL=2 +repository_install_file() { + INSTALL_CALL=$((INSTALL_CALL + 1)) + (( INSTALL_CALL != FAIL_INSTALL_CALL )) || return 1 + command install -o 0 -g 0 -m "$1" -- "$2" "$3" +} +if configure_repository >/dev/null 2>&1; then fail "source stage write failure succeeded"; fi +assert_old_generation +) +pass "formal source write failure rolls back" + +( +new_case +set_old_generation +RENAME_CALL=0 +FAIL_RENAME_CALL=2 +repository_rename() { + RENAME_CALL=$((RENAME_CALL + 1)) + (( RENAME_CALL != FAIL_RENAME_CALL )) || return 1 + command mv -fT -- "$1" "$2" +} +if configure_repository >/dev/null 2>&1; then fail "source commit failure succeeded"; fi +assert_old_generation +) +pass "key success/source failure rolls back" + +( +new_case +set_old_generation +RENAME_CALL=0 +FAIL_RENAME_CALL=1 +repository_rename() { + RENAME_CALL=$((RENAME_CALL + 1)) + (( RENAME_CALL != FAIL_RENAME_CALL )) || return 1 + command mv -fT -- "$1" "$2" +} +if configure_repository >/dev/null 2>&1; then fail "key commit failure succeeded"; fi +assert_old_generation +) +pass "source remains old when key commit fails" + +run_failure_case "APT probe failure rollback" 'export FAKE_APT_UPDATE_FAIL=1' 'run_repository_apt_transaction install' +run_failure_case "APT install failure rollback" 'export FAKE_APT_INSTALL_FAIL=1' 'run_repository_apt_transaction install' + +for signal in HUP INT TERM; do + case "$signal" in HUP) expected=129 ;; INT) expected=130 ;; TERM) expected=143 ;; esac + for phase in download validate stage key-commit source-commit apt-probe apt-install; do + new_case + set_old_generation + cat > "$CASE_DIR/signal-runner.sh" < "$LEGACY_SERVICE" < "$LEGACY_TIMER" <<'EOF' -[Unit] -Description=Cloudflared Auto Updater Timer +set -euo pipefail +export PATH=$(printf '%q' "$PATH") +export FAKE_LOG=$(printf '%q' "$FAKE_LOG") +export CLOUDFLARED_TRUST_ANCHOR=$(printf '%q' "$CLOUDFLARED_TRUST_ANCHOR") +export CLOUDFLARED_KEYRING=$(printf '%q' "$CLOUDFLARED_KEYRING") +export CLOUDFLARED_SOURCE_FILE=$(printf '%q' "$CLOUDFLARED_SOURCE_FILE") +export CLOUDFLARED_APT_SOURCE_ROOT=$(printf '%q' "$CLOUDFLARED_APT_SOURCE_ROOT") +export CLOUDFLARED_STATE_DIR=$(printf '%q' "$CLOUDFLARED_STATE_DIR") +export CLOUDFLARED_REPOSITORY_STATE_DIR=$(printf '%q' "$CLOUDFLARED_REPOSITORY_STATE_DIR") +source $(printf '%q' "$ROOT_DIR/tools/cloudflare_tunnel.sh") +repository_transaction_hook() { + if [[ \$1 == $(printf '%q' "$phase") ]]; then kill -s $(printf '%q' "$signal") \$BASHPID; fi +} +if [[ $(printf '%q' "$phase") == apt-* ]]; then + run_repository_apt_transaction install +else + configure_repository +fi EOF -legacy_auto_update_present || fail "legacy auto-update intent was not detected" -pass "detect enabled legacy auto-update intent" -SYSTEMCTL_TIMER_ENABLED=false -if legacy_auto_update_present; then - fail "disabled legacy timer unexpectedly enabled new auto-update" + chmod 0700 "$CASE_DIR/signal-runner.sh" + set +e + timeout 30 bash "$CASE_DIR/signal-runner.sh" >"$CASE_DIR/out" 2>"$CASE_DIR/err" + status=$? + set -e + [[ "$status" == "$expected" ]] || fail "$signal at $phase returned $status, expected $expected" + assert_old_generation + [[ ! -d "$CLOUDFLARED_REPOSITORY_STATE_DIR/lock" ]] || fail "$signal at $phase left lock" + pass "$signal=$expected rolls back at $phase" + done +done + +( +new_case +set_old_generation +RENAME_CALL=0 +repository_rename() { + RENAME_CALL=$((RENAME_CALL + 1)) + case "$RENAME_CALL" in 2|3) return 1 ;; esac + command mv -fT -- "$1" "$2" +} +if configure_repository >/dev/null 2>&1; then fail "rollback failure unexpectedly succeeded"; fi +find "$CLOUDFLARED_REPOSITORY_STATE_DIR" -maxdepth 2 -type f -name rollback.log -print -quit | grep -q . || + fail "rollback failure did not preserve evidence" +) +pass "rollback failure returns nonzero and preserves evidence" + +new_case +set_old_generation +configure_repository +current="$CLOUDFLARED_REPOSITORY_STATE_DIR/current" +[[ -f "$current" ]] || fail "generation marker missing" +key_hash=$(sha256sum "$CLOUDFLARED_KEYRING" | awk '{print $1}') +source_hash=$(sha256sum "$CLOUDFLARED_SOURCE_FILE" | awk '{print $1}') +grep -Fxq "key_sha256=$key_hash" "$current" || fail "key generation hash mismatch" +grep -Fxq "source_sha256=$source_hash" "$current" || fail "source generation hash mismatch" +pass "key/source commit as one recorded generation" + +new_case +set_old_generation +export FAKE_APT_INSTALL_FAIL=1 +if run_repository_apt_transaction install >/dev/null 2>&1; then fail "old generation rollback test succeeded"; fi +assert_old_generation +pass "old key/source preserved on transaction failure" + +new_case +run_repository_apt_transaction install +grep -Fxq 'apt-get:update' "$FAKE_LOG" || fail "APT probe not called" +grep -Fxq 'apt-get:install -y cloudflared' "$FAKE_LOG" || fail "cloudflared package selection changed" +if grep -Eqi 'remove|purge|autoremove|linux-image|linux-headers' "$FAKE_LOG"; then + fail "repository transaction performed unrelated APT changes" fi -pass "do not preserve disabled legacy timer" -SYSTEMCTL_TIMER_ENABLED=true -cleanup_legacy_updater -[[ ! -e "$LEGACY_UPDATER" && ! -e "$LEGACY_SERVICE" && ! -e "$LEGACY_TIMER" ]] || - fail "managed legacy updater was not removed" -find "$STATE_DIR" -type f -name cloudflared-update -print -quit | grep -q . || - fail "legacy updater was not backed up" -pass "backup and remove recognized legacy updater" - -cat > "$BINARY_UPDATE_SERVICE" <<'EOF' -[Unit] -Description=Update cloudflared +pass "no kernel deletion or unrelated APT change" + +script_hashes_before=$(sha256sum "$ROOT_DIR/linux_setup.sh" "$ROOT_DIR"/modules/*.sh "$ROOT_DIR"/tools/push.sh \ + "$ROOT_DIR"/tools/setup-motd.sh "$ROOT_DIR"/tools/xanmod-install.sh "$ROOT_DIR"/tools/traffic-shape.sh) +script_hashes_after=$(sha256sum "$ROOT_DIR/linux_setup.sh" "$ROOT_DIR"/modules/*.sh "$ROOT_DIR"/tools/push.sh \ + "$ROOT_DIR"/tools/setup-motd.sh "$ROOT_DIR"/tools/xanmod-install.sh "$ROOT_DIR"/tools/traffic-shape.sh) +[[ "$script_hashes_before" == "$script_hashes_after" ]] || fail "payload or unrelated scripts changed during tests" +pass "payload and unrelated scripts remain unchanged" + +# Keep prior lifecycle coverage with fake systemctl only. +new_case +cat > "$CLOUDFLARED_SERVICE_FILE" < "$BINARY_UPDATE_TIMER" <<'EOF' -[Unit] -Description=Update cloudflared -[Timer] -OnCalendar=daily +cat > "$CLOUDFLARED_LEGACY_BIN" <<'EOF' +#!/usr/bin/env bash +echo 'cloudflared version 2025.1.0' EOF -cleanup_binary_updater -[[ ! -e "$BINARY_UPDATE_SERVICE" && ! -e "$BINARY_UPDATE_TIMER" ]] || - fail "package-incompatible binary updater was not removed" -pass "remove binary updater after migrating to APT" - -printf custom > "$LEGACY_UPDATER" -if cleanup_legacy_updater >/dev/null 2>&1; then - fail "unrecognized legacy updater unexpectedly removed" -fi -[[ -f "$LEGACY_UPDATER" ]] || fail "unrecognized legacy updater was deleted" -pass "preserve unrecognized legacy updater" - -script="$ROOT_DIR/tools/cloudflare_tunnel.sh" -entrypoint_output=$(bash -c "$(cat "$script")" cloudflare_tunnel.sh help) -grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || - fail "bash -c entrypoint did not dispatch script arguments" -pass "support bash -c one-line invocation" - -grep -Fq 'https://pkg.cloudflare.com/cloudflared' "$script" || fail "official APT repository missing" -grep -Fq 'apt-get install -y --only-upgrade cloudflared' "$script" || fail "APT upgrade path missing" -grep -Fq 'read -r -s -p' "$script" || fail "Token input is not hidden" -grep -Fq 'service install --no-update-service' "$script" || fail "package install still enables binary self-update" -grep -Fq 'enable-auto-update' "$script" || fail "opt-in auto-update command missing" -pass "use official APT lifecycle with opt-in update detection" - -printf 'All cloudflare wrapper tests passed.\n' +chmod 0755 "$CLOUDFLARED_LEGACY_BIN" +migrate_legacy_binary +assert_absent "$CLOUDFLARED_LEGACY_BIN" "recognized legacy binary was not migrated" +grep -Fq "ExecStart=$CLOUDFLARED_APT_BIN --no-autoupdate --token-file /etc/cloudflared/token" "$CLOUDFLARED_SERVICE_FILE" || + fail "legacy service path was not preserved" +pass "legacy binary and service migration behavior remains" + +entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) +grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" +pass "bash -c entrypoint remains compatible" + +printf 'All cloudflare wrapper tests passed. PASS=%d\n' "$pass_count" diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index db061c2..34c8704 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -8,7 +8,12 @@ readonly KEYRING="${CLOUDFLARED_KEYRING:-/usr/share/keyrings/cloudflare-main.gpg readonly SOURCE_FILE="${CLOUDFLARED_SOURCE_FILE:-/etc/apt/sources.list.d/cloudflared.list}" readonly STATE_DIR="${CLOUDFLARED_STATE_DIR:-/var/lib/cloudflared-wrapper}" readonly KEY_URL="https://pkg.cloudflare.com/cloudflare-main.gpg" -readonly REPOSITORY="https://pkg.cloudflare.com/cloudflared" +readonly REPOSITORY="https://pkg.cloudflare.com/cloudflare-main.gpg" +readonly KEY_FINGERPRINT="CC94B39C77AE7342A68B89628A682D308D4E5E73" +readonly KEY_UID="CloudFlare Software Packaging 2025 " +readonly REPOSITORY_STATE_DIR="${CLOUDFLARED_REPOSITORY_STATE_DIR:-$STATE_DIR/repository}" +readonly TRUST_ANCHOR="${CLOUDFLARED_TRUST_ANCHOR:-/}" +readonly APT_SOURCE_ROOT="${CLOUDFLARED_APT_SOURCE_ROOT:-/etc/apt}" readonly LEGACY_BIN="${CLOUDFLARED_LEGACY_BIN:-/usr/local/bin/cloudflared}" readonly APT_BIN="${CLOUDFLARED_APT_BIN:-/usr/bin/cloudflared}" readonly LEGACY_UPDATER="${CLOUDFLARED_LEGACY_UPDATER:-/usr/local/bin/cloudflared-update}" @@ -57,39 +62,578 @@ backup_path() { cp -a "$path" "$backup_dir/$(basename "$path")" } -configure_repository() { - local key_temp source_temp backup_dir - command -v curl >/dev/null || { - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl +REPOSITORY_TRANSACTION_ACTIVE=false +REPOSITORY_TRANSACTION_DIR="" +REPOSITORY_KEY_STAGE="" +REPOSITORY_SOURCE_STAGE="" +REPOSITORY_LOCK_DIR="" +REPOSITORY_GENERATION="" +REPOSITORY_OLD_KEY=false +REPOSITORY_OLD_SOURCE=false +REPOSITORY_OLD_STATE=false +REPOSITORY_PREVIOUS_HUP_TRAP="" +REPOSITORY_PREVIOUS_INT_TRAP="" +REPOSITORY_PREVIOUS_TERM_TRAP="" +REPOSITORY_PREVIOUS_EXIT_TRAP="" + +repository_source_content() { + printf 'deb [signed-by=%s] %s any main\n' "$KEYRING" "$REPOSITORY" +} + +repository_legacy_source_content() { + cat </dev/null) + [[ -e "$SOURCE_FILE" || -L "$SOURCE_FILE" ]] || return 0 + validate_secure_file "$SOURCE_FILE" 644 || return 1 + expected=$(repository_source_content) + legacy=$(repository_legacy_source_content) + if [[ "$(cat -- "$SOURCE_FILE")" != "$expected" && "$(cat -- "$SOURCE_FILE")" != "$legacy" ]]; then + error "现有软件源不是精确官方配置或受支持旧配置,拒绝覆盖: $SOURCE_FILE" return 1 fi - backup_dir="$STATE_DIR/repository-$(date +%Y%m%d_%H%M%S)" - backup_path "$KEYRING" "$backup_dir" - backup_path "$SOURCE_FILE" "$backup_dir" - key_temp=$(mktemp) - source_temp=$(mktemp) - trap 'rm -f "$key_temp" "$source_temp"' RETURN +} - curl -fsSL --connect-timeout 10 --max-time 60 "$KEY_URL" -o "$key_temp" - [[ -s "$key_temp" ]] || { error "Cloudflare 签名密钥为空"; return 1; } - install -m 0644 "$key_temp" "$KEYRING" +validate_repository_state_entries() { + local entry base + shopt -s nullglob + for entry in "$REPOSITORY_STATE_DIR"/*; do + base=$(basename -- "$entry") + case "$base" in + current) + validate_secure_file "$entry" 600 || return 1 + ;; + history-*|failure-*) + validate_secure_directory "$entry" 700 || return 1 + find "$entry" -mindepth 1 -type l -print -quit | grep -q . && { + error "事务证据目录包含符号链接: $entry" + return 1 + } + while IFS= read -r -d '' evidence; do + validate_secure_file "$evidence" 600 || return 1 + done < <(find "$entry" -mindepth 1 -maxdepth 1 -type f -print0) + find "$entry" -mindepth 1 -maxdepth 1 ! -type f -print -quit | grep -q . && { + error "事务证据目录包含未知类型: $entry" + return 1 + } + ;; + lock) + error "另一个 Cloudflare 仓库事务正在运行,或存在待人工审查的锁: $entry" + return 1 + ;; + *) + error "Cloudflare 仓库状态目录包含陌生残留: $entry" + return 1 + ;; + esac + done + shopt -u nullglob +} - cat > "$source_temp" < "$STATE_DIR/repository-managed" - trap - RETURN - rm -f "$key_temp" "$source_temp" +prepare_repository_state() { + local state_parent repository_parent + state_parent=$(dirname -- "$STATE_DIR") + repository_parent=$(dirname -- "$REPOSITORY_STATE_DIR") + validate_directory_chain "$state_parent" || return 1 + if [[ ! -e "$STATE_DIR" && ! -L "$STATE_DIR" ]]; then + install -d -o 0 -g 0 -m 0700 -- "$STATE_DIR" || return 1 + fi + validate_secure_directory "$STATE_DIR" 700 || return 1 + [[ "$repository_parent" == "$STATE_DIR" ]] || { + error "仓库状态目录必须直接位于状态目录内" + return 1 + } + if [[ ! -e "$REPOSITORY_STATE_DIR" && ! -L "$REPOSITORY_STATE_DIR" ]]; then + install -d -o 0 -g 0 -m 0700 -- "$REPOSITORY_STATE_DIR" || return 1 + fi + validate_secure_directory "$REPOSITORY_STATE_DIR" 700 || return 1 + validate_repository_state_entries +} + +acquire_repository_lock() { + REPOSITORY_LOCK_DIR="$REPOSITORY_STATE_DIR/lock" + if ! mkdir -m 0700 -- "$REPOSITORY_LOCK_DIR" 2>/dev/null; then + error "无法取得 Cloudflare 仓库事务锁: $REPOSITORY_LOCK_DIR" + return 1 + fi + if ! validate_secure_directory "$REPOSITORY_LOCK_DIR" 700; then + rmdir -- "$REPOSITORY_LOCK_DIR" 2>/dev/null || true + return 1 + fi +} + +save_repository_traps() { + REPOSITORY_PREVIOUS_HUP_TRAP=$(trap -p HUP || true) + REPOSITORY_PREVIOUS_INT_TRAP=$(trap -p INT || true) + REPOSITORY_PREVIOUS_TERM_TRAP=$(trap -p TERM || true) + REPOSITORY_PREVIOUS_EXIT_TRAP=$(trap -p EXIT || true) + trap 'repository_signal_handler 129 HUP' HUP + trap 'repository_signal_handler 130 INT' INT + trap 'repository_signal_handler 143 TERM' TERM + trap 'repository_exit_handler $?' EXIT +} + +restore_one_trap() { + local signal="$1" saved="$2" + trap - "$signal" + if [[ -n "$saved" ]]; then + eval "$saved" + fi +} + +restore_repository_traps() { + restore_one_trap HUP "$REPOSITORY_PREVIOUS_HUP_TRAP" + restore_one_trap INT "$REPOSITORY_PREVIOUS_INT_TRAP" + restore_one_trap TERM "$REPOSITORY_PREVIOUS_TERM_TRAP" + restore_one_trap EXIT "$REPOSITORY_PREVIOUS_EXIT_TRAP" +} + +release_repository_lock() { + [[ -n "$REPOSITORY_LOCK_DIR" ]] || return 0 + if [[ -d "$REPOSITORY_LOCK_DIR" && ! -L "$REPOSITORY_LOCK_DIR" ]]; then + rmdir -- "$REPOSITORY_LOCK_DIR" || return 1 + elif [[ -e "$REPOSITORY_LOCK_DIR" || -L "$REPOSITORY_LOCK_DIR" ]]; then + error "事务锁类型在运行中发生变化: $REPOSITORY_LOCK_DIR" + return 1 + fi + REPOSITORY_LOCK_DIR="" +} + +repository_copy_file() { + cp --no-dereference --preserve=mode,ownership,timestamps -- "$1" "$2" +} + +repository_install_file() { + install -o 0 -g 0 -m "$1" -- "$2" "$3" +} + +repository_rename() { + mv -fT -- "$1" "$2" +} + +repository_download_key() { + curl -fsSL --connect-timeout 10 --max-time 60 "$KEY_URL" -o "$1" +} + +repository_transaction_hook() { + : +} + +repository_key_records() { + LC_ALL=C gpg --batch --no-options --no-default-keyring \ + --show-keys --with-colons --with-fingerprint --with-fingerprint -- "$1" +} + +validate_downloaded_key() { + local key="$1" expected_mode="${2:-600}" records primary_count fingerprint uid_count uid + validate_secure_file "$key" "$expected_mode" || return 1 + [[ -s "$key" ]] || { error "Cloudflare 签名密钥为空"; return 1; } + if ! records=$(repository_key_records "$key"); then + error "gpg 无法解析 Cloudflare 签名密钥" + return 1 + fi + primary_count=$(awk -F: '$1 == "pub" {count++} END {print count+0}' <<< "$records") + [[ "$primary_count" == 1 ]] || { + error "Cloudflare keyring 必须且只能包含一个主公钥" + return 1 + } + fingerprint=$(awk -F: ' + $1 == "pub" {in_primary=1; next} + in_primary && $1 == "fpr" {print $10; exit} + ' <<< "$records") + [[ "$fingerprint" == "$KEY_FINGERPRINT" ]] || { + error "Cloudflare 主公钥 fingerprint 不匹配" + return 1 + } + uid_count=$(awk -F: ' + $1 == "pub" {in_primary=1; next} + $1 == "sub" {in_primary=0} + in_primary && $1 == "uid" {count++} + END {print count+0} + ' <<< "$records") + uid=$(awk -F: ' + $1 == "pub" {in_primary=1; next} + $1 == "sub" {in_primary=0} + in_primary && $1 == "uid" {print $10; exit} + ' <<< "$records") + [[ "$uid_count" == 1 && "$uid" == "$KEY_UID" ]] || { + error "Cloudflare 主公钥 UID/主身份不匹配" + return 1 + } +} + +write_transaction_status() { + local text="$1" + printf '%s\n' "$text" > "$REPOSITORY_TRANSACTION_DIR/status" + chmod 0600 "$REPOSITORY_TRANSACTION_DIR/status" +} + +backup_repository_generation() { + if [[ -e "$KEYRING" || -L "$KEYRING" ]]; then + repository_copy_file "$KEYRING" "$REPOSITORY_TRANSACTION_DIR/old-key" || return 1 + chmod 0600 "$REPOSITORY_TRANSACTION_DIR/old-key" || return 1 + REPOSITORY_OLD_KEY=true + fi + if [[ -e "$SOURCE_FILE" || -L "$SOURCE_FILE" ]]; then + repository_copy_file "$SOURCE_FILE" "$REPOSITORY_TRANSACTION_DIR/old-source" || return 1 + chmod 0600 "$REPOSITORY_TRANSACTION_DIR/old-source" || return 1 + REPOSITORY_OLD_SOURCE=true + fi + if [[ -f "$REPOSITORY_STATE_DIR/current" ]]; then + repository_copy_file "$REPOSITORY_STATE_DIR/current" "$REPOSITORY_TRANSACTION_DIR/old-current" || return 1 + chmod 0600 "$REPOSITORY_TRANSACTION_DIR/old-current" || return 1 + REPOSITORY_OLD_STATE=true + fi +} + +restore_repository_file() { + local had_old="$1" backup="$2" target="$3" restore_stage + if [[ "$had_old" == true ]]; then + restore_stage=$(mktemp "$(dirname -- "$target")/.cloudflared-rollback.XXXXXX") || return 1 + if ! repository_install_file 0644 "$backup" "$restore_stage" || + ! validate_secure_file "$restore_stage" 644 || + ! repository_rename "$restore_stage" "$target"; then + rm -f -- "$restore_stage" 2>/dev/null || true + return 1 + fi + else + if [[ -e "$target" || -L "$target" ]]; then + [[ -f "$target" && ! -L "$target" ]] || return 1 + rm -f -- "$target" || return 1 + fi + fi +} + +archive_failed_transaction() { + local failed_dir="$REPOSITORY_STATE_DIR/failure-$REPOSITORY_GENERATION" + [[ -d "$REPOSITORY_TRANSACTION_DIR" ]] || return 0 + if repository_rename "$REPOSITORY_TRANSACTION_DIR" "$failed_dir"; then + REPOSITORY_TRANSACTION_DIR="$failed_dir" + return 0 + fi + return 1 +} + +rollback_repository_transaction() { + local reason="$1" rollback_failed=false + [[ "$REPOSITORY_TRANSACTION_ACTIVE" == true ]] || return 0 + REPOSITORY_TRANSACTION_ACTIVE=false + trap - HUP INT TERM EXIT + printf '%s\n' "$reason" >> "$REPOSITORY_TRANSACTION_DIR/rollback.log" 2>/dev/null || rollback_failed=true + chmod 0600 "$REPOSITORY_TRANSACTION_DIR/rollback.log" 2>/dev/null || rollback_failed=true + restore_repository_file "$REPOSITORY_OLD_SOURCE" "$REPOSITORY_TRANSACTION_DIR/old-source" "$SOURCE_FILE" || rollback_failed=true + restore_repository_file "$REPOSITORY_OLD_KEY" "$REPOSITORY_TRANSACTION_DIR/old-key" "$KEYRING" || rollback_failed=true + if [[ "$REPOSITORY_OLD_STATE" == true ]]; then + repository_install_file 0600 "$REPOSITORY_TRANSACTION_DIR/old-current" "$REPOSITORY_STATE_DIR/current.rollback" || rollback_failed=true + if [[ -f "$REPOSITORY_STATE_DIR/current.rollback" ]]; then + repository_rename "$REPOSITORY_STATE_DIR/current.rollback" "$REPOSITORY_STATE_DIR/current" || rollback_failed=true + fi + else + rm -f -- "$REPOSITORY_STATE_DIR/current" 2>/dev/null || rollback_failed=true + fi + rm -f -- "$REPOSITORY_KEY_STAGE" "$REPOSITORY_SOURCE_STAGE" 2>/dev/null || rollback_failed=true + archive_failed_transaction || rollback_failed=true + release_repository_lock || rollback_failed=true + restore_repository_traps + if [[ "$rollback_failed" == true ]]; then + error "Cloudflare 仓库事务回滚不完整;失败证据已尽量保留: $REPOSITORY_TRANSACTION_DIR" + return 1 + fi + error "Cloudflare 仓库事务失败,旧 key/source 已恢复;证据: $REPOSITORY_TRANSACTION_DIR" + return 0 +} + +repository_transaction_fail() { + local reason="$1" + error "$reason" + rollback_repository_transaction "$reason" || true + return 1 +} + +repository_signal_handler() { + local code="$1" signal="$2" + rollback_repository_transaction "收到 $signal 信号" || true + exit "$code" +} + +repository_exit_handler() { + local status="$1" + if [[ "$REPOSITORY_TRANSACTION_ACTIVE" == true ]]; then + rollback_repository_transaction "进程异常退出,状态 $status" || true + fi + exit "$status" +} + +validate_committed_repository() { + local expected + validate_secure_file "$KEYRING" 644 || return 1 + validate_secure_file "$SOURCE_FILE" 644 || return 1 + expected=$(repository_source_content) + [[ "$(cat -- "$SOURCE_FILE")" == "$expected" ]] || { + error "正式 source 内容不精确" + return 1 + } + validate_downloaded_key "$KEYRING" 644 +} + +begin_repository_transaction() { + local key_parent source_parent downloaded marker_stage key_hash source_hash + command -v curl >/dev/null || { error "缺少 curl;正式仓库提交前不会通过 APT 安装依赖"; return 1; } + command -v gpg >/dev/null || { error "缺少 gpg;无法校验 OpenPGP 主身份"; return 1; } + command -v realpath >/dev/null || { error "缺少 realpath"; return 1; } + key_parent=$(dirname -- "$KEYRING") + source_parent=$(dirname -- "$SOURCE_FILE") + validate_directory_chain "$key_parent" || return 1 + validate_directory_chain "$source_parent" || return 1 + validate_directory_chain "$APT_SOURCE_ROOT" || return 1 + prepare_repository_state || return 1 + acquire_repository_lock || return 1 + + REPOSITORY_GENERATION="$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM" + REPOSITORY_TRANSACTION_DIR="$REPOSITORY_STATE_DIR/transaction-$REPOSITORY_GENERATION" + if ! mkdir -m 0700 -- "$REPOSITORY_TRANSACTION_DIR" || + ! validate_secure_directory "$REPOSITORY_TRANSACTION_DIR" 700; then + release_repository_lock || true + return 1 + fi + REPOSITORY_TRANSACTION_ACTIVE=true + save_repository_traps + + validate_existing_repository_file "$KEYRING" || repository_transaction_fail "现有 keyring 类型或元数据不可信" + [[ "$REPOSITORY_TRANSACTION_ACTIVE" == true ]] || return 1 + validate_existing_source || repository_transaction_fail "现有 source 类型、元数据或内容不可信" + [[ "$REPOSITORY_TRANSACTION_ACTIVE" == true ]] || return 1 + backup_repository_generation || repository_transaction_fail "备份旧 key/source 失败" + [[ "$REPOSITORY_TRANSACTION_ACTIVE" == true ]] || return 1 + + downloaded="$REPOSITORY_TRANSACTION_DIR/downloaded-key" + repository_transaction_hook download + if ! : > "$downloaded" || ! chmod 0600 "$downloaded" || + ! repository_download_key "$downloaded"; then + repository_transaction_fail "Cloudflare key URL 下载失败" + return 1 + fi + repository_transaction_hook validate + validate_downloaded_key "$downloaded" || { + repository_transaction_fail "Cloudflare 下载密钥校验失败" + return 1 + } + + repository_transaction_hook stage + REPOSITORY_KEY_STAGE=$(mktemp "$key_parent/.cloudflare-main.gpg.stage.XXXXXX") || { + repository_transaction_fail "创建 key stage 失败" + return 1 + } + REPOSITORY_SOURCE_STAGE=$(mktemp "$source_parent/.cloudflared.list.stage.XXXXXX") || { + repository_transaction_fail "创建 source stage 失败" + return 1 + } + if ! repository_install_file 0644 "$downloaded" "$REPOSITORY_KEY_STAGE" || + ! validate_secure_file "$REPOSITORY_KEY_STAGE" 644; then + repository_transaction_fail "写入或校验 key stage 失败" + return 1 + fi + if ! repository_source_content > "$REPOSITORY_TRANSACTION_DIR/source" || + ! chmod 0600 "$REPOSITORY_TRANSACTION_DIR/source" || + ! repository_install_file 0644 "$REPOSITORY_TRANSACTION_DIR/source" "$REPOSITORY_SOURCE_STAGE" || + ! validate_secure_file "$REPOSITORY_SOURCE_STAGE" 644; then + repository_transaction_fail "写入或校验 source stage 失败" + return 1 + fi + + write_transaction_status "generation=$REPOSITORY_GENERATION staged" || { + repository_transaction_fail "写入事务状态失败" + return 1 + } + repository_transaction_hook key-commit + if ! repository_rename "$REPOSITORY_KEY_STAGE" "$KEYRING"; then + repository_transaction_fail "提交正式 keyring 失败" + return 1 + fi + REPOSITORY_KEY_STAGE="" + validate_secure_file "$KEYRING" 644 || { + repository_transaction_fail "正式 keyring 提交后校验失败" + return 1 + } + repository_transaction_hook source-commit + if ! repository_rename "$REPOSITORY_SOURCE_STAGE" "$SOURCE_FILE"; then + repository_transaction_fail "提交正式 source 失败" + return 1 + fi + REPOSITORY_SOURCE_STAGE="" + repository_transaction_hook committed + validate_committed_repository || { + repository_transaction_fail "正式 key/source 同世代校验失败" + return 1 + } + + key_hash=$(sha256sum -- "$KEYRING" | awk '{print $1}') || { + repository_transaction_fail "计算 keyring 世代摘要失败" + return 1 + } + source_hash=$(sha256sum -- "$SOURCE_FILE" | awk '{print $1}') || { + repository_transaction_fail "计算 source 世代摘要失败" + return 1 + } + marker_stage="$REPOSITORY_STATE_DIR/current.stage-$REPOSITORY_GENERATION" + if ! printf 'generation=%s\nkey_sha256=%s\nsource_sha256=%s\n' \ + "$REPOSITORY_GENERATION" "$key_hash" "$source_hash" > "$marker_stage" || + ! chmod 0600 "$marker_stage" || + ! validate_secure_file "$marker_stage" 600 || + ! repository_rename "$marker_stage" "$REPOSITORY_STATE_DIR/current"; then + rm -f -- "$marker_stage" 2>/dev/null || true + repository_transaction_fail "提交 key/source 世代状态失败" + return 1 + fi + write_transaction_status "generation=$REPOSITORY_GENERATION committed" || { + repository_transaction_fail "记录事务提交状态失败" + return 1 + } +} + +finish_repository_transaction() { + local history_dir="$REPOSITORY_STATE_DIR/history-$REPOSITORY_GENERATION" + [[ "$REPOSITORY_TRANSACTION_ACTIVE" == true ]] || return 1 + if ! repository_rename "$REPOSITORY_TRANSACTION_DIR" "$history_dir"; then + repository_transaction_fail "归档成功事务失败" + return 1 + fi + REPOSITORY_TRANSACTION_DIR="$history_dir" + REPOSITORY_TRANSACTION_ACTIVE=false + trap - HUP INT TERM EXIT + if ! release_repository_lock; then + restore_repository_traps + error "仓库提交成功,但事务锁释放失败: $REPOSITORY_LOCK_DIR" + return 1 + fi + restore_repository_traps +} + +configure_repository() { + begin_repository_transaction || return 1 + finish_repository_transaction +} + +run_repository_apt_transaction() { + local operation="$1" + begin_repository_transaction || return 1 + repository_transaction_hook apt-probe + if ! apt-get update; then + repository_transaction_fail "Cloudflare APT probe 失败" + return 1 + fi + repository_transaction_hook apt-install + case "$operation" in + install) + if ! DEBIAN_FRONTEND=noninteractive apt-get install -y cloudflared; then + repository_transaction_fail "cloudflared APT 安装失败" + return 1 + fi + ;; + upgrade) + if ! DEBIAN_FRONTEND=noninteractive apt-get install -y --only-upgrade cloudflared; then + repository_transaction_fail "cloudflared APT 升级失败" + return 1 + fi + ;; + *) + repository_transaction_fail "未知 APT 仓库事务: $operation" + return 1 + ;; + esac + finish_repository_transaction } legacy_updater_is_managed() { @@ -430,9 +974,7 @@ install_package() { validate_migration_inputs PRESERVE_AUTO_UPDATE=false legacy_auto_update_present && PRESERVE_AUTO_UPDATE=true || true - configure_repository - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y cloudflared + run_repository_apt_transaction install migrate_legacy_binary cleanup_legacy_updater || { error "旧自定义更新组件清理失败"; return 1; } cleanup_binary_updater || { error "二进制更新单元清理失败"; return 1; } @@ -493,9 +1035,7 @@ upgrade_cloudflared() { validate_migration_inputs PRESERVE_AUTO_UPDATE=false legacy_auto_update_present && PRESERVE_AUTO_UPDATE=true || true - configure_repository - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y --only-upgrade cloudflared + run_repository_apt_transaction upgrade migrate_legacy_binary cleanup_legacy_updater || { error "旧自定义更新组件清理失败"; return 1; } cleanup_binary_updater || { error "二进制更新单元清理失败"; return 1; } From 88f643991b5c89ee62089ac357ae886a4e4a7744 Mon Sep 17 00:00:00 2001 From: LucaLin <78164141+LucaLin233@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:55:14 +0800 Subject: [PATCH 02/32] fix: close Cloudflare repository lifecycle gaps --- tests/test-cloudflare-tunnel.sh | 9 ++- tools/cloudflare_tunnel.sh | 134 ++++++++++++++++++++++---------- 2 files changed, 97 insertions(+), 46 deletions(-) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index f35ef71..b9a985e 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -129,6 +129,7 @@ new_case() { source "$ROOT_DIR/tools/cloudflare_tunnel.sh" SCRIPT_SOURCED=1 fi + init_runtime_config } set_old_generation() { @@ -157,7 +158,7 @@ run_failure_case() { fail "$name unexpectedly succeeded" fi assert_old_generation - [[ ! -d "$CLOUDFLARED_REPOSITORY_STATE_DIR/lock" ]] || fail "$name left lock" + [[ ! -d "$CLOUDFLARED_STATE_DIR.lock" ]] || fail "$name left lock" find "$CLOUDFLARED_REPOSITORY_STATE_DIR" -maxdepth 1 -type d -name 'failure-*' -print -quit | grep -q . || fail "$name did not preserve failure evidence" pass "$name" @@ -175,8 +176,8 @@ after=$(find "$CASE_DIR/root" -mindepth 1 -printf '%P %y %m\n' | sort) pass "source rendering has zero side effects and exact fields" new_case -mkdir -p "$CLOUDFLARED_REPOSITORY_STATE_DIR/lock" -chmod 0700 "$CLOUDFLARED_STATE_DIR" "$CLOUDFLARED_REPOSITORY_STATE_DIR" "$CLOUDFLARED_REPOSITORY_STATE_DIR/lock" +mkdir -p "$CLOUDFLARED_STATE_DIR.lock" +chmod 0700 "$CLOUDFLARED_STATE_DIR.lock" if configure_repository >/dev/null 2>&1; then fail "lock competition unexpectedly succeeded"; fi pass "key/source lock competition" @@ -334,7 +335,7 @@ EOF set -e [[ "$status" == "$expected" ]] || fail "$signal at $phase returned $status, expected $expected" assert_old_generation - [[ ! -d "$CLOUDFLARED_REPOSITORY_STATE_DIR/lock" ]] || fail "$signal at $phase left lock" + [[ ! -d "$CLOUDFLARED_STATE_DIR.lock" ]] || fail "$signal at $phase left lock" pass "$signal=$expected rolls back at $phase" done done diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index 34c8704..baaa92c 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -2,31 +2,31 @@ # Cloudflare Tunnel manager for Debian/Ubuntu. # Uses Cloudflare's official stable APT repository and service command. -set -euo pipefail - -readonly KEYRING="${CLOUDFLARED_KEYRING:-/usr/share/keyrings/cloudflare-main.gpg}" -readonly SOURCE_FILE="${CLOUDFLARED_SOURCE_FILE:-/etc/apt/sources.list.d/cloudflared.list}" -readonly STATE_DIR="${CLOUDFLARED_STATE_DIR:-/var/lib/cloudflared-wrapper}" -readonly KEY_URL="https://pkg.cloudflare.com/cloudflare-main.gpg" -readonly REPOSITORY="https://pkg.cloudflare.com/cloudflare-main.gpg" -readonly KEY_FINGERPRINT="CC94B39C77AE7342A68B89628A682D308D4E5E73" -readonly KEY_UID="CloudFlare Software Packaging 2025 " -readonly REPOSITORY_STATE_DIR="${CLOUDFLARED_REPOSITORY_STATE_DIR:-$STATE_DIR/repository}" -readonly TRUST_ANCHOR="${CLOUDFLARED_TRUST_ANCHOR:-/}" -readonly APT_SOURCE_ROOT="${CLOUDFLARED_APT_SOURCE_ROOT:-/etc/apt}" -readonly LEGACY_BIN="${CLOUDFLARED_LEGACY_BIN:-/usr/local/bin/cloudflared}" -readonly APT_BIN="${CLOUDFLARED_APT_BIN:-/usr/bin/cloudflared}" -readonly LEGACY_UPDATER="${CLOUDFLARED_LEGACY_UPDATER:-/usr/local/bin/cloudflared-update}" -readonly LEGACY_SERVICE="${CLOUDFLARED_LEGACY_SERVICE:-/etc/systemd/system/cloudflared-updater.service}" -readonly LEGACY_TIMER="${CLOUDFLARED_LEGACY_TIMER:-/etc/systemd/system/cloudflared-updater.timer}" -readonly AUTO_UPDATE_SCRIPT="${CLOUDFLARED_AUTO_UPDATE_SCRIPT:-/usr/local/libexec/cloudflared-apt-update}" -readonly AUTO_UPDATE_SERVICE="${CLOUDFLARED_AUTO_UPDATE_SERVICE:-/etc/systemd/system/cloudflared-apt-update.service}" -readonly AUTO_UPDATE_TIMER="${CLOUDFLARED_AUTO_UPDATE_TIMER:-/etc/systemd/system/cloudflared-apt-update.timer}" -readonly SERVICE_FILE="${CLOUDFLARED_SERVICE_FILE:-/etc/systemd/system/cloudflared.service}" -readonly BINARY_UPDATE_SERVICE="${CLOUDFLARED_BINARY_UPDATE_SERVICE:-/etc/systemd/system/cloudflared-update.service}" -readonly BINARY_UPDATE_TIMER="${CLOUDFLARED_BINARY_UPDATE_TIMER:-/etc/systemd/system/cloudflared-update.timer}" - -PRESERVE_AUTO_UPDATE=false +init_runtime_config() { + KEYRING="${CLOUDFLARED_KEYRING:-/usr/share/keyrings/cloudflare-main.gpg}" + SOURCE_FILE="${CLOUDFLARED_SOURCE_FILE:-/etc/apt/sources.list.d/cloudflared.list}" + STATE_DIR="${CLOUDFLARED_STATE_DIR:-/var/lib/cloudflared-wrapper}" + KEY_URL="https://pkg.cloudflare.com/cloudflare-main.gpg" + REPOSITORY="https://pkg.cloudflare.com/cloudflare-main.gpg" + KEY_FINGERPRINT="CC94B39C77AE7342A68B89628A682D308D4E5E73" + KEY_UID="CloudFlare Software Packaging 2025 " + REPOSITORY_STATE_DIR="${CLOUDFLARED_REPOSITORY_STATE_DIR:-$STATE_DIR/repository}" + REPOSITORY_LOCK_DIR="${CLOUDFLARED_REPOSITORY_LOCK_DIR:-$STATE_DIR.lock}" + TRUST_ANCHOR="${CLOUDFLARED_TRUST_ANCHOR:-/}" + APT_SOURCE_ROOT="${CLOUDFLARED_APT_SOURCE_ROOT:-/etc/apt}" + LEGACY_BIN="${CLOUDFLARED_LEGACY_BIN:-/usr/local/bin/cloudflared}" + APT_BIN="${CLOUDFLARED_APT_BIN:-/usr/bin/cloudflared}" + LEGACY_UPDATER="${CLOUDFLARED_LEGACY_UPDATER:-/usr/local/bin/cloudflared-update}" + LEGACY_SERVICE="${CLOUDFLARED_LEGACY_SERVICE:-/etc/systemd/system/cloudflared-updater.service}" + LEGACY_TIMER="${CLOUDFLARED_LEGACY_TIMER:-/etc/systemd/system/cloudflared-updater.timer}" + AUTO_UPDATE_SCRIPT="${CLOUDFLARED_AUTO_UPDATE_SCRIPT:-/usr/local/libexec/cloudflared-apt-update}" + AUTO_UPDATE_SERVICE="${CLOUDFLARED_AUTO_UPDATE_SERVICE:-/etc/systemd/system/cloudflared-apt-update.service}" + AUTO_UPDATE_TIMER="${CLOUDFLARED_AUTO_UPDATE_TIMER:-/etc/systemd/system/cloudflared-apt-update.timer}" + SERVICE_FILE="${CLOUDFLARED_SERVICE_FILE:-/etc/systemd/system/cloudflared.service}" + BINARY_UPDATE_SERVICE="${CLOUDFLARED_BINARY_UPDATE_SERVICE:-/etc/systemd/system/cloudflared-update.service}" + BINARY_UPDATE_TIMER="${CLOUDFLARED_BINARY_UPDATE_TIMER:-/etc/systemd/system/cloudflared-update.timer}" + PRESERVE_AUTO_UPDATE=false +} info() { printf '[INFO] %s\n' "$*"; } warn() { printf '[WARN] %s\n' "$*" >&2; } @@ -62,25 +62,14 @@ backup_path() { cp -a "$path" "$backup_dir/$(basename "$path")" } -REPOSITORY_TRANSACTION_ACTIVE=false -REPOSITORY_TRANSACTION_DIR="" -REPOSITORY_KEY_STAGE="" -REPOSITORY_SOURCE_STAGE="" -REPOSITORY_LOCK_DIR="" -REPOSITORY_GENERATION="" -REPOSITORY_OLD_KEY=false -REPOSITORY_OLD_SOURCE=false -REPOSITORY_OLD_STATE=false -REPOSITORY_PREVIOUS_HUP_TRAP="" -REPOSITORY_PREVIOUS_INT_TRAP="" -REPOSITORY_PREVIOUS_TERM_TRAP="" -REPOSITORY_PREVIOUS_EXIT_TRAP="" repository_source_content() { + init_runtime_config printf 'deb [signed-by=%s] %s any main\n' "$KEYRING" "$REPOSITORY" } repository_legacy_source_content() { + init_runtime_config cat </dev/null; then error "无法取得 Cloudflare 仓库事务锁: $REPOSITORY_LOCK_DIR" return 1 @@ -281,6 +283,7 @@ restore_repository_traps() { } release_repository_lock() { + init_runtime_config [[ -n "$REPOSITORY_LOCK_DIR" ]] || return 0 if [[ -d "$REPOSITORY_LOCK_DIR" && ! -L "$REPOSITORY_LOCK_DIR" ]]; then rmdir -- "$REPOSITORY_LOCK_DIR" || return 1 @@ -292,18 +295,22 @@ release_repository_lock() { } repository_copy_file() { + init_runtime_config cp --no-dereference --preserve=mode,ownership,timestamps -- "$1" "$2" } repository_install_file() { + init_runtime_config install -o 0 -g 0 -m "$1" -- "$2" "$3" } repository_rename() { + init_runtime_config mv -fT -- "$1" "$2" } repository_download_key() { + init_runtime_config curl -fsSL --connect-timeout 10 --max-time 60 "$KEY_URL" -o "$1" } @@ -312,11 +319,13 @@ repository_transaction_hook() { } repository_key_records() { + init_runtime_config LC_ALL=C gpg --batch --no-options --no-default-keyring \ --show-keys --with-colons --with-fingerprint --with-fingerprint -- "$1" } validate_downloaded_key() { + init_runtime_config local key="$1" expected_mode="${2:-600}" records primary_count fingerprint uid_count uid validate_secure_file "$key" "$expected_mode" || return 1 [[ -s "$key" ]] || { error "Cloudflare 签名密钥为空"; return 1; } @@ -355,12 +364,14 @@ validate_downloaded_key() { } write_transaction_status() { + init_runtime_config local text="$1" printf '%s\n' "$text" > "$REPOSITORY_TRANSACTION_DIR/status" chmod 0600 "$REPOSITORY_TRANSACTION_DIR/status" } backup_repository_generation() { + init_runtime_config if [[ -e "$KEYRING" || -L "$KEYRING" ]]; then repository_copy_file "$KEYRING" "$REPOSITORY_TRANSACTION_DIR/old-key" || return 1 chmod 0600 "$REPOSITORY_TRANSACTION_DIR/old-key" || return 1 @@ -379,6 +390,7 @@ backup_repository_generation() { } restore_repository_file() { + init_runtime_config local had_old="$1" backup="$2" target="$3" restore_stage if [[ "$had_old" == true ]]; then restore_stage=$(mktemp "$(dirname -- "$target")/.cloudflared-rollback.XXXXXX") || return 1 @@ -397,6 +409,7 @@ restore_repository_file() { } archive_failed_transaction() { + init_runtime_config local failed_dir="$REPOSITORY_STATE_DIR/failure-$REPOSITORY_GENERATION" [[ -d "$REPOSITORY_TRANSACTION_DIR" ]] || return 0 if repository_rename "$REPOSITORY_TRANSACTION_DIR" "$failed_dir"; then @@ -407,6 +420,7 @@ archive_failed_transaction() { } rollback_repository_transaction() { + init_runtime_config local reason="$1" rollback_failed=false [[ "$REPOSITORY_TRANSACTION_ACTIVE" == true ]] || return 0 REPOSITORY_TRANSACTION_ACTIVE=false @@ -436,6 +450,7 @@ rollback_repository_transaction() { } repository_transaction_fail() { + init_runtime_config local reason="$1" error "$reason" rollback_repository_transaction "$reason" || true @@ -443,12 +458,14 @@ repository_transaction_fail() { } repository_signal_handler() { + init_runtime_config local code="$1" signal="$2" rollback_repository_transaction "收到 $signal 信号" || true exit "$code" } repository_exit_handler() { + init_runtime_config local status="$1" if [[ "$REPOSITORY_TRANSACTION_ACTIVE" == true ]]; then rollback_repository_transaction "进程异常退出,状态 $status" || true @@ -457,6 +474,7 @@ repository_exit_handler() { } validate_committed_repository() { + init_runtime_config local expected validate_secure_file "$KEYRING" 644 || return 1 validate_secure_file "$SOURCE_FILE" 644 || return 1 @@ -469,7 +487,21 @@ validate_committed_repository() { } begin_repository_transaction() { + init_runtime_config local key_parent source_parent downloaded marker_stage key_hash source_hash + REPOSITORY_TRANSACTION_ACTIVE=false + REPOSITORY_TRANSACTION_DIR="" + REPOSITORY_KEY_STAGE="" + REPOSITORY_SOURCE_STAGE="" + REPOSITORY_LOCK_DIR="${CLOUDFLARED_REPOSITORY_LOCK_DIR:-$STATE_DIR.lock}" + REPOSITORY_GENERATION="" + REPOSITORY_OLD_KEY=false + REPOSITORY_OLD_SOURCE=false + REPOSITORY_OLD_STATE=false + REPOSITORY_PREVIOUS_HUP_TRAP="" + REPOSITORY_PREVIOUS_INT_TRAP="" + REPOSITORY_PREVIOUS_TERM_TRAP="" + REPOSITORY_PREVIOUS_EXIT_TRAP="" command -v curl >/dev/null || { error "缺少 curl;正式仓库提交前不会通过 APT 安装依赖"; return 1; } command -v gpg >/dev/null || { error "缺少 gpg;无法校验 OpenPGP 主身份"; return 1; } command -v realpath >/dev/null || { error "缺少 realpath"; return 1; } @@ -478,8 +510,11 @@ begin_repository_transaction() { validate_directory_chain "$key_parent" || return 1 validate_directory_chain "$source_parent" || return 1 validate_directory_chain "$APT_SOURCE_ROOT" || return 1 - prepare_repository_state || return 1 acquire_repository_lock || return 1 + if ! prepare_repository_state; then + release_repository_lock || true + return 1 + fi REPOSITORY_GENERATION="$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM" REPOSITORY_TRANSACTION_DIR="$REPOSITORY_STATE_DIR/transaction-$REPOSITORY_GENERATION" @@ -584,6 +619,7 @@ begin_repository_transaction() { } finish_repository_transaction() { + init_runtime_config local history_dir="$REPOSITORY_STATE_DIR/history-$REPOSITORY_GENERATION" [[ "$REPOSITORY_TRANSACTION_ACTIVE" == true ]] || return 1 if ! repository_rename "$REPOSITORY_TRANSACTION_DIR" "$history_dir"; then @@ -602,11 +638,13 @@ finish_repository_transaction() { } configure_repository() { + init_runtime_config begin_repository_transaction || return 1 finish_repository_transaction } run_repository_apt_transaction() { + init_runtime_config local operation="$1" begin_repository_transaction || return 1 repository_transaction_hook apt-probe @@ -1075,13 +1113,23 @@ show_status() { } remove_managed_repository() { + init_runtime_config local backup_dir + validate_directory_chain "$(dirname -- "$REPOSITORY_LOCK_DIR")" || return 1 + acquire_repository_lock || return 1 backup_dir="$STATE_DIR/uninstall-$(date +%Y%m%d_%H%M%S)" if [[ -f "$STATE_DIR/repository-managed" && -f "$SOURCE_FILE" ]] && grep -Fq '# Managed by tools/cloudflare_tunnel.sh' "$SOURCE_FILE"; then - backup_path "$SOURCE_FILE" "$backup_dir" - rm -f "$SOURCE_FILE" "$STATE_DIR/repository-managed" + backup_path "$SOURCE_FILE" "$backup_dir" || { + release_repository_lock || true + return 1 + } + rm -f "$SOURCE_FILE" "$STATE_DIR/repository-managed" || { + release_repository_lock || true + return 1 + } fi + release_repository_lock } uninstall_cloudflared() { @@ -1159,5 +1207,7 @@ main() { } if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then + init_runtime_config + set -euo pipefail main "$@" fi From 3e69c80e70d3a1613415e0b320599d3d0315da4e Mon Sep 17 00:00:00 2001 From: LucaLin <78164141+LucaLin233@users.noreply.github.com> Date: Mon, 7 Sep 2026 06:06:28 +0800 Subject: [PATCH 03/32] fix: close Cloudflare source and uninstall trust gaps --- tests/test-cloudflare-tunnel.sh | 1 + tools/cloudflare_tunnel.sh | 29 ++++++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index b9a985e..4064915 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -122,6 +122,7 @@ new_case() { export CLOUDFLARED_SERVICE_FILE="$CASE_DIR/cloudflared.service" export CLOUDFLARED_BINARY_UPDATE_SERVICE="$CASE_DIR/cloudflared-update.service" export CLOUDFLARED_BINARY_UPDATE_TIMER="$CASE_DIR/cloudflared-update.timer" + export CLOUDFLARED_TEST_INTERNALS=1 unset FAKE_CURL_FAIL FAKE_GPG_FAIL FAKE_GPG_CASE FAKE_APT_UPDATE_FAIL FAKE_APT_INSTALL_FAIL unset FAIL_INSTALL_CALL FAIL_RENAME_CALL INSTALL_CALL RENAME_CALL SIGNAL_PHASE if [[ "${SCRIPT_SOURCED:-}" != 1 ]]; then diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index baaa92c..78c14df 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -2,6 +2,12 @@ # Cloudflare Tunnel manager for Debian/Ubuntu. # Uses Cloudflare's official stable APT repository and service command. +# Sourcing exposes no legacy global function API. Internal tests opt in explicitly. +if [[ "${BASH_SOURCE[0]:-$0}" != "$0" && "${CLOUDFLARED_TEST_INTERNALS:-}" != 1 ]]; then + cloudflare_tunnel_source() { :; } + return 0 +fi + init_runtime_config() { KEYRING="${CLOUDFLARED_KEYRING:-/usr/share/keyrings/cloudflare-main.gpg}" SOURCE_FILE="${CLOUDFLARED_SOURCE_FILE:-/etc/apt/sources.list.d/cloudflared.list}" @@ -1117,14 +1123,31 @@ remove_managed_repository() { local backup_dir validate_directory_chain "$(dirname -- "$REPOSITORY_LOCK_DIR")" || return 1 acquire_repository_lock || return 1 + prepare_repository_state || { + release_repository_lock || true + return 1 + } + validate_directory_chain "$(dirname -- "$SOURCE_FILE")" || { + release_repository_lock || true + return 1 + } + if [[ -e "$SOURCE_FILE" || -L "$SOURCE_FILE" ]]; then + validate_secure_file "$SOURCE_FILE" 644 || { + release_repository_lock || true + return 1 + } + validate_existing_source || { + release_repository_lock || true + return 1 + } + fi backup_dir="$STATE_DIR/uninstall-$(date +%Y%m%d_%H%M%S)" - if [[ -f "$STATE_DIR/repository-managed" && -f "$SOURCE_FILE" ]] && - grep -Fq '# Managed by tools/cloudflare_tunnel.sh' "$SOURCE_FILE"; then + if [[ -f "$STATE_DIR/repository-managed" && -f "$SOURCE_FILE" ]]; then backup_path "$SOURCE_FILE" "$backup_dir" || { release_repository_lock || true return 1 } - rm -f "$SOURCE_FILE" "$STATE_DIR/repository-managed" || { + rm -f -- "$SOURCE_FILE" "$STATE_DIR/repository-managed" || { release_repository_lock || true return 1 } From 623c55711e38256a0b413996e4ba7f455772b2fa Mon Sep 17 00:00:00 2001 From: root Date: Mon, 7 Sep 2026 07:40:21 +0800 Subject: [PATCH 04/32] fix: serialize Cloudflare uninstall lifecycle --- tests/test-cloudflare-tunnel.sh | 63 ++++++++++++++++++++ tools/cloudflare_tunnel.sh | 100 ++++++++++++++++++++------------ 2 files changed, 125 insertions(+), 38 deletions(-) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index 4064915..de2dab3 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -176,8 +176,71 @@ after=$(find "$CASE_DIR/root" -mindepth 1 -printf '%P %y %m\n' | sort) fail "source is not exact official definition" pass "source rendering has zero side effects and exact fields" +( + new_case + source_cwd=$PWD + source_before=$(find "$CASE_DIR/root" -mindepth 1 -printf '%P %y %m\n' | sort) + cloudflare_tunnel_source() { printf 'sentinel:%s\n' "$*"; } + sentinel_before=$(declare -f cloudflare_tunnel_source) + trap 'sentinel-trap' HUP + set -o noclobber + umask 027 + source "$ROOT_DIR/tools/cloudflare_tunnel.sh" + sentinel_after=$(declare -f cloudflare_tunnel_source) + [[ "$sentinel_before" == "$sentinel_after" ]] || fail "ordinary source replaced sentinel function" + [[ "$(cloudflare_tunnel_source probe)" == sentinel:probe ]] || fail "ordinary source changed sentinel behavior" + [[ "$(trap -p HUP)" == *sentinel-trap* ]] || fail "ordinary source changed caller trap" + [[ "$-" == *C* ]] || fail "ordinary source changed caller shell options" + [[ "$(umask)" == 0027 ]] || fail "ordinary source changed caller umask" + [[ "$PWD" == "$source_cwd" ]] || fail "ordinary source changed caller cwd" + [[ "${CLOUDFLARED_STATE_DIR+x}" == x ]] || fail "test setup lost state variable" + after=$(find "$CASE_DIR/root" -mindepth 1 -printf '%P %y %m\n' | sort) + [[ "$source_before" == "$after" ]] || fail "ordinary source changed filesystem" +) +pass "ordinary source preserves sentinels, shell state, and filesystem" + +new_case +before=$(find "$CASE_DIR/root" -mindepth 1 -printf '%P %y %m\\n' | sort) +bash "$ROOT_DIR/tools/cloudflare_tunnel.sh" status >/dev/null 2>&1 || fail "standalone status failed in fake environment" +after=$(find "$CASE_DIR/root" -mindepth 1 -printf '%P %y %m\\n' | sort) +[[ "$before" == "$after" ]] || fail "ordinary status created state, lock, or temp files" +pass "ordinary help/status paths have zero state and lock side effects" + +new_case +mkdir -p "$CLOUDFLARED_REPOSITORY_STATE_DIR" +chmod 0700 "$CLOUDFLARED_STATE_DIR" "$CLOUDFLARED_REPOSITORY_STATE_DIR" +printf 'managed\n' > "$CLOUDFLARED_STATE_DIR/repository-managed" +printf 'deb [signed-by=%s] %s any main\\n' "$CLOUDFLARED_KEYRING" 'https://pkg.cloudflare.com/cloudflare-main.gpg' > "$CLOUDFLARED_SOURCE_FILE" +printf key > "$CLOUDFLARED_KEYRING" +chmod 0644 "$CLOUDFLARED_SOURCE_FILE" "$CLOUDFLARED_KEYRING" +require_root() { :; } +check_platform() { :; } +disable_auto_update_locked() { printf 'disable-auto-update\n' >> "$FAKE_LOG"; } +remove_managed_repository_locked() { printf 'remove-repository\n' >> "$FAKE_LOG"; } +uninstall_cloudflared --confirmed || fail "uninstall normal path failed" +[[ ! -d "$CLOUDFLARED_STATE_DIR.lock" ]] || fail "uninstall left repository lock" +grep -Fxq 'disable-auto-update' "$FAKE_LOG" || fail "uninstall skipped locked auto-update step" +grep -Fxq 'remove-repository' "$FAKE_LOG" || fail "uninstall skipped locked repository step" +grep -Fxq 'apt-get:remove -y cloudflared' "$FAKE_LOG" || fail "uninstall skipped package removal" +pass "uninstall normal path holds and releases repository lock" + +new_case +mkdir -p "$CLOUDFLARED_STATE_DIR.lock" +mkdir -p "$CLOUDFLARED_AUTO_UPDATE_SCRIPT" +printf marker > "$CASE_DIR/marker" +require_root() { :; } +check_platform() { :; } +if uninstall_cloudflared --confirmed >/dev/null 2>&1; then fail "uninstall lock competition unexpectedly succeeded"; fi +[[ -d "$CLOUDFLARED_STATE_DIR.lock" ]] || fail "uninstall lock competition removed lock" +[[ -d "$CLOUDFLARED_AUTO_UPDATE_SCRIPT" ]] || fail "lock competition touched auto-update path" +! grep -Eq 'remove|disable|daemon-reload|apt-get:' "$FAKE_LOG" || fail "lock competition performed mutation" +pass "uninstall lock competition fails before auto-update, systemctl, and APT" + new_case mkdir -p "$CLOUDFLARED_STATE_DIR.lock" +if disable_auto_update --confirmed >/dev/null 2>&1; then fail "disable-auto-update lock competition unexpectedly succeeded"; fi +[[ -d "$CLOUDFLARED_STATE_DIR.lock" ]] || fail "disable-auto-update removed competing lock" +pass "auto-update lock competition fails before state mutation" chmod 0700 "$CLOUDFLARED_STATE_DIR.lock" if configure_repository >/dev/null 2>&1; then fail "lock competition unexpectedly succeeded"; fi pass "key/source lock competition" diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index 78c14df..024477f 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -4,7 +4,6 @@ # Sourcing exposes no legacy global function API. Internal tests opt in explicitly. if [[ "${BASH_SOURCE[0]:-$0}" != "$0" && "${CLOUDFLARED_TEST_INTERNALS:-}" != 1 ]]; then - cloudflare_tunnel_source() { :; } return 0 fi @@ -955,7 +954,7 @@ enable_auto_update() { info "已启用每日 APT 更新检查;更新时仅重启原本正在运行的 cloudflared 服务。" } -disable_auto_update() { +disable_auto_update_locked() { local confirmed="${1:-}" path backup_dir require_root check_platform @@ -981,6 +980,17 @@ disable_auto_update() { info "APT 自动更新组件已禁用;备份目录: $backup_dir" } +disable_auto_update() { + init_runtime_config + validate_directory_chain "$(dirname -- "$REPOSITORY_LOCK_DIR")" || return 1 + acquire_repository_lock || return 1 + if ! prepare_repository_state || ! disable_auto_update_locked "$@"; then + release_repository_lock || return 1 + return 1 + fi + release_repository_lock +} + show_auto_update_status() { if command -v systemctl >/dev/null && systemctl is-enabled --quiet cloudflared-apt-update.timer 2>/dev/null; then echo "APT 自动更新: 已启用" @@ -1118,45 +1128,35 @@ show_status() { show_auto_update_status } -remove_managed_repository() { +remove_managed_repository_locked() { init_runtime_config local backup_dir - validate_directory_chain "$(dirname -- "$REPOSITORY_LOCK_DIR")" || return 1 - acquire_repository_lock || return 1 - prepare_repository_state || { - release_repository_lock || true - return 1 - } - validate_directory_chain "$(dirname -- "$SOURCE_FILE")" || { - release_repository_lock || true - return 1 - } + validate_existing_repository_file "$KEYRING" || return 1 + validate_directory_chain "$(dirname -- "$SOURCE_FILE")" || return 1 if [[ -e "$SOURCE_FILE" || -L "$SOURCE_FILE" ]]; then - validate_secure_file "$SOURCE_FILE" 644 || { - release_repository_lock || true - return 1 - } - validate_existing_source || { - release_repository_lock || true - return 1 - } + validate_secure_file "$SOURCE_FILE" 644 || return 1 + validate_existing_source || return 1 fi backup_dir="$STATE_DIR/uninstall-$(date +%Y%m%d_%H%M%S)" if [[ -f "$STATE_DIR/repository-managed" && -f "$SOURCE_FILE" ]]; then - backup_path "$SOURCE_FILE" "$backup_dir" || { - release_repository_lock || true - return 1 - } - rm -f -- "$SOURCE_FILE" "$STATE_DIR/repository-managed" || { - release_repository_lock || true - return 1 - } + backup_path "$SOURCE_FILE" "$backup_dir" || return 1 + rm -f -- "$SOURCE_FILE" "$STATE_DIR/repository-managed" || return 1 + fi +} + +remove_managed_repository() { + init_runtime_config + validate_directory_chain "$(dirname -- "$REPOSITORY_LOCK_DIR")" || return 1 + acquire_repository_lock || return 1 + if ! prepare_repository_state || ! remove_managed_repository_locked; then + release_repository_lock || return 1 + return 1 fi release_repository_lock } uninstall_cloudflared() { - local confirmed="${1:-}" + local confirmed="${1:-}" uninstall_status=0 release_status=0 lock_path require_root check_platform warn "将删除 cloudflared 服务和 APT 包;Tunnel 配置与凭据默认保留。" @@ -1164,14 +1164,38 @@ uninstall_cloudflared() { confirm "继续卸载?" || { info "已取消"; return 0; } fi - disable_auto_update --confirmed - if command -v cloudflared >/dev/null 2>&1; then - cloudflared service uninstall >/dev/null 2>&1 || true + init_runtime_config + validate_directory_chain "$(dirname -- "$REPOSITORY_LOCK_DIR")" || return 1 + lock_path="$REPOSITORY_LOCK_DIR" + acquire_repository_lock || return 1 + if ! prepare_repository_state; then + release_repository_lock || return 1 + return 1 + fi + + disable_auto_update_locked --confirmed || uninstall_status=$? + if (( uninstall_status == 0 )); then + if command -v cloudflared >/dev/null 2>&1; then + cloudflared service uninstall >/dev/null 2>&1 || true + fi + systemctl disable --now cloudflared.service >/dev/null 2>&1 || true + DEBIAN_FRONTEND=noninteractive apt-get remove -y cloudflared || uninstall_status=$? + fi + if (( uninstall_status == 0 )); then + remove_managed_repository_locked || uninstall_status=$? + fi + if (( uninstall_status == 0 )); then + apt-get update || uninstall_status=$? + fi + + release_repository_lock || release_status=$? + if (( uninstall_status != 0 )); then + return "$uninstall_status" + fi + if (( release_status != 0 )); then + error "卸载完成,但仓库事务锁释放失败: $lock_path" + return "$release_status" fi - systemctl disable --now cloudflared.service >/dev/null 2>&1 || true - DEBIAN_FRONTEND=noninteractive apt-get remove -y cloudflared - remove_managed_repository - apt-get update info "卸载完成;/etc/cloudflared 与用户 .cloudflared 目录未删除。" } @@ -1216,7 +1240,7 @@ main() { upgrade) upgrade_cloudflared ;; status) show_status ;; enable-auto-update) enable_auto_update ;; - disable-auto-update) disable_auto_update ;; + disable-auto-update) disable_auto_update "${2:-}" ;; migrate-legacy) require_root check_platform From 12c7349f398e3bb833ace1eb212dcb508a021d17 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 7 Sep 2026 08:24:03 +0800 Subject: [PATCH 05/32] fix: complete Cloudflare uninstall transaction safety --- README.md | 7 +- tests/test-cloudflare-tunnel.sh | 125 ++++++++++++++++++++++++++ tools/cloudflare_tunnel.sh | 152 ++++++++++++++++++++++++++++---- 3 files changed, 266 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 27a9b88..aea771e 100644 --- a/README.md +++ b/README.md @@ -329,9 +329,10 @@ cloudflared 时再启用。`upgrade` 可用于立即手动检查、升级并重 脚本使用 `service install --no-update-service`,并识别、备份和清理旧版裸二进制更新单元,避免 APT 包与 `cloudflared update` 混用。若旧环境已有每日自动更新 timer,迁移时会自动换成新的 -APT timer;旧环境未启用自动更新时仍保持关闭并询问是否启用。`uninstall` 删除服务、APT 包及 -本脚本管理的软件源,但保留 Tunnel 配置和凭据。彻底清理须显式运行 `purge`,并在交互终端 -输入 `PURGE` 二次确认。 +APT timer;旧环境未启用自动更新时仍保持关闭并询问是否启用。`uninstall` 在同一事务锁内验证 +`current` 清单与 key/source 摘要,备份并删除受管 source,保留 keyring、Tunnel 配置和凭据。 +文件阶段失败会恢复可恢复配置;APT 包删除属于不可逆边界,失败时不会尝试自动重装。 +彻底清理须显式运行 `purge`,并在交互终端输入 `PURGE` 二次确认。 ### 出口流量整形 tcshape diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index de2dab3..8010d86 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -206,6 +206,42 @@ after=$(find "$CASE_DIR/root" -mindepth 1 -printf '%P %y %m\\n' | sort) [[ "$before" == "$after" ]] || fail "ordinary status created state, lock, or temp files" pass "ordinary help/status paths have zero state and lock side effects" +cat > "$TEST_DIR/source-contract.sh" </dev/null' _ \ + "$ROOT_DIR/tools/cloudflare_tunnel.sh" || fail "internal source mode unavailable" +pass "explicit internal source mode exposes test functions" + new_case mkdir -p "$CLOUDFLARED_REPOSITORY_STATE_DIR" chmod 0700 "$CLOUDFLARED_STATE_DIR" "$CLOUDFLARED_REPOSITORY_STATE_DIR" @@ -223,6 +259,9 @@ grep -Fxq 'disable-auto-update' "$FAKE_LOG" || fail "uninstall skipped locked au grep -Fxq 'remove-repository' "$FAKE_LOG" || fail "uninstall skipped locked repository step" grep -Fxq 'apt-get:remove -y cloudflared' "$FAKE_LOG" || fail "uninstall skipped package removal" pass "uninstall normal path holds and releases repository lock" +unset -f disable_auto_update_locked remove_managed_repository_locked +source "$ROOT_DIR/tools/cloudflare_tunnel.sh" +init_runtime_config new_case mkdir -p "$CLOUDFLARED_STATE_DIR.lock" @@ -430,6 +469,92 @@ grep -Fxq "key_sha256=$key_hash" "$current" || fail "key generation hash mismatc grep -Fxq "source_sha256=$source_hash" "$current" || fail "source generation hash mismatch" pass "key/source commit as one recorded generation" +new_case +set_old_generation +configure_repository +validate_current_repository_manifest || fail "trusted current manifest validation failed" +remove_managed_repository +init_runtime_config +assert_absent "$CLOUDFLARED_SOURCE_FILE" "trusted current did not remove managed source" +[[ -f "$CLOUDFLARED_KEYRING" ]] || fail "managed repository removal deleted keyring" +[[ ! -f "$CLOUDFLARED_REPOSITORY_STATE_DIR/current" ]] || fail "managed repository removal retained current" +find "$CLOUDFLARED_STATE_DIR" -maxdepth 1 -type d -name 'uninstall-*' -print -quit | grep -q . || + fail "managed source backup missing" +[[ ! -d "$CLOUDFLARED_STATE_DIR.lock" ]] || fail "managed repository removal left lock" +pass "configure then real repository uninstall removes source and preserves keyring" + +for signal in HUP INT TERM; do + case "$signal" in HUP) expected=129 ;; INT) expected=130 ;; TERM) expected=143 ;; esac + for phase in lock-acquired before-disable-auto-update after-disable-auto-update before-apt-remove after-apt-remove before-source-remove after-source-remove final-apt-update before-lock-release; do + new_case + set_old_generation + configure_repository + cp "$CLOUDFLARED_SOURCE_FILE" "$CASE_DIR/expected-source" + cp "$CLOUDFLARED_REPOSITORY_STATE_DIR/current" "$CASE_DIR/expected-current" + set +e + CLOUDFLARED_TEST_INTERNALS=1 SIGNAL_PHASE="$phase" SIGNAL_NAME="$signal" \ + bash -c ' + source "$1" + require_root() { :; } + check_platform() { :; } + uninstall_transaction_hook() { + [[ "$1" != "$SIGNAL_PHASE" ]] || kill -s "$SIGNAL_NAME" "$BASHPID" + } + uninstall_cloudflared --confirmed + ' _ "$ROOT_DIR/tools/cloudflare_tunnel.sh" >"$CASE_DIR/out" 2>"$CASE_DIR/err" + status=$? + set -e + [[ "$status" == "$expected" ]] || fail "uninstall $signal at $phase returned $status" + [[ ! -d "$CLOUDFLARED_STATE_DIR.lock" ]] || fail "uninstall $signal at $phase left lock" + assert_same "$CLOUDFLARED_SOURCE_FILE" "$CASE_DIR/expected-source" "uninstall $signal at $phase failed source restore" + assert_same "$CLOUDFLARED_REPOSITORY_STATE_DIR/current" "$CASE_DIR/expected-current" "uninstall $signal at $phase failed current restore" + acquire_repository_lock || fail "uninstall $signal at $phase lock not reusable" + release_repository_lock || fail "uninstall $signal at $phase lock cleanup failed" + pass "uninstall $signal=$expected cleans $phase" + done +done + +new_case +set_old_generation +configure_repository +uninstall_transaction_hook() { [[ "$1" != after-disable-auto-update ]] || exit 0; } +set +e +( uninstall_cloudflared --confirmed ) >"$CASE_DIR/out" 2>"$CASE_DIR/err" +status=$? +set -e +[[ "$status" == 1 ]] || fail "active uninstall exit 0 returned $status" +[[ ! -d "$CLOUDFLARED_STATE_DIR.lock" ]] || fail "active uninstall exit 0 left lock" +grep -R -Fq '活动卸载事务异常退出' "$CLOUDFLARED_REPOSITORY_STATE_DIR" || fail "active uninstall exit 0 lacked evidence" +pass "active uninstall exit 0 returns 1 and cleans transaction" + +for manifest_case in missing duplicate unknown generation key-digest source-digest source-content symlink directory fifo owner gid mode; do + new_case + set_old_generation + configure_repository + current="$CLOUDFLARED_REPOSITORY_STATE_DIR/current" + case "$manifest_case" in + missing) sed -i '/^source_sha256=/d' "$current" ;; + duplicate) cat "$current" >> "$current.copy"; cat "$current.copy" >> "$current"; rm "$current.copy" ;; + unknown) printf 'unknown=value\n' >> "$current" ;; + generation) sed -i 's/^generation=.*/generation=invalid/' "$current" ;; + key-digest) sed -i 's/^key_sha256=.*/key_sha256=0000000000000000000000000000000000000000000000000000000000000000/' "$current" ;; + source-digest) sed -i 's/^source_sha256=.*/source_sha256=0000000000000000000000000000000000000000000000000000000000000000/' "$current" ;; + source-content) printf '# tampered\n' >> "$CLOUDFLARED_SOURCE_FILE" ;; + symlink) rm "$current"; ln -s "$CASE_DIR/external" "$current"; printf external > "$CASE_DIR/external" ;; + directory) rm "$current"; mkdir "$current" ;; + fifo) rm "$current"; mkfifo "$current" ;; + owner) chown 65534:0 "$current" ;; + gid) chown 0:65534 "$current" ;; + mode) chmod 0644 "$current" ;; + esac + if remove_managed_repository >/dev/null 2>&1; then fail "current $manifest_case unexpectedly trusted"; fi + [[ -f "$CLOUDFLARED_KEYRING" ]] || fail "current $manifest_case deleted keyring" + [[ -e "$CLOUDFLARED_SOURCE_FILE" ]] || fail "current $manifest_case deleted source" + [[ ! -e "$CASE_DIR/external" || "$(cat "$CASE_DIR/external")" == external ]] || fail "current symlink target changed" + [[ ! -d "$CLOUDFLARED_STATE_DIR.lock" ]] || fail "current $manifest_case left lock" + pass "current manifest rejects $manifest_case" +done + new_case set_old_generation export FAKE_APT_INSTALL_FAIL=1 diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index 024477f..88e1ce6 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -1128,20 +1128,47 @@ show_status() { show_auto_update_status } +validate_current_repository_manifest() { + init_runtime_config + local current="$REPOSITORY_STATE_DIR/current" generation key_hash source_hash expected_key_hash expected_source_hash + validate_secure_file "$current" 600 || return 1 + awk -F= ' + NF != 2 || ($1 != "generation" && $1 != "key_sha256" && $1 != "source_sha256") || seen[$1]++ {bad=1} + END {if (bad || seen["generation"] != 1 || seen["key_sha256"] != 1 || seen["source_sha256"] != 1) exit 1} + ' "$current" || return 1 + generation=$(awk -F= '$1 == "generation" {print $2}' "$current") + [[ "$generation" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9]+-[0-9]+$ ]] || return 1 + key_hash=$(awk -F= '$1 == "key_sha256" {print $2}' "$current") + source_hash=$(awk -F= '$1 == "source_sha256" {print $2}' "$current") + [[ "$key_hash" =~ ^[[:xdigit:]]{64}$ && "$source_hash" =~ ^[[:xdigit:]]{64}$ ]] || return 1 + validate_secure_file "$KEYRING" 644 || return 1 + validate_secure_file "$SOURCE_FILE" 644 || return 1 + expected_key_hash=$(sha256sum -- "$KEYRING" | awk '{print $1}') || return 1 + expected_source_hash=$(sha256sum -- "$SOURCE_FILE" | awk '{print $1}') || return 1 + [[ "$key_hash" == "$expected_key_hash" && "$source_hash" == "$expected_source_hash" ]] || return 1 + [[ "$(cat -- "$SOURCE_FILE")" == "$(repository_source_content)" ]] || return 1 +} + remove_managed_repository_locked() { init_runtime_config - local backup_dir - validate_existing_repository_file "$KEYRING" || return 1 + local backup_dir managed=false validate_directory_chain "$(dirname -- "$SOURCE_FILE")" || return 1 - if [[ -e "$SOURCE_FILE" || -L "$SOURCE_FILE" ]]; then + if [[ -f "$REPOSITORY_STATE_DIR/current" ]] && validate_current_repository_manifest; then + managed=true + fi + if [[ "$managed" != true && -f "$STATE_DIR/repository-managed" ]] && + validate_secure_file "$STATE_DIR/repository-managed" 600 && + [[ "$(cat -- "$STATE_DIR/repository-managed")" == managed ]]; then validate_secure_file "$SOURCE_FILE" 644 || return 1 - validate_existing_source || return 1 + [[ "$(cat -- "$SOURCE_FILE")" == "$(repository_legacy_source_content)" ]] || return 1 + managed=true fi + [[ "$managed" == true ]] || { error "无法验证 Cloudflare source 管理归属,拒绝删除"; return 1; } backup_dir="$STATE_DIR/uninstall-$(date +%Y%m%d_%H%M%S)" - if [[ -f "$STATE_DIR/repository-managed" && -f "$SOURCE_FILE" ]]; then - backup_path "$SOURCE_FILE" "$backup_dir" || return 1 - rm -f -- "$SOURCE_FILE" "$STATE_DIR/repository-managed" || return 1 - fi + backup_path "$SOURCE_FILE" "$backup_dir" || return 1 + rm -f -- "$SOURCE_FILE" "$STATE_DIR/repository-managed" || return 1 + rm -f -- "$REPOSITORY_STATE_DIR/current" || return 1 + unset REPOSITORY_STATE_DIR } remove_managed_repository() { @@ -1155,8 +1182,82 @@ remove_managed_repository() { release_repository_lock } +uninstall_transaction_hook() { + : +} + +uninstall_cleanup() { + init_runtime_config + local reason="$1" failed=false evidence + [[ "${UNINSTALL_TRANSACTION_ACTIVE:-false}" == true ]] || return 0 + UNINSTALL_TRANSACTION_ACTIVE=false + trap - HUP INT TERM EXIT + evidence="$REPOSITORY_STATE_DIR/failure-uninstall-${UNINSTALL_GENERATION:-unknown}" + mkdir -m 0700 -- "$evidence" 2>/dev/null || failed=true + printf '%s\n' "$reason" > "$evidence/rollback.log" 2>/dev/null || failed=true + chmod 0600 "$evidence/rollback.log" 2>/dev/null || failed=true + if [[ -n "${UNINSTALL_SNAPSHOT_DIR:-}" && -d "$UNINSTALL_SNAPSHOT_DIR" ]]; then + for path in "$AUTO_UPDATE_SCRIPT" "$AUTO_UPDATE_SERVICE" "$AUTO_UPDATE_TIMER" "$SOURCE_FILE" "$REPOSITORY_STATE_DIR/current" "$STATE_DIR/repository-managed"; do + if [[ -f "$UNINSTALL_SNAPSHOT_DIR/$(basename -- "$path")" ]]; then + install -o 0 -g 0 -m "$(stat -c %a "$UNINSTALL_SNAPSHOT_DIR/$(basename -- "$path")")" -- "$UNINSTALL_SNAPSHOT_DIR/$(basename -- "$path")" "$path" || failed=true + fi + done + rm -rf -- "$UNINSTALL_SNAPSHOT_DIR" 2>/dev/null || failed=true + fi + release_repository_lock || failed=true + restore_repository_traps + [[ "$failed" == false ]] || { error "卸载事务清理不完整;失败证据: $evidence"; return 1; } + error "Cloudflare 卸载事务失败;可恢复文件已恢复;证据: $evidence" +} + +uninstall_signal_handler() { + local code="$1" signal="$2" + uninstall_cleanup "收到 $signal 信号" || true + exit "$code" +} + +uninstall_exit_handler() { + local status="$1" + if [[ "${UNINSTALL_TRANSACTION_ACTIVE:-false}" == true ]]; then + uninstall_cleanup "活动卸载事务异常退出,原状态 $status" || true + (( status == 0 )) && status=1 + fi + exit "$status" +} + +begin_uninstall_transaction() { + init_runtime_config + UNINSTALL_GENERATION="$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM" + UNINSTALL_SNAPSHOT_DIR="$REPOSITORY_STATE_DIR/uninstall-$UNINSTALL_GENERATION" + mkdir -m 0700 -- "$UNINSTALL_SNAPSHOT_DIR" || return 1 + for path in "$AUTO_UPDATE_SCRIPT" "$AUTO_UPDATE_SERVICE" "$AUTO_UPDATE_TIMER" "$SOURCE_FILE" "$REPOSITORY_STATE_DIR/current" "$STATE_DIR/repository-managed"; do + [[ -e "$path" || -L "$path" ]] || continue + validate_secure_file "$path" "$(stat -Lc %a -- "$path")" || return 1 + repository_copy_file "$path" "$UNINSTALL_SNAPSHOT_DIR/$(basename -- "$path")" || return 1 + done + UNINSTALL_TRANSACTION_ACTIVE=true + save_repository_traps + trap 'uninstall_signal_handler 129 HUP' HUP + trap 'uninstall_signal_handler 130 INT' INT + trap 'uninstall_signal_handler 143 TERM' TERM + trap 'uninstall_exit_handler $?' EXIT +} + +finish_uninstall_transaction() { + local lock_path="$REPOSITORY_LOCK_DIR" + UNINSTALL_TRANSACTION_ACTIVE=false + trap - HUP INT TERM EXIT + rm -rf -- "$UNINSTALL_SNAPSHOT_DIR" || return 1 + if ! release_repository_lock; then + restore_repository_traps + error "卸载完成,但仓库事务锁释放失败: $lock_path" + return 1 + fi + restore_repository_traps +} + uninstall_cloudflared() { - local confirmed="${1:-}" uninstall_status=0 release_status=0 lock_path + local confirmed="${1:-}" uninstall_status=0 irreversible=false require_root check_platform warn "将删除 cloudflared 服务和 APT 包;Tunnel 配置与凭据默认保留。" @@ -1166,36 +1267,57 @@ uninstall_cloudflared() { init_runtime_config validate_directory_chain "$(dirname -- "$REPOSITORY_LOCK_DIR")" || return 1 - lock_path="$REPOSITORY_LOCK_DIR" acquire_repository_lock || return 1 if ! prepare_repository_state; then release_repository_lock || return 1 return 1 fi + if [[ -f "$REPOSITORY_STATE_DIR/current" ]]; then + validate_current_repository_manifest || { + release_repository_lock || return 1 + return 1 + } + elif [[ ! -f "$STATE_DIR/repository-managed" ]]; then + error "无法验证 Cloudflare source 管理归属,拒绝卸载" + release_repository_lock || return 1 + return 1 + fi + begin_uninstall_transaction || { + release_repository_lock || return 1 + return 1 + } + uninstall_transaction_hook lock-acquired + uninstall_transaction_hook before-disable-auto-update disable_auto_update_locked --confirmed || uninstall_status=$? + uninstall_transaction_hook after-disable-auto-update if (( uninstall_status == 0 )); then if command -v cloudflared >/dev/null 2>&1; then cloudflared service uninstall >/dev/null 2>&1 || true fi systemctl disable --now cloudflared.service >/dev/null 2>&1 || true + uninstall_transaction_hook before-apt-remove + irreversible=true DEBIAN_FRONTEND=noninteractive apt-get remove -y cloudflared || uninstall_status=$? + uninstall_transaction_hook after-apt-remove fi if (( uninstall_status == 0 )); then + uninstall_transaction_hook before-source-remove remove_managed_repository_locked || uninstall_status=$? + uninstall_transaction_hook after-source-remove fi if (( uninstall_status == 0 )); then + uninstall_transaction_hook final-apt-update apt-get update || uninstall_status=$? fi - release_repository_lock || release_status=$? if (( uninstall_status != 0 )); then + uninstall_cleanup "卸载步骤失败,状态 $uninstall_status" || true + [[ "$irreversible" == true ]] && error "APT 包状态可能已部分改变;未尝试自动重新安装 cloudflared" return "$uninstall_status" fi - if (( release_status != 0 )); then - error "卸载完成,但仓库事务锁释放失败: $lock_path" - return "$release_status" - fi + uninstall_transaction_hook before-lock-release + finish_uninstall_transaction || return 1 info "卸载完成;/etc/cloudflared 与用户 .cloudflared 目录未删除。" } From 7622f07d896c3163929b6475c618d27dec057290 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 7 Sep 2026 09:43:07 +0800 Subject: [PATCH 06/32] fix: close Cloudflare uninstall snapshot lifecycle gaps --- tests/test-cloudflare-tunnel.sh | 5 +- tools/cloudflare_tunnel.sh | 86 ++++++++++++++++++++++----------- 2 files changed, 62 insertions(+), 29 deletions(-) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index 8010d86..2b46599 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -246,6 +246,7 @@ new_case mkdir -p "$CLOUDFLARED_REPOSITORY_STATE_DIR" chmod 0700 "$CLOUDFLARED_STATE_DIR" "$CLOUDFLARED_REPOSITORY_STATE_DIR" printf 'managed\n' > "$CLOUDFLARED_STATE_DIR/repository-managed" +chmod 0600 "$CLOUDFLARED_STATE_DIR/repository-managed" printf 'deb [signed-by=%s] %s any main\\n' "$CLOUDFLARED_KEYRING" 'https://pkg.cloudflare.com/cloudflare-main.gpg' > "$CLOUDFLARED_SOURCE_FILE" printf key > "$CLOUDFLARED_KEYRING" chmod 0644 "$CLOUDFLARED_SOURCE_FILE" "$CLOUDFLARED_KEYRING" @@ -572,9 +573,9 @@ fi pass "no kernel deletion or unrelated APT change" script_hashes_before=$(sha256sum "$ROOT_DIR/linux_setup.sh" "$ROOT_DIR"/modules/*.sh "$ROOT_DIR"/tools/push.sh \ - "$ROOT_DIR"/tools/setup-motd.sh "$ROOT_DIR"/tools/xanmod-install.sh "$ROOT_DIR"/tools/traffic-shape.sh) + "$ROOT_DIR"/tools/setup-motd.sh "$ROOT_DIR"/tools/xanmod-install.sh) script_hashes_after=$(sha256sum "$ROOT_DIR/linux_setup.sh" "$ROOT_DIR"/modules/*.sh "$ROOT_DIR"/tools/push.sh \ - "$ROOT_DIR"/tools/setup-motd.sh "$ROOT_DIR"/tools/xanmod-install.sh "$ROOT_DIR"/tools/traffic-shape.sh) + "$ROOT_DIR"/tools/setup-motd.sh "$ROOT_DIR"/tools/xanmod-install.sh) [[ "$script_hashes_before" == "$script_hashes_after" ]] || fail "payload or unrelated scripts changed during tests" pass "payload and unrelated scripts remain unchanged" diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index 88e1ce6..6a9c433 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -1186,24 +1186,41 @@ uninstall_transaction_hook() { : } +uninstall_snapshot_targets() { + printf '%s\t%s\n' \ + "$AUTO_UPDATE_SCRIPT" 755 \ + "$AUTO_UPDATE_SERVICE" 644 \ + "$AUTO_UPDATE_TIMER" 644 \ + "$SOURCE_FILE" 644 \ + "$REPOSITORY_STATE_DIR/current" 600 \ + "$STATE_DIR/repository-managed" 600 +} + uninstall_cleanup() { init_runtime_config - local reason="$1" failed=false evidence - [[ "${UNINSTALL_TRANSACTION_ACTIVE:-false}" == true ]] || return 0 - UNINSTALL_TRANSACTION_ACTIVE=false + local reason="$1" failed=false evidence path mode state + state="${UNINSTALL_TRANSACTION_STATE:-NONE}" + [[ "$state" == BUILDING || "$state" == ACTIVE ]] || return 0 + UNINSTALL_TRANSACTION_STATE=NONE trap - HUP INT TERM EXIT evidence="$REPOSITORY_STATE_DIR/failure-uninstall-${UNINSTALL_GENERATION:-unknown}" mkdir -m 0700 -- "$evidence" 2>/dev/null || failed=true printf '%s\n' "$reason" > "$evidence/rollback.log" 2>/dev/null || failed=true chmod 0600 "$evidence/rollback.log" 2>/dev/null || failed=true - if [[ -n "${UNINSTALL_SNAPSHOT_DIR:-}" && -d "$UNINSTALL_SNAPSHOT_DIR" ]]; then - for path in "$AUTO_UPDATE_SCRIPT" "$AUTO_UPDATE_SERVICE" "$AUTO_UPDATE_TIMER" "$SOURCE_FILE" "$REPOSITORY_STATE_DIR/current" "$STATE_DIR/repository-managed"; do - if [[ -f "$UNINSTALL_SNAPSHOT_DIR/$(basename -- "$path")" ]]; then - install -o 0 -g 0 -m "$(stat -c %a "$UNINSTALL_SNAPSHOT_DIR/$(basename -- "$path")")" -- "$UNINSTALL_SNAPSHOT_DIR/$(basename -- "$path")" "$path" || failed=true - fi - done - rm -rf -- "$UNINSTALL_SNAPSHOT_DIR" 2>/dev/null || failed=true + if [[ "$state" == ACTIVE && -f "$UNINSTALL_SNAPSHOT_DIR/manifest" ]]; then + while IFS=$'\t' read -r path mode; do + case "$mode" in + absent) + [[ ! -e "$path" && ! -L "$path" ]] || rm -f -- "$path" || failed=true + ;; + 600|644|755) + repository_install_file "$mode" "$UNINSTALL_SNAPSHOT_DIR/files/$(printf '%s' "$path" | sha256sum | awk '{print $1}')" "$path" || failed=true + ;; + *) failed=true ;; + esac + done < "$UNINSTALL_SNAPSHOT_DIR/manifest" fi + rm -rf -- "${UNINSTALL_SNAPSHOT_DIR:-}" 2>/dev/null || failed=true release_repository_lock || failed=true restore_repository_traps [[ "$failed" == false ]] || { error "卸载事务清理不完整;失败证据: $evidence"; return 1; } @@ -1212,14 +1229,14 @@ uninstall_cleanup() { uninstall_signal_handler() { local code="$1" signal="$2" - uninstall_cleanup "收到 $signal 信号" || true + uninstall_cleanup "收到 $signal 信号" || code=1 exit "$code" } uninstall_exit_handler() { local status="$1" - if [[ "${UNINSTALL_TRANSACTION_ACTIVE:-false}" == true ]]; then - uninstall_cleanup "活动卸载事务异常退出,原状态 $status" || true + if [[ "${UNINSTALL_TRANSACTION_STATE:-NONE}" == BUILDING || "${UNINSTALL_TRANSACTION_STATE:-NONE}" == ACTIVE ]]; then + uninstall_cleanup "活动卸载事务异常退出,原状态 $status" || status=1 (( status == 0 )) && status=1 fi exit "$status" @@ -1227,33 +1244,48 @@ uninstall_exit_handler() { begin_uninstall_transaction() { init_runtime_config + local path mode digest UNINSTALL_GENERATION="$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM" UNINSTALL_SNAPSHOT_DIR="$REPOSITORY_STATE_DIR/uninstall-$UNINSTALL_GENERATION" - mkdir -m 0700 -- "$UNINSTALL_SNAPSHOT_DIR" || return 1 - for path in "$AUTO_UPDATE_SCRIPT" "$AUTO_UPDATE_SERVICE" "$AUTO_UPDATE_TIMER" "$SOURCE_FILE" "$REPOSITORY_STATE_DIR/current" "$STATE_DIR/repository-managed"; do - [[ -e "$path" || -L "$path" ]] || continue - validate_secure_file "$path" "$(stat -Lc %a -- "$path")" || return 1 - repository_copy_file "$path" "$UNINSTALL_SNAPSHOT_DIR/$(basename -- "$path")" || return 1 - done - UNINSTALL_TRANSACTION_ACTIVE=true + UNINSTALL_TRANSACTION_STATE=BUILDING save_repository_traps trap 'uninstall_signal_handler 129 HUP' HUP trap 'uninstall_signal_handler 130 INT' INT trap 'uninstall_signal_handler 143 TERM' TERM trap 'uninstall_exit_handler $?' EXIT + uninstall_transaction_hook before-snapshot-create + mkdir -m 0700 -- "$UNINSTALL_SNAPSHOT_DIR" || return 1 + mkdir -m 0700 -- "$UNINSTALL_SNAPSHOT_DIR/files" || return 1 + : > "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 + chmod 0600 "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 + while IFS=$'\t' read -r path mode; do + if [[ -e "$path" || -L "$path" ]]; then + validate_secure_file "$path" "$mode" || return 1 + digest=$(printf '%s' "$path" | sha256sum | awk '{print $1}') || return 1 + repository_copy_file "$path" "$UNINSTALL_SNAPSHOT_DIR/files/$digest" || return 1 + chmod 0600 "$UNINSTALL_SNAPSHOT_DIR/files/$digest" || return 1 + else + mode=absent + fi + printf '%s\t%s\n' "$path" "$mode" >> "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 + done < <(uninstall_snapshot_targets) + validate_secure_file "$UNINSTALL_SNAPSHOT_DIR/manifest" 600 || return 1 + [[ "$(wc -l < "$UNINSTALL_SNAPSHOT_DIR/manifest")" == 6 ]] || return 1 + UNINSTALL_TRANSACTION_STATE=ACTIVE + uninstall_transaction_hook after-snapshot-active } finish_uninstall_transaction() { - local lock_path="$REPOSITORY_LOCK_DIR" - UNINSTALL_TRANSACTION_ACTIVE=false + local lock_path="$REPOSITORY_LOCK_DIR" failed=false + UNINSTALL_TRANSACTION_STATE=NONE trap - HUP INT TERM EXIT - rm -rf -- "$UNINSTALL_SNAPSHOT_DIR" || return 1 - if ! release_repository_lock; then - restore_repository_traps - error "卸载完成,但仓库事务锁释放失败: $lock_path" + rm -rf -- "$UNINSTALL_SNAPSHOT_DIR" || failed=true + release_repository_lock || failed=true + restore_repository_traps + if [[ "$failed" == true ]]; then + error "卸载收尾失败;可能残留锁或 snapshot: $lock_path $UNINSTALL_SNAPSHOT_DIR" return 1 fi - restore_repository_traps } uninstall_cloudflared() { From efc40636d6bd2086fcd6b56cdc699140d5149749 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 7 Sep 2026 18:59:54 +0800 Subject: [PATCH 07/32] fix: make Cloudflare uninstall rollback verifiable --- tests/test-cloudflare-tunnel.sh | 2 +- tools/cloudflare_tunnel.sh | 183 +++++++++++++++++++++++++------- 2 files changed, 143 insertions(+), 42 deletions(-) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index 2b46599..6906050 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -105,7 +105,7 @@ new_case() { "$CASE_DIR/root/etc/apt/sources.list.d" "$CASE_DIR/root/var" "$CASE_DIR/root/var/lib" make_fake_commands "$CASE_DIR/bin" export PATH="$CASE_DIR/bin:$ORIGINAL_PATH" - export CLOUDFLARED_TRUST_ANCHOR="$CASE_DIR/root" + export CLOUDFLARED_TRUST_ANCHOR="$CASE_DIR" export CLOUDFLARED_KEYRING="$CASE_DIR/root/usr/share/keyrings/cloudflare-main.gpg" export CLOUDFLARED_SOURCE_FILE="$CASE_DIR/root/etc/apt/sources.list.d/cloudflared.list" export CLOUDFLARED_APT_SOURCE_ROOT="$CASE_DIR/root/etc/apt" diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index 6a9c433..95e7dc1 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -1187,56 +1187,148 @@ uninstall_transaction_hook() { } uninstall_snapshot_targets() { - printf '%s\t%s\n' \ - "$AUTO_UPDATE_SCRIPT" 755 \ - "$AUTO_UPDATE_SERVICE" 644 \ - "$AUTO_UPDATE_TIMER" 644 \ - "$SOURCE_FILE" 644 \ - "$REPOSITORY_STATE_DIR/current" 600 \ - "$STATE_DIR/repository-managed" 600 + printf '%s\n' auto_update_script auto_update_service auto_update_timer source current legacy_marker +} + +uninstall_target_path() { + case "$1" in + auto_update_script) printf '%s\n' "$AUTO_UPDATE_SCRIPT" ;; + auto_update_service) printf '%s\n' "$AUTO_UPDATE_SERVICE" ;; + auto_update_timer) printf '%s\n' "$AUTO_UPDATE_TIMER" ;; + source) printf '%s\n' "$SOURCE_FILE" ;; + current) printf '%s\n' "$REPOSITORY_STATE_DIR/current" ;; + legacy_marker) printf '%s\n' "$STATE_DIR/repository-managed" ;; + *) return 1 ;; + esac +} + +uninstall_target_mode() { + case "$1" in + auto_update_script) printf '%s\n' 755 ;; + auto_update_service|auto_update_timer|source) printf '%s\n' 644 ;; + current|legacy_marker) printf '%s\n' 600 ;; + *) return 1 ;; + esac +} + +uninstall_manifest_field() { + local manifest="$1" id="$2" field="$3" + awk -F= -v id="$id" -v field="$field" ' + $1 == "target_id" {match_id=($2 == id); next} + match_id && $1 == field {print $2; exit} + ' "$manifest" +} + +uninstall_manifest_valid() { + local manifest="$1" id state mode uid gid sha snapshot count + validate_secure_file "$manifest" 600 || return 1 + [[ "$(wc -l < "$manifest")" == 44 ]] || return 1 + [[ "$(sed -n '1p' "$manifest")" == version=1 ]] || return 1 + grep -Eq '^generation=[0-9]{8}T[0-9]{6}Z-[0-9]+-[0-9]+$' "$manifest" || return 1 + [[ "$(grep -c '^generation=' "$manifest")" == 1 ]] || return 1 + while read -r id; do + count=$(grep -c "^target_id=$id$" "$manifest") + [[ "$count" == 1 ]] || return 1 + state=$(uninstall_manifest_field "$manifest" "$id" state) + mode=$(uninstall_manifest_field "$manifest" "$id" mode) + uid=$(uninstall_manifest_field "$manifest" "$id" uid) + gid=$(uninstall_manifest_field "$manifest" "$id" gid) + sha=$(uninstall_manifest_field "$manifest" "$id" sha256) + snapshot=$(uninstall_manifest_field "$manifest" "$id" snapshot) + [[ "$state" == regular || "$state" == absent ]] || return 1 + [[ "$mode" == "$(uninstall_target_mode "$id")" ]] || return 1 + [[ "$uid" == 0 && "$gid" == 0 ]] || return 1 + if [[ "$state" == regular ]]; then + [[ "$sha" =~ ^[[:xdigit:]]{64}$ && "$snapshot" == "target-$id" ]] || return 1 + validate_secure_file "$UNINSTALL_SNAPSHOT_DIR/files/$snapshot" 600 || return 1 + [[ "$(sha256sum -- "$UNINSTALL_SNAPSHOT_DIR/files/$snapshot" | awk '{print $1}')" == "$sha" ]] || return 1 + else + [[ "$sha" == 0000000000000000000000000000000000000000000000000000000000000000 && "$snapshot" == none ]] || return 1 + fi + done < <(uninstall_snapshot_targets) + find "$UNINSTALL_SNAPSHOT_DIR/files" -mindepth 1 -maxdepth 1 -type f -printf '%f\n' | while read -r snapshot; do + grep -Fxq "snapshot=$snapshot" "$manifest" || exit 1 + done +} + +uninstall_snapshot_path_valid() { + [[ -n "${UNINSTALL_SNAPSHOT_DIR:-}" && -n "${UNINSTALL_GENERATION:-}" ]] || return 1 + [[ "$UNINSTALL_SNAPSHOT_DIR" == "$REPOSITORY_STATE_DIR/uninstall-$UNINSTALL_GENERATION" ]] || return 1 + [[ ! -L "$UNINSTALL_SNAPSHOT_DIR" ]] || return 1 + [[ -d "$UNINSTALL_SNAPSHOT_DIR" ]] || return 1 + validate_secure_directory "$UNINSTALL_SNAPSHOT_DIR" 700 +} + +restore_uninstall_target() { + local id="$1" path mode payload sha stage + path=$(uninstall_target_path "$id") || return 1 + mode=$(uninstall_target_mode "$id") || return 1 + payload="$UNINSTALL_SNAPSHOT_DIR/files/target-$id" + sha=$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" sha256) + validate_secure_file "$payload" 600 || return 1 + [[ "$(sha256sum -- "$payload" | awk '{print $1}')" == "$sha" ]] || return 1 + validate_directory_chain "$(dirname -- "$path")" || return 1 + stage=$(mktemp "$(dirname -- "$path")/.cloudflared-uninstall-restore.XXXXXX") || return 1 + if ! repository_install_file "$mode" "$payload" "$stage" || + ! validate_secure_file "$stage" "$mode" || + ! repository_rename "$stage" "$path" || + ! validate_secure_file "$path" "$mode"; then + rm -f -- "$stage" 2>/dev/null || true + return 1 + fi } uninstall_cleanup() { init_runtime_config - local reason="$1" failed=false evidence path mode state + local reason="$1" failed=false state path target_state mode + local evidence="$REPOSITORY_STATE_DIR/failure-uninstall-${UNINSTALL_GENERATION:-unknown}" state="${UNINSTALL_TRANSACTION_STATE:-NONE}" [[ "$state" == BUILDING || "$state" == ACTIVE ]] || return 0 - UNINSTALL_TRANSACTION_STATE=NONE - trap - HUP INT TERM EXIT - evidence="$REPOSITORY_STATE_DIR/failure-uninstall-${UNINSTALL_GENERATION:-unknown}" + if [[ "$state" == ACTIVE ]]; then + if ! uninstall_snapshot_path_valid || ! uninstall_manifest_valid "$UNINSTALL_SNAPSHOT_DIR/manifest"; then + failed=true + else + while read -r path; do + target_state=$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$path" state) + mode=$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$path" mode) + if [[ "$target_state" == regular ]]; then + restore_uninstall_target "$path" || failed=true + elif [[ "$target_state" == absent ]]; then + : + else + failed=true + fi + done < <(uninstall_snapshot_targets) + fi + fi mkdir -m 0700 -- "$evidence" 2>/dev/null || failed=true printf '%s\n' "$reason" > "$evidence/rollback.log" 2>/dev/null || failed=true chmod 0600 "$evidence/rollback.log" 2>/dev/null || failed=true - if [[ "$state" == ACTIVE && -f "$UNINSTALL_SNAPSHOT_DIR/manifest" ]]; then - while IFS=$'\t' read -r path mode; do - case "$mode" in - absent) - [[ ! -e "$path" && ! -L "$path" ]] || rm -f -- "$path" || failed=true - ;; - 600|644|755) - repository_install_file "$mode" "$UNINSTALL_SNAPSHOT_DIR/files/$(printf '%s' "$path" | sha256sum | awk '{print $1}')" "$path" || failed=true - ;; - *) failed=true ;; - esac - done < "$UNINSTALL_SNAPSHOT_DIR/manifest" - fi - rm -rf -- "${UNINSTALL_SNAPSHOT_DIR:-}" 2>/dev/null || failed=true + if uninstall_snapshot_path_valid; then + rm -rf -- "$UNINSTALL_SNAPSHOT_DIR" 2>/dev/null || failed=true + else + failed=true + fi release_repository_lock || failed=true restore_repository_traps - [[ "$failed" == false ]] || { error "卸载事务清理不完整;失败证据: $evidence"; return 1; } - error "Cloudflare 卸载事务失败;可恢复文件已恢复;证据: $evidence" + UNINSTALL_TRANSACTION_STATE=NONE + if [[ "$failed" == true ]]; then + error "卸载事务清理不完整;失败证据: $evidence;锁路径: ${REPOSITORY_LOCK_DIR:-unknown}" + return 1 + fi + error "Cloudflare 卸载事务失败;可恢复文件已处理;证据: $evidence" } uninstall_signal_handler() { local code="$1" signal="$2" - uninstall_cleanup "收到 $signal 信号" || code=1 + uninstall_cleanup "收到 $signal 信号" || true exit "$code" } uninstall_exit_handler() { local status="$1" if [[ "${UNINSTALL_TRANSACTION_STATE:-NONE}" == BUILDING || "${UNINSTALL_TRANSACTION_STATE:-NONE}" == ACTIVE ]]; then - uninstall_cleanup "活动卸载事务异常退出,原状态 $status" || status=1 + uninstall_cleanup "活动卸载事务异常退出,原状态 $status" || true (( status == 0 )) && status=1 fi exit "$status" @@ -1244,7 +1336,7 @@ uninstall_exit_handler() { begin_uninstall_transaction() { init_runtime_config - local path mode digest + local id path mode sha state UNINSTALL_GENERATION="$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM" UNINSTALL_SNAPSHOT_DIR="$REPOSITORY_STATE_DIR/uninstall-$UNINSTALL_GENERATION" UNINSTALL_TRANSACTION_STATE=BUILDING @@ -1254,34 +1346,43 @@ begin_uninstall_transaction() { trap 'uninstall_signal_handler 143 TERM' TERM trap 'uninstall_exit_handler $?' EXIT uninstall_transaction_hook before-snapshot-create + validate_directory_chain "$(dirname -- "$UNINSTALL_SNAPSHOT_DIR")" || return 1 + [[ "$UNINSTALL_SNAPSHOT_DIR" == "$REPOSITORY_STATE_DIR/uninstall-$UNINSTALL_GENERATION" ]] || return 1 + [[ ! -e "$UNINSTALL_SNAPSHOT_DIR" && ! -L "$UNINSTALL_SNAPSHOT_DIR" ]] || return 1 mkdir -m 0700 -- "$UNINSTALL_SNAPSHOT_DIR" || return 1 mkdir -m 0700 -- "$UNINSTALL_SNAPSHOT_DIR/files" || return 1 : > "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 chmod 0600 "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 - while IFS=$'\t' read -r path mode; do + printf 'version=1\ngeneration=%s\n' "$UNINSTALL_GENERATION" > "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 + while read -r id; do + path=$(uninstall_target_path "$id") || return 1 + mode=$(uninstall_target_mode "$id") || return 1 + validate_directory_chain "$(dirname -- "$path")" || return 1 if [[ -e "$path" || -L "$path" ]]; then validate_secure_file "$path" "$mode" || return 1 - digest=$(printf '%s' "$path" | sha256sum | awk '{print $1}') || return 1 - repository_copy_file "$path" "$UNINSTALL_SNAPSHOT_DIR/files/$digest" || return 1 - chmod 0600 "$UNINSTALL_SNAPSHOT_DIR/files/$digest" || return 1 + sha=$(sha256sum -- "$path" | awk '{print $1}') || return 1 + repository_copy_file "$path" "$UNINSTALL_SNAPSHOT_DIR/files/target-$id" || return 1 + chmod 0600 "$UNINSTALL_SNAPSHOT_DIR/files/target-$id" || return 1 + state=regular + printf 'target_id=%s\nstate=%s\nmode=%s\nuid=0\ngid=0\nsha256=%s\nsnapshot=target-%s\n' "$id" "$state" "$mode" "$sha" "$id" >> "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 else - mode=absent + printf 'target_id=%s\nstate=absent\nmode=%s\nuid=0\ngid=0\nsha256=0000000000000000000000000000000000000000000000000000000000000000\nsnapshot=none\n' "$id" "$mode" >> "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 fi - printf '%s\t%s\n' "$path" "$mode" >> "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 done < <(uninstall_snapshot_targets) - validate_secure_file "$UNINSTALL_SNAPSHOT_DIR/manifest" 600 || return 1 - [[ "$(wc -l < "$UNINSTALL_SNAPSHOT_DIR/manifest")" == 6 ]] || return 1 + uninstall_manifest_valid "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 UNINSTALL_TRANSACTION_STATE=ACTIVE uninstall_transaction_hook after-snapshot-active } finish_uninstall_transaction() { local lock_path="$REPOSITORY_LOCK_DIR" failed=false - UNINSTALL_TRANSACTION_STATE=NONE - trap - HUP INT TERM EXIT - rm -rf -- "$UNINSTALL_SNAPSHOT_DIR" || failed=true + uninstall_snapshot_path_valid || failed=true + if [[ "$failed" == false ]]; then + rm -rf -- "$UNINSTALL_SNAPSHOT_DIR" || failed=true + fi release_repository_lock || failed=true restore_repository_traps + UNINSTALL_TRANSACTION_STATE=NONE if [[ "$failed" == true ]]; then error "卸载收尾失败;可能残留锁或 snapshot: $lock_path $UNINSTALL_SNAPSHOT_DIR" return 1 From 6c9c410b020d8e04677c97d2930f8be7c7a7a9ca Mon Sep 17 00:00:00 2001 From: root Date: Mon, 7 Sep 2026 21:19:10 +0800 Subject: [PATCH 08/32] fix: close Cloudflare uninstall snapshot lifecycle gaps --- tools/cloudflare_tunnel.sh | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index 95e7dc1..63064b8 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -1283,6 +1283,8 @@ uninstall_cleanup() { local reason="$1" failed=false state path target_state mode local evidence="$REPOSITORY_STATE_DIR/failure-uninstall-${UNINSTALL_GENERATION:-unknown}" state="${UNINSTALL_TRANSACTION_STATE:-NONE}" + if [[ "${UNINSTALL_SNAPSHOT_BUILDING:-false}" == true ]]; then state=BUILDING; fi + if [[ "${UNINSTALL_TRANSACTION_ACTIVE:-false}" == true ]]; then state=ACTIVE; fi [[ "$state" == BUILDING || "$state" == ACTIVE ]] || return 0 if [[ "$state" == ACTIVE ]]; then if ! uninstall_snapshot_path_valid || ! uninstall_manifest_valid "$UNINSTALL_SNAPSHOT_DIR/manifest"; then @@ -1291,10 +1293,17 @@ uninstall_cleanup() { while read -r path; do target_state=$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$path" state) mode=$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$path" mode) - if [[ "$target_state" == regular ]]; then + if [[ "$target_state" == regular ]]; then restore_uninstall_target "$path" || failed=true elif [[ "$target_state" == absent ]]; then - : + target=$(uninstall_target_path "$path") || { failed=true; continue; } + if [[ -e "$target" || -L "$target" ]]; then + if [[ -f "$target" && ! -L "$target" ]]; then + rm -f -- "$target" || failed=true + else + failed=true + fi + fi else failed=true fi @@ -1311,6 +1320,8 @@ uninstall_cleanup() { fi release_repository_lock || failed=true restore_repository_traps + UNINSTALL_SNAPSHOT_BUILDING=false + UNINSTALL_TRANSACTION_ACTIVE=false UNINSTALL_TRANSACTION_STATE=NONE if [[ "$failed" == true ]]; then error "卸载事务清理不完整;失败证据: $evidence;锁路径: ${REPOSITORY_LOCK_DIR:-unknown}" @@ -1339,6 +1350,13 @@ begin_uninstall_transaction() { local id path mode sha state UNINSTALL_GENERATION="$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM" UNINSTALL_SNAPSHOT_DIR="$REPOSITORY_STATE_DIR/uninstall-$UNINSTALL_GENERATION" + UNINSTALL_SNAPSHOT_BUILDING=false + UNINSTALL_TRANSACTION_ACTIVE=false + validate_directory_chain "$(dirname -- "$UNINSTALL_SNAPSHOT_DIR")" || return 1 + [[ "$UNINSTALL_SNAPSHOT_DIR" == "$REPOSITORY_STATE_DIR/uninstall-$UNINSTALL_GENERATION" ]] || return 1 + [[ ! -e "$UNINSTALL_SNAPSHOT_DIR" && ! -L "$UNINSTALL_SNAPSHOT_DIR" ]] || return 1 + mkdir -m 0700 -- "$UNINSTALL_SNAPSHOT_DIR" || return 1 + UNINSTALL_SNAPSHOT_BUILDING=true UNINSTALL_TRANSACTION_STATE=BUILDING save_repository_traps trap 'uninstall_signal_handler 129 HUP' HUP @@ -1346,10 +1364,6 @@ begin_uninstall_transaction() { trap 'uninstall_signal_handler 143 TERM' TERM trap 'uninstall_exit_handler $?' EXIT uninstall_transaction_hook before-snapshot-create - validate_directory_chain "$(dirname -- "$UNINSTALL_SNAPSHOT_DIR")" || return 1 - [[ "$UNINSTALL_SNAPSHOT_DIR" == "$REPOSITORY_STATE_DIR/uninstall-$UNINSTALL_GENERATION" ]] || return 1 - [[ ! -e "$UNINSTALL_SNAPSHOT_DIR" && ! -L "$UNINSTALL_SNAPSHOT_DIR" ]] || return 1 - mkdir -m 0700 -- "$UNINSTALL_SNAPSHOT_DIR" || return 1 mkdir -m 0700 -- "$UNINSTALL_SNAPSHOT_DIR/files" || return 1 : > "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 chmod 0600 "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 @@ -1370,6 +1384,8 @@ begin_uninstall_transaction() { fi done < <(uninstall_snapshot_targets) uninstall_manifest_valid "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 + UNINSTALL_SNAPSHOT_BUILDING=false + UNINSTALL_TRANSACTION_ACTIVE=true UNINSTALL_TRANSACTION_STATE=ACTIVE uninstall_transaction_hook after-snapshot-active } @@ -1382,6 +1398,8 @@ finish_uninstall_transaction() { fi release_repository_lock || failed=true restore_repository_traps + UNINSTALL_SNAPSHOT_BUILDING=false + UNINSTALL_TRANSACTION_ACTIVE=false UNINSTALL_TRANSACTION_STATE=NONE if [[ "$failed" == true ]]; then error "卸载收尾失败;可能残留锁或 snapshot: $lock_path $UNINSTALL_SNAPSHOT_DIR" From efed29177833b14af44bbb831a8637fb17ee2dd6 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 04:07:09 +0800 Subject: [PATCH 09/32] fix: harden Cloudflare uninstall evidence lifecycle --- tests/test-cloudflare-tunnel.sh | 82 +++++++++--- tools/cloudflare_tunnel.sh | 222 ++++++++++++++++++++------------ 2 files changed, 204 insertions(+), 100 deletions(-) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index 6906050..c191909 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -88,6 +88,11 @@ FAKE #!/usr/bin/env bash printf 'systemctl:%s\n' "$*" >> "$FAKE_LOG" exit 0 +FAKE + cat > "$bin/cloudflared" <<'FAKE' +#!/usr/bin/env bash +printf 'cloudflared:%s\n' "$*" >> "$FAKE_LOG" +exit 0 FAKE chmod 0755 "$bin"/* } @@ -185,6 +190,7 @@ pass "source rendering has zero side effects and exact fields" trap 'sentinel-trap' HUP set -o noclobber umask 027 + unset CLOUDFLARED_TEST_INTERNALS source "$ROOT_DIR/tools/cloudflare_tunnel.sh" sentinel_after=$(declare -f cloudflare_tunnel_source) [[ "$sentinel_before" == "$sentinel_after" ]] || fail "ordinary source replaced sentinel function" @@ -227,13 +233,13 @@ before_functions=\$(declare -F | sort) before_function=\$(declare -f cloudflare_tunnel_source) before_files=\$(find . -mindepth 1 -printf '%P %y %m\\n' | sort) source $(printf '%q' "$ROOT_DIR/tools/cloudflare_tunnel.sh") -[[ \$(set +o) == "\$before_options" ]] -[[ \$(trap -p HUP INT TERM EXIT) == "\$before_traps" ]] -[[ \$(declare -F | sort) == "\$before_functions" ]] -[[ \$(declare -f cloudflare_tunnel_source) == "\$before_function" ]] -[[ \$(cloudflare_tunnel_source) == sentinel ]] -[[ \$sentinel == unchanged && \$PWD == $(printf '%q' "$TEST_DIR") && \$(umask) == 0027 ]] -[[ \$(find . -mindepth 1 -printf '%P %y %m\\n' | sort) == "\$before_files" ]] +[[ \$(set +o) == "\$before_options" ]] || exit 1 +[[ \$(trap -p HUP INT TERM EXIT) == "\$before_traps" ]] || exit 1 +[[ \$(declare -F | sort) == "\$before_functions" ]] || exit 1 +[[ \$(declare -f cloudflare_tunnel_source) == "\$before_function" ]] || exit 1 +[[ \$(cloudflare_tunnel_source) == sentinel ]] || exit 1 +[[ \$sentinel == unchanged && \$PWD == $(printf '%q' "$TEST_DIR") && \$(umask) == 0027 ]] || exit 1 +[[ \$(find . -mindepth 1 -printf '%P %y %m\\n' | sort) == "\$before_files" ]] || exit 1 EOF bash "$TEST_DIR/source-contract.sh" || fail "ordinary source independent subprocess contract failed" pass "ordinary source independent subprocess preserves full shell contract" @@ -243,25 +249,16 @@ CLOUDFLARED_TEST_INTERNALS=1 bash -c 'source "$1"; declare -F configure_reposito pass "explicit internal source mode exposes test functions" new_case -mkdir -p "$CLOUDFLARED_REPOSITORY_STATE_DIR" -chmod 0700 "$CLOUDFLARED_STATE_DIR" "$CLOUDFLARED_REPOSITORY_STATE_DIR" -printf 'managed\n' > "$CLOUDFLARED_STATE_DIR/repository-managed" -chmod 0600 "$CLOUDFLARED_STATE_DIR/repository-managed" -printf 'deb [signed-by=%s] %s any main\\n' "$CLOUDFLARED_KEYRING" 'https://pkg.cloudflare.com/cloudflare-main.gpg' > "$CLOUDFLARED_SOURCE_FILE" -printf key > "$CLOUDFLARED_KEYRING" -chmod 0644 "$CLOUDFLARED_SOURCE_FILE" "$CLOUDFLARED_KEYRING" +configure_repository || fail "configure before real uninstall failed" require_root() { :; } check_platform() { :; } -disable_auto_update_locked() { printf 'disable-auto-update\n' >> "$FAKE_LOG"; } -remove_managed_repository_locked() { printf 'remove-repository\n' >> "$FAKE_LOG"; } uninstall_cloudflared --confirmed || fail "uninstall normal path failed" [[ ! -d "$CLOUDFLARED_STATE_DIR.lock" ]] || fail "uninstall left repository lock" -grep -Fxq 'disable-auto-update' "$FAKE_LOG" || fail "uninstall skipped locked auto-update step" -grep -Fxq 'remove-repository' "$FAKE_LOG" || fail "uninstall skipped locked repository step" grep -Fxq 'apt-get:remove -y cloudflared' "$FAKE_LOG" || fail "uninstall skipped package removal" -pass "uninstall normal path holds and releases repository lock" -unset -f disable_auto_update_locked remove_managed_repository_locked -source "$ROOT_DIR/tools/cloudflare_tunnel.sh" +assert_absent "$CLOUDFLARED_SOURCE_FILE" "real uninstall retained source" +assert_absent "$CLOUDFLARED_REPOSITORY_STATE_DIR/current" "real uninstall retained current" +[[ -f "$CLOUDFLARED_KEYRING" ]] || fail "real uninstall removed keyring" +pass "configure then real uninstall preserves keyring and releases lock" init_runtime_config new_case @@ -596,6 +593,49 @@ grep -Fq "ExecStart=$CLOUDFLARED_APT_BIN --no-autoupdate --token-file /etc/cloud fail "legacy service path was not preserved" pass "legacy binary and service migration behavior remains" + +# Each malformed snapshot runs in a separate process: transaction traps and +# intentional corrupt fixtures must not leak into subsequent cases. +for schema_case in version generation duplicate unknown order state mode uid gid digest created payload-name payload-missing payload-extra payload-fifo payload-symlink manifest-fifo ancestor; do + ( + new_case + configure_repository || fail "schema fixture configure" + acquire_repository_lock || fail "schema fixture lock" + begin_uninstall_transaction || fail "schema fixture capture" + manifest="$UNINSTALL_SNAPSHOT_DIR/manifest" + case "$schema_case" in + version) sed -i '1d' "$manifest" ;; + generation) sed -i '2s/.*/generation=20000101T000000Z-1-1/' "$manifest" ;; + duplicate) sed -i '3s/.*/version=1/' "$manifest" ;; + unknown) sed -i '10s/.*/path=\/tmp\/forbidden/' "$manifest" ;; + order) sed -i '3s/auto_update_script/auto_update_timer/' "$manifest" ;; + state) sed -i '4s/.*/state=invalid/' "$manifest" ;; + mode) sed -i '5s/.*/mode=777/' "$manifest" ;; + uid) sed -i '6s/.*/uid=1/' "$manifest" ;; + gid) sed -i '7s/.*/gid=1/' "$manifest" ;; + digest) sed -i '8s/.*/sha256=invalid/' "$manifest" ;; + created) sed -i 's/^transaction_created=false$/transaction_created=true/' "$manifest" ;; + payload-name) sed -i 's/^snapshot=target-source$/snapshot=target-current/' "$manifest" ;; + payload-missing) rm -- "$UNINSTALL_SNAPSHOT_DIR/files/target-source" ;; + payload-extra) printf extra > "$UNINSTALL_SNAPSHOT_DIR/files/.extra"; chmod 600 "$UNINSTALL_SNAPSHOT_DIR/files/.extra" ;; + payload-fifo) mkfifo "$UNINSTALL_SNAPSHOT_DIR/files/.fifo" ;; + payload-symlink) ln -s target-source "$UNINSTALL_SNAPSHOT_DIR/files/.link" ;; + manifest-fifo) rm -- "$manifest"; mkfifo "$manifest" ;; + ancestor) chmod 0777 "$UNINSTALL_SNAPSHOT_DIR/files" ;; + esac + if uninstall_manifest_valid "$manifest"; then fail "schema accepted $schema_case"; fi + # No rollback is appropriate for this read-only parser test. + UNINSTALL_TRANSACTION_ACTIVE=false + UNINSTALL_SNAPSHOT_BUILDING=false + UNINSTALL_TRANSACTION_STATE=NONE + restore_repository_traps + # Parent alone owns TEST_DIR cleanup; do not run its EXIT trap here. + trap - EXIT + release_repository_lock || fail "schema fixture release" + ) > "$TEST_DIR/schema-$schema_case.log" 2>&1 || fail "schema $schema_case (see $TEST_DIR/schema-$schema_case.log)" + pass "uninstall manifest rejects $schema_case" +done + entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" pass "bash -c entrypoint remains compatible" diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index 63064b8..d65bf70 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -1220,45 +1220,104 @@ uninstall_manifest_field() { } uninstall_manifest_valid() { - local manifest="$1" id state mode uid gid sha snapshot count + local manifest="$1" id state sha snapshot entry expected_entries=0 actual_entries=0 + local -a rows=() + local offset=2 + [[ "$manifest" == "$UNINSTALL_SNAPSHOT_DIR/manifest" ]] || return 1 + uninstall_snapshot_path_valid || return 1 + validate_directory_chain "$UNINSTALL_SNAPSHOT_DIR/files" || return 1 + validate_secure_directory "$UNINSTALL_SNAPSHOT_DIR/files" 700 || return 1 validate_secure_file "$manifest" 600 || return 1 - [[ "$(wc -l < "$manifest")" == 44 ]] || return 1 - [[ "$(sed -n '1p' "$manifest")" == version=1 ]] || return 1 - grep -Eq '^generation=[0-9]{8}T[0-9]{6}Z-[0-9]+-[0-9]+$' "$manifest" || return 1 - [[ "$(grep -c '^generation=' "$manifest")" == 1 ]] || return 1 - while read -r id; do - count=$(grep -c "^target_id=$id$" "$manifest") - [[ "$count" == 1 ]] || return 1 - state=$(uninstall_manifest_field "$manifest" "$id" state) - mode=$(uninstall_manifest_field "$manifest" "$id" mode) - uid=$(uninstall_manifest_field "$manifest" "$id" uid) - gid=$(uninstall_manifest_field "$manifest" "$id" gid) - sha=$(uninstall_manifest_field "$manifest" "$id" sha256) - snapshot=$(uninstall_manifest_field "$manifest" "$id" snapshot) - [[ "$state" == regular || "$state" == absent ]] || return 1 - [[ "$mode" == "$(uninstall_target_mode "$id")" ]] || return 1 - [[ "$uid" == 0 && "$gid" == 0 ]] || return 1 + mapfile -t rows < "$manifest" || return 1 + [[ "${#rows[@]}" == 80 ]] || return 1 + [[ "${rows[0]}" == version=1 && "${rows[1]}" == "generation=$UNINSTALL_GENERATION" ]] || return 1 + [[ "$UNINSTALL_GENERATION" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9]+-[0-9]+$ ]] || return 1 + while IFS= read -r id; do + [[ "${rows[offset]}" == "target_id=$id" ]] || return 1 + [[ "${rows[offset+1]}" =~ ^state=(regular|absent)$ ]] || return 1 + [[ "${rows[offset+2]}" == "mode=$(uninstall_target_mode "$id")" ]] || return 1 + [[ "${rows[offset+3]}" == uid=0 && "${rows[offset+4]}" == gid=0 ]] || return 1 + [[ "${rows[offset+5]}" =~ ^sha256=[0-9a-f]{64}$ ]] || return 1 + [[ "${rows[offset+6]}" == snapshot=none || "${rows[offset+6]}" == "snapshot=target-$id" ]] || return 1 + [[ "${rows[offset+7]}" == transaction_created=true || "${rows[offset+7]}" == transaction_created=false ]] || return 1 + [[ "${rows[offset+8]}" =~ ^created_uid=[0-9]+$ && "${rows[offset+9]}" =~ ^created_gid=[0-9]+$ ]] || return 1 + [[ "${rows[offset+10]}" =~ ^created_dev=[0-9]+$ && "${rows[offset+11]}" =~ ^created_ino=[0-9]+$ ]] || return 1 + [[ "${rows[offset+12]}" =~ ^created_sha256=[0-9a-f]{64}$ ]] || return 1 + state=${rows[offset+1]#state=}; sha=${rows[offset+5]#sha256=}; snapshot="target-$id" + created=${rows[offset+7]#transaction_created=} + [[ "$state" == absent || "$created" == false ]] || return 1 if [[ "$state" == regular ]]; then - [[ "$sha" =~ ^[[:xdigit:]]{64}$ && "$snapshot" == "target-$id" ]] || return 1 + [[ "${rows[offset+6]}" == "snapshot=$snapshot" ]] || return 1 validate_secure_file "$UNINSTALL_SNAPSHOT_DIR/files/$snapshot" 600 || return 1 [[ "$(sha256sum -- "$UNINSTALL_SNAPSHOT_DIR/files/$snapshot" | awk '{print $1}')" == "$sha" ]] || return 1 + expected_entries=$((expected_entries + 1)) else - [[ "$sha" == 0000000000000000000000000000000000000000000000000000000000000000 && "$snapshot" == none ]] || return 1 + [[ "${rows[offset+6]}" == snapshot=none && "$sha" == $(printf '0%.0s' {1..64}) ]] || return 1 fi + offset=$((offset + 13)) done < <(uninstall_snapshot_targets) - find "$UNINSTALL_SNAPSHOT_DIR/files" -mindepth 1 -maxdepth 1 -type f -printf '%f\n' | while read -r snapshot; do - grep -Fxq "snapshot=$snapshot" "$manifest" || exit 1 - done + while IFS= read -r -d '' entry; do + validate_secure_file "$entry" 600 || return 1 + actual_entries=$((actual_entries + 1)) + done < <(find "$UNINSTALL_SNAPSHOT_DIR/files" -mindepth 1 -maxdepth 1 -print0) + [[ "$actual_entries" == "$expected_entries" ]] } uninstall_snapshot_path_valid() { + init_runtime_config [[ -n "${UNINSTALL_SNAPSHOT_DIR:-}" && -n "${UNINSTALL_GENERATION:-}" ]] || return 1 [[ "$UNINSTALL_SNAPSHOT_DIR" == "$REPOSITORY_STATE_DIR/uninstall-$UNINSTALL_GENERATION" ]] || return 1 - [[ ! -L "$UNINSTALL_SNAPSHOT_DIR" ]] || return 1 - [[ -d "$UNINSTALL_SNAPSHOT_DIR" ]] || return 1 + [[ ! -L "$UNINSTALL_SNAPSHOT_DIR" && -d "$UNINSTALL_SNAPSHOT_DIR" ]] || return 1 + validate_directory_chain "$UNINSTALL_SNAPSHOT_DIR" || return 1 validate_secure_directory "$UNINSTALL_SNAPSHOT_DIR" 700 } +uninstall_update_manifest_creation() { + local id="$1" path="$2" digest metadata dev ino tmp manifest="$UNINSTALL_SNAPSHOT_DIR/manifest" + [[ -f "$path" && ! -L "$path" ]] || return 1 + [[ "$(uninstall_manifest_field "$manifest" "$id" state)" == absent ]] || return 1 + validate_secure_file "$path" "$(uninstall_target_mode "$id")" || return 1 + metadata=$(stat -Lc '%u %g %d %i' -- "$path") || return 1 + read -r _ _ dev ino <<< "$metadata" + digest=$(sha256sum -- "$path" | awk '{print $1}') || return 1 + tmp=$(mktemp "$UNINSTALL_SNAPSHOT_DIR/manifest.stage.XXXXXX") || return 1 + awk -F= -v id="$id" -v dev="$dev" -v ino="$ino" -v digest="$digest" ' + BEGIN {OFS="="} + $1=="target_id" {hit=($2==id); print; next} + hit && $1=="transaction_created" {$2="true"; print; next} + hit && $1=="created_uid" {$2="0"; print; next} + hit && $1=="created_gid" {$2="0"; print; next} + hit && $1=="created_dev" {$2=dev; print; next} + hit && $1=="created_ino" {$2=ino; print; next} + hit && $1=="created_sha256" {$2=digest; print; next} + {print} + ' "$manifest" > "$tmp" || { rm -f -- "$tmp"; return 1; } + chmod 0600 "$tmp" && repository_rename "$tmp" "$manifest" || { rm -f -- "$tmp"; return 1; } +} + +uninstall_register_exclusive_creation() { + local id="$1" path + [[ "${UNINSTALL_TRANSACTION_STATE:-NONE}" == ACTIVE ]] || return 1 + path=$(uninstall_target_path "$id") || return 1 + uninstall_update_manifest_creation "$id" "$path" +} + +uninstall_created_identity_valid() { + local id="$1" path metadata dev ino digest + path=$(uninstall_target_path "$id") || return 1 + [[ "$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" transaction_created)" == true ]] || return 1 + [[ -f "$path" && ! -L "$path" ]] || return 1 + validate_secure_file "$path" "$(uninstall_target_mode "$id")" || return 1 + metadata=$(stat -Lc '%u %g %d %i' -- "$path") || return 1 + read -r _ _ dev ino <<< "$metadata" + [[ "$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" created_uid)" == 0 ]] || return 1 + [[ "$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" created_gid)" == 0 ]] || return 1 + [[ "$dev" == "$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" created_dev)" ]] || return 1 + [[ "$ino" == "$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" created_ino)" ]] || return 1 + digest=$(sha256sum -- "$path" | awk '{print $1}') || return 1 + [[ "$digest" == "$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" created_sha256)" ]] +} + restore_uninstall_target() { local id="$1" path mode payload sha stage path=$(uninstall_target_path "$id") || return 1 @@ -1270,39 +1329,59 @@ restore_uninstall_target() { validate_directory_chain "$(dirname -- "$path")" || return 1 stage=$(mktemp "$(dirname -- "$path")/.cloudflared-uninstall-restore.XXXXXX") || return 1 if ! repository_install_file "$mode" "$payload" "$stage" || - ! validate_secure_file "$stage" "$mode" || - ! repository_rename "$stage" "$path" || - ! validate_secure_file "$path" "$mode"; then - rm -f -- "$stage" 2>/dev/null || true + ! validate_secure_file "$stage" "$mode" || + ! repository_rename "$stage" "$path" || + ! validate_secure_file "$path" "$mode" || + [[ "$(sha256sum -- "$path" | awk '{print $1}')" != "$sha" ]]; then + error "恢复目标失败,保留 stage 证据: $stage" return 1 fi } +write_uninstall_journal() { + local state="$1" reason="$2" journal="$UNINSTALL_SNAPSHOT_DIR/journal" stage + stage=$(mktemp "$UNINSTALL_SNAPSHOT_DIR/journal.stage.XXXXXX") || return 1 + printf 'version=1\ngeneration=%s\nstate=%s\nreason=%s\n' "$UNINSTALL_GENERATION" "$state" "$reason" > "$stage" || { rm -f -- "$stage"; return 1; } + chmod 0600 "$stage" || { rm -f -- "$stage"; return 1; } + repository_rename "$stage" "$journal" || { rm -f -- "$stage"; return 1; } + validate_secure_file "$journal" 600 +} + +archive_uninstall_evidence() { + local archive="$1" path + [[ ! -e "$archive" && ! -L "$archive" ]] || return 1 + repository_rename "$UNINSTALL_SNAPSHOT_DIR" "$archive" || return 1 + chmod 0500 "$archive" || return 1 + find "$archive" -maxdepth 1 -type d -exec chmod 0500 -- {} + || return 1 + find "$archive" -maxdepth 1 -type f -exec chmod 0400 -- {} + || return 1 + path="$archive/files" + if [[ -d "$path" ]]; then + chmod 0500 "$path" || return 1 + find "$path" -maxdepth 1 -type f -exec chmod 0400 -- {} + || return 1 + fi + UNINSTALL_SNAPSHOT_DIR="$archive" +} + uninstall_cleanup() { init_runtime_config - local reason="$1" failed=false state path target_state mode + local reason="$1" failed=false state path target_state local evidence="$REPOSITORY_STATE_DIR/failure-uninstall-${UNINSTALL_GENERATION:-unknown}" state="${UNINSTALL_TRANSACTION_STATE:-NONE}" - if [[ "${UNINSTALL_SNAPSHOT_BUILDING:-false}" == true ]]; then state=BUILDING; fi - if [[ "${UNINSTALL_TRANSACTION_ACTIVE:-false}" == true ]]; then state=ACTIVE; fi + [[ "${UNINSTALL_SNAPSHOT_BUILDING:-false}" == true ]] && state=BUILDING + [[ "${UNINSTALL_TRANSACTION_ACTIVE:-false}" == true ]] && state=ACTIVE [[ "$state" == BUILDING || "$state" == ACTIVE ]] || return 0 if [[ "$state" == ACTIVE ]]; then if ! uninstall_snapshot_path_valid || ! uninstall_manifest_valid "$UNINSTALL_SNAPSHOT_DIR/manifest"; then failed=true else - while read -r path; do + while IFS= read -r path; do target_state=$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$path" state) - mode=$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$path" mode) - if [[ "$target_state" == regular ]]; then + if [[ "$target_state" == regular ]]; then restore_uninstall_target "$path" || failed=true elif [[ "$target_state" == absent ]]; then target=$(uninstall_target_path "$path") || { failed=true; continue; } if [[ -e "$target" || -L "$target" ]]; then - if [[ -f "$target" && ! -L "$target" ]]; then - rm -f -- "$target" || failed=true - else - failed=true - fi + uninstall_created_identity_valid "$path" && rm -f -- "$target" || failed=true fi else failed=true @@ -1313,21 +1392,21 @@ uninstall_cleanup() { mkdir -m 0700 -- "$evidence" 2>/dev/null || failed=true printf '%s\n' "$reason" > "$evidence/rollback.log" 2>/dev/null || failed=true chmod 0600 "$evidence/rollback.log" 2>/dev/null || failed=true - if uninstall_snapshot_path_valid; then - rm -rf -- "$UNINSTALL_SNAPSHOT_DIR" 2>/dev/null || failed=true - else - failed=true + write_uninstall_journal "$([[ "$failed" == true ]] && printf FAILED || printf ROLLED_BACK)" "$reason" || failed=true + # Retain complete snapshot as read-only evidence; never garbage-collect it. + if [[ -d "$UNINSTALL_SNAPSHOT_DIR" ]]; then + archive_uninstall_evidence "$evidence/snapshot" || failed=true fi release_repository_lock || failed=true restore_repository_traps UNINSTALL_SNAPSHOT_BUILDING=false UNINSTALL_TRANSACTION_ACTIVE=false - UNINSTALL_TRANSACTION_STATE=NONE + UNINSTALL_TRANSACTION_STATE=$([[ "$failed" == true ]] && printf FAILED || printf ROLLED_BACK) if [[ "$failed" == true ]]; then - error "卸载事务清理不完整;失败证据: $evidence;锁路径: ${REPOSITORY_LOCK_DIR:-unknown}" + error "卸载事务清理不完整;状态 FAILED/PENDING;证据: $evidence;snapshot: ${UNINSTALL_SNAPSHOT_DIR:-unknown}" return 1 fi - error "Cloudflare 卸载事务失败;可恢复文件已处理;证据: $evidence" + error "Cloudflare 卸载事务失败;证据: $evidence" } uninstall_signal_handler() { @@ -1347,64 +1426,49 @@ uninstall_exit_handler() { begin_uninstall_transaction() { init_runtime_config - local id path mode sha state + local id path mode sha UNINSTALL_GENERATION="$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM" UNINSTALL_SNAPSHOT_DIR="$REPOSITORY_STATE_DIR/uninstall-$UNINSTALL_GENERATION" - UNINSTALL_SNAPSHOT_BUILDING=false - UNINSTALL_TRANSACTION_ACTIVE=false + UNINSTALL_SNAPSHOT_BUILDING=false; UNINSTALL_TRANSACTION_ACTIVE=false; UNINSTALL_TRANSACTION_STATE=NONE validate_directory_chain "$(dirname -- "$UNINSTALL_SNAPSHOT_DIR")" || return 1 - [[ "$UNINSTALL_SNAPSHOT_DIR" == "$REPOSITORY_STATE_DIR/uninstall-$UNINSTALL_GENERATION" ]] || return 1 [[ ! -e "$UNINSTALL_SNAPSHOT_DIR" && ! -L "$UNINSTALL_SNAPSHOT_DIR" ]] || return 1 mkdir -m 0700 -- "$UNINSTALL_SNAPSHOT_DIR" || return 1 - UNINSTALL_SNAPSHOT_BUILDING=true - UNINSTALL_TRANSACTION_STATE=BUILDING + UNINSTALL_SNAPSHOT_BUILDING=true; UNINSTALL_TRANSACTION_STATE=BUILDING save_repository_traps - trap 'uninstall_signal_handler 129 HUP' HUP - trap 'uninstall_signal_handler 130 INT' INT - trap 'uninstall_signal_handler 143 TERM' TERM - trap 'uninstall_exit_handler $?' EXIT + trap 'uninstall_signal_handler 129 HUP' HUP; trap 'uninstall_signal_handler 130 INT' INT; trap 'uninstall_signal_handler 143 TERM' TERM; trap 'uninstall_exit_handler $?' EXIT uninstall_transaction_hook before-snapshot-create mkdir -m 0700 -- "$UNINSTALL_SNAPSHOT_DIR/files" || return 1 - : > "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 - chmod 0600 "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 + : > "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1; chmod 0600 "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 printf 'version=1\ngeneration=%s\n' "$UNINSTALL_GENERATION" > "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 - while read -r id; do - path=$(uninstall_target_path "$id") || return 1 - mode=$(uninstall_target_mode "$id") || return 1 + while IFS= read -r id; do + path=$(uninstall_target_path "$id") || return 1; mode=$(uninstall_target_mode "$id") || return 1 validate_directory_chain "$(dirname -- "$path")" || return 1 if [[ -e "$path" || -L "$path" ]]; then validate_secure_file "$path" "$mode" || return 1 sha=$(sha256sum -- "$path" | awk '{print $1}') || return 1 - repository_copy_file "$path" "$UNINSTALL_SNAPSHOT_DIR/files/target-$id" || return 1 - chmod 0600 "$UNINSTALL_SNAPSHOT_DIR/files/target-$id" || return 1 - state=regular - printf 'target_id=%s\nstate=%s\nmode=%s\nuid=0\ngid=0\nsha256=%s\nsnapshot=target-%s\n' "$id" "$state" "$mode" "$sha" "$id" >> "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 + repository_copy_file "$path" "$UNINSTALL_SNAPSHOT_DIR/files/target-$id" || return 1; chmod 0600 "$UNINSTALL_SNAPSHOT_DIR/files/target-$id" || return 1 + printf 'target_id=%s\nstate=regular\nmode=%s\nuid=0\ngid=0\nsha256=%s\nsnapshot=target-%s\ntransaction_created=false\ncreated_uid=0\ncreated_gid=0\ncreated_dev=0\ncreated_ino=0\ncreated_sha256=%064d\n' "$id" "$mode" "$sha" "$id" 0 >> "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 else - printf 'target_id=%s\nstate=absent\nmode=%s\nuid=0\ngid=0\nsha256=0000000000000000000000000000000000000000000000000000000000000000\nsnapshot=none\n' "$id" "$mode" >> "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 + printf 'target_id=%s\nstate=absent\nmode=%s\nuid=0\ngid=0\nsha256=%064d\nsnapshot=none\ntransaction_created=false\ncreated_uid=0\ncreated_gid=0\ncreated_dev=0\ncreated_ino=0\ncreated_sha256=%064d\n' "$id" "$mode" 0 0 >> "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 fi done < <(uninstall_snapshot_targets) uninstall_manifest_valid "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 - UNINSTALL_SNAPSHOT_BUILDING=false - UNINSTALL_TRANSACTION_ACTIVE=true - UNINSTALL_TRANSACTION_STATE=ACTIVE + UNINSTALL_SNAPSHOT_BUILDING=false; UNINSTALL_TRANSACTION_ACTIVE=true; UNINSTALL_TRANSACTION_STATE=ACTIVE + write_uninstall_journal ACTIVE capture-complete || return 1 uninstall_transaction_hook after-snapshot-active } finish_uninstall_transaction() { local lock_path="$REPOSITORY_LOCK_DIR" failed=false uninstall_snapshot_path_valid || failed=true - if [[ "$failed" == false ]]; then - rm -rf -- "$UNINSTALL_SNAPSHOT_DIR" || failed=true - fi + uninstall_manifest_valid "$UNINSTALL_SNAPSHOT_DIR/manifest" || failed=true + if [[ "$failed" == false ]]; then write_uninstall_journal COMMITTED uninstall-complete || failed=true; fi + if [[ "$failed" == false ]]; then archive_uninstall_evidence "$REPOSITORY_STATE_DIR/history-uninstall-$UNINSTALL_GENERATION" || failed=true; fi release_repository_lock || failed=true restore_repository_traps - UNINSTALL_SNAPSHOT_BUILDING=false - UNINSTALL_TRANSACTION_ACTIVE=false - UNINSTALL_TRANSACTION_STATE=NONE - if [[ "$failed" == true ]]; then - error "卸载收尾失败;可能残留锁或 snapshot: $lock_path $UNINSTALL_SNAPSHOT_DIR" - return 1 - fi + UNINSTALL_SNAPSHOT_BUILDING=false; UNINSTALL_TRANSACTION_ACTIVE=false + UNINSTALL_TRANSACTION_STATE=$([[ "$failed" == true ]] && printf FAILED || printf COMMITTED) + if [[ "$failed" == true ]]; then error "卸载收尾失败;状态 FAILED/PENDING;锁或证据: $lock_path $UNINSTALL_SNAPSHOT_DIR"; return 1; fi } uninstall_cloudflared() { From c2789b6dc5cde1a31912ec9867ff4be080ae5d89 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 04:42:24 +0800 Subject: [PATCH 10/32] fix: make Cloudflare uninstall evidence transactional --- tests/test-cloudflare-tunnel.sh | 4 +- tools/cloudflare_tunnel.sh | 170 +++++++++++++++++++------------- 2 files changed, 103 insertions(+), 71 deletions(-) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index c191909..f432bb8 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -614,10 +614,10 @@ for schema_case in version generation duplicate unknown order state mode uid gid uid) sed -i '6s/.*/uid=1/' "$manifest" ;; gid) sed -i '7s/.*/gid=1/' "$manifest" ;; digest) sed -i '8s/.*/sha256=invalid/' "$manifest" ;; - created) sed -i 's/^transaction_created=false$/transaction_created=true/' "$manifest" ;; + created) sed -i '0,/^type=regular$/s//type=absent/' "$manifest" ;; payload-name) sed -i 's/^snapshot=target-source$/snapshot=target-current/' "$manifest" ;; payload-missing) rm -- "$UNINSTALL_SNAPSHOT_DIR/files/target-source" ;; - payload-extra) printf extra > "$UNINSTALL_SNAPSHOT_DIR/files/.extra"; chmod 600 "$UNINSTALL_SNAPSHOT_DIR/files/.extra" ;; + payload-extra) printf extra > "$UNINSTALL_SNAPSHOT_DIR/files/target-extra"; chmod 600 "$UNINSTALL_SNAPSHOT_DIR/files/target-extra" ;; payload-fifo) mkfifo "$UNINSTALL_SNAPSHOT_DIR/files/.fifo" ;; payload-symlink) ln -s target-source "$UNINSTALL_SNAPSHOT_DIR/files/.link" ;; manifest-fifo) rm -- "$manifest"; mkfifo "$manifest" ;; diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index d65bf70..5394c49 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -200,20 +200,23 @@ validate_repository_state_entries() { current) validate_secure_file "$entry" 600 || return 1 ;; - history-*|failure-*) + history-uninstall-*|history-*|failure-*) validate_secure_directory "$entry" 700 || return 1 - find "$entry" -mindepth 1 -type l -print -quit | grep -q . && { - error "事务证据目录包含符号链接: $entry" - return 1 - } + while IFS= read -r -d '' evidence; do + validate_secure_directory "$evidence" 700 || return 1 + done < <(find "$entry" -mindepth 1 -type d -print0) while IFS= read -r -d '' evidence; do validate_secure_file "$evidence" 600 || return 1 - done < <(find "$entry" -mindepth 1 -maxdepth 1 -type f -print0) - find "$entry" -mindepth 1 -maxdepth 1 ! -type f -print -quit | grep -q . && { - error "事务证据目录包含未知类型: $entry" + done < <(find "$entry" -type f -print0) + find "$entry" -type l -o ! -type f -a ! -type d | grep -q . && { + error "事务证据目录包含符号链接或未知类型: $entry" return 1 } ;; + uninstall-*) + error "发现未完成的 Cloudflare 卸载事务,需人工处理: $entry" + return 1 + ;; lock) error "另一个 Cloudflare 仓库事务正在运行,或存在待人工审查的锁: $entry" return 1 @@ -1220,7 +1223,7 @@ uninstall_manifest_field() { } uninstall_manifest_valid() { - local manifest="$1" id state sha snapshot entry expected_entries=0 actual_entries=0 + local manifest="$1" id state type mode uid gid sha snapshot entry expected_entries=0 actual_entries=0 local -a rows=() local offset=2 [[ "$manifest" == "$UNINSTALL_SNAPSHOT_DIR/manifest" ]] || return 1 @@ -1229,35 +1232,35 @@ uninstall_manifest_valid() { validate_secure_directory "$UNINSTALL_SNAPSHOT_DIR/files" 700 || return 1 validate_secure_file "$manifest" 600 || return 1 mapfile -t rows < "$manifest" || return 1 - [[ "${#rows[@]}" == 80 ]] || return 1 + [[ "${#rows[@]}" == 50 ]] || return 1 [[ "${rows[0]}" == version=1 && "${rows[1]}" == "generation=$UNINSTALL_GENERATION" ]] || return 1 [[ "$UNINSTALL_GENERATION" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9]+-[0-9]+$ ]] || return 1 while IFS= read -r id; do [[ "${rows[offset]}" == "target_id=$id" ]] || return 1 [[ "${rows[offset+1]}" =~ ^state=(regular|absent)$ ]] || return 1 - [[ "${rows[offset+2]}" == "mode=$(uninstall_target_mode "$id")" ]] || return 1 - [[ "${rows[offset+3]}" == uid=0 && "${rows[offset+4]}" == gid=0 ]] || return 1 - [[ "${rows[offset+5]}" =~ ^sha256=[0-9a-f]{64}$ ]] || return 1 - [[ "${rows[offset+6]}" == snapshot=none || "${rows[offset+6]}" == "snapshot=target-$id" ]] || return 1 - [[ "${rows[offset+7]}" == transaction_created=true || "${rows[offset+7]}" == transaction_created=false ]] || return 1 - [[ "${rows[offset+8]}" =~ ^created_uid=[0-9]+$ && "${rows[offset+9]}" =~ ^created_gid=[0-9]+$ ]] || return 1 - [[ "${rows[offset+10]}" =~ ^created_dev=[0-9]+$ && "${rows[offset+11]}" =~ ^created_ino=[0-9]+$ ]] || return 1 - [[ "${rows[offset+12]}" =~ ^created_sha256=[0-9a-f]{64}$ ]] || return 1 - state=${rows[offset+1]#state=}; sha=${rows[offset+5]#sha256=}; snapshot="target-$id" - created=${rows[offset+7]#transaction_created=} - [[ "$state" == absent || "$created" == false ]] || return 1 + [[ "${rows[offset+2]}" =~ ^type=(regular|absent)$ ]] || return 1 + [[ "${rows[offset+3]}" == "mode=$(uninstall_target_mode "$id")" ]] || return 1 + [[ "${rows[offset+4]}" == uid=0 && "${rows[offset+5]}" == gid=0 ]] || return 1 + [[ "${rows[offset+6]}" =~ ^sha256=[0-9a-f]{64}$ ]] || return 1 + [[ "${rows[offset+7]}" == snapshot=none || "${rows[offset+7]}" == "snapshot=target-$id" ]] || return 1 + state=${rows[offset+1]#state=}; type=${rows[offset+2]#type=} + mode=${rows[offset+3]#mode=}; uid=${rows[offset+4]#uid=}; gid=${rows[offset+5]#gid=} + sha=${rows[offset+6]#sha256=}; snapshot=${rows[offset+7]#snapshot=} + [[ "$state" == "$type" && "$uid" == 0 && "$gid" == 0 ]] || return 1 if [[ "$state" == regular ]]; then - [[ "${rows[offset+6]}" == "snapshot=$snapshot" ]] || return 1 + [[ "$snapshot" == target-$id && "$sha" != $(printf '0%.0s' {1..64}) ]] || return 1 validate_secure_file "$UNINSTALL_SNAPSHOT_DIR/files/$snapshot" 600 || return 1 [[ "$(sha256sum -- "$UNINSTALL_SNAPSHOT_DIR/files/$snapshot" | awk '{print $1}')" == "$sha" ]] || return 1 expected_entries=$((expected_entries + 1)) else - [[ "${rows[offset+6]}" == snapshot=none && "$sha" == $(printf '0%.0s' {1..64}) ]] || return 1 + [[ "$snapshot" == none && "$sha" == $(printf '0%.0s' {1..64}) ]] || return 1 fi - offset=$((offset + 13)) + offset=$((offset + 8)) done < <(uninstall_snapshot_targets) while IFS= read -r -d '' entry; do + [[ -f "$entry" && ! -L "$entry" ]] || return 1 validate_secure_file "$entry" 600 || return 1 + [[ "$(basename -- "$entry")" == target-* ]] || return 1 actual_entries=$((actual_entries + 1)) done < <(find "$UNINSTALL_SNAPSHOT_DIR/files" -mindepth 1 -maxdepth 1 -print0) [[ "$actual_entries" == "$expected_entries" ]] @@ -1272,50 +1275,52 @@ uninstall_snapshot_path_valid() { validate_secure_directory "$UNINSTALL_SNAPSHOT_DIR" 700 } -uninstall_update_manifest_creation() { - local id="$1" path="$2" digest metadata dev ino tmp manifest="$UNINSTALL_SNAPSHOT_DIR/manifest" - [[ -f "$path" && ! -L "$path" ]] || return 1 - [[ "$(uninstall_manifest_field "$manifest" "$id" state)" == absent ]] || return 1 - validate_secure_file "$path" "$(uninstall_target_mode "$id")" || return 1 - metadata=$(stat -Lc '%u %g %d %i' -- "$path") || return 1 - read -r _ _ dev ino <<< "$metadata" - digest=$(sha256sum -- "$path" | awk '{print $1}') || return 1 - tmp=$(mktemp "$UNINSTALL_SNAPSHOT_DIR/manifest.stage.XXXXXX") || return 1 - awk -F= -v id="$id" -v dev="$dev" -v ino="$ino" -v digest="$digest" ' - BEGIN {OFS="="} - $1=="target_id" {hit=($2==id); print; next} - hit && $1=="transaction_created" {$2="true"; print; next} - hit && $1=="created_uid" {$2="0"; print; next} - hit && $1=="created_gid" {$2="0"; print; next} - hit && $1=="created_dev" {$2=dev; print; next} - hit && $1=="created_ino" {$2=ino; print; next} - hit && $1=="created_sha256" {$2=digest; print; next} - {print} - ' "$manifest" > "$tmp" || { rm -f -- "$tmp"; return 1; } - chmod 0600 "$tmp" && repository_rename "$tmp" "$manifest" || { rm -f -- "$tmp"; return 1; } +uninstall_prepare_exclusive_creation() { + local id="$1" path marker + [[ "${UNINSTALL_TRANSACTION_STATE:-NONE}" == ACTIVE ]] || return 1 + path=$(uninstall_target_path "$id") || return 1 + [[ "$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" state)" == absent ]] || return 1 + [[ ! -e "$path" && ! -L "$path" ]] || return 1 + marker="$UNINSTALL_SNAPSHOT_DIR/created-$id" + [[ ! -e "$marker" && ! -L "$marker" ]] || return 1 + (set -C; : > "$marker") || return 1 + chmod 0600 "$marker" || return 1 + printf 'state=armed\ntarget_id=%s\npath=%s\n' "$id" "$path" > "$marker" } uninstall_register_exclusive_creation() { - local id="$1" path + local id="$1" path marker metadata dev ino digest stage [[ "${UNINSTALL_TRANSACTION_STATE:-NONE}" == ACTIVE ]] || return 1 path=$(uninstall_target_path "$id") || return 1 - uninstall_update_manifest_creation "$id" "$path" + marker="$UNINSTALL_SNAPSHOT_DIR/created-$id" + validate_secure_file "$marker" 600 || return 1 + [[ "$(sed -n '1p' "$marker")" == state=armed ]] || return 1 + [[ "$(sed -n '2p' "$marker")" == "target_id=$id" && "$(sed -n '3p' "$marker")" == "path=$path" ]] || return 1 + [[ -f "$path" && ! -L "$path" ]] || return 1 + validate_secure_file "$path" "$(uninstall_target_mode "$id")" || return 1 + metadata=$(stat -Lc '%u %g %d %i' -- "$path") || return 1 + read -r uid gid dev ino <<< "$metadata" + digest=$(sha256sum -- "$path" | awk '{print $1}') || return 1 + stage=$(mktemp "$UNINSTALL_SNAPSHOT_DIR/created-$id.stage.XXXXXX") || return 1 + printf 'state=created\ntarget_id=%s\npath=%s\nuid=%s\ngid=%s\ndev=%s\nino=%s\nsha256=%s\n' \ + "$id" "$path" "$uid" "$gid" "$dev" "$ino" "$digest" > "$stage" || { rm -f -- "$stage"; return 1; } + chmod 0600 "$stage" && repository_rename "$stage" "$marker" || { rm -f -- "$stage"; return 1; } } uninstall_created_identity_valid() { - local id="$1" path metadata dev ino digest + local id="$1" path marker metadata uid gid dev ino digest path=$(uninstall_target_path "$id") || return 1 - [[ "$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" transaction_created)" == true ]] || return 1 + marker="$UNINSTALL_SNAPSHOT_DIR/created-$id" + validate_secure_file "$marker" 600 || return 1 + [[ "$(sed -n '1p' "$marker")" == state=created && "$(sed -n '2p' "$marker")" == "target_id=$id" && "$(sed -n '3p' "$marker")" == "path=$path" ]] || return 1 [[ -f "$path" && ! -L "$path" ]] || return 1 validate_secure_file "$path" "$(uninstall_target_mode "$id")" || return 1 metadata=$(stat -Lc '%u %g %d %i' -- "$path") || return 1 - read -r _ _ dev ino <<< "$metadata" - [[ "$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" created_uid)" == 0 ]] || return 1 - [[ "$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" created_gid)" == 0 ]] || return 1 - [[ "$dev" == "$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" created_dev)" ]] || return 1 - [[ "$ino" == "$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" created_ino)" ]] || return 1 + read -r uid gid dev ino <<< "$metadata" + [[ "$uid" == "$(sed -n '4p' "$marker" | cut -d= -f2)" && "$gid" == "$(sed -n '5p' "$marker" | cut -d= -f2)" ]] || return 1 + [[ "$dev" == "$(sed -n '6p' "$marker" | cut -d= -f2)" && "$ino" == "$(sed -n '7p' "$marker" | cut -d= -f2)" ]] || return 1 digest=$(sha256sum -- "$path" | awk '{print $1}') || return 1 - [[ "$digest" == "$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" created_sha256)" ]] + [[ "$digest" == "$(sed -n '8p' "$marker" | cut -d= -f2)" ]] } restore_uninstall_target() { @@ -1330,6 +1335,7 @@ restore_uninstall_target() { stage=$(mktemp "$(dirname -- "$path")/.cloudflared-uninstall-restore.XXXXXX") || return 1 if ! repository_install_file "$mode" "$payload" "$stage" || ! validate_secure_file "$stage" "$mode" || + [[ "$(sha256sum -- "$stage" | awk '{print $1}')" != "$sha" ]] || ! repository_rename "$stage" "$path" || ! validate_secure_file "$path" "$mode" || [[ "$(sha256sum -- "$path" | awk '{print $1}')" != "$sha" ]]; then @@ -1348,23 +1354,38 @@ write_uninstall_journal() { } archive_uninstall_evidence() { - local archive="$1" path + local archive="$1" [[ ! -e "$archive" && ! -L "$archive" ]] || return 1 + [[ -d "$UNINSTALL_SNAPSHOT_DIR" && ! -L "$UNINSTALL_SNAPSHOT_DIR" ]] || return 1 repository_rename "$UNINSTALL_SNAPSHOT_DIR" "$archive" || return 1 - chmod 0500 "$archive" || return 1 - find "$archive" -maxdepth 1 -type d -exec chmod 0500 -- {} + || return 1 - find "$archive" -maxdepth 1 -type f -exec chmod 0400 -- {} + || return 1 - path="$archive/files" - if [[ -d "$path" ]]; then - chmod 0500 "$path" || return 1 - find "$path" -maxdepth 1 -type f -exec chmod 0400 -- {} + || return 1 - fi + validate_secure_directory "$archive" 700 || return 1 + validate_secure_directory "$archive/files" 700 || return 1 + validate_secure_file "$archive/manifest" 600 || return 1 UNINSTALL_SNAPSHOT_DIR="$archive" } +uninstall_targets_group_valid() { + local id path state sha + uninstall_snapshot_path_valid || return 1 + uninstall_manifest_valid "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 + while IFS= read -r id; do + path=$(uninstall_target_path "$id") || return 1 + state=$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" state) + if [[ "$state" == regular ]]; then + sha=$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" sha256) + validate_secure_file "$path" "$(uninstall_target_mode "$id")" || return 1 + [[ "$(sha256sum -- "$path" | awk '{print $1}')" == "$sha" ]] || return 1 + elif [[ "$state" == absent ]]; then + [[ ! -e "$path" && ! -L "$path" ]] || return 1 + else + return 1 + fi + done < <(uninstall_snapshot_targets) +} + uninstall_cleanup() { init_runtime_config - local reason="$1" failed=false state path target_state + local reason="$1" failed=false state path target_state snapshot_valid=false local evidence="$REPOSITORY_STATE_DIR/failure-uninstall-${UNINSTALL_GENERATION:-unknown}" state="${UNINSTALL_TRANSACTION_STATE:-NONE}" [[ "${UNINSTALL_SNAPSHOT_BUILDING:-false}" == true ]] && state=BUILDING @@ -1374,6 +1395,7 @@ uninstall_cleanup() { if ! uninstall_snapshot_path_valid || ! uninstall_manifest_valid "$UNINSTALL_SNAPSHOT_DIR/manifest"; then failed=true else + snapshot_valid=true while IFS= read -r path; do target_state=$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$path" state) if [[ "$target_state" == regular ]]; then @@ -1381,12 +1403,18 @@ uninstall_cleanup() { elif [[ "$target_state" == absent ]]; then target=$(uninstall_target_path "$path") || { failed=true; continue; } if [[ -e "$target" || -L "$target" ]]; then - uninstall_created_identity_valid "$path" && rm -f -- "$target" || failed=true + if uninstall_register_exclusive_creation "$path" && uninstall_created_identity_valid "$path" && rm -f -- "$target" && + [[ ! -e "$target" && ! -L "$target" ]]; then + : + else + failed=true + fi fi else failed=true fi done < <(uninstall_snapshot_targets) + uninstall_targets_group_valid || failed=true fi fi mkdir -m 0700 -- "$evidence" 2>/dev/null || failed=true @@ -1394,7 +1422,7 @@ uninstall_cleanup() { chmod 0600 "$evidence/rollback.log" 2>/dev/null || failed=true write_uninstall_journal "$([[ "$failed" == true ]] && printf FAILED || printf ROLLED_BACK)" "$reason" || failed=true # Retain complete snapshot as read-only evidence; never garbage-collect it. - if [[ -d "$UNINSTALL_SNAPSHOT_DIR" ]]; then + if [[ "$failed" == false && "$snapshot_valid" == true && -d "$UNINSTALL_SNAPSHOT_DIR" ]]; then archive_uninstall_evidence "$evidence/snapshot" || failed=true fi release_repository_lock || failed=true @@ -1447,13 +1475,17 @@ begin_uninstall_transaction() { validate_secure_file "$path" "$mode" || return 1 sha=$(sha256sum -- "$path" | awk '{print $1}') || return 1 repository_copy_file "$path" "$UNINSTALL_SNAPSHOT_DIR/files/target-$id" || return 1; chmod 0600 "$UNINSTALL_SNAPSHOT_DIR/files/target-$id" || return 1 - printf 'target_id=%s\nstate=regular\nmode=%s\nuid=0\ngid=0\nsha256=%s\nsnapshot=target-%s\ntransaction_created=false\ncreated_uid=0\ncreated_gid=0\ncreated_dev=0\ncreated_ino=0\ncreated_sha256=%064d\n' "$id" "$mode" "$sha" "$id" 0 >> "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 + printf 'target_id=%s\nstate=regular\ntype=regular\nmode=%s\nuid=0\ngid=0\nsha256=%s\nsnapshot=target-%s\n' "$id" "$mode" "$sha" "$id" >> "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 else - printf 'target_id=%s\nstate=absent\nmode=%s\nuid=0\ngid=0\nsha256=%064d\nsnapshot=none\ntransaction_created=false\ncreated_uid=0\ncreated_gid=0\ncreated_dev=0\ncreated_ino=0\ncreated_sha256=%064d\n' "$id" "$mode" 0 0 >> "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 + printf 'target_id=%s\nstate=absent\ntype=absent\nmode=%s\nuid=0\ngid=0\nsha256=%064d\nsnapshot=none\n' "$id" "$mode" 0 >> "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 fi done < <(uninstall_snapshot_targets) uninstall_manifest_valid "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 UNINSTALL_SNAPSHOT_BUILDING=false; UNINSTALL_TRANSACTION_ACTIVE=true; UNINSTALL_TRANSACTION_STATE=ACTIVE + while IFS= read -r id; do + [[ "$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" state)" == absent ]] || continue + uninstall_prepare_exclusive_creation "$id" || return 1 + done < <(uninstall_snapshot_targets) write_uninstall_journal ACTIVE capture-complete || return 1 uninstall_transaction_hook after-snapshot-active } From 142b3dca5d0ce14a4e8f3d19f494b0f84754f331 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 07:02:23 +0800 Subject: [PATCH 11/32] fix: verify Cloudflare creation identity and failure journals --- tests/test-cloudflare-tunnel.sh | 138 +++++++++++++++++++++++++++++++- tools/cloudflare_tunnel.sh | 133 +++++++++++++++++++----------- 2 files changed, 223 insertions(+), 48 deletions(-) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index f432bb8..0463d97 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -614,7 +614,7 @@ for schema_case in version generation duplicate unknown order state mode uid gid uid) sed -i '6s/.*/uid=1/' "$manifest" ;; gid) sed -i '7s/.*/gid=1/' "$manifest" ;; digest) sed -i '8s/.*/sha256=invalid/' "$manifest" ;; - created) sed -i '0,/^type=regular$/s//type=absent/' "$manifest" ;; + created) sed -i '0,/^transaction_created=false$/s//transaction_created=invalid/' "$manifest" ;; payload-name) sed -i 's/^snapshot=target-source$/snapshot=target-current/' "$manifest" ;; payload-missing) rm -- "$UNINSTALL_SNAPSHOT_DIR/files/target-source" ;; payload-extra) printf extra > "$UNINSTALL_SNAPSHOT_DIR/files/target-extra"; chmod 600 "$UNINSTALL_SNAPSHOT_DIR/files/target-extra" ;; @@ -636,6 +636,142 @@ for schema_case in version generation duplicate unknown order state mode uid gid pass "uninstall manifest rejects $schema_case" done + +for ownership_case in owned foreign inode mode owner gid symlink directory fifo; do + ( + new_case + uninstall_transaction_hook() { :; } + trap - EXIT + configure_repository || fail "ownership configure" + acquire_repository_lock || fail "ownership lock" + begin_uninstall_transaction || fail "ownership capture" + target="$AUTO_UPDATE_SCRIPT" + printf 'owned-content\n' > "$CASE_DIR/payload" + chmod 0600 "$CASE_DIR/payload" + if [[ "$ownership_case" == foreign ]]; then + printf foreign > "$target" + chmod 0755 "$target" + else + uninstall_create_absent_target auto_update_script "$CASE_DIR/payload" || fail "exclusive creation" + case "$ownership_case" in + inode) mv "$target" "$target.original"; cp "$target.original" "$target" ;; + mode) chmod 0644 "$target" ;; + owner) chown 65534:0 "$target" ;; + gid) chown 0:65534 "$target" ;; + symlink) rm "$target"; ln -s "$CASE_DIR/payload" "$target" ;; + directory) rm "$target"; mkdir "$target" ;; + fifo) rm "$target"; mkfifo "$target" ;; + esac + fi + if [[ "$ownership_case" == owned ]]; then + uninstall_cleanup ownership-test || fail "owned rollback" + assert_absent "$target" "owned file survived" + else + if uninstall_cleanup ownership-test; then fail "foreign identity accepted"; fi + [[ -e "$target" || -L "$target" ]] || fail "foreign object deleted" + [[ -f "$UNINSTALL_SNAPSHOT_DIR/manifest" ]] || fail "manifest lost" + [[ -f "$UNINSTALL_SNAPSHOT_DIR/files/target-source" ]] || fail "payload lost" + fi + trap - EXIT + ) > "$TEST_DIR/ownership-$ownership_case.log" 2>&1 || { cat "$TEST_DIR/ownership-$ownership_case.log"; fail "ownership $ownership_case"; } + pass "absent ownership $ownership_case" +done + +for journal_case in generation duplicate state unknown reason; do + ( + new_case + trap - EXIT + uninstall_transaction_hook() { :; } + configure_repository || fail "journal configure" + acquire_repository_lock || fail "journal lock" + begin_uninstall_transaction || fail "journal capture" + journal="$UNINSTALL_SNAPSHOT_DIR/journal" + case "$journal_case" in + generation) sed -i '2s/.*/generation=wrong/' "$journal" ;; + duplicate) printf 'state=ACTIVE\n' >> "$journal" ;; + state) sed -i '3s/.*/state=COMMITTED/' "$journal" ;; + unknown) sed -i '1s/.*/unknown=1/' "$journal" ;; + reason) sed -i '4s/.*/path=invalid/' "$journal" ;; + esac + if uninstall_journal_valid "$journal" ACTIVE; then fail "bad journal accepted"; fi + UNINSTALL_TRANSACTION_STATE=FAILED + UNINSTALL_TRANSACTION_ACTIVE=false + UNINSTALL_SNAPSHOT_BUILDING=false + restore_repository_traps + release_repository_lock || fail "journal release" + ) > "$TEST_DIR/journal-$journal_case.log" 2>&1 || fail "journal $journal_case" + pass "journal rejects $journal_case" +done + +for finalization_case in journal archive release restore; do + ( + new_case + trap - EXIT + uninstall_transaction_hook() { :; } + configure_repository || fail "finalization configure" + acquire_repository_lock || fail "finalization lock" + begin_uninstall_transaction || fail "finalization capture" + snapshot_before="$UNINSTALL_SNAPSHOT_DIR" + case "$finalization_case" in + journal|archive) + repository_rename() { + if [[ "$finalization_case" == journal && "$2" == */journal ]] || + [[ "$finalization_case" == archive && "$1" == "$snapshot_before" ]]; then return 1; fi + command mv -fT -- "$1" "$2" + } + ;; + release) + # A nonempty lock is an actual rmdir failure, not a core stub. + printf retained > "$REPOSITORY_LOCK_DIR/injected" + ;; + restore) + repository_install_file() { + [[ "$2" != */target-source ]] || return 1 + command install -o 0 -g 0 -m "$1" -- "$2" "$3" + } + ;; + esac + if uninstall_cleanup "injected-$finalization_case"; then fail "finalization failure accepted"; fi + [[ "$UNINSTALL_TRANSACTION_STATE" == FAILED ]] || fail "failed guard state" + [[ "$UNINSTALL_TRANSACTION_ACTIVE" == false ]] || fail "active guard survived" + [[ -f "$UNINSTALL_SNAPSHOT_DIR/manifest" ]] || fail "manifest deleted" + [[ -f "$UNINSTALL_SNAPSHOT_DIR/files/target-source" ]] || fail "source payload deleted" + [[ -f "$UNINSTALL_SNAPSHOT_DIR/files/target-current" ]] || fail "current payload deleted" + cmp "$SOURCE_FILE" "$UNINSTALL_SNAPSHOT_DIR/files/target-source" || fail "source changed" + [[ -f "$KEYRING" ]] || fail "keyring deleted" + if [[ "$finalization_case" == release ]]; then + [[ -d "$CLOUDFLARED_STATE_DIR.lock" ]] || fail "lock evidence missing" + fi + ) > "$TEST_DIR/finalization-$finalization_case.log" 2>&1 || { + cat "$TEST_DIR/finalization-$finalization_case.log" + fail "finalization $finalization_case" + } + pass "finalization $finalization_case failure preserves complete evidence" +done + +for capture_id in auto_update_script auto_update_timer legacy_marker; do + ( + new_case + trap - EXIT + configure_repository || fail "capture configure" + cp "$SOURCE_FILE" "$CASE_DIR/source-before" + cp "$KEYRING" "$CASE_DIR/key-before" + acquire_repository_lock || fail "capture lock" + uninstall_transaction_hook() { [[ "$1" != "capture-$capture_id" ]]; } + if begin_uninstall_transaction; then fail "capture fault ignored"; fi + [[ "$UNINSTALL_TRANSACTION_STATE" == BUILDING ]] || fail "partial capture activated" + if uninstall_cleanup capture-failed; then fail "partial capture reported recovery"; fi + cmp "$SOURCE_FILE" "$CASE_DIR/source-before" || fail "partial source changed" + cmp "$KEYRING" "$CASE_DIR/key-before" || fail "partial key changed" + [[ -f "$UNINSTALL_SNAPSHOT_DIR/journal" ]] || fail "partial journal missing" + [[ "$UNINSTALL_TRANSACTION_ACTIVE" == false ]] || fail "partial guard active" + ) > "$TEST_DIR/capture-$capture_id.log" 2>&1 || { + cat "$TEST_DIR/capture-$capture_id.log" + fail "capture $capture_id" + } + pass "capture failure $capture_id retains partial evidence without restoration" +done + entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" pass "bash -c entrypoint remains compatible" diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index 5394c49..f5bdd5f 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -1223,7 +1223,7 @@ uninstall_manifest_field() { } uninstall_manifest_valid() { - local manifest="$1" id state type mode uid gid sha snapshot entry expected_entries=0 actual_entries=0 + local manifest="$1" id state created mode uid gid sha snapshot entry expected_entries=0 actual_entries=0 local -a rows=() local offset=2 [[ "$manifest" == "$UNINSTALL_SNAPSHOT_DIR/manifest" ]] || return 1 @@ -1238,16 +1238,18 @@ uninstall_manifest_valid() { while IFS= read -r id; do [[ "${rows[offset]}" == "target_id=$id" ]] || return 1 [[ "${rows[offset+1]}" =~ ^state=(regular|absent)$ ]] || return 1 - [[ "${rows[offset+2]}" =~ ^type=(regular|absent)$ ]] || return 1 - [[ "${rows[offset+3]}" == "mode=$(uninstall_target_mode "$id")" ]] || return 1 - [[ "${rows[offset+4]}" == uid=0 && "${rows[offset+5]}" == gid=0 ]] || return 1 - [[ "${rows[offset+6]}" =~ ^sha256=[0-9a-f]{64}$ ]] || return 1 - [[ "${rows[offset+7]}" == snapshot=none || "${rows[offset+7]}" == "snapshot=target-$id" ]] || return 1 - state=${rows[offset+1]#state=}; type=${rows[offset+2]#type=} - mode=${rows[offset+3]#mode=}; uid=${rows[offset+4]#uid=}; gid=${rows[offset+5]#gid=} - sha=${rows[offset+6]#sha256=}; snapshot=${rows[offset+7]#snapshot=} - [[ "$state" == "$type" && "$uid" == 0 && "$gid" == 0 ]] || return 1 + [[ "${rows[offset+2]}" == "mode=$(uninstall_target_mode "$id")" ]] || return 1 + [[ "${rows[offset+3]}" == uid=0 && "${rows[offset+4]}" == gid=0 ]] || return 1 + [[ "${rows[offset+5]}" =~ ^sha256=[0-9a-f]{64}$ ]] || return 1 + [[ "${rows[offset+6]}" == snapshot=none || "${rows[offset+6]}" == "snapshot=target-$id" ]] || return 1 + [[ "${rows[offset+7]}" =~ ^transaction_created=(true|false)$ ]] || return 1 + created=${rows[offset+7]#transaction_created=} + state=${rows[offset+1]#state=} + mode=${rows[offset+2]#mode=}; uid=${rows[offset+3]#uid=}; gid=${rows[offset+4]#gid=} + sha=${rows[offset+5]#sha256=}; snapshot=${rows[offset+6]#snapshot=} + [[ "$uid" == 0 && "$gid" == 0 ]] || return 1 if [[ "$state" == regular ]]; then + [[ "$created" == false ]] || return 1 [[ "$snapshot" == target-$id && "$sha" != $(printf '0%.0s' {1..64}) ]] || return 1 validate_secure_file "$UNINSTALL_SNAPSHOT_DIR/files/$snapshot" 600 || return 1 [[ "$(sha256sum -- "$UNINSTALL_SNAPSHOT_DIR/files/$snapshot" | awk '{print $1}')" == "$sha" ]] || return 1 @@ -1255,6 +1257,9 @@ uninstall_manifest_valid() { else [[ "$snapshot" == none && "$sha" == $(printf '0%.0s' {1..64}) ]] || return 1 fi + if [[ "$created" == true ]]; then + validate_secure_file "$UNINSTALL_SNAPSHOT_DIR/created-$id" 600 || return 1 + fi offset=$((offset + 8)) done < <(uninstall_snapshot_targets) while IFS= read -r -d '' entry; do @@ -1275,36 +1280,40 @@ uninstall_snapshot_path_valid() { validate_secure_directory "$UNINSTALL_SNAPSHOT_DIR" 700 } -uninstall_prepare_exclusive_creation() { - local id="$1" path marker +# Create rather than retrospectively claim an object. Hard-link publication +# fails if any object already occupies the fixed destination. +uninstall_create_absent_target() { + local id="$1" payload="$2" path mode stage marker digest identity manifest_stage [[ "${UNINSTALL_TRANSACTION_STATE:-NONE}" == ACTIVE ]] || return 1 + uninstall_manifest_valid "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 path=$(uninstall_target_path "$id") || return 1 + mode=$(uninstall_target_mode "$id") || return 1 [[ "$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" state)" == absent ]] || return 1 [[ ! -e "$path" && ! -L "$path" ]] || return 1 + validate_directory_chain "$(dirname -- "$path")" || return 1 + validate_secure_file "$payload" 600 || return 1 + stage=$(mktemp "$(dirname -- "$path")/.cloudflared-create.XXXXXX") || return 1 + repository_install_file "$mode" "$payload" "$stage" || return 1 + validate_secure_file "$stage" "$mode" || return 1 + digest=$(sha256sum -- "$stage" | awk '{print $1}') || return 1 + identity=$(stat -c '%d:%i' -- "$stage") || return 1 + # GNU ln -T cannot follow a destination symlink or replace an existing file. + ln -T -- "$stage" "$path" || return 1 + [[ "$(stat -c '%d:%i' -- "$path")" == "$identity" ]] || return 1 marker="$UNINSTALL_SNAPSHOT_DIR/created-$id" - [[ ! -e "$marker" && ! -L "$marker" ]] || return 1 - (set -C; : > "$marker") || return 1 - chmod 0600 "$marker" || return 1 - printf 'state=armed\ntarget_id=%s\npath=%s\n' "$id" "$path" > "$marker" -} - -uninstall_register_exclusive_creation() { - local id="$1" path marker metadata dev ino digest stage - [[ "${UNINSTALL_TRANSACTION_STATE:-NONE}" == ACTIVE ]] || return 1 - path=$(uninstall_target_path "$id") || return 1 - marker="$UNINSTALL_SNAPSHOT_DIR/created-$id" + (set -C; umask 077; printf 'state=created\ntarget_id=%s\npath=%s\nuid=0\ngid=0\ndev=%s\nino=%s\nsha256=%s\n' \ + "$id" "$path" "${identity%:*}" "${identity#*:}" "$digest" > "$marker") || return 1 validate_secure_file "$marker" 600 || return 1 - [[ "$(sed -n '1p' "$marker")" == state=armed ]] || return 1 - [[ "$(sed -n '2p' "$marker")" == "target_id=$id" && "$(sed -n '3p' "$marker")" == "path=$path" ]] || return 1 - [[ -f "$path" && ! -L "$path" ]] || return 1 - validate_secure_file "$path" "$(uninstall_target_mode "$id")" || return 1 - metadata=$(stat -Lc '%u %g %d %i' -- "$path") || return 1 - read -r uid gid dev ino <<< "$metadata" - digest=$(sha256sum -- "$path" | awk '{print $1}') || return 1 - stage=$(mktemp "$UNINSTALL_SNAPSHOT_DIR/created-$id.stage.XXXXXX") || return 1 - printf 'state=created\ntarget_id=%s\npath=%s\nuid=%s\ngid=%s\ndev=%s\nino=%s\nsha256=%s\n' \ - "$id" "$path" "$uid" "$gid" "$dev" "$ino" "$digest" > "$stage" || { rm -f -- "$stage"; return 1; } - chmod 0600 "$stage" && repository_rename "$stage" "$marker" || { rm -f -- "$stage"; return 1; } + manifest_stage=$(mktemp "$UNINSTALL_SNAPSHOT_DIR/manifest.stage.XXXXXX") || return 1 + awk -v id="$id" ' + /^target_id=/ {hit=($0 == "target_id=" id)} + hit && /^transaction_created=/ {print "transaction_created=true"; next} + {print} + ' "$UNINSTALL_SNAPSHOT_DIR/manifest" > "$manifest_stage" || return 1 + chmod 0600 "$manifest_stage" || return 1 + repository_rename "$manifest_stage" "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 + uninstall_created_identity_valid "$id" || return 1 + rm -- "$stage" } uninstall_created_identity_valid() { @@ -1344,20 +1353,41 @@ restore_uninstall_target() { fi } +uninstall_journal_valid() { + local journal="$1" expected="$2" + local -a rows=() + validate_secure_file "$journal" 600 || return 1 + mapfile -t rows < "$journal" || return 1 + [[ "${#rows[@]}" == 4 ]] || return 1 + [[ "${rows[0]}" == version=1 && "${rows[1]}" == "generation=$UNINSTALL_GENERATION" ]] || return 1 + [[ "${rows[2]}" == "state=$expected" && "${rows[3]}" == reason=* ]] +} + write_uninstall_journal() { local state="$1" reason="$2" journal="$UNINSTALL_SNAPSHOT_DIR/journal" stage + case "$state" in BUILDING|ACTIVE|FAILED|COMMITTED|ROLLED_BACK) ;; *) return 1 ;; esac + [[ "$reason" != *$'\n'* && "$reason" != *$'\r'* ]] || return 1 + uninstall_snapshot_path_valid || return 1 stage=$(mktemp "$UNINSTALL_SNAPSHOT_DIR/journal.stage.XXXXXX") || return 1 - printf 'version=1\ngeneration=%s\nstate=%s\nreason=%s\n' "$UNINSTALL_GENERATION" "$state" "$reason" > "$stage" || { rm -f -- "$stage"; return 1; } - chmod 0600 "$stage" || { rm -f -- "$stage"; return 1; } - repository_rename "$stage" "$journal" || { rm -f -- "$stage"; return 1; } - validate_secure_file "$journal" 600 + printf 'version=1\ngeneration=%s\nstate=%s\nreason=%s\n' "$UNINSTALL_GENERATION" "$state" "$reason" > "$stage" || return 1 + chmod 0600 "$stage" || return 1 + uninstall_journal_valid "$stage" "$state" || return 1 + repository_rename "$stage" "$journal" || return 1 + uninstall_journal_valid "$journal" "$state" } archive_uninstall_evidence() { local archive="$1" [[ ! -e "$archive" && ! -L "$archive" ]] || return 1 [[ -d "$UNINSTALL_SNAPSHOT_DIR" && ! -L "$UNINSTALL_SNAPSHOT_DIR" ]] || return 1 + uninstall_manifest_valid "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 + local terminal + terminal=$(sed -n '3s/^state=//p' "$UNINSTALL_SNAPSHOT_DIR/journal") || return 1 + case "$terminal" in COMMITTED|ROLLED_BACK) ;; *) return 1 ;; esac + uninstall_journal_valid "$UNINSTALL_SNAPSHOT_DIR/journal" "$terminal" || return 1 repository_rename "$UNINSTALL_SNAPSHOT_DIR" "$archive" || return 1 + # Record the new evidence path immediately, even if post-rename validation fails. + UNINSTALL_SNAPSHOT_DIR="$archive" validate_secure_directory "$archive" 700 || return 1 validate_secure_directory "$archive/files" 700 || return 1 validate_secure_file "$archive/manifest" 600 || return 1 @@ -1385,12 +1415,16 @@ uninstall_targets_group_valid() { uninstall_cleanup() { init_runtime_config - local reason="$1" failed=false state path target_state snapshot_valid=false + local reason="$1" failed=false state path target target_state snapshot_valid=false local evidence="$REPOSITORY_STATE_DIR/failure-uninstall-${UNINSTALL_GENERATION:-unknown}" state="${UNINSTALL_TRANSACTION_STATE:-NONE}" [[ "${UNINSTALL_SNAPSHOT_BUILDING:-false}" == true ]] && state=BUILDING [[ "${UNINSTALL_TRANSACTION_ACTIVE:-false}" == true ]] && state=ACTIVE [[ "$state" == BUILDING || "$state" == ACTIVE ]] || return 0 + if [[ "$state" == BUILDING ]]; then + # Partial capture never enters restoration or claims ROLLED_BACK. + failed=true + fi if [[ "$state" == ACTIVE ]]; then if ! uninstall_snapshot_path_valid || ! uninstall_manifest_valid "$UNINSTALL_SNAPSHOT_DIR/manifest"; then failed=true @@ -1403,7 +1437,7 @@ uninstall_cleanup() { elif [[ "$target_state" == absent ]]; then target=$(uninstall_target_path "$path") || { failed=true; continue; } if [[ -e "$target" || -L "$target" ]]; then - if uninstall_register_exclusive_creation "$path" && uninstall_created_identity_valid "$path" && rm -f -- "$target" && + if [[ "$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$path" transaction_created)" == true ]] && uninstall_created_identity_valid "$path" && rm -f -- "$target" && [[ ! -e "$target" && ! -L "$target" ]]; then : else @@ -1464,28 +1498,26 @@ begin_uninstall_transaction() { UNINSTALL_SNAPSHOT_BUILDING=true; UNINSTALL_TRANSACTION_STATE=BUILDING save_repository_traps trap 'uninstall_signal_handler 129 HUP' HUP; trap 'uninstall_signal_handler 130 INT' INT; trap 'uninstall_signal_handler 143 TERM' TERM; trap 'uninstall_exit_handler $?' EXIT - uninstall_transaction_hook before-snapshot-create + write_uninstall_journal BUILDING capture-start || return 1 + uninstall_transaction_hook before-snapshot-create || return 1 mkdir -m 0700 -- "$UNINSTALL_SNAPSHOT_DIR/files" || return 1 : > "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1; chmod 0600 "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 printf 'version=1\ngeneration=%s\n' "$UNINSTALL_GENERATION" > "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 while IFS= read -r id; do + uninstall_transaction_hook "capture-$id" || return 1 path=$(uninstall_target_path "$id") || return 1; mode=$(uninstall_target_mode "$id") || return 1 validate_directory_chain "$(dirname -- "$path")" || return 1 if [[ -e "$path" || -L "$path" ]]; then validate_secure_file "$path" "$mode" || return 1 sha=$(sha256sum -- "$path" | awk '{print $1}') || return 1 repository_copy_file "$path" "$UNINSTALL_SNAPSHOT_DIR/files/target-$id" || return 1; chmod 0600 "$UNINSTALL_SNAPSHOT_DIR/files/target-$id" || return 1 - printf 'target_id=%s\nstate=regular\ntype=regular\nmode=%s\nuid=0\ngid=0\nsha256=%s\nsnapshot=target-%s\n' "$id" "$mode" "$sha" "$id" >> "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 + printf 'target_id=%s\nstate=regular\nmode=%s\nuid=0\ngid=0\nsha256=%s\nsnapshot=target-%s\ntransaction_created=false\n' "$id" "$mode" "$sha" "$id" >> "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 else - printf 'target_id=%s\nstate=absent\ntype=absent\nmode=%s\nuid=0\ngid=0\nsha256=%064d\nsnapshot=none\n' "$id" "$mode" 0 >> "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 + printf 'target_id=%s\nstate=absent\nmode=%s\nuid=0\ngid=0\nsha256=%064d\nsnapshot=none\ntransaction_created=false\n' "$id" "$mode" 0 >> "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 fi done < <(uninstall_snapshot_targets) uninstall_manifest_valid "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 UNINSTALL_SNAPSHOT_BUILDING=false; UNINSTALL_TRANSACTION_ACTIVE=true; UNINSTALL_TRANSACTION_STATE=ACTIVE - while IFS= read -r id; do - [[ "$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" state)" == absent ]] || continue - uninstall_prepare_exclusive_creation "$id" || return 1 - done < <(uninstall_snapshot_targets) write_uninstall_journal ACTIVE capture-complete || return 1 uninstall_transaction_hook after-snapshot-active } @@ -1494,6 +1526,12 @@ finish_uninstall_transaction() { local lock_path="$REPOSITORY_LOCK_DIR" failed=false uninstall_snapshot_path_valid || failed=true uninstall_manifest_valid "$UNINSTALL_SNAPSHOT_DIR/manifest" || failed=true + local id path + while IFS= read -r id; do + path=$(uninstall_target_path "$id") || { failed=true; continue; } + validate_directory_chain "$(dirname -- "$path")" || failed=true + [[ ! -e "$path" && ! -L "$path" ]] || failed=true + done < <(uninstall_snapshot_targets) if [[ "$failed" == false ]]; then write_uninstall_journal COMMITTED uninstall-complete || failed=true; fi if [[ "$failed" == false ]]; then archive_uninstall_evidence "$REPOSITORY_STATE_DIR/history-uninstall-$UNINSTALL_GENERATION" || failed=true; fi release_repository_lock || failed=true @@ -1530,6 +1568,7 @@ uninstall_cloudflared() { return 1 fi begin_uninstall_transaction || { + uninstall_cleanup "snapshot capture failed" || true release_repository_lock || return 1 return 1 } From 014230f438486fcfb3e434d84ce1cc73a596cb40 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 08:02:15 +0800 Subject: [PATCH 12/32] fix: revalidate published uninstall evidence and trap failures --- tests/test-cloudflare-tunnel.sh | 32 ++++++++++++++++++++++++++++++++ tools/cloudflare_tunnel.sh | 24 ++++++++++++++++-------- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index 0463d97..58f5d89 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -772,6 +772,38 @@ for capture_id in auto_update_script auto_update_timer legacy_marker; do pass "capture failure $capture_id retains partial evidence without restoration" done +for archive_case in intact payload journal; do + ( + new_case + trap - EXIT + uninstall_transaction_hook() { :; } + configure_repository || fail "archive configure" + acquire_repository_lock || fail "archive lock" + begin_uninstall_transaction || fail "archive capture" + uninstall_transaction_hook() { + [[ "$1" == after-evidence-rename ]] || return 0 + case "$archive_case" in + payload) printf corrupt >> "$UNINSTALL_SNAPSHOT_DIR/files/target-source" ;; + journal) printf 'unknown=1\n' >> "$UNINSTALL_SNAPSHOT_DIR/journal" ;; + esac + return 0 + } + rc=0 + uninstall_cleanup archive-verification || rc=$? + if [[ "$archive_case" == intact ]]; then + [[ "$rc" == 0 && "$UNINSTALL_TRANSACTION_STATE" == ROLLED_BACK ]] || fail "valid archive rejected" + else + [[ "$rc" != 0 && "$UNINSTALL_TRANSACTION_STATE" == FAILED ]] || fail "corrupt archive accepted" + fi + [[ -f "$UNINSTALL_SNAPSHOT_DIR/manifest" && -f "$UNINSTALL_SNAPSHOT_DIR/journal" ]] || fail "archive evidence lost" + [[ -f "$UNINSTALL_SNAPSHOT_DIR/files/target-current" ]] || fail "archive payload lost" + ) > "$TEST_DIR/archive-$archive_case.log" 2>&1 || { + cat "$TEST_DIR/archive-$archive_case.log" + fail "archive $archive_case" + } + pass "post-rename archive verification $archive_case" +done + entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" pass "bash -c entrypoint remains compatible" diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index f5bdd5f..afc4bbe 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -284,10 +284,12 @@ restore_one_trap() { } restore_repository_traps() { - restore_one_trap HUP "$REPOSITORY_PREVIOUS_HUP_TRAP" - restore_one_trap INT "$REPOSITORY_PREVIOUS_INT_TRAP" - restore_one_trap TERM "$REPOSITORY_PREVIOUS_TERM_TRAP" - restore_one_trap EXIT "$REPOSITORY_PREVIOUS_EXIT_TRAP" + local failed=0 + restore_one_trap HUP "$REPOSITORY_PREVIOUS_HUP_TRAP" || failed=1 + restore_one_trap INT "$REPOSITORY_PREVIOUS_INT_TRAP" || failed=1 + restore_one_trap TERM "$REPOSITORY_PREVIOUS_TERM_TRAP" || failed=1 + restore_one_trap EXIT "$REPOSITORY_PREVIOUS_EXIT_TRAP" || failed=1 + return "$failed" } release_repository_lock() { @@ -1274,7 +1276,10 @@ uninstall_manifest_valid() { uninstall_snapshot_path_valid() { init_runtime_config [[ -n "${UNINSTALL_SNAPSHOT_DIR:-}" && -n "${UNINSTALL_GENERATION:-}" ]] || return 1 - [[ "$UNINSTALL_SNAPSHOT_DIR" == "$REPOSITORY_STATE_DIR/uninstall-$UNINSTALL_GENERATION" ]] || return 1 + case "$UNINSTALL_SNAPSHOT_DIR" in + "$REPOSITORY_STATE_DIR/uninstall-$UNINSTALL_GENERATION"| "$REPOSITORY_STATE_DIR/history-uninstall-$UNINSTALL_GENERATION"| "$REPOSITORY_STATE_DIR/failure-uninstall-$UNINSTALL_GENERATION/snapshot") ;; + *) return 1 ;; + esac [[ ! -L "$UNINSTALL_SNAPSHOT_DIR" && -d "$UNINSTALL_SNAPSHOT_DIR" ]] || return 1 validate_directory_chain "$UNINSTALL_SNAPSHOT_DIR" || return 1 validate_secure_directory "$UNINSTALL_SNAPSHOT_DIR" 700 @@ -1391,7 +1396,10 @@ archive_uninstall_evidence() { validate_secure_directory "$archive" 700 || return 1 validate_secure_directory "$archive/files" 700 || return 1 validate_secure_file "$archive/manifest" 600 || return 1 - UNINSTALL_SNAPSHOT_DIR="$archive" + uninstall_transaction_hook after-evidence-rename || return 1 + # Revalidate all bytes after publication, not just directory metadata. + uninstall_manifest_valid "$archive/manifest" || return 1 + uninstall_journal_valid "$archive/journal" "$terminal" || return 1 } uninstall_targets_group_valid() { @@ -1460,7 +1468,7 @@ uninstall_cleanup() { archive_uninstall_evidence "$evidence/snapshot" || failed=true fi release_repository_lock || failed=true - restore_repository_traps + restore_repository_traps || failed=true UNINSTALL_SNAPSHOT_BUILDING=false UNINSTALL_TRANSACTION_ACTIVE=false UNINSTALL_TRANSACTION_STATE=$([[ "$failed" == true ]] && printf FAILED || printf ROLLED_BACK) @@ -1535,7 +1543,7 @@ finish_uninstall_transaction() { if [[ "$failed" == false ]]; then write_uninstall_journal COMMITTED uninstall-complete || failed=true; fi if [[ "$failed" == false ]]; then archive_uninstall_evidence "$REPOSITORY_STATE_DIR/history-uninstall-$UNINSTALL_GENERATION" || failed=true; fi release_repository_lock || failed=true - restore_repository_traps + restore_repository_traps || failed=true UNINSTALL_SNAPSHOT_BUILDING=false; UNINSTALL_TRANSACTION_ACTIVE=false UNINSTALL_TRANSACTION_STATE=$([[ "$failed" == true ]] && printf FAILED || printf COMMITTED) if [[ "$failed" == true ]]; then error "卸载收尾失败;状态 FAILED/PENDING;锁或证据: $lock_path $UNINSTALL_SNAPSHOT_DIR"; return 1; fi From 97da2a2dcf4b7b8ce1d1dba77f855d66056b96c4 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 08:09:16 +0800 Subject: [PATCH 13/32] fix: seal uninstall archives and block interrupted finalization --- tests/test-cloudflare-tunnel.sh | 38 ++++++++++++++++++ tools/cloudflare_tunnel.sh | 71 +++++++++++++++++++++++++++++---- 2 files changed, 101 insertions(+), 8 deletions(-) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index 58f5d89..7361bea 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -804,6 +804,44 @@ for archive_case in intact payload journal; do pass "post-rename archive verification $archive_case" done +for interrupted_phase in before-snapshot-create after-snapshot-active after-evidence-rename after-evidence-seal before-pending-clear; do + ( + new_case + trap - EXIT + configure_repository || fail "kill configure" + cp "$SOURCE_FILE" "$CASE_DIR/source-before" + ( + trap - EXIT + acquire_repository_lock || exit 1 + uninstall_transaction_hook() { + if [[ "$1" == "$interrupted_phase" ]]; then kill -KILL "$BASHPID"; fi + } + begin_uninstall_transaction || exit 1 + uninstall_cleanup kill-test + ) > "$CASE_DIR/killed.log" 2>&1 & + child=$! + rc=0 + wait "$child" || rc=$? + [[ "$rc" == 137 ]] || fail "SIGKILL did not reach $interrupted_phase" + # Simulate operator removing only the stale lock; pending must still block. + rmdir "$CLOUDFLARED_STATE_DIR.lock" || fail "stale lock remove" + : > "$FAKE_LOG" + for operation in configure_repository upgrade_cloudflared uninstall_cloudflared disable_auto_update; do + ( + trap - EXIT + "$operation" --confirmed + ) > "$CASE_DIR/retry-$operation.log" 2>&1 && fail "pending allowed $operation" + done + [[ ! -s "$FAKE_LOG" ]] || fail "pending executed external command" + cmp "$SOURCE_FILE" "$CASE_DIR/source-before" || fail "pending changed source" + compgen -G "$CLOUDFLARED_STATE_DIR/repository/pending-uninstall-*" >/dev/null || fail "pending evidence missing" + ) > "$TEST_DIR/kill-$interrupted_phase.log" 2>&1 || { + cat "$TEST_DIR/kill-$interrupted_phase.log" + fail "SIGKILL $interrupted_phase" + } + pass "SIGKILL $interrupted_phase fails closed in independent entrypoints" +done + entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" pass "bash -c entrypoint remains compatible" diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index afc4bbe..dd8665a 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -200,7 +200,15 @@ validate_repository_state_entries() { current) validate_secure_file "$entry" 600 || return 1 ;; - history-uninstall-*|history-*|failure-*) + history-uninstall-*) + validate_uninstall_archive "$entry" "${base#history-uninstall-}" || return 1 + ;; + failure-uninstall-*) + validate_secure_directory "$entry" 700 || return 1 + validate_secure_file "$entry/rollback.log" 600 || return 1 + validate_uninstall_archive "$entry/snapshot" "${base#failure-uninstall-}" || return 1 + ;; + history-*|failure-*) validate_secure_directory "$entry" 700 || return 1 while IFS= read -r -d '' evidence; do validate_secure_directory "$evidence" 700 || return 1 @@ -1231,8 +1239,8 @@ uninstall_manifest_valid() { [[ "$manifest" == "$UNINSTALL_SNAPSHOT_DIR/manifest" ]] || return 1 uninstall_snapshot_path_valid || return 1 validate_directory_chain "$UNINSTALL_SNAPSHOT_DIR/files" || return 1 - validate_secure_directory "$UNINSTALL_SNAPSHOT_DIR/files" 700 || return 1 - validate_secure_file "$manifest" 600 || return 1 + validate_secure_directory "$UNINSTALL_SNAPSHOT_DIR/files" "${UNINSTALL_EVIDENCE_DIR_MODE:-700}" || return 1 + validate_secure_file "$manifest" "${UNINSTALL_EVIDENCE_FILE_MODE:-600}" || return 1 mapfile -t rows < "$manifest" || return 1 [[ "${#rows[@]}" == 50 ]] || return 1 [[ "${rows[0]}" == version=1 && "${rows[1]}" == "generation=$UNINSTALL_GENERATION" ]] || return 1 @@ -1253,20 +1261,20 @@ uninstall_manifest_valid() { if [[ "$state" == regular ]]; then [[ "$created" == false ]] || return 1 [[ "$snapshot" == target-$id && "$sha" != $(printf '0%.0s' {1..64}) ]] || return 1 - validate_secure_file "$UNINSTALL_SNAPSHOT_DIR/files/$snapshot" 600 || return 1 + validate_secure_file "$UNINSTALL_SNAPSHOT_DIR/files/$snapshot" "${UNINSTALL_EVIDENCE_FILE_MODE:-600}" || return 1 [[ "$(sha256sum -- "$UNINSTALL_SNAPSHOT_DIR/files/$snapshot" | awk '{print $1}')" == "$sha" ]] || return 1 expected_entries=$((expected_entries + 1)) else [[ "$snapshot" == none && "$sha" == $(printf '0%.0s' {1..64}) ]] || return 1 fi if [[ "$created" == true ]]; then - validate_secure_file "$UNINSTALL_SNAPSHOT_DIR/created-$id" 600 || return 1 + validate_secure_file "$UNINSTALL_SNAPSHOT_DIR/created-$id" "${UNINSTALL_EVIDENCE_FILE_MODE:-600}" || return 1 fi offset=$((offset + 8)) done < <(uninstall_snapshot_targets) while IFS= read -r -d '' entry; do [[ -f "$entry" && ! -L "$entry" ]] || return 1 - validate_secure_file "$entry" 600 || return 1 + validate_secure_file "$entry" "${UNINSTALL_EVIDENCE_FILE_MODE:-600}" || return 1 [[ "$(basename -- "$entry")" == target-* ]] || return 1 actual_entries=$((actual_entries + 1)) done < <(find "$UNINSTALL_SNAPSHOT_DIR/files" -mindepth 1 -maxdepth 1 -print0) @@ -1282,7 +1290,7 @@ uninstall_snapshot_path_valid() { esac [[ ! -L "$UNINSTALL_SNAPSHOT_DIR" && -d "$UNINSTALL_SNAPSHOT_DIR" ]] || return 1 validate_directory_chain "$UNINSTALL_SNAPSHOT_DIR" || return 1 - validate_secure_directory "$UNINSTALL_SNAPSHOT_DIR" 700 + validate_secure_directory "$UNINSTALL_SNAPSHOT_DIR" "${UNINSTALL_EVIDENCE_DIR_MODE:-700}" } # Create rather than retrospectively claim an object. Hard-link publication @@ -1361,7 +1369,7 @@ restore_uninstall_target() { uninstall_journal_valid() { local journal="$1" expected="$2" local -a rows=() - validate_secure_file "$journal" 600 || return 1 + validate_secure_file "$journal" "${UNINSTALL_EVIDENCE_FILE_MODE:-600}" || return 1 mapfile -t rows < "$journal" || return 1 [[ "${#rows[@]}" == 4 ]] || return 1 [[ "${rows[0]}" == version=1 && "${rows[1]}" == "generation=$UNINSTALL_GENERATION" ]] || return 1 @@ -1381,6 +1389,37 @@ write_uninstall_journal() { uninstall_journal_valid "$journal" "$state" } +validate_uninstall_archive() ( + local UNINSTALL_SNAPSHOT_DIR="$1" UNINSTALL_GENERATION="$2" + local UNINSTALL_EVIDENCE_DIR_MODE=500 UNINSTALL_EVIDENCE_FILE_MODE=400 + local terminal entry name + uninstall_manifest_valid "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 + terminal=$(sed -n '3s/^state=//p' "$UNINSTALL_SNAPSHOT_DIR/journal") || return 1 + case "$terminal" in COMMITTED|ROLLED_BACK) ;; *) return 1 ;; esac + uninstall_journal_valid "$UNINSTALL_SNAPSHOT_DIR/journal" "$terminal" || return 1 + while IFS= read -r -d '' entry; do + name=${entry##*/} + case "$name" in + files|manifest|journal) ;; + created-*) + [[ "$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "${name#created-}" transaction_created)" == true ]] || return 1 + validate_secure_file "$entry" 400 || return 1 ;; + *) return 1 ;; + esac + done < <(find "$UNINSTALL_SNAPSHOT_DIR" -mindepth 1 -maxdepth 1 -print0) +) + +seal_uninstall_archive() { + local entry + # No recursive chmod on unvalidated objects and no automatic evidence GC. + while IFS= read -r -d '' entry; do + validate_secure_file "$entry" 600 || return 1 + chmod 0400 -- "$entry" || return 1 + done < <(find "$UNINSTALL_SNAPSHOT_DIR" -type f -print0) + chmod 0500 -- "$UNINSTALL_SNAPSHOT_DIR/files" "$UNINSTALL_SNAPSHOT_DIR" || return 1 + validate_uninstall_archive "$UNINSTALL_SNAPSHOT_DIR" "$UNINSTALL_GENERATION" +} + archive_uninstall_evidence() { local archive="$1" [[ ! -e "$archive" && ! -L "$archive" ]] || return 1 @@ -1400,6 +1439,8 @@ archive_uninstall_evidence() { # Revalidate all bytes after publication, not just directory metadata. uninstall_manifest_valid "$archive/manifest" || return 1 uninstall_journal_valid "$archive/journal" "$terminal" || return 1 + seal_uninstall_archive || return 1 + uninstall_transaction_hook after-evidence-seal } uninstall_targets_group_valid() { @@ -1467,6 +1508,12 @@ uninstall_cleanup() { if [[ "$failed" == false && "$snapshot_valid" == true && -d "$UNINSTALL_SNAPSHOT_DIR" ]]; then archive_uninstall_evidence "$evidence/snapshot" || failed=true fi + if [[ "$failed" == false ]]; then + uninstall_transaction_hook before-pending-clear || failed=true + if [[ "$failed" == false ]]; then + validate_secure_file "$UNINSTALL_PENDING" 600 && rm -- "$UNINSTALL_PENDING" || failed=true + fi + fi release_repository_lock || failed=true restore_repository_traps || failed=true UNINSTALL_SNAPSHOT_BUILDING=false @@ -1502,6 +1549,8 @@ begin_uninstall_transaction() { UNINSTALL_SNAPSHOT_BUILDING=false; UNINSTALL_TRANSACTION_ACTIVE=false; UNINSTALL_TRANSACTION_STATE=NONE validate_directory_chain "$(dirname -- "$UNINSTALL_SNAPSHOT_DIR")" || return 1 [[ ! -e "$UNINSTALL_SNAPSHOT_DIR" && ! -L "$UNINSTALL_SNAPSHOT_DIR" ]] || return 1 + UNINSTALL_PENDING="$REPOSITORY_STATE_DIR/pending-uninstall-$UNINSTALL_GENERATION" + (set -C; umask 077; printf 'generation=%s\n' "$UNINSTALL_GENERATION" > "$UNINSTALL_PENDING") || return 1 mkdir -m 0700 -- "$UNINSTALL_SNAPSHOT_DIR" || return 1 UNINSTALL_SNAPSHOT_BUILDING=true; UNINSTALL_TRANSACTION_STATE=BUILDING save_repository_traps @@ -1542,6 +1591,12 @@ finish_uninstall_transaction() { done < <(uninstall_snapshot_targets) if [[ "$failed" == false ]]; then write_uninstall_journal COMMITTED uninstall-complete || failed=true; fi if [[ "$failed" == false ]]; then archive_uninstall_evidence "$REPOSITORY_STATE_DIR/history-uninstall-$UNINSTALL_GENERATION" || failed=true; fi + if [[ "$failed" == false ]]; then + uninstall_transaction_hook before-pending-clear || failed=true + if [[ "$failed" == false ]]; then + validate_secure_file "$UNINSTALL_PENDING" 600 && rm -- "$UNINSTALL_PENDING" || failed=true + fi + fi release_repository_lock || failed=true restore_repository_traps || failed=true UNINSTALL_SNAPSHOT_BUILDING=false; UNINSTALL_TRANSACTION_ACTIVE=false From 84e34cda78ff1ca0cba42e70dfdb38815413d341 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 08:12:45 +0800 Subject: [PATCH 14/32] fix: stop install and upgrade after repository rejection --- tests/test-cloudflare-tunnel.sh | 2 +- tools/cloudflare_tunnel.sh | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index 7361bea..3f5e717 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -826,7 +826,7 @@ for interrupted_phase in before-snapshot-create after-snapshot-active after-evid # Simulate operator removing only the stale lock; pending must still block. rmdir "$CLOUDFLARED_STATE_DIR.lock" || fail "stale lock remove" : > "$FAKE_LOG" - for operation in configure_repository upgrade_cloudflared uninstall_cloudflared disable_auto_update; do + for operation in install_cloudflared upgrade_cloudflared uninstall_cloudflared disable_auto_update; do ( trap - EXIT "$operation" --confirmed diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index dd8665a..3692b92 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -1041,7 +1041,7 @@ install_package() { validate_migration_inputs PRESERVE_AUTO_UPDATE=false legacy_auto_update_present && PRESERVE_AUTO_UPDATE=true || true - run_repository_apt_transaction install + run_repository_apt_transaction install || return $? migrate_legacy_binary cleanup_legacy_updater || { error "旧自定义更新组件清理失败"; return 1; } cleanup_binary_updater || { error "二进制更新单元清理失败"; return 1; } @@ -1081,8 +1081,8 @@ install_service() { install_cloudflared() { require_root check_platform - install_package - install_service + install_package || return $? + install_service || return $? info "安装完成。版本由 APT 管理。" if [[ "$PRESERVE_AUTO_UPDATE" == true ]]; then info "旧版自动更新行为已保留,无需再次确认。" @@ -1102,7 +1102,7 @@ upgrade_cloudflared() { validate_migration_inputs PRESERVE_AUTO_UPDATE=false legacy_auto_update_present && PRESERVE_AUTO_UPDATE=true || true - run_repository_apt_transaction upgrade + run_repository_apt_transaction upgrade || return $? migrate_legacy_binary cleanup_legacy_updater || { error "旧自定义更新组件清理失败"; return 1; } cleanup_binary_updater || { error "二进制更新单元清理失败"; return 1; } @@ -1715,7 +1715,7 @@ main() { migrate-legacy) require_root check_platform - install_package + install_package || return $? ;; uninstall) uninstall_cloudflared ;; purge) purge_config ;; From 20deb4f58f7dc483ef9aa072d9744fe3f1b3ce74 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 08:16:44 +0800 Subject: [PATCH 15/32] fix: retain failed snapshots and clean only owned uninstall stages --- tests/test-cloudflare-tunnel.sh | 31 +++++++++++++++++ tools/cloudflare_tunnel.sh | 59 ++++++++++++++++++++++++++++----- 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index 3f5e717..409f9bd 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -842,6 +842,37 @@ for interrupted_phase in before-snapshot-create after-snapshot-active after-evid pass "SIGKILL $interrupted_phase fails closed in independent entrypoints" done +for stage_case in owned foreign; do + ( + new_case + trap - EXIT + configure_repository || fail "stage configure" + acquire_repository_lock || fail "stage lock" + begin_uninstall_transaction || fail "stage capture" + stage=$(mktemp "$CASE_DIR/root/.cloudflared-create.XXXXXX") + uninstall_register_stage "$stage" || fail "stage register" + if [[ "$stage_case" == foreign ]]; then + mv "$stage" "$stage.original" + printf foreign > "$stage" + fi + rc=0 + uninstall_cleanup stage-test || rc=$? + if [[ "$stage_case" == owned ]]; then + [[ "$rc" == 0 && ! -e "$stage" ]] || fail "owned stage not cleaned" + [[ "$(stat -c %a "$UNINSTALL_SNAPSHOT_DIR")" == 500 ]] || fail "archive writable" + [[ "$(stat -c %a "$UNINSTALL_SNAPSHOT_DIR/manifest")" == 400 ]] || fail "manifest writable" + validate_uninstall_archive "$UNINSTALL_SNAPSHOT_DIR" "$UNINSTALL_GENERATION" || fail "sealed archive invalid" + else + [[ "$rc" != 0 && "$(cat "$stage")" == foreign ]] || fail "foreign stage removed" + [[ -f "$UNINSTALL_PENDING" ]] || fail "stage failure lost pending" + fi + ) > "$TEST_DIR/stage-$stage_case.log" 2>&1 || { + cat "$TEST_DIR/stage-$stage_case.log" + fail "stage $stage_case" + } + pass "stage cleanup verifies $stage_case identity" +done + entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" pass "bash -c entrypoint remains compatible" diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index 3692b92..1a41001 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -719,7 +719,9 @@ cleanup_legacy_updater() { systemctl stop cloudflared-updater.service >/dev/null 2>&1 || true for path in "${paths[@]}"; do [[ -e "$path" ]] || continue - backup_path "$path" "$backup_dir" || return 1 + if [[ "${UNINSTALL_TRANSACTION_STATE:-NONE}" != ACTIVE ]]; then + backup_path "$path" "$backup_dir" || return 1 + fi rm -f "$path" || return 1 done systemctl daemon-reload @@ -758,7 +760,9 @@ cleanup_binary_updater() { systemctl stop cloudflared-update.service >/dev/null 2>&1 || true for path in "${paths[@]}"; do [[ -e "$path" ]] || continue - backup_path "$path" "$backup_dir" || return 1 + if [[ "${UNINSTALL_TRANSACTION_STATE:-NONE}" != ACTIVE ]]; then + backup_path "$path" "$backup_dir" || return 1 + fi rm -f "$path" || return 1 done systemctl daemon-reload @@ -986,7 +990,9 @@ disable_auto_update_locked() { systemctl stop cloudflared-apt-update.service >/dev/null 2>&1 || true for path in "$AUTO_UPDATE_SCRIPT" "$AUTO_UPDATE_SERVICE" "$AUTO_UPDATE_TIMER"; do [[ -e "$path" ]] || continue - backup_path "$path" "$backup_dir" || return 1 + if [[ "${UNINSTALL_TRANSACTION_STATE:-NONE}" != ACTIVE ]]; then + backup_path "$path" "$backup_dir" || return 1 + fi rm -f "$path" || return 1 done systemctl daemon-reload @@ -1178,10 +1184,11 @@ remove_managed_repository_locked() { fi [[ "$managed" == true ]] || { error "无法验证 Cloudflare source 管理归属,拒绝删除"; return 1; } backup_dir="$STATE_DIR/uninstall-$(date +%Y%m%d_%H%M%S)" - backup_path "$SOURCE_FILE" "$backup_dir" || return 1 + if [[ "${UNINSTALL_TRANSACTION_STATE:-NONE}" != ACTIVE ]]; then + backup_path "$SOURCE_FILE" "$backup_dir" || return 1 + fi rm -f -- "$SOURCE_FILE" "$STATE_DIR/repository-managed" || return 1 rm -f -- "$REPOSITORY_STATE_DIR/current" || return 1 - unset REPOSITORY_STATE_DIR } remove_managed_repository() { @@ -1306,6 +1313,7 @@ uninstall_create_absent_target() { validate_directory_chain "$(dirname -- "$path")" || return 1 validate_secure_file "$payload" 600 || return 1 stage=$(mktemp "$(dirname -- "$path")/.cloudflared-create.XXXXXX") || return 1 + uninstall_register_stage "$stage" || return 1 repository_install_file "$mode" "$payload" "$stage" || return 1 validate_secure_file "$stage" "$mode" || return 1 digest=$(sha256sum -- "$stage" | awk '{print $1}') || return 1 @@ -1345,6 +1353,31 @@ uninstall_created_identity_valid() { [[ "$digest" == "$(sed -n '8p' "$marker" | cut -d= -f2)" ]] } +uninstall_register_stage() { + local stage="$1" + validate_directory_chain "$(dirname -- "$stage")" || return 1 + [[ -f "$stage" && ! -L "$stage" ]] || return 1 + UNINSTALL_STAGES+=("$stage") + UNINSTALL_STAGE_IDENTITIES+=("$(stat -c '%d:%i' -- "$stage")") +} + +uninstall_cleanup_stages() { + local i stage failed=0 + for i in "${!UNINSTALL_STAGES[@]}"; do + stage=${UNINSTALL_STAGES[i]} + [[ -e "$stage" || -L "$stage" ]] || continue + if validate_directory_chain "$(dirname -- "$stage")" && + [[ -f "$stage" && ! -L "$stage" && "$(stat -c '%d:%i' -- "$stage")" == "${UNINSTALL_STAGE_IDENTITIES[i]}" ]] && + [[ "$(stat -c '%u:%g' -- "$stage")" == 0:0 ]]; then + rm -- "$stage" || failed=1 + else + error "stage 身份变化,保留人工处理: $stage" + failed=1 + fi + done + return "$failed" +} + restore_uninstall_target() { local id="$1" path mode payload sha stage path=$(uninstall_target_path "$id") || return 1 @@ -1355,6 +1388,7 @@ restore_uninstall_target() { [[ "$(sha256sum -- "$payload" | awk '{print $1}')" == "$sha" ]] || return 1 validate_directory_chain "$(dirname -- "$path")" || return 1 stage=$(mktemp "$(dirname -- "$path")/.cloudflared-uninstall-restore.XXXXXX") || return 1 + uninstall_register_stage "$stage" || return 1 if ! repository_install_file "$mode" "$payload" "$stage" || ! validate_secure_file "$stage" "$mode" || [[ "$(sha256sum -- "$stage" | awk '{print $1}')" != "$sha" ]] || @@ -1500,14 +1534,19 @@ uninstall_cleanup() { uninstall_targets_group_valid || failed=true fi fi - mkdir -m 0700 -- "$evidence" 2>/dev/null || failed=true - printf '%s\n' "$reason" > "$evidence/rollback.log" 2>/dev/null || failed=true - chmod 0600 "$evidence/rollback.log" 2>/dev/null || failed=true - write_uninstall_journal "$([[ "$failed" == true ]] && printf FAILED || printf ROLLED_BACK)" "$reason" || failed=true + if mkdir -m 0700 -- "$evidence" 2>/dev/null && validate_secure_directory "$evidence" 700; then + (set -C; umask 077; printf '%s\n' "$reason" > "$evidence/rollback.log") || failed=true + else + failed=true + fi + if [[ "$failed" == false ]]; then + write_uninstall_journal ROLLED_BACK "$reason" || failed=true + fi # Retain complete snapshot as read-only evidence; never garbage-collect it. if [[ "$failed" == false && "$snapshot_valid" == true && -d "$UNINSTALL_SNAPSHOT_DIR" ]]; then archive_uninstall_evidence "$evidence/snapshot" || failed=true fi + if [[ "$failed" == false ]]; then uninstall_cleanup_stages || failed=true; fi if [[ "$failed" == false ]]; then uninstall_transaction_hook before-pending-clear || failed=true if [[ "$failed" == false ]]; then @@ -1544,6 +1583,7 @@ uninstall_exit_handler() { begin_uninstall_transaction() { init_runtime_config local id path mode sha + UNINSTALL_STAGES=(); UNINSTALL_STAGE_IDENTITIES=() UNINSTALL_GENERATION="$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM" UNINSTALL_SNAPSHOT_DIR="$REPOSITORY_STATE_DIR/uninstall-$UNINSTALL_GENERATION" UNINSTALL_SNAPSHOT_BUILDING=false; UNINSTALL_TRANSACTION_ACTIVE=false; UNINSTALL_TRANSACTION_STATE=NONE @@ -1591,6 +1631,7 @@ finish_uninstall_transaction() { done < <(uninstall_snapshot_targets) if [[ "$failed" == false ]]; then write_uninstall_journal COMMITTED uninstall-complete || failed=true; fi if [[ "$failed" == false ]]; then archive_uninstall_evidence "$REPOSITORY_STATE_DIR/history-uninstall-$UNINSTALL_GENERATION" || failed=true; fi + if [[ "$failed" == false ]]; then uninstall_cleanup_stages || failed=true; fi if [[ "$failed" == false ]]; then uninstall_transaction_hook before-pending-clear || failed=true if [[ "$failed" == false ]]; then From 0aea42c8ebdfa5f6bf5368c9d3897806bf617677 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 08:19:42 +0800 Subject: [PATCH 16/32] fix: guard uninstall publication against terminal signals --- tests/test-cloudflare-tunnel.sh | 27 +++++++++++++++++++++++++++ tools/cloudflare_tunnel.sh | 18 ++++++++++++++---- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index 409f9bd..4aee2d3 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -873,6 +873,33 @@ for stage_case in owned foreign; do pass "stage cleanup verifies $stage_case identity" done +for terminal_signal in HUP INT TERM; do + case "$terminal_signal" in HUP) expected=129 ;; INT) expected=130 ;; TERM) expected=143 ;; esac + ( + new_case + trap - EXIT + configure_repository || fail "terminal signal configure" + rc=0 + ( + trap - EXIT + acquire_repository_lock || exit 1 + uninstall_transaction_hook() { + if [[ "$1" == after-evidence-seal ]]; then kill -s "$terminal_signal" "$BASHPID"; fi + } + begin_uninstall_transaction || exit 1 + uninstall_cleanup terminal-signal + ) > "$CASE_DIR/signal.log" 2>&1 || rc=$? + [[ "$rc" == "$expected" ]] || fail "terminal signal status $rc" + [[ ! -d "$CLOUDFLARED_STATE_DIR.lock" ]] || fail "terminal signal lock retained" + compgen -G "$CLOUDFLARED_STATE_DIR/repository/pending-uninstall-*" >/dev/null || fail "terminal signal pending missing" + [[ -f "$SOURCE_FILE" ]] || fail "terminal signal source lost" + ) > "$TEST_DIR/terminal-$terminal_signal.log" 2>&1 || { + cat "$TEST_DIR/terminal-$terminal_signal.log" + fail "terminal signal $terminal_signal" + } + pass "terminal $terminal_signal=$expected retains evidence without repeated rollback" +done + entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" pass "bash -c entrypoint remains compatible" diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index 1a41001..f7c666f 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -1463,6 +1463,8 @@ archive_uninstall_evidence() { terminal=$(sed -n '3s/^state=//p' "$UNINSTALL_SNAPSHOT_DIR/journal") || return 1 case "$terminal" in COMMITTED|ROLLED_BACK) ;; *) return 1 ;; esac uninstall_journal_valid "$UNINSTALL_SNAPSHOT_DIR/journal" "$terminal" || return 1 + UNINSTALL_TRANSACTION_ACTIVE=false + UNINSTALL_TRANSACTION_STATE=FINALIZING repository_rename "$UNINSTALL_SNAPSHOT_DIR" "$archive" || return 1 # Record the new evidence path immediately, even if post-rename validation fails. UNINSTALL_SNAPSHOT_DIR="$archive" @@ -1503,6 +1505,14 @@ uninstall_cleanup() { state="${UNINSTALL_TRANSACTION_STATE:-NONE}" [[ "${UNINSTALL_SNAPSHOT_BUILDING:-false}" == true ]] && state=BUILDING [[ "${UNINSTALL_TRANSACTION_ACTIVE:-false}" == true ]] && state=ACTIVE + if [[ "$state" == FINALIZING ]]; then + # Never replay rollback after terminal evidence publication has begun. + release_repository_lock || true + restore_repository_traps || true + UNINSTALL_TRANSACTION_STATE=FAILED + error "终态收尾中断;保留 pending、journal 与归档: $UNINSTALL_PENDING $UNINSTALL_SNAPSHOT_DIR" + return 1 + fi [[ "$state" == BUILDING || "$state" == ACTIVE ]] || return 0 if [[ "$state" == BUILDING ]]; then # Partial capture never enters restoration or claims ROLLED_BACK. @@ -1573,7 +1583,7 @@ uninstall_signal_handler() { uninstall_exit_handler() { local status="$1" - if [[ "${UNINSTALL_TRANSACTION_STATE:-NONE}" == BUILDING || "${UNINSTALL_TRANSACTION_STATE:-NONE}" == ACTIVE ]]; then + if [[ "${UNINSTALL_TRANSACTION_STATE:-NONE}" == BUILDING || "${UNINSTALL_TRANSACTION_STATE:-NONE}" == ACTIVE || "${UNINSTALL_TRANSACTION_STATE:-NONE}" == FINALIZING ]]; then uninstall_cleanup "活动卸载事务异常退出,原状态 $status" || true (( status == 0 )) && status=1 fi @@ -1590,11 +1600,11 @@ begin_uninstall_transaction() { validate_directory_chain "$(dirname -- "$UNINSTALL_SNAPSHOT_DIR")" || return 1 [[ ! -e "$UNINSTALL_SNAPSHOT_DIR" && ! -L "$UNINSTALL_SNAPSHOT_DIR" ]] || return 1 UNINSTALL_PENDING="$REPOSITORY_STATE_DIR/pending-uninstall-$UNINSTALL_GENERATION" - (set -C; umask 077; printf 'generation=%s\n' "$UNINSTALL_GENERATION" > "$UNINSTALL_PENDING") || return 1 - mkdir -m 0700 -- "$UNINSTALL_SNAPSHOT_DIR" || return 1 - UNINSTALL_SNAPSHOT_BUILDING=true; UNINSTALL_TRANSACTION_STATE=BUILDING save_repository_traps + UNINSTALL_SNAPSHOT_BUILDING=true; UNINSTALL_TRANSACTION_STATE=BUILDING trap 'uninstall_signal_handler 129 HUP' HUP; trap 'uninstall_signal_handler 130 INT' INT; trap 'uninstall_signal_handler 143 TERM' TERM; trap 'uninstall_exit_handler $?' EXIT + (set -C; umask 077; printf 'generation=%s\n' "$UNINSTALL_GENERATION" > "$UNINSTALL_PENDING") || return 1 + mkdir -m 0700 -- "$UNINSTALL_SNAPSHOT_DIR" || return 1 write_uninstall_journal BUILDING capture-start || return 1 uninstall_transaction_hook before-snapshot-create || return 1 mkdir -m 0700 -- "$UNINSTALL_SNAPSHOT_DIR/files" || return 1 From 30e2f0b1bdee1dec661567c625898269348631bc Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 08:22:30 +0800 Subject: [PATCH 17/32] test: verify every uninstall restore failure preserves the full snapshot --- tests/test-cloudflare-tunnel.sh | 34 +++++++++++++++++++++++++++++++++ tools/cloudflare_tunnel.sh | 10 +++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index 4aee2d3..42fd9b3 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -900,6 +900,40 @@ for terminal_signal in HUP INT TERM; do pass "terminal $terminal_signal=$expected retains evidence without repeated rollback" done +for restore_id in auto_update_script auto_update_service auto_update_timer source current legacy_marker; do + ( + new_case + trap - EXIT + configure_repository || fail "six target configure" + mkdir -p "$(dirname "$AUTO_UPDATE_SCRIPT")" "$(dirname "$AUTO_UPDATE_SERVICE")" + write_auto_update_files || fail "six target auto update" + printf managed > "$STATE_DIR/repository-managed" + chmod 0600 "$STATE_DIR/repository-managed" + acquire_repository_lock || fail "six target lock" + begin_uninstall_transaction || fail "six target capture" + snapshot=$UNINSTALL_SNAPSHOT_DIR + while IFS= read -r id; do rm -- "$(uninstall_target_path "$id")"; done < <(uninstall_snapshot_targets) + repository_install_file() { + [[ "$2" != "$snapshot/files/target-$restore_id" ]] || return 1 + command install -o 0 -g 0 -m "$1" -- "$2" "$3" + } + if uninstall_cleanup six-target-failure; then fail "restore fault accepted"; fi + while IFS= read -r id; do + [[ -f "$snapshot/files/target-$id" ]] || fail "snapshot payload lost $id" + if [[ "$id" != "$restore_id" ]]; then + cmp "$(uninstall_target_path "$id")" "$snapshot/files/target-$id" || fail "restore skipped $id" + validate_secure_file "$(uninstall_target_path "$id")" "$(uninstall_target_mode "$id")" || fail "restore metadata $id" + fi + done < <(uninstall_snapshot_targets) + [[ -f "$UNINSTALL_PENDING" ]] || fail "restore pending lost" + uninstall_journal_valid "$snapshot/journal" ACTIVE || fail "failure overwrote original journal" + ) > "$TEST_DIR/restore-$restore_id.log" 2>&1 || { + cat "$TEST_DIR/restore-$restore_id.log" + fail "restore failure $restore_id" + } + pass "restore failure $restore_id continues remaining five targets and preserves original evidence" +done + entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" pass "bash -c entrypoint remains compatible" diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index f7c666f..a43d57b 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -1342,7 +1342,12 @@ uninstall_created_identity_valid() { path=$(uninstall_target_path "$id") || return 1 marker="$UNINSTALL_SNAPSHOT_DIR/created-$id" validate_secure_file "$marker" 600 || return 1 - [[ "$(sed -n '1p' "$marker")" == state=created && "$(sed -n '2p' "$marker")" == "target_id=$id" && "$(sed -n '3p' "$marker")" == "path=$path" ]] || return 1 + local -a marker_rows=() + mapfile -t marker_rows < "$marker" || return 1 + [[ "${#marker_rows[@]}" == 8 ]] || return 1 + [[ "${marker_rows[0]}" == state=created && "${marker_rows[1]}" == "target_id=$id" && "${marker_rows[2]}" == "path=$path" ]] || return 1 + [[ "${marker_rows[3]}" == uid=0 && "${marker_rows[4]}" == gid=0 ]] || return 1 + [[ "${marker_rows[5]}" =~ ^dev=[0-9]+$ && "${marker_rows[6]}" =~ ^ino=[0-9]+$ && "${marker_rows[7]}" =~ ^sha256=[0-9a-f]{64}$ ]] || return 1 [[ -f "$path" && ! -L "$path" ]] || return 1 validate_secure_file "$path" "$(uninstall_target_mode "$id")" || return 1 metadata=$(stat -Lc '%u %g %d %i' -- "$path") || return 1 @@ -1387,6 +1392,9 @@ restore_uninstall_target() { validate_secure_file "$payload" 600 || return 1 [[ "$(sha256sum -- "$payload" | awk '{print $1}')" == "$sha" ]] || return 1 validate_directory_chain "$(dirname -- "$path")" || return 1 + if [[ -e "$path" || -L "$path" ]]; then + validate_secure_file "$path" "$mode" || return 1 + fi stage=$(mktemp "$(dirname -- "$path")/.cloudflared-uninstall-restore.XXXXXX") || return 1 uninstall_register_stage "$stage" || return 1 if ! repository_install_file "$mode" "$payload" "$stage" || From 72938d4c6f0cc4b0fdf4a8a319039d38f65223e8 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 08:25:19 +0800 Subject: [PATCH 18/32] test: cover uninstall capture metadata and building signals --- README.md | 6 ++++++ tests/test-cloudflare-tunnel.sh | 34 ++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2204096..8d64030 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,12 @@ APT 包与 `cloudflared update` 混用。若旧环境已有每日自动更新 ti APT timer;旧环境未启用自动更新时仍保持关闭并询问是否启用。`uninstall` 在同一事务锁内验证 `current` 清单与 key/source 摘要,备份并删除受管 source,保留 keyring、Tunnel 配置和凭据。 文件阶段失败会恢复可恢复配置;APT 包删除属于不可逆边界,失败时不会尝试自动重装。 +卸载快照固定包含六个受管目标,完整捕获后才进入 ACTIVE。恢复失败保留原 manifest、payload +和 journal,不覆盖失败证据。成功卸载或完整恢复后,终态 journal 与快照在锁内归档,目录 +权限为 `0500`、文件为 `0400`;不自动清理历史证据。只删除身份仍匹配的本事务临时文件。 +`SIGKILL` 或收尾失败留下 `pending-uninstall-*`、快照/归档及可能的锁。后续 install、upgrade、 +uninstall、disable-auto-update 拒绝继续;应先人工审查错误输出中的状态路径,不要仅删除锁后重试。 +HUP/INT/TERM 分别返回 129/130/143;终态归档开始后的中断不会再次执行配置回滚。 彻底清理须显式运行 `purge`,并在交互终端输入 `PURGE` 二次确认。 ### 多服务器文件推送 diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index 42fd9b3..22400f8 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -483,7 +483,7 @@ pass "configure then real repository uninstall removes source and preserves keyr for signal in HUP INT TERM; do case "$signal" in HUP) expected=129 ;; INT) expected=130 ;; TERM) expected=143 ;; esac - for phase in lock-acquired before-disable-auto-update after-disable-auto-update before-apt-remove after-apt-remove before-source-remove after-source-remove final-apt-update before-lock-release; do + for phase in before-snapshot-create capture-auto_update_script capture-auto_update_timer capture-legacy_marker lock-acquired before-disable-auto-update after-disable-auto-update before-apt-remove after-apt-remove before-source-remove after-source-remove final-apt-update before-lock-release; do new_case set_old_generation configure_repository @@ -934,6 +934,38 @@ for restore_id in auto_update_script auto_update_service auto_update_timer sourc pass "restore failure $restore_id continues remaining five targets and preserves original evidence" done +for target_id in auto_update_script auto_update_service auto_update_timer source current legacy_marker; do + for bad_metadata in mode owner gid symlink directory fifo; do + ( + new_case + trap - EXIT + configure_repository || fail "metadata configure" + mkdir -p "$(dirname "$AUTO_UPDATE_SCRIPT")" "$(dirname "$AUTO_UPDATE_SERVICE")" + write_auto_update_files || fail "metadata auto update" + printf managed > "$STATE_DIR/repository-managed" + chmod 0600 "$STATE_DIR/repository-managed" + target=$(uninstall_target_path "$target_id") + case "$bad_metadata" in + mode) chmod 0666 "$target" ;; + owner) chown 1:0 "$target" ;; + gid) chown 0:1 "$target" ;; + symlink) mv "$target" "$target.external"; ln -s "$target.external" "$target" ;; + directory) rm "$target"; mkdir "$target" ;; + fifo) rm "$target"; mkfifo "$target" ;; + esac + acquire_repository_lock || fail "metadata lock" + if begin_uninstall_transaction; then fail "bad target captured"; fi + uninstall_cleanup bad-metadata >/dev/null 2>&1 || : + [[ -e "$target" || -L "$target" ]] || fail "untrusted target deleted" + [[ "$UNINSTALL_TRANSACTION_STATE" == FAILED ]] || fail "capture failure state lost" + ) > "$TEST_DIR/meta-$target_id-$bad_metadata.log" 2>&1 || { + cat "$TEST_DIR/meta-$target_id-$bad_metadata.log" + fail "metadata $target_id $bad_metadata" + } + pass "capture rejects $target_id $bad_metadata without deleting target" + done +done + entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" pass "bash -c entrypoint remains compatible" From a6016da262b45d1705a1e1b57c0a8844c418bc56 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 08:28:02 +0800 Subject: [PATCH 19/32] fix: complete all uninstall target verification before returning failure --- tools/cloudflare_tunnel.sh | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index a43d57b..dfab44b 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -1415,7 +1415,8 @@ uninstall_journal_valid() { mapfile -t rows < "$journal" || return 1 [[ "${#rows[@]}" == 4 ]] || return 1 [[ "${rows[0]}" == version=1 && "${rows[1]}" == "generation=$UNINSTALL_GENERATION" ]] || return 1 - [[ "${rows[2]}" == "state=$expected" && "${rows[3]}" == reason=* ]] + case "$expected" in BUILDING|ACTIVE|FAILED|COMMITTED|ROLLED_BACK) ;; *) return 1 ;; esac + [[ "${rows[2]}" == "state=$expected" && "${rows[3]}" == reason=* && "${rows[3]}" != *$'\r'* ]] } write_uninstall_journal() { @@ -1488,22 +1489,24 @@ archive_uninstall_evidence() { } uninstall_targets_group_valid() { - local id path state sha + local id path state sha failed=0 uninstall_snapshot_path_valid || return 1 uninstall_manifest_valid "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 while IFS= read -r id; do - path=$(uninstall_target_path "$id") || return 1 + path=$(uninstall_target_path "$id") || { failed=1; continue; } state=$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" state) if [[ "$state" == regular ]]; then sha=$(uninstall_manifest_field "$UNINSTALL_SNAPSHOT_DIR/manifest" "$id" sha256) - validate_secure_file "$path" "$(uninstall_target_mode "$id")" || return 1 - [[ "$(sha256sum -- "$path" | awk '{print $1}')" == "$sha" ]] || return 1 + validate_directory_chain "$(dirname -- "$path")" || failed=1 + validate_secure_file "$path" "$(uninstall_target_mode "$id")" || failed=1 + [[ "$(sha256sum -- "$path" | awk '{print $1}')" == "$sha" ]] || failed=1 elif [[ "$state" == absent ]]; then - [[ ! -e "$path" && ! -L "$path" ]] || return 1 + [[ ! -e "$path" && ! -L "$path" ]] || failed=1 else - return 1 + failed=1 fi done < <(uninstall_snapshot_targets) + return "$failed" } uninstall_cleanup() { From 0be0c3d207313c3f236f2615cd68f1d15f24719f Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 08:46:51 +0800 Subject: [PATCH 20/32] fix: handle root anchors and record creation intent before publication --- tests/test-cloudflare-tunnel.sh | 49 +++++++++++++++++++++++++++++++++ tools/cloudflare_tunnel.sh | 14 ++++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index 22400f8..b78ac57 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -966,6 +966,55 @@ for target_id in auto_update_script auto_update_service auto_update_timer source done done +( + unset CLOUDFLARED_TRUST_ANCHOR + init_runtime_config + [[ "$TRUST_ANCHOR" == / ]] || exit 1 + path_is_beneath_anchor / || exit 1 + path_is_beneath_anchor /var/lib || exit 1 + CLOUDFLARED_TRUST_ANCHOR=/var + path_is_beneath_anchor /var || exit 1 + path_is_beneath_anchor /var/lib || exit 1 + if path_is_beneath_anchor /variable; then exit 1; fi + if path_is_beneath_anchor /var/../etc; then exit 1; fi + path_is_beneath_anchor /var/lib/../lib || exit 1 +) || fail "pure anchor boundaries" +pass "pure paths validate default root anchor and normalized non-root boundaries" + +for creation_phase in before-create-marker before-create-manifest-stage before-create-manifest-rename before-create-publish after-create-publish; do + for creation_fault in failure HUP INT TERM; do + ( + new_case + trap - EXIT + configure_repository || fail "creation configure" + printf payload > "$CASE_DIR/payload" + chmod 0600 "$CASE_DIR/payload" + rc=0 + ( + trap - EXIT + acquire_repository_lock || exit 1 + begin_uninstall_transaction || exit 1 + uninstall_transaction_hook() { + [[ "$1" == "$creation_phase" ]] || return 0 + if [[ "$creation_fault" == failure ]]; then return 1; fi + kill -s "$creation_fault" "$BASHPID" + } + if uninstall_create_absent_target auto_update_script "$CASE_DIR/payload"; then exit 90; fi + uninstall_cleanup creation-failure || : + exit 1 + ) > "$CASE_DIR/creation.log" 2>&1 || rc=$? + case "$creation_fault" in failure) expected=1 ;; HUP) expected=129 ;; INT) expected=130 ;; TERM) expected=143 ;; esac + [[ "$rc" == "$expected" ]] || fail "creation status $rc" + [[ ! -e "$AUTO_UPDATE_SCRIPT" && ! -L "$AUTO_UPDATE_SCRIPT" ]] || fail "owned publication not recovered" + [[ ! -d "$CLOUDFLARED_STATE_DIR.lock" ]] || fail "creation lock leaked" + ) > "$TEST_DIR/create-$creation_phase-$creation_fault.log" 2>&1 || { + cat "$TEST_DIR/create-$creation_phase-$creation_fault.log" + fail "creation $creation_phase $creation_fault" + } + pass "creation $creation_phase $creation_fault preserves ownership across publication" + done +done + entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" pass "bash -c entrypoint remains compatible" diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index dfab44b..204b3fb 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -87,6 +87,7 @@ path_is_beneath_anchor() { local path anchor path=$(realpath -ms -- "$1") || return 1 anchor=$(realpath -ms -- "$TRUST_ANCHOR") || return 1 + [[ "$anchor" == / ]] && return 0 [[ "$path" == "$anchor" || "$path" == "$anchor"/* ]] } @@ -1318,13 +1319,14 @@ uninstall_create_absent_target() { validate_secure_file "$stage" "$mode" || return 1 digest=$(sha256sum -- "$stage" | awk '{print $1}') || return 1 identity=$(stat -c '%d:%i' -- "$stage") || return 1 - # GNU ln -T cannot follow a destination symlink or replace an existing file. - ln -T -- "$stage" "$path" || return 1 - [[ "$(stat -c '%d:%i' -- "$path")" == "$identity" ]] || return 1 + # Record intent and exact stage identity BEFORE publication. A true manifest + # flag permits removal only when the destination matches this identity. + uninstall_transaction_hook before-create-marker || return 1 marker="$UNINSTALL_SNAPSHOT_DIR/created-$id" (set -C; umask 077; printf 'state=created\ntarget_id=%s\npath=%s\nuid=0\ngid=0\ndev=%s\nino=%s\nsha256=%s\n' \ "$id" "$path" "${identity%:*}" "${identity#*:}" "$digest" > "$marker") || return 1 validate_secure_file "$marker" 600 || return 1 + uninstall_transaction_hook before-create-manifest-stage || return 1 manifest_stage=$(mktemp "$UNINSTALL_SNAPSHOT_DIR/manifest.stage.XXXXXX") || return 1 awk -v id="$id" ' /^target_id=/ {hit=($0 == "target_id=" id)} @@ -1332,7 +1334,13 @@ uninstall_create_absent_target() { {print} ' "$UNINSTALL_SNAPSHOT_DIR/manifest" > "$manifest_stage" || return 1 chmod 0600 "$manifest_stage" || return 1 + uninstall_transaction_hook before-create-manifest-rename || return 1 repository_rename "$manifest_stage" "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 + uninstall_manifest_valid "$UNINSTALL_SNAPSHOT_DIR/manifest" || return 1 + uninstall_transaction_hook before-create-publish || return 1 + # No marker/manifest writes are needed after successful exclusive publication. + ln -T -- "$stage" "$path" || return 1 + uninstall_transaction_hook after-create-publish || return 1 uninstall_created_identity_valid "$id" || return 1 rm -- "$stage" } From afaec4318dd6b3ecc3aa8befe515530a5b95b661 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 08:47:18 +0800 Subject: [PATCH 21/32] test: inject actual creation bookkeeping failures before linking --- tests/test-cloudflare-tunnel.sh | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index b78ac57..7282a99 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -1015,6 +1015,38 @@ for creation_phase in before-create-marker before-create-manifest-stage before-c done done +for bookkeeping_fault in marker stage rename; do + ( + new_case + trap - EXIT + configure_repository || fail "bookkeeping configure" + printf payload > "$CASE_DIR/payload"; chmod 0600 "$CASE_DIR/payload" + acquire_repository_lock || fail "bookkeeping lock" + begin_uninstall_transaction || fail "bookkeeping capture" + uninstall_transaction_hook() { + if [[ "$bookkeeping_fault" == marker && "$1" == before-create-marker ]]; then + mkdir "$UNINSTALL_SNAPSHOT_DIR/created-auto_update_script" + fi + return 0 + } + mktemp() { + if [[ "$bookkeeping_fault" == stage && "$1" == */manifest.stage.* ]]; then return 1; fi + command mktemp "$@" + } + repository_rename() { + if [[ "$bookkeeping_fault" == rename && "$1" == */manifest.stage.* ]]; then return 1; fi + command mv -fT -- "$1" "$2" + } + if uninstall_create_absent_target auto_update_script "$CASE_DIR/payload"; then fail "bookkeeping fault ignored"; fi + [[ ! -e "$AUTO_UPDATE_SCRIPT" ]] || fail "published before durable bookkeeping" + uninstall_cleanup bookkeeping-failure || : + ) > "$TEST_DIR/bookkeeping-$bookkeeping_fault.log" 2>&1 || { + cat "$TEST_DIR/bookkeeping-$bookkeeping_fault.log" + fail "bookkeeping $bookkeeping_fault" + } + pass "actual $bookkeeping_fault failure precedes ln publication" +done + entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" pass "bash -c entrypoint remains compatible" From 4bf6e8f18ed0b0fc5485ffecbf9d1e7b0bb19473 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 08:57:28 +0800 Subject: [PATCH 22/32] fix: use cloudflared APT repository rather than signing key URL --- README.md | 5 +++-- tests/test-cloudflare-tunnel.sh | 27 ++++++++++++++++++++++++--- tools/cloudflare_tunnel.sh | 2 +- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8d64030..644a199 100644 --- a/README.md +++ b/README.md @@ -235,8 +235,9 @@ Linux 仓库不再内置 `network-optimize` 和 `traffic-shape` 网络调优脚 ### Cloudflare Tunnel [`tools/cloudflare_tunnel.sh`](tools/cloudflare_tunnel.sh) 是 Cloudflare 官方 APT 安装流程的薄包装器, -只支持 Debian/Ubuntu 与 systemd。它使用 Cloudflare 官方 key/source: -`deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflare-main.gpg any main`, +只支持 Debian/Ubuntu 与 systemd。密钥下载地址为 `https://pkg.cloudflare.com/cloudflare-main.gpg`; +按照 [官方推荐配置](https://pkg.cloudflare.com/index.html),APT source 为: +`deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main`, 并以 `cloudflared service install` 配置服务,不再下载裸二进制。keyring 会严格校验单一主公钥 fingerprint `CC94B39C77AE7342A68B89628A682D308D4E5E73` 与 UID `CloudFlare Software Packaging 2025 `。key/source 同一事务提交;APT probe 或安装 diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index 7282a99..6f5a4df 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -177,7 +177,7 @@ before=$(find "$CASE_DIR/root" -mindepth 1 -printf '%P %y %m\n' | sort) repository_source_content > "$CASE_DIR/source-output" after=$(find "$CASE_DIR/root" -mindepth 1 -printf '%P %y %m\n' | sort) [[ "$before" == "$after" ]] || fail "source rendering had side effects" -[[ "$(cat "$CASE_DIR/source-output")" == "deb [signed-by=$CLOUDFLARED_KEYRING] https://pkg.cloudflare.com/cloudflare-main.gpg any main" ]] || +[[ "$(cat "$CASE_DIR/source-output")" == "deb [signed-by=$CLOUDFLARED_KEYRING] https://pkg.cloudflare.com/cloudflared any main" ]] || fail "source is not exact official definition" pass "source rendering has zero side effects and exact fields" @@ -334,7 +334,7 @@ assert_absent "$CLOUDFLARED_SOURCE_FILE" "duplicate source wrote managed source" pass "reject duplicate or extra Cloudflare source before commit" new_case -printf 'deb [signed-by=%s trusted=yes] https://pkg.cloudflare.com/cloudflare-main.gpg any main\n' \ +printf 'deb [signed-by=%s trusted=yes] https://pkg.cloudflare.com/cloudflared any main\n' \ "$CLOUDFLARED_KEYRING" > "$CLOUDFLARED_SOURCE_FILE" chmod 0644 "$CLOUDFLARED_SOURCE_FILE" if configure_repository >/dev/null 2>&1; then fail "source injection accepted"; fi @@ -462,7 +462,9 @@ configure_repository current="$CLOUDFLARED_REPOSITORY_STATE_DIR/current" [[ -f "$current" ]] || fail "generation marker missing" key_hash=$(sha256sum "$CLOUDFLARED_KEYRING" | awk '{print $1}') -source_hash=$(sha256sum "$CLOUDFLARED_SOURCE_FILE" | awk '{print $1}') +printf 'deb [signed-by=%s] https://pkg.cloudflare.com/cloudflared any main\n' "$CLOUDFLARED_KEYRING" > "$CASE_DIR/independent-source" +assert_same "$CLOUDFLARED_SOURCE_FILE" "$CASE_DIR/independent-source" "source differs from official literal" +source_hash=$(sha256sum "$CASE_DIR/independent-source" | awk '{print $1}') grep -Fxq "key_sha256=$key_hash" "$current" || fail "key generation hash mismatch" grep -Fxq "source_sha256=$source_hash" "$current" || fail "source generation hash mismatch" pass "key/source commit as one recorded generation" @@ -1047,6 +1049,25 @@ for bookkeeping_fault in marker stage rename; do pass "actual $bookkeeping_fault failure precedes ln publication" done +new_case +configure_repository || fail "URL configure" +grep '^curl:' "$FAKE_LOG" | grep -Fq 'https://pkg.cloudflare.com/cloudflare-main.gpg' || fail "key download URL changed" +printf 'deb [signed-by=%s] https://pkg.cloudflare.com/cloudflared any main\n' "$CLOUDFLARED_KEYRING" > "$CASE_DIR/official-source" +assert_same "$CLOUDFLARED_SOURCE_FILE" "$CASE_DIR/official-source" "APT repository URL incorrect" +validate_current_repository_manifest || fail "official current rejected" +remove_managed_repository || fail "official source removal failed" +assert_absent "$CLOUDFLARED_SOURCE_FILE" "official source not removed" +pass "literal key URL and repository source validate current and real removal" + +new_case +printf 'deb [signed-by=%s] https://pkg.cloudflare.com/cloudflare-main.gpg any main\n' "$CLOUDFLARED_KEYRING" > "$CLOUDFLARED_SOURCE_FILE" +chmod 0644 "$CLOUDFLARED_SOURCE_FILE" +cp "$CLOUDFLARED_SOURCE_FILE" "$CASE_DIR/wrong-source" +if configure_repository > "$CASE_DIR/rejected.log" 2>&1; then fail "gpg repository accepted"; fi +assert_same "$CLOUDFLARED_SOURCE_FILE" "$CASE_DIR/wrong-source" "unowned wrong source overwritten" +[[ ! -s "$FAKE_LOG" ]] || fail "invalid source triggered external operation" +pass "gpg repository URL rejected without overwrite or download" + entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" pass "bash -c entrypoint remains compatible" diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index 204b3fb..8ede34c 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -12,7 +12,7 @@ init_runtime_config() { SOURCE_FILE="${CLOUDFLARED_SOURCE_FILE:-/etc/apt/sources.list.d/cloudflared.list}" STATE_DIR="${CLOUDFLARED_STATE_DIR:-/var/lib/cloudflared-wrapper}" KEY_URL="https://pkg.cloudflare.com/cloudflare-main.gpg" - REPOSITORY="https://pkg.cloudflare.com/cloudflare-main.gpg" + REPOSITORY="https://pkg.cloudflare.com/cloudflared" KEY_FINGERPRINT="CC94B39C77AE7342A68B89628A682D308D4E5E73" KEY_UID="CloudFlare Software Packaging 2025 " REPOSITORY_STATE_DIR="${CLOUDFLARED_REPOSITORY_STATE_DIR:-$STATE_DIR/repository}" From 85d14f73dbb4981c1fe46d43a89cbe242d99ba7b Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 09:03:28 +0800 Subject: [PATCH 23/32] fix: reject invalid repository sources before activating rollback --- tools/cloudflare_tunnel.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index 8ede34c..f70d3c3 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -535,6 +535,12 @@ begin_repository_transaction() { return 1 fi + # Reject untrusted input before activating rollback: no old-generation + # snapshot exists yet, so rollback must not treat existing files as absent. + if ! validate_existing_repository_file "$KEYRING" || ! validate_existing_source; then + release_repository_lock || return 1 + return 1 + fi REPOSITORY_GENERATION="$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM" REPOSITORY_TRANSACTION_DIR="$REPOSITORY_STATE_DIR/transaction-$REPOSITORY_GENERATION" if ! mkdir -m 0700 -- "$REPOSITORY_TRANSACTION_DIR" || From 381b560feb1fe82dcf03c46adaf2e1b4358c2fdd Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 09:14:01 +0800 Subject: [PATCH 24/32] fix: prevent repository rollback before verified snapshot completion --- tests/test-cloudflare-tunnel.sh | 66 +++++++++++++++++++++++++++++++++ tools/cloudflare_tunnel.sh | 61 ++++++++++++++++++------------ 2 files changed, 104 insertions(+), 23 deletions(-) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index 6f5a4df..3da87eb 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -1068,6 +1068,72 @@ assert_same "$CLOUDFLARED_SOURCE_FILE" "$CASE_DIR/wrong-source" "unowned wrong s [[ ! -s "$FAKE_LOG" ]] || fail "invalid source triggered external operation" pass "gpg repository URL rejected without overwrite or download" +for capture_file in old-key old-source old-current; do + for capture_fault in copy partial chmod HUP INT TERM exit0; do + ( + new_case + trap - EXIT + configure_repository || fail "capture fixture" + for object in "$KEYRING" "$SOURCE_FILE" "$REPOSITORY_STATE_DIR/current"; do + cp -p "$object" "$CASE_DIR/$(basename "$object").before" + done + : > "$FAKE_LOG" + rc=0 + ( + trap - EXIT + repository_copy_file() { + if [[ "$2" == */"$capture_file" ]]; then + case "$capture_fault" in + copy) return 1 ;; + partial) printf partial > "$2"; return 1 ;; + HUP|INT|TERM) kill -s "$capture_fault" "$BASHPID" ;; + exit0) exit 0 ;; + esac + fi + command cp -- "$1" "$2" + } + chmod() { + if [[ "$capture_fault" == chmod && "$2" == */"$capture_file" ]]; then return 1; fi + command chmod "$@" + } + configure_repository + ) > "$CASE_DIR/capture.log" 2>&1 || rc=$? + case "$capture_fault" in HUP) expected=129 ;; INT) expected=130 ;; TERM) expected=143 ;; *) expected=1 ;; esac + [[ "$rc" == "$expected" ]] || fail "capture status $rc" + for object in "$KEYRING" "$SOURCE_FILE" "$REPOSITORY_STATE_DIR/current"; do + cmp "$object" "$CASE_DIR/$(basename "$object").before" || fail "capture modified formal bytes" + [[ "$(stat -c '%u:%g:%a' "$object")" == "$(stat -c '%u:%g:%a' "$CASE_DIR/$(basename "$object").before")" ]] || fail "capture modified metadata" + done + [[ ! -s "$FAKE_LOG" ]] || fail "capture downloaded or called APT" + grep -q '未恢复或删除正式文件' "$CASE_DIR/capture.log" || fail "capture diagnostic missing" + acquire_repository_lock || fail "capture lock retained" + release_repository_lock || fail "capture lock release" + ) > "$TEST_DIR/capture-boundary-$capture_file-$capture_fault.log" 2>&1 || { + cat "$TEST_DIR/capture-boundary-$capture_file-$capture_fault.log" + fail "capture boundary $capture_file $capture_fault" + } + pass "repository capture $capture_file $capture_fault preserves formal bytes and metadata" + done +done + +( + new_case + trap - EXIT + set_old_generation + rc=0 + ( + trap - EXIT + begin_repository_transaction || exit 1 + exit 0 + ) > "$CASE_DIR/active-exit.log" 2>&1 || rc=$? + [[ "$rc" == 1 ]] || fail "active exit zero accepted" + assert_old_generation + [[ ! -e "$REPOSITORY_STATE_DIR/current" ]] || fail "absent current restored incorrectly" + acquire_repository_lock || fail "active exit lock retained" + release_repository_lock || fail "active exit release" +) || fail "active exit zero" +pass "complete repository transaction exit zero returns nonzero and restores absent current" + entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" pass "bash -c entrypoint remains compatible" diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index f70d3c3..cbb47d9 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -391,21 +391,26 @@ write_transaction_status() { backup_repository_generation() { init_runtime_config - if [[ -e "$KEYRING" || -L "$KEYRING" ]]; then - repository_copy_file "$KEYRING" "$REPOSITORY_TRANSACTION_DIR/old-key" || return 1 - chmod 0600 "$REPOSITORY_TRANSACTION_DIR/old-key" || return 1 - REPOSITORY_OLD_KEY=true - fi - if [[ -e "$SOURCE_FILE" || -L "$SOURCE_FILE" ]]; then - repository_copy_file "$SOURCE_FILE" "$REPOSITORY_TRANSACTION_DIR/old-source" || return 1 - chmod 0600 "$REPOSITORY_TRANSACTION_DIR/old-source" || return 1 - REPOSITORY_OLD_SOURCE=true - fi - if [[ -f "$REPOSITORY_STATE_DIR/current" ]]; then - repository_copy_file "$REPOSITORY_STATE_DIR/current" "$REPOSITORY_TRANSACTION_DIR/old-current" || return 1 - chmod 0600 "$REPOSITORY_TRANSACTION_DIR/old-current" || return 1 - REPOSITORY_OLD_STATE=true - fi + local target backup flag mode before + for flag in KEY SOURCE STATE; do + case "$flag" in + KEY) target=$KEYRING; backup=old-key; mode=644 ;; + SOURCE) target=$SOURCE_FILE; backup=old-source; mode=644 ;; + STATE) target=$REPOSITORY_STATE_DIR/current; backup=old-current; mode=600 ;; + esac + if [[ -e "$target" || -L "$target" ]]; then + validate_secure_file "$target" "$mode" || return 1 + before=$(sha256sum -- "$target" | awk '{print $1}') || return 1 + repository_copy_file "$target" "$REPOSITORY_TRANSACTION_DIR/$backup" || return 1 + chmod 0600 "$REPOSITORY_TRANSACTION_DIR/$backup" || return 1 + validate_secure_file "$REPOSITORY_TRANSACTION_DIR/$backup" 600 || return 1 + [[ "$(sha256sum -- "$REPOSITORY_TRANSACTION_DIR/$backup" | awk '{print $1}')" == "$before" ]] || return 1 + validate_secure_file "$target" "$mode" || return 1 + [[ "$(sha256sum -- "$target" | awk '{print $1}')" == "$before" ]] || return 1 + printf -v "REPOSITORY_OLD_$flag" true + fi + done + REPOSITORY_SNAPSHOT_READY=true } restore_repository_file() { @@ -444,8 +449,11 @@ rollback_repository_transaction() { [[ "$REPOSITORY_TRANSACTION_ACTIVE" == true ]] || return 0 REPOSITORY_TRANSACTION_ACTIVE=false trap - HUP INT TERM EXIT - printf '%s\n' "$reason" >> "$REPOSITORY_TRANSACTION_DIR/rollback.log" 2>/dev/null || rollback_failed=true - chmod 0600 "$REPOSITORY_TRANSACTION_DIR/rollback.log" 2>/dev/null || rollback_failed=true + if [[ -n "$REPOSITORY_TRANSACTION_DIR" && -d "$REPOSITORY_TRANSACTION_DIR" ]]; then + printf '%s\n' "$reason" >> "$REPOSITORY_TRANSACTION_DIR/rollback.log" 2>/dev/null || rollback_failed=true + chmod 0600 "$REPOSITORY_TRANSACTION_DIR/rollback.log" 2>/dev/null || rollback_failed=true + fi + if [[ "${REPOSITORY_SNAPSHOT_READY:-false}" == true ]]; then restore_repository_file "$REPOSITORY_OLD_SOURCE" "$REPOSITORY_TRANSACTION_DIR/old-source" "$SOURCE_FILE" || rollback_failed=true restore_repository_file "$REPOSITORY_OLD_KEY" "$REPOSITORY_TRANSACTION_DIR/old-key" "$KEYRING" || rollback_failed=true if [[ "$REPOSITORY_OLD_STATE" == true ]]; then @@ -456,6 +464,7 @@ rollback_repository_transaction() { else rm -f -- "$REPOSITORY_STATE_DIR/current" 2>/dev/null || rollback_failed=true fi + fi rm -f -- "$REPOSITORY_KEY_STAGE" "$REPOSITORY_SOURCE_STAGE" 2>/dev/null || rollback_failed=true archive_failed_transaction || rollback_failed=true release_repository_lock || rollback_failed=true @@ -464,7 +473,11 @@ rollback_repository_transaction() { error "Cloudflare 仓库事务回滚不完整;失败证据已尽量保留: $REPOSITORY_TRANSACTION_DIR" return 1 fi - error "Cloudflare 仓库事务失败,旧 key/source 已恢复;证据: $REPOSITORY_TRANSACTION_DIR" + if [[ "${REPOSITORY_SNAPSHOT_READY:-false}" == true ]]; then + error "Cloudflare 仓库事务失败,旧 key/source 已恢复;证据: $REPOSITORY_TRANSACTION_DIR" + else + error "Cloudflare 快照构建失败;未恢复或删除正式文件;证据: $REPOSITORY_TRANSACTION_DIR" + fi return 0 } @@ -488,6 +501,7 @@ repository_exit_handler() { local status="$1" if [[ "$REPOSITORY_TRANSACTION_ACTIVE" == true ]]; then rollback_repository_transaction "进程异常退出,状态 $status" || true + (( status == 0 )) && status=1 fi exit "$status" } @@ -509,6 +523,7 @@ begin_repository_transaction() { init_runtime_config local key_parent source_parent downloaded marker_stage key_hash source_hash REPOSITORY_TRANSACTION_ACTIVE=false + REPOSITORY_SNAPSHOT_READY=false REPOSITORY_TRANSACTION_DIR="" REPOSITORY_KEY_STAGE="" REPOSITORY_SOURCE_STAGE="" @@ -530,26 +545,26 @@ begin_repository_transaction() { validate_directory_chain "$source_parent" || return 1 validate_directory_chain "$APT_SOURCE_ROOT" || return 1 acquire_repository_lock || return 1 + REPOSITORY_TRANSACTION_ACTIVE=true + save_repository_traps if ! prepare_repository_state; then - release_repository_lock || true + repository_transaction_fail "准备仓库状态失败" return 1 fi # Reject untrusted input before activating rollback: no old-generation # snapshot exists yet, so rollback must not treat existing files as absent. if ! validate_existing_repository_file "$KEYRING" || ! validate_existing_source; then - release_repository_lock || return 1 + repository_transaction_fail "现有仓库输入不可信" return 1 fi REPOSITORY_GENERATION="$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM" REPOSITORY_TRANSACTION_DIR="$REPOSITORY_STATE_DIR/transaction-$REPOSITORY_GENERATION" if ! mkdir -m 0700 -- "$REPOSITORY_TRANSACTION_DIR" || ! validate_secure_directory "$REPOSITORY_TRANSACTION_DIR" 700; then - release_repository_lock || true + repository_transaction_fail "创建快照目录失败" return 1 fi - REPOSITORY_TRANSACTION_ACTIVE=true - save_repository_traps validate_existing_repository_file "$KEYRING" || repository_transaction_fail "现有 keyring 类型或元数据不可信" [[ "$REPOSITORY_TRANSACTION_ACTIVE" == true ]] || return 1 From 4c4e12a45c30f6c55621224edf075a927eeaf9e9 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 09:16:36 +0800 Subject: [PATCH 25/32] test: verify complete snapshot group and failed build evidence --- tests/test-cloudflare-tunnel.sh | 37 +++++++++++++++++++++++++++++++++ tools/cloudflare_tunnel.sh | 16 ++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index 3da87eb..f991af1 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -1134,6 +1134,43 @@ done ) || fail "active exit zero" pass "complete repository transaction exit zero returns nonzero and restores absent current" +for build_case in absent archive-failure; do + ( + new_case + trap - EXIT + if [[ "$build_case" == archive-failure ]]; then set_old_generation; fi + trap ':' HUP + before_traps=$(trap -p HUP INT TERM EXIT) + repository_copy_file() { printf partial > "$2"; return 1; } + if [[ "$build_case" == absent ]]; then + # No formal object exists; fail before any download via snapshot wrapper. + chmod() { + [[ "$2" != */transaction-* ]] || return 1 + command chmod "$@" + } + repository_transaction_hook() { exit 0; } + rc=0 + (begin_repository_transaction) > "$CASE_DIR/absent.log" 2>&1 || rc=$? + [[ "$rc" != 0 ]] || fail "absent abnormal exit accepted" + [[ ! -e "$KEYRING" && ! -e "$SOURCE_FILE" && ! -e "$REPOSITORY_STATE_DIR/current" ]] || fail "absent formal object created" + else + repository_rename() { return 1; } + if configure_repository > "$CASE_DIR/build.log" 2>&1; then fail "failed capture accepted"; fi + assert_old_generation + [[ -f "$REPOSITORY_TRANSACTION_DIR/old-key" ]] || fail "partial evidence lost" + [[ -f "$REPOSITORY_TRANSACTION_DIR/rollback.log" ]] || fail "failure diagnostic lost" + grep -q '回滚不完整' "$CASE_DIR/build.log" || fail "cleanup error not reported" + fi + [[ "$(trap -p HUP INT TERM EXIT)" == "$before_traps" ]] || fail "build traps changed" + acquire_repository_lock || fail "build cleanup lock retained" + release_repository_lock || fail "build cleanup lock release" + ) > "$TEST_DIR/build-$build_case.log" 2>&1 || { + cat "$TEST_DIR/build-$build_case.log" + fail "build $build_case" + } + pass "repository building $build_case preserves objects traps and evidence" +done + entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" pass "bash -c entrypoint remains compatible" diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index cbb47d9..3875d6c 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -410,6 +410,22 @@ backup_repository_generation() { printf -v "REPOSITORY_OLD_$flag" true fi done + # Recheck the complete group after the last copy before authorizing rollback. + for flag in KEY SOURCE STATE; do + case "$flag" in + KEY) target=$KEYRING; backup=old-key; mode=644 ;; + SOURCE) target=$SOURCE_FILE; backup=old-source; mode=644 ;; + STATE) target=$REPOSITORY_STATE_DIR/current; backup=old-current; mode=600 ;; + esac + local flag_name="REPOSITORY_OLD_$flag" + if [[ "${!flag_name}" == true ]]; then + validate_secure_file "$target" "$mode" || return 1 + validate_secure_file "$REPOSITORY_TRANSACTION_DIR/$backup" 600 || return 1 + cmp -s -- "$target" "$REPOSITORY_TRANSACTION_DIR/$backup" || return 1 + else + [[ ! -e "$target" && ! -L "$target" ]] || return 1 + fi + done REPOSITORY_SNAPSHOT_READY=true } From 64e6a6550ca407e0360a8f6f00419c8cc71426ef Mon Sep 17 00:00:00 2001 From: root Date: Wed, 9 Sep 2026 02:25:59 +0800 Subject: [PATCH 26/32] fix: verify repository restore stages and final restored generation --- tests/test-cloudflare-tunnel.sh | 52 ++++++++++++++++++++++++++++++ tools/cloudflare_tunnel.sh | 56 +++++++++++++++++++++++++-------- 2 files changed, 95 insertions(+), 13 deletions(-) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index f991af1..cb9bf71 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -1171,6 +1171,58 @@ for build_case in absent archive-failure; do pass "repository building $build_case preserves objects traps and evidence" done +for restore_object in key source current; do + for restore_fault in partial wrong-content wrong-mode rename post-content post-mode; do + ( + new_case + trap - EXIT + configure_repository || fail "restore fixture" + begin_repository_transaction || fail "restore transaction" + snapshot=$REPOSITORY_TRANSACTION_DIR + printf historical > "$REPOSITORY_STATE_DIR/current.rollback" + cp "$REPOSITORY_STATE_DIR/current.rollback" "$CASE_DIR/historical" + case "$restore_object" in key) target=$KEYRING ;; source) target=$SOURCE_FILE ;; current) target=$REPOSITORY_STATE_DIR/current ;; esac + cp "$target" "$CASE_DIR/pre-restore" + : > "$CASE_DIR/attempts" + repository_install_file() { + printf '%s\n' "$2" >> "$CASE_DIR/attempts" + if [[ "$2" == "$snapshot/old-$restore_object" ]]; then + case "$restore_fault" in + partial) printf partial > "$3"; return 1 ;; + wrong-content) command install -o 0 -g 0 -m "$1" "$2" "$3"; printf wrong > "$3"; return 0 ;; + wrong-mode) command install -o 0 -g 0 -m 0777 "$2" "$3"; return 0 ;; + esac + fi + command install -o 0 -g 0 -m "$1" "$2" "$3" + } + repository_rename() { + if [[ "$2" == "$target" && "$1" == */.cloudflared-rollback.* ]]; then + [[ "$restore_fault" != rename ]] || return 1 + command mv -fT -- "$1" "$2" || return 1 + case "$restore_fault" in post-content) printf corrupt > "$2" ;; post-mode) chmod 0777 "$2" ;; esac + return 0 + fi + command mv -fT -- "$1" "$2" + } + if rollback_repository_transaction injected > "$CASE_DIR/restore.log" 2>&1; then fail "restore fault accepted"; fi + for item in key source current; do + [[ -f "$REPOSITORY_TRANSACTION_DIR/old-$item" ]] || fail "backup lost $item" + grep -Fxq "$snapshot/old-$item" "$CASE_DIR/attempts" || fail "restore skipped $item" + done + case "$restore_fault" in partial|wrong-content|wrong-mode|rename) cmp "$target" "$CASE_DIR/pre-restore" || fail "invalid stage published" ;; esac + cmp "$REPOSITORY_STATE_DIR/current.rollback" "$CASE_DIR/historical" || fail "historical rollback touched" + if grep -q '旧 key/source 已恢复' "$CASE_DIR/restore.log"; then fail "false recovery success"; fi + grep -q '回滚不完整' "$CASE_DIR/restore.log" || fail "missing failure diagnosis" + acquire_repository_lock || fail "restore lock retained" + release_repository_lock || fail "restore lock release" + ) > "$TEST_DIR/publish-$restore_object-$restore_fault.log" 2>&1 || { + cat "$TEST_DIR/publish-$restore_object-$restore_fault.log" + fail "restore publish $restore_object $restore_fault" + } + pass "repository restore $restore_object $restore_fault rejects corruption and retains all backups" + done +done + entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" pass "bash -c entrypoint remains compatible" diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index 3875d6c..495c559 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -431,13 +431,26 @@ backup_repository_generation() { restore_repository_file() { init_runtime_config - local had_old="$1" backup="$2" target="$3" restore_stage + local had_old="$1" backup="$2" target="$3" mode="${4:-644}" restore_stage identity + validate_directory_chain "$(dirname -- "$target")" || return 1 if [[ "$had_old" == true ]]; then + validate_secure_file "$backup" 600 || return 1 restore_stage=$(mktemp "$(dirname -- "$target")/.cloudflared-rollback.XXXXXX") || return 1 - if ! repository_install_file 0644 "$backup" "$restore_stage" || - ! validate_secure_file "$restore_stage" 644 || - ! repository_rename "$restore_stage" "$target"; then - rm -f -- "$restore_stage" 2>/dev/null || true + identity=$(stat -c '%d:%i' -- "$restore_stage") || return 1 + if ! repository_install_file "$mode" "$backup" "$restore_stage" || + ! validate_secure_file "$restore_stage" "$mode" || + ! cmp -s -- "$backup" "$restore_stage" || + ! repository_rename "$restore_stage" "$target" || + ! validate_secure_file "$target" "$mode" || + ! cmp -s -- "$backup" "$target"; then + error "仓库恢复验证失败: target=$target backup=$backup stage=$restore_stage" + if [[ -e "$restore_stage" || -L "$restore_stage" ]]; then + if [[ -f "$restore_stage" && ! -L "$restore_stage" && "$(stat -c '%u:%g:%d:%i' -- "$restore_stage")" == "0:0:$identity" ]]; then + rm -- "$restore_stage" || error "无法清理本事务 stage: $restore_stage" + else + error "stage 身份改变,保留人工处理: $restore_stage" + fi + fi return 1 fi else @@ -445,9 +458,32 @@ restore_repository_file() { [[ -f "$target" && ! -L "$target" ]] || return 1 rm -f -- "$target" || return 1 fi + [[ ! -e "$target" && ! -L "$target" ]] || return 1 fi } +validate_restored_repository_group() { + local flag target backup mode name failed=0 + for flag in KEY SOURCE STATE; do + case "$flag" in + KEY) target=$KEYRING; backup=old-key; mode=644 ;; + SOURCE) target=$SOURCE_FILE; backup=old-source; mode=644 ;; + STATE) target=$REPOSITORY_STATE_DIR/current; backup=old-current; mode=600 ;; + esac + name="REPOSITORY_OLD_$flag" + validate_directory_chain "$(dirname -- "$target")" || failed=1 + if [[ "${!name}" == true ]]; then + validate_secure_file "$REPOSITORY_TRANSACTION_DIR/$backup" 600 || failed=1 + validate_secure_file "$target" "$mode" || failed=1 + cmp -s -- "$target" "$REPOSITORY_TRANSACTION_DIR/$backup" || failed=1 + else + [[ ! -e "$target" && ! -L "$target" ]] || failed=1 + fi + done + (( failed == 0 )) || error "仓库恢复最终三文件组验证失败: $REPOSITORY_TRANSACTION_DIR" + return "$failed" +} + archive_failed_transaction() { init_runtime_config local failed_dir="$REPOSITORY_STATE_DIR/failure-$REPOSITORY_GENERATION" @@ -472,14 +508,8 @@ rollback_repository_transaction() { if [[ "${REPOSITORY_SNAPSHOT_READY:-false}" == true ]]; then restore_repository_file "$REPOSITORY_OLD_SOURCE" "$REPOSITORY_TRANSACTION_DIR/old-source" "$SOURCE_FILE" || rollback_failed=true restore_repository_file "$REPOSITORY_OLD_KEY" "$REPOSITORY_TRANSACTION_DIR/old-key" "$KEYRING" || rollback_failed=true - if [[ "$REPOSITORY_OLD_STATE" == true ]]; then - repository_install_file 0600 "$REPOSITORY_TRANSACTION_DIR/old-current" "$REPOSITORY_STATE_DIR/current.rollback" || rollback_failed=true - if [[ -f "$REPOSITORY_STATE_DIR/current.rollback" ]]; then - repository_rename "$REPOSITORY_STATE_DIR/current.rollback" "$REPOSITORY_STATE_DIR/current" || rollback_failed=true - fi - else - rm -f -- "$REPOSITORY_STATE_DIR/current" 2>/dev/null || rollback_failed=true - fi + restore_repository_file "$REPOSITORY_OLD_STATE" "$REPOSITORY_TRANSACTION_DIR/old-current" "$REPOSITORY_STATE_DIR/current" 600 || rollback_failed=true + validate_restored_repository_group || rollback_failed=true fi rm -f -- "$REPOSITORY_KEY_STAGE" "$REPOSITORY_SOURCE_STAGE" 2>/dev/null || rollback_failed=true archive_failed_transaction || rollback_failed=true From a46c7ef026c2c36cfda6e32d684ed2fa54329e5c Mon Sep 17 00:00:00 2001 From: root Date: Wed, 9 Sep 2026 03:06:39 +0800 Subject: [PATCH 27/32] fix: preserve updater policy and close reviewed lifecycle trust gaps --- tests/test-cloudflare-tunnel.sh | 87 ++++++++++++++ tools/cloudflare_tunnel.sh | 200 ++++++++++++++++++++------------ 2 files changed, 215 insertions(+), 72 deletions(-) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index cb9bf71..113d167 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -1223,6 +1223,93 @@ for restore_object in key source current; do done done +for operation in install_cloudflared upgrade_cloudflared; do + ( + new_case + trap - EXIT + cp "$CASE_DIR/bin/cloudflared" "$APT_BIN" + dpkg-query() { [[ "$*" == *'db:Status-Status'* ]] && { printf installed; return 0; }; return 1; } + timer_enabled=true + systemctl() { + printf 'systemctl:%s\n' "$*" >> "$FAKE_LOG" + if [[ "$1" == is-enabled || "$1" == is-active ]]; then [[ "$timer_enabled" == true ]]; else return 0; fi + } + write_auto_update_files || fail "timer fixture" + auto_update_file_is_managed "$AUTO_UPDATE_SCRIPT" || fail "main script template rejected" + auto_update_file_is_managed "$AUTO_UPDATE_SERVICE" || fail "main service template rejected" + auto_update_file_is_managed "$AUTO_UPDATE_TIMER" || fail "main timer template rejected" + "$operation" || fail "enabled operation" + [[ "$PRESERVE_AUTO_UPDATE" == true ]] || fail "strategy reset by init" + grep -Fxq 'systemctl:enable --now cloudflared-apt-update.timer' "$FAKE_LOG" || fail "timer not enabled" + timer_enabled=false + : > "$FAKE_LOG" + "$operation" || fail "disabled operation" + [[ "$PRESERVE_AUTO_UPDATE" == false ]] || fail "strategy leaked between operations" + if grep -Fxq 'systemctl:enable --now cloudflared-apt-update.timer' "$FAKE_LOG"; then fail "disabled timer enabled"; fi + timer_enabled=true + export FAKE_APT_UPDATE_FAIL=1 + : > "$FAKE_LOG" + if "$operation"; then fail "repository failure ignored"; fi + if grep -Eq 'systemctl:(disable|stop|enable|daemon-reload)' "$FAKE_LOG"; then fail "repository failure continued lifecycle"; fi + ) > "$TEST_DIR/policy-$operation.log" 2>&1 || { + cat "$TEST_DIR/policy-$operation.log" + fail "policy $operation" + } + pass "policy $operation preserves enabled main timer without cross-operation leakage" +done + +( + new_case + trap - EXIT + write_auto_update_files || fail "template fixture" + printf '# changed\n' >> "$AUTO_UPDATE_TIMER" + if auto_update_file_is_managed "$AUTO_UPDATE_TIMER"; then fail "partial marker trusted"; fi + cp "$AUTO_UPDATE_TIMER" "$CASE_DIR/foreign-timer" + if disable_auto_update --confirmed; then fail "unknown timer deleted"; fi + cmp "$AUTO_UPDATE_TIMER" "$CASE_DIR/foreign-timer" || fail "unknown timer changed" +) || fail "strict main template" +pass "main updater templates reject extra content without deleting unknown files" + +( + new_case + trap - EXIT + begin_repository_transaction || fail "absent transaction" + mv "$SOURCE_FILE" "$CASE_DIR/owned-source" + printf foreign > "$SOURCE_FILE"; chmod 0644 "$SOURCE_FILE" + if rollback_repository_transaction foreign; then fail "foreign absent accepted"; fi + [[ "$(cat "$SOURCE_FILE")" == foreign ]] || fail "foreign absent deleted" + [[ -f "$REPOSITORY_TRANSACTION_DIR/pending" ]] || fail "rollback pending missing" + : > "$FAKE_LOG" + if configure_repository; then fail "pending allowed restart"; fi + [[ ! -s "$FAKE_LOG" ]] || fail "pending performed external work" +) || fail "absent ownership" +pass "repository absent foreign object survives rollback and blocks restart" + +( + new_case + trap - EXIT + acquire_repository_lock || fail "lock fixture" + release_repository_lock || fail "first release" + mkdir "$CLOUDFLARED_STATE_DIR.lock" + release_repository_lock || fail "idempotent release" + [[ -d "$CLOUDFLARED_STATE_DIR.lock" ]] || fail "released another operation lock" +) || fail "lock identity" +pass "repeated lock release preserves next operation lock" + +( + new_case + trap - EXIT + set_old_generation + mkdir -p "$STATE_DIR" + chmod 0700 "$STATE_DIR" + printf unknown > "$STATE_DIR/repository-managed" + chmod 0600 "$STATE_DIR/repository-managed" + : > "$FAKE_LOG" + if uninstall_cloudflared --confirmed; then fail "bad legacy marker accepted"; fi + [[ ! -s "$FAKE_LOG" ]] || fail "legacy ownership checked after destructive operations" +) || fail "legacy preflight" +pass "legacy uninstall rejects unknown ownership before systemctl or APT" + entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" pass "bash -c entrypoint remains compatible" diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index 495c559..585efbb 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -30,7 +30,6 @@ init_runtime_config() { SERVICE_FILE="${CLOUDFLARED_SERVICE_FILE:-/etc/systemd/system/cloudflared.service}" BINARY_UPDATE_SERVICE="${CLOUDFLARED_BINARY_UPDATE_SERVICE:-/etc/systemd/system/cloudflared-update.service}" BINARY_UPDATE_TIMER="${CLOUDFLARED_BINARY_UPDATE_TIMER:-/etc/systemd/system/cloudflared-update.timer}" - PRESERVE_AUTO_UPDATE=false } info() { printf '[INFO] %s\n' "$*"; } @@ -63,7 +62,7 @@ confirm() { backup_path() { local path="$1" backup_dir="$2" [[ -e "$path" || -L "$path" ]] || return 0 - install -d -m 0700 "$backup_dir" + install -d -m 0700 "$backup_dir" || return $? cp -a "$path" "$backup_dir/$(basename "$path")" } @@ -210,6 +209,10 @@ validate_repository_state_entries() { validate_uninstall_archive "$entry/snapshot" "${base#failure-uninstall-}" || return 1 ;; history-*|failure-*) + if [[ -e "$entry/pending" || -L "$entry/pending" ]]; then + error "仓库恢复未完成,需人工处理: $entry" + return 1 + fi validate_secure_directory "$entry" 700 || return 1 while IFS= read -r -d '' evidence; do validate_secure_directory "$evidence" 700 || return 1 @@ -267,6 +270,8 @@ acquire_repository_lock() { error "无法取得 Cloudflare 仓库事务锁: $REPOSITORY_LOCK_DIR" return 1 fi + REPOSITORY_OWNED_LOCK="$REPOSITORY_LOCK_DIR" + REPOSITORY_LOCK_IDENTITY=$(stat -c '%d:%i' -- "$REPOSITORY_LOCK_DIR") || return 1 if ! validate_secure_directory "$REPOSITORY_LOCK_DIR" 700; then rmdir -- "$REPOSITORY_LOCK_DIR" 2>/dev/null || true return 1 @@ -303,7 +308,13 @@ restore_repository_traps() { release_repository_lock() { init_runtime_config - [[ -n "$REPOSITORY_LOCK_DIR" ]] || return 0 + [[ -n "${REPOSITORY_OWNED_LOCK:-}" ]] || return 0 + [[ "$REPOSITORY_LOCK_DIR" == "$REPOSITORY_OWNED_LOCK" && + ! -L "$REPOSITORY_LOCK_DIR" && -d "$REPOSITORY_LOCK_DIR" && + "$(stat -c '%d:%i' -- "$REPOSITORY_LOCK_DIR")" == "$REPOSITORY_LOCK_IDENTITY" ]] || { + error "锁身份改变,拒绝释放: $REPOSITORY_OWNED_LOCK" + return 1 + } if [[ -d "$REPOSITORY_LOCK_DIR" && ! -L "$REPOSITORY_LOCK_DIR" ]]; then rmdir -- "$REPOSITORY_LOCK_DIR" || return 1 elif [[ -e "$REPOSITORY_LOCK_DIR" || -L "$REPOSITORY_LOCK_DIR" ]]; then @@ -311,6 +322,8 @@ release_repository_lock() { return 1 fi REPOSITORY_LOCK_DIR="" + REPOSITORY_OWNED_LOCK="" + REPOSITORY_LOCK_IDENTITY="" } repository_copy_file() { @@ -429,6 +442,26 @@ backup_repository_generation() { REPOSITORY_SNAPSHOT_READY=true } +repository_publish_file() { + local stage="$1" target="$2" mode="$3" + validate_secure_file "$stage" "$mode" || return 1 + REPOSITORY_PUBLISHED_IDENTITIES["$target"]=$(stat -c '%d:%i' -- "$stage") || return 1 + REPOSITORY_PUBLISHED_HASHES["$target"]=$(sha256sum -- "$stage" | awk '{print $1}') || return 1 + repository_rename "$stage" "$target" +} + +repository_remove_owned() { + local target="$1" mode="$2" + validate_secure_file "$target" "$mode" || return 1 + [[ -n "${REPOSITORY_PUBLISHED_IDENTITIES[$target]:-}" && + "$(stat -c '%d:%i' -- "$target")" == "${REPOSITORY_PUBLISHED_IDENTITIES[$target]}" && + "$(sha256sum -- "$target" | awk '{print $1}')" == "${REPOSITORY_PUBLISHED_HASHES[$target]}" ]] || { + error "无法证明 absent 目标属于本事务,保留: $target" + return 1 + } + rm -- "$target" +} + restore_repository_file() { init_runtime_config local had_old="$1" backup="$2" target="$3" mode="${4:-644}" restore_stage identity @@ -455,8 +488,7 @@ restore_repository_file() { fi else if [[ -e "$target" || -L "$target" ]]; then - [[ -f "$target" && ! -L "$target" ]] || return 1 - rm -f -- "$target" || return 1 + repository_remove_owned "$target" "$mode" || return 1 fi [[ ! -e "$target" && ! -L "$target" ]] || return 1 fi @@ -512,9 +544,12 @@ rollback_repository_transaction() { validate_restored_repository_group || rollback_failed=true fi rm -f -- "$REPOSITORY_KEY_STAGE" "$REPOSITORY_SOURCE_STAGE" 2>/dev/null || rollback_failed=true + if [[ "$rollback_failed" == true && -d "$REPOSITORY_TRANSACTION_DIR" ]]; then + (umask 077; printf 'rollback incomplete\n' > "$REPOSITORY_TRANSACTION_DIR/pending") || true + fi archive_failed_transaction || rollback_failed=true release_repository_lock || rollback_failed=true - restore_repository_traps + restore_repository_traps || rollback_failed=true if [[ "$rollback_failed" == true ]]; then error "Cloudflare 仓库事务回滚不完整;失败证据已尽量保留: $REPOSITORY_TRANSACTION_DIR" return 1 @@ -570,6 +605,8 @@ begin_repository_transaction() { local key_parent source_parent downloaded marker_stage key_hash source_hash REPOSITORY_TRANSACTION_ACTIVE=false REPOSITORY_SNAPSHOT_READY=false + unset REPOSITORY_PUBLISHED_IDENTITIES REPOSITORY_PUBLISHED_HASHES + declare -gA REPOSITORY_PUBLISHED_IDENTITIES=() REPOSITORY_PUBLISHED_HASHES=() REPOSITORY_TRANSACTION_DIR="" REPOSITORY_KEY_STAGE="" REPOSITORY_SOURCE_STAGE="" @@ -642,14 +679,16 @@ begin_repository_transaction() { return 1 } if ! repository_install_file 0644 "$downloaded" "$REPOSITORY_KEY_STAGE" || - ! validate_secure_file "$REPOSITORY_KEY_STAGE" 644; then + ! validate_secure_file "$REPOSITORY_KEY_STAGE" 644 || + ! cmp -s -- "$downloaded" "$REPOSITORY_KEY_STAGE"; then repository_transaction_fail "写入或校验 key stage 失败" return 1 fi if ! repository_source_content > "$REPOSITORY_TRANSACTION_DIR/source" || ! chmod 0600 "$REPOSITORY_TRANSACTION_DIR/source" || ! repository_install_file 0644 "$REPOSITORY_TRANSACTION_DIR/source" "$REPOSITORY_SOURCE_STAGE" || - ! validate_secure_file "$REPOSITORY_SOURCE_STAGE" 644; then + ! validate_secure_file "$REPOSITORY_SOURCE_STAGE" 644 || + ! cmp -s -- "$REPOSITORY_TRANSACTION_DIR/source" "$REPOSITORY_SOURCE_STAGE"; then repository_transaction_fail "写入或校验 source stage 失败" return 1 fi @@ -659,7 +698,7 @@ begin_repository_transaction() { return 1 } repository_transaction_hook key-commit - if ! repository_rename "$REPOSITORY_KEY_STAGE" "$KEYRING"; then + if ! repository_publish_file "$REPOSITORY_KEY_STAGE" "$KEYRING" 644; then repository_transaction_fail "提交正式 keyring 失败" return 1 fi @@ -669,7 +708,7 @@ begin_repository_transaction() { return 1 } repository_transaction_hook source-commit - if ! repository_rename "$REPOSITORY_SOURCE_STAGE" "$SOURCE_FILE"; then + if ! repository_publish_file "$REPOSITORY_SOURCE_STAGE" "$SOURCE_FILE" 644; then repository_transaction_fail "提交正式 source 失败" return 1 fi @@ -688,12 +727,16 @@ begin_repository_transaction() { repository_transaction_fail "计算 source 世代摘要失败" return 1 } - marker_stage="$REPOSITORY_STATE_DIR/current.stage-$REPOSITORY_GENERATION" + marker_stage=$(mktemp "$REPOSITORY_STATE_DIR/current.stage.XXXXXX") || { + repository_transaction_fail "创建 current stage 失败" + return 1 + } if ! printf 'generation=%s\nkey_sha256=%s\nsource_sha256=%s\n' \ "$REPOSITORY_GENERATION" "$key_hash" "$source_hash" > "$marker_stage" || ! chmod 0600 "$marker_stage" || ! validate_secure_file "$marker_stage" 600 || - ! repository_rename "$marker_stage" "$REPOSITORY_STATE_DIR/current"; then + ! repository_publish_file "$marker_stage" "$REPOSITORY_STATE_DIR/current" 600 || + ! validate_current_repository_manifest; then rm -f -- "$marker_stage" 2>/dev/null || true repository_transaction_fail "提交 key/source 世代状态失败" return 1 @@ -761,13 +804,8 @@ run_repository_apt_transaction() { } legacy_updater_is_managed() { - local path="$1" - case "$path" in - "$LEGACY_UPDATER") grep -Fq 'cloudflared 自动更新脚本 (由安装脚本生成)' "$path" ;; - "$LEGACY_SERVICE") grep -Fq 'Description=Cloudflared Auto Updater' "$path" && grep -Fq "ExecStart=$LEGACY_UPDATER" "$path" ;; - "$LEGACY_TIMER") grep -Fq 'Description=Cloudflared Auto Updater Timer' "$path" ;; - *) return 1 ;; - esac + error "历史 updater 缺少已核实完整模板,保留并需人工处理: $1" + return 1 } cleanup_legacy_updater() { @@ -776,7 +814,7 @@ cleanup_legacy_updater() { local -a paths=("$LEGACY_UPDATER" "$LEGACY_SERVICE" "$LEGACY_TIMER") for path in "${paths[@]}"; do - [[ -e "$path" ]] || continue + [[ -e "$path" || -L "$path" ]] || continue if ! legacy_updater_is_managed "$path"; then warn "发现无法确认归属的旧文件,保留: $path" return 1 @@ -786,38 +824,28 @@ cleanup_legacy_updater() { systemctl disable --now cloudflared-updater.timer >/dev/null 2>&1 || true systemctl stop cloudflared-updater.service >/dev/null 2>&1 || true for path in "${paths[@]}"; do - [[ -e "$path" ]] || continue + [[ -e "$path" || -L "$path" ]] || continue if [[ "${UNINSTALL_TRANSACTION_STATE:-NONE}" != ACTIVE ]]; then backup_path "$path" "$backup_dir" || return 1 fi rm -f "$path" || return 1 done - systemctl daemon-reload + systemctl daemon-reload || return $? for path in "${paths[@]}"; do [[ ! -e "$path" ]] || return 1 done } binary_updater_is_managed() { - local path="$1" - case "$path" in - "$BINARY_UPDATE_SERVICE") - grep -Fq 'Description=Update cloudflared' "$path" && - grep -Fq ' update; code=$?' "$path" - ;; - "$BINARY_UPDATE_TIMER") - grep -Fq 'Description=Update cloudflared' "$path" && - grep -Fq 'OnCalendar=daily' "$path" - ;; - *) return 1 ;; - esac + error "历史 updater 缺少已核实完整模板,保留并需人工处理: $1" + return 1 } cleanup_binary_updater() { local path backup_dir local -a paths=("$BINARY_UPDATE_SERVICE" "$BINARY_UPDATE_TIMER") for path in "${paths[@]}"; do - [[ -e "$path" ]] || continue + [[ -e "$path" || -L "$path" ]] || continue binary_updater_is_managed "$path" || { warn "发现无法确认归属的 cloudflared 二进制更新单元,保留: $path" return 1 @@ -827,19 +855,23 @@ cleanup_binary_updater() { systemctl disable --now cloudflared-update.timer >/dev/null 2>&1 || true systemctl stop cloudflared-update.service >/dev/null 2>&1 || true for path in "${paths[@]}"; do - [[ -e "$path" ]] || continue + [[ -e "$path" || -L "$path" ]] || continue if [[ "${UNINSTALL_TRANSACTION_STATE:-NONE}" != ACTIVE ]]; then backup_path "$path" "$backup_dir" || return 1 fi rm -f "$path" || return 1 done - systemctl daemon-reload + systemctl daemon-reload || return $? for path in "${paths[@]}"; do [[ ! -e "$path" ]] || return 1 done } legacy_auto_update_present() { + if [[ -f "$AUTO_UPDATE_TIMER" ]] && auto_update_file_is_managed "$AUTO_UPDATE_TIMER" && + systemctl is-enabled --quiet cloudflared-apt-update.timer 2>/dev/null; then + return 0 + fi if [[ -f "$LEGACY_TIMER" ]] && legacy_updater_is_managed "$LEGACY_TIMER" && { systemctl is-enabled --quiet cloudflared-updater.timer 2>/dev/null || systemctl is-active --quiet cloudflared-updater.timer 2>/dev/null; }; then @@ -887,7 +919,7 @@ migrate_legacy_service_path() { local backup_dir service_temp was_active=false backup_dir="$STATE_DIR/legacy-service-$(date +%Y%m%d_%H%M%S)" - backup_path "$SERVICE_FILE" "$backup_dir" + backup_path "$SERVICE_FILE" "$backup_dir" || return $? service_temp=$(mktemp) sed "s#^ExecStart=$LEGACY_BIN #ExecStart=$APT_BIN #" "$SERVICE_FILE" > "$service_temp" grep -Fq "ExecStart=$APT_BIN " "$service_temp" || { @@ -896,13 +928,13 @@ migrate_legacy_service_path() { return 1 } systemctl is-active --quiet cloudflared.service && was_active=true || true - install -m 0644 "$service_temp" "$SERVICE_FILE" + install -m 0644 "$service_temp" "$SERVICE_FILE" || { rm -f "$service_temp"; return 1; } rm -f "$service_temp" - systemctl daemon-reload + systemctl daemon-reload || return $? if [[ "$was_active" == true ]]; then if ! systemctl restart cloudflared.service || ! systemctl is-active --quiet cloudflared.service; then cp -a "$backup_dir/$(basename "$SERVICE_FILE")" "$SERVICE_FILE" - systemctl daemon-reload + systemctl daemon-reload || return $? systemctl restart cloudflared.service >/dev/null 2>&1 || true error "新 APT 二进制启动失败,已恢复旧服务路径" return 1 @@ -924,11 +956,11 @@ migrate_legacy_binary() { fi info "检测到可安全迁移的旧版 cloudflared 安装" - migrate_legacy_service_path + migrate_legacy_service_path || return $? local backup_dir backup_dir="$STATE_DIR/legacy-$(date +%Y%m%d_%H%M%S)" - backup_path "$LEGACY_BIN" "$backup_dir" + backup_path "$LEGACY_BIN" "$backup_dir" || return $? rm -f "$LEGACY_BIN" info "旧二进制已自动备份并移除: $backup_dir" } @@ -1006,24 +1038,36 @@ EOF } auto_update_file_is_managed() { - grep -Fq '# Managed by tools/cloudflare_tunnel.sh' "$1" + local path="$1" expected actual mode + case "$path" in + "$AUTO_UPDATE_SCRIPT") mode=755; expected=29927fb2755a5ce8e43eee305ef2a1833fd87f1ef342f51442c34ff47c4a066e ;; + "$AUTO_UPDATE_TIMER") mode=644; expected=5aebba15b5de91d6182e3bc662a6cbb0481eff451926fd4703a7a4e16661b827 ;; + "$AUTO_UPDATE_SERVICE") + mode=644 + expected=$(printf '# Managed by tools/cloudflare_tunnel.sh\n[Unit]\nDescription=Check and install cloudflared APT updates\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nExecStart=%s\n' "$AUTO_UPDATE_SCRIPT" | sha256sum | awk '{print $1}') ;; + *) return 1 ;; + esac + validate_directory_chain "$(dirname -- "$path")" || return 1 + validate_secure_file "$path" "$mode" || return 1 + actual=$(sha256sum -- "$path" | awk '{print $1}') || return 1 + [[ "$actual" == "$expected" ]] } enable_auto_update() { local path backup_dir require_root - check_platform + check_platform || return $? dpkg-query -W -f='${db:Status-Status}' cloudflared 2>/dev/null | grep -qx installed || { error "请先安装 cloudflared APT 包" return 1 } - configure_repository + configure_repository || return $? command -v flock >/dev/null || { apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y util-linux } for path in "$AUTO_UPDATE_SCRIPT" "$AUTO_UPDATE_SERVICE" "$AUTO_UPDATE_TIMER"; do - [[ -e "$path" ]] || continue + [[ -e "$path" || -L "$path" ]] || continue auto_update_file_is_managed "$path" || { error "现有自动更新文件不受本脚本管理,拒绝覆盖: $path" return 1 @@ -1031,20 +1075,20 @@ enable_auto_update() { done backup_dir="$STATE_DIR/auto-update-previous-$(date +%Y%m%d_%H%M%S)" for path in "$AUTO_UPDATE_SCRIPT" "$AUTO_UPDATE_SERVICE" "$AUTO_UPDATE_TIMER"; do - backup_path "$path" "$backup_dir" + backup_path "$path" "$backup_dir" || return $? done - write_auto_update_files - systemctl daemon-reload - systemctl enable --now cloudflared-apt-update.timer + write_auto_update_files || return $? + systemctl daemon-reload || return $? + systemctl enable --now cloudflared-apt-update.timer || return $? info "已启用每日 APT 更新检查;更新时仅重启原本正在运行的 cloudflared 服务。" } disable_auto_update_locked() { local confirmed="${1:-}" path backup_dir require_root - check_platform + check_platform || return $? for path in "$AUTO_UPDATE_SCRIPT" "$AUTO_UPDATE_SERVICE" "$AUTO_UPDATE_TIMER"; do - [[ -e "$path" ]] || continue + [[ -e "$path" || -L "$path" ]] || continue auto_update_file_is_managed "$path" || { error "自动更新文件不受本脚本管理,拒绝删除: $path" return 1 @@ -1057,13 +1101,13 @@ disable_auto_update_locked() { systemctl disable --now cloudflared-apt-update.timer >/dev/null 2>&1 || true systemctl stop cloudflared-apt-update.service >/dev/null 2>&1 || true for path in "$AUTO_UPDATE_SCRIPT" "$AUTO_UPDATE_SERVICE" "$AUTO_UPDATE_TIMER"; do - [[ -e "$path" ]] || continue + [[ -e "$path" || -L "$path" ]] || continue if [[ "${UNINSTALL_TRANSACTION_STATE:-NONE}" != ACTIVE ]]; then backup_path "$path" "$backup_dir" || return 1 fi rm -f "$path" || return 1 done - systemctl daemon-reload + systemctl daemon-reload || return $? info "APT 自动更新组件已禁用;备份目录: $backup_dir" } @@ -1096,14 +1140,14 @@ validate_migration_inputs() { return 1 fi for path in "$LEGACY_UPDATER" "$LEGACY_SERVICE" "$LEGACY_TIMER"; do - [[ -e "$path" ]] || continue + [[ -e "$path" || -L "$path" ]] || continue legacy_updater_is_managed "$path" || { error "旧更新文件归属不明,拒绝自动迁移: $path" return 1 } done for path in "$BINARY_UPDATE_SERVICE" "$BINARY_UPDATE_TIMER"; do - [[ -e "$path" ]] || continue + [[ -e "$path" || -L "$path" ]] || continue binary_updater_is_managed "$path" || { error "二进制更新单元归属不明,拒绝自动迁移: $path" return 1 @@ -1112,15 +1156,16 @@ validate_migration_inputs() { } install_package() { - validate_migration_inputs PRESERVE_AUTO_UPDATE=false + init_runtime_config + validate_migration_inputs || return $? legacy_auto_update_present && PRESERVE_AUTO_UPDATE=true || true run_repository_apt_transaction install || return $? - migrate_legacy_binary + migrate_legacy_binary || return $? cleanup_legacy_updater || { error "旧自定义更新组件清理失败"; return 1; } cleanup_binary_updater || { error "二进制更新单元清理失败"; return 1; } if [[ "$PRESERVE_AUTO_UPDATE" == true ]]; then - enable_auto_update + enable_auto_update || return $? info "检测到旧版每日更新配置,已自动迁移为 APT timer。" fi @@ -1154,7 +1199,7 @@ install_service() { install_cloudflared() { require_root - check_platform + check_platform || return $? install_package || return $? install_service || return $? info "安装完成。版本由 APT 管理。" @@ -1163,7 +1208,7 @@ install_cloudflared() { else warn "自动更新安装新版时会重启正在运行的 cloudflared,单实例 Tunnel 会短暂中断。" if confirm "是否启用每日 APT 更新检测与安装?"; then - enable_auto_update + enable_auto_update || return $? else info "自动更新未启用;稍后可运行: sudo $(basename "$0") enable-auto-update" fi @@ -1171,17 +1216,19 @@ install_cloudflared() { } upgrade_cloudflared() { + PRESERVE_AUTO_UPDATE=false + init_runtime_config require_root - check_platform - validate_migration_inputs + check_platform || return $? + validate_migration_inputs || return $? PRESERVE_AUTO_UPDATE=false legacy_auto_update_present && PRESERVE_AUTO_UPDATE=true || true run_repository_apt_transaction upgrade || return $? - migrate_legacy_binary + migrate_legacy_binary || return $? cleanup_legacy_updater || { error "旧自定义更新组件清理失败"; return 1; } cleanup_binary_updater || { error "二进制更新单元清理失败"; return 1; } if [[ "$PRESERVE_AUTO_UPDATE" == true ]]; then - enable_auto_update + enable_auto_update || return $? info "检测到旧版每日更新配置,已自动迁移为 APT timer。" fi if systemctl cat cloudflared.service >/dev/null 2>&1; then @@ -1236,9 +1283,9 @@ validate_current_repository_manifest() { [[ "$(cat -- "$SOURCE_FILE")" == "$(repository_source_content)" ]] || return 1 } -remove_managed_repository_locked() { +validate_managed_repository_ownership() { init_runtime_config - local backup_dir managed=false + local managed=false validate_directory_chain "$(dirname -- "$SOURCE_FILE")" || return 1 if [[ -f "$REPOSITORY_STATE_DIR/current" ]] && validate_current_repository_manifest; then managed=true @@ -1251,6 +1298,11 @@ remove_managed_repository_locked() { managed=true fi [[ "$managed" == true ]] || { error "无法验证 Cloudflare source 管理归属,拒绝删除"; return 1; } +} + +remove_managed_repository_locked() { + local backup_dir + validate_managed_repository_ownership || return 1 backup_dir="$STATE_DIR/uninstall-$(date +%Y%m%d_%H%M%S)" if [[ "${UNINSTALL_TRANSACTION_STATE:-NONE}" != ACTIVE ]]; then backup_path "$SOURCE_FILE" "$backup_dir" || return 1 @@ -1744,7 +1796,7 @@ finish_uninstall_transaction() { uninstall_cloudflared() { local confirmed="${1:-}" uninstall_status=0 irreversible=false require_root - check_platform + check_platform || return $? warn "将删除 cloudflared 服务和 APT 包;Tunnel 配置与凭据默认保留。" if [[ "$confirmed" != --confirmed ]]; then confirm "继续卸载?" || { info "已取消"; return 0; } @@ -1767,6 +1819,10 @@ uninstall_cloudflared() { release_repository_lock || return 1 return 1 fi + validate_managed_repository_ownership || { + release_repository_lock || return 1 + return 1 + } begin_uninstall_transaction || { uninstall_cleanup "snapshot capture failed" || true release_repository_lock || return 1 @@ -1809,13 +1865,13 @@ uninstall_cloudflared() { purge_config() { require_root - check_platform + check_platform || return $? warn "此操作会永久删除 /etc/cloudflared、/root/.cloudflared 和当前用户配置。" [[ -t 0 ]] || { error "彻底清理必须在交互终端执行"; return 1; } local answer read -r -p "请输入 PURGE 确认: " answer [[ "$answer" == PURGE ]] || { info "已取消"; return 0; } - uninstall_cloudflared --confirmed + uninstall_cloudflared --confirmed || return $? rm -rf /etc/cloudflared /root/.cloudflared if [[ "${HOME:-/root}" != /root ]]; then rm -rf "$HOME/.cloudflared" @@ -1851,7 +1907,7 @@ main() { disable-auto-update) disable_auto_update "${2:-}" ;; migrate-legacy) require_root - check_platform + check_platform || return $? install_package || return $? ;; uninstall) uninstall_cloudflared ;; From bd0f4d6c3e7f00e1ee5e1c716d71611e9a427898 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 9 Sep 2026 03:16:04 +0800 Subject: [PATCH 28/32] fix: track owned repository stages and isolate timer fixture state --- tests/test-cloudflare-tunnel.sh | 6 ++++- tools/cloudflare_tunnel.sh | 41 +++++++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index 113d167..8f19c10 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -1232,7 +1232,11 @@ for operation in install_cloudflared upgrade_cloudflared; do timer_enabled=true systemctl() { printf 'systemctl:%s\n' "$*" >> "$FAKE_LOG" - if [[ "$1" == is-enabled || "$1" == is-active ]]; then [[ "$timer_enabled" == true ]]; else return 0; fi + if [[ "$*" == *cloudflared-apt-update.timer* && ( "$1" == is-enabled || "$1" == is-active ) ]]; then + [[ "$timer_enabled" == true ]] + else + return 0 + fi } write_auto_update_files || fail "timer fixture" auto_update_file_is_managed "$AUTO_UPDATE_SCRIPT" || fail "main script template rejected" diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index 585efbb..3a0de2c 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -442,6 +442,26 @@ backup_repository_generation() { REPOSITORY_SNAPSHOT_READY=true } +repository_track_stage() { + local path="$1" + REPOSITORY_STAGE_IDS["$path"]=$(stat -c '%d:%i' -- "$path") || return 1 +} + +repository_cleanup_stages() { + local path failed=0 + for path in "${!REPOSITORY_STAGE_IDS[@]}"; do + [[ -e "$path" || -L "$path" ]] || continue + if validate_directory_chain "$(dirname -- "$path")" && + [[ -f "$path" && ! -L "$path" && "$(stat -c '%u:%g:%d:%i' -- "$path")" == "0:0:${REPOSITORY_STAGE_IDS[$path]}" ]]; then + rm -- "$path" || failed=1 + else + error "仓库 stage 身份改变,保留: $path" + failed=1 + fi + done + return "$failed" +} + repository_publish_file() { local stage="$1" target="$2" mode="$3" validate_secure_file "$stage" "$mode" || return 1 @@ -543,7 +563,7 @@ rollback_repository_transaction() { restore_repository_file "$REPOSITORY_OLD_STATE" "$REPOSITORY_TRANSACTION_DIR/old-current" "$REPOSITORY_STATE_DIR/current" 600 || rollback_failed=true validate_restored_repository_group || rollback_failed=true fi - rm -f -- "$REPOSITORY_KEY_STAGE" "$REPOSITORY_SOURCE_STAGE" 2>/dev/null || rollback_failed=true + repository_cleanup_stages || rollback_failed=true if [[ "$rollback_failed" == true && -d "$REPOSITORY_TRANSACTION_DIR" ]]; then (umask 077; printf 'rollback incomplete\n' > "$REPOSITORY_TRANSACTION_DIR/pending") || true fi @@ -551,6 +571,9 @@ rollback_repository_transaction() { release_repository_lock || rollback_failed=true restore_repository_traps || rollback_failed=true if [[ "$rollback_failed" == true ]]; then + if [[ -d "$REPOSITORY_TRANSACTION_DIR" ]]; then + (umask 077; printf 'rollback incomplete\n' > "$REPOSITORY_TRANSACTION_DIR/pending") || true + fi error "Cloudflare 仓库事务回滚不完整;失败证据已尽量保留: $REPOSITORY_TRANSACTION_DIR" return 1 fi @@ -607,6 +630,8 @@ begin_repository_transaction() { REPOSITORY_SNAPSHOT_READY=false unset REPOSITORY_PUBLISHED_IDENTITIES REPOSITORY_PUBLISHED_HASHES declare -gA REPOSITORY_PUBLISHED_IDENTITIES=() REPOSITORY_PUBLISHED_HASHES=() + unset REPOSITORY_STAGE_IDS + declare -gA REPOSITORY_STAGE_IDS=() REPOSITORY_TRANSACTION_DIR="" REPOSITORY_KEY_STAGE="" REPOSITORY_SOURCE_STAGE="" @@ -674,10 +699,12 @@ begin_repository_transaction() { repository_transaction_fail "创建 key stage 失败" return 1 } + repository_track_stage "$REPOSITORY_KEY_STAGE" || { repository_transaction_fail "记录 key stage 失败"; return 1; } REPOSITORY_SOURCE_STAGE=$(mktemp "$source_parent/.cloudflared.list.stage.XXXXXX") || { repository_transaction_fail "创建 source stage 失败" return 1 } + repository_track_stage "$REPOSITORY_SOURCE_STAGE" || { repository_transaction_fail "记录 source stage 失败"; return 1; } if ! repository_install_file 0644 "$downloaded" "$REPOSITORY_KEY_STAGE" || ! validate_secure_file "$REPOSITORY_KEY_STAGE" 644 || ! cmp -s -- "$downloaded" "$REPOSITORY_KEY_STAGE"; then @@ -731,13 +758,13 @@ begin_repository_transaction() { repository_transaction_fail "创建 current stage 失败" return 1 } + repository_track_stage "$marker_stage" || { repository_transaction_fail "记录 current stage 失败"; return 1; } if ! printf 'generation=%s\nkey_sha256=%s\nsource_sha256=%s\n' \ "$REPOSITORY_GENERATION" "$key_hash" "$source_hash" > "$marker_stage" || ! chmod 0600 "$marker_stage" || ! validate_secure_file "$marker_stage" 600 || ! repository_publish_file "$marker_stage" "$REPOSITORY_STATE_DIR/current" 600 || ! validate_current_repository_manifest; then - rm -f -- "$marker_stage" 2>/dev/null || true repository_transaction_fail "提交 key/source 世代状态失败" return 1 fi @@ -966,9 +993,9 @@ migrate_legacy_binary() { } write_auto_update_files() { - install -d -m 0755 "$(dirname "$AUTO_UPDATE_SCRIPT")" "$(dirname "$AUTO_UPDATE_SERVICE")" + install -d -m 0755 "$(dirname "$AUTO_UPDATE_SCRIPT")" "$(dirname "$AUTO_UPDATE_SERVICE")" || return $? - cat > "$AUTO_UPDATE_SCRIPT" <<'UPDATER' + cat > "$AUTO_UPDATE_SCRIPT" <<'UPDATER' || return $? #!/usr/bin/env bash # Managed by tools/cloudflare_tunnel.sh set -euo pipefail @@ -1007,9 +1034,9 @@ if [[ "$was_active" == true ]]; then fi /usr/bin/cloudflared version UPDATER - chmod 0755 "$AUTO_UPDATE_SCRIPT" + chmod 0755 "$AUTO_UPDATE_SCRIPT" || return $? - cat > "$AUTO_UPDATE_SERVICE" < "$AUTO_UPDATE_SERVICE" < "$AUTO_UPDATE_TIMER" <<'EOF' + cat > "$AUTO_UPDATE_TIMER" <<'EOF' || return $? # Managed by tools/cloudflare_tunnel.sh [Unit] Description=Daily cloudflared APT update check From 78481170704660229b257433ccd94c8192c5d5ab Mon Sep 17 00:00:00 2001 From: root Date: Wed, 9 Sep 2026 03:17:06 +0800 Subject: [PATCH 29/32] test: cover reviewed cleanup failures and document verified updater compatibility --- README.md | 5 +++-- tests/test-cloudflare-tunnel.sh | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 938cf63..d60d789 100644 --- a/README.md +++ b/README.md @@ -274,8 +274,9 @@ cloudflared 时再启用。`upgrade` 可用于立即手动检查、升级并重 `migrate-legacy` 可单独执行相同迁移流程。 脚本使用 `service install --no-update-service`,并识别、备份和清理旧版裸二进制更新单元,避免 -APT 包与 `cloudflared update` 混用。若旧环境已有每日自动更新 timer,迁移时会自动换成新的 -APT timer;旧环境未启用自动更新时仍保持关闭并询问是否启用。`uninstall` 在同一事务锁内验证 +APT 包与 `cloudflared update` 混用。自动处理只接受已核实的完整 APT updater 模板(脚本、service、timer), +并校验类型、权限与目录信任链;更早的裸二进制 updater 或手改模板保留并停止,需人工处理。 +当前 main 原样生成的 APT timer 已启用时保持启用;原本关闭时仍保持关闭,安装流程可询问是否启用。`uninstall` 在同一事务锁内验证 `current` 清单与 key/source 摘要,备份并删除受管 source,保留 keyring、Tunnel 配置和凭据。 文件阶段失败会恢复可恢复配置;APT 包删除属于不可逆边界,失败时不会尝试自动重装。 卸载快照固定包含六个受管目标,完整捕获后才进入 ACTIVE。恢复失败保留原 manifest、payload diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index 8f19c10..cd837bd 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -1314,6 +1314,44 @@ pass "repeated lock release preserves next operation lock" ) || fail "legacy preflight" pass "legacy uninstall rejects unknown ownership before systemctl or APT" +for review_fault in timer-enable stage-identity trap-restore; do + ( + new_case + trap - EXIT + case "$review_fault" in + timer-enable) + dpkg-query() { printf installed; } + systemctl() { + printf '%s\n' "$*" >> "$FAKE_LOG" + [[ "$*" != 'enable --now cloudflared-apt-update.timer' ]] + } + if enable_auto_update > "$CASE_DIR/error.log" 2>&1; then fail "timer enable failure hidden"; fi + grep -Fxq 'enable --now cloudflared-apt-update.timer' "$FAKE_LOG" || fail "timer injection not hit" + ;; + stage-identity) + begin_repository_transaction || fail "stage fixture" + stage=$(mktemp "$CASE_DIR/.stage.XXXXXX") + repository_track_stage "$stage" || fail "track stage" + mv "$stage" "$stage.owned" + printf foreign > "$stage" + if rollback_repository_transaction injected; then fail "foreign stage ignored"; fi + [[ "$(cat "$stage")" == foreign ]] || fail "foreign stage deleted" + ;; + trap-restore) + begin_repository_transaction || fail "trap fixture" + restore_one_trap() { printf '%s\n' "$1" >> "$CASE_DIR/trap-attempts"; return 1; } + if rollback_repository_transaction injected; then fail "trap failure hidden"; fi + [[ "$(wc -l < "$CASE_DIR/trap-attempts")" == 4 ]] || fail "trap attempts skipped" + [[ -f "$REPOSITORY_TRANSACTION_DIR/pending" ]] || fail "trap failure pending missing" + ;; + esac + ) > "$TEST_DIR/review-$review_fault.log" 2>&1 || { + cat "$TEST_DIR/review-$review_fault.log" + fail "review $review_fault" + } + pass "review $review_fault injection reached and failure preserved" +done + entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" pass "bash -c entrypoint remains compatible" From e1517c8efc898a9511794948002bb618811bd567 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 9 Sep 2026 03:31:46 +0800 Subject: [PATCH 30/32] fix: serialize updater entrypoints and retain interrupted operation evidence --- README.md | 4 +- tests/test-cloudflare-tunnel.sh | 59 ++++++++++++++++++++++++ tools/cloudflare_tunnel.sh | 80 ++++++++++++++++++++++++++++----- 3 files changed, 132 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d60d789..078d5e5 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ cloudflared 时再启用。`upgrade` 可用于立即手动检查、升级并重 旧版脚本用户无需先卸载,可直接重新运行 `install`。确认旧二进制、unit 路径和版本均匹配旧版 受管安装后,脚本会全自动安装 APT 包,把 `cloudflared.service` 从 `/usr/local/bin/cloudflared` 事务式迁移到 `/usr/bin/cloudflared`,原样保留 Token/config 参数, -验证服务后再备份并移除旧二进制,无需重新输入 Token。任一验证失败都会恢复旧 unit 和运行状态; +验证服务后再备份并移除旧二进制,无需重新输入 Token。服务启动验证失败会尝试恢复备份 unit;恢复也可能失败,不能保证运行状态恢复,需人工检查备份与服务状态; 归属证据不足则保留文件并停止,不盲删。若上一次迁移已完成 APT 安装和 unit 切换,只留下 `/usr/local/bin/cloudflared -> /usr/bin/cloudflared` 兼容链接,重新运行也会自动识别、备份并收尾。 `migrate-legacy` 可单独执行相同迁移流程。 @@ -285,6 +285,8 @@ APT 包与 `cloudflared update` 混用。自动处理只接受已核实的完整 `SIGKILL` 或收尾失败留下 `pending-uninstall-*`、快照/归档及可能的锁。后续 install、upgrade、 uninstall、disable-auto-update 拒绝继续;应先人工审查错误输出中的状态路径,不要仅删除锁后重试。 HUP/INT/TERM 分别返回 129/130/143;终态归档开始后的中断不会再次执行配置回滚。 +独立 enable/disable-auto-update 全过程使用仓库同一互斥锁;中断或部分修改失败保留 pending, +后续仓库操作失败关闭,需人工检查配置组、备份及服务状态,不自动重放。 彻底清理须显式运行 `purge`,并在交互终端输入 `PURGE` 二次确认。 ### 多服务器文件推送 diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index cd837bd..139496c 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -1352,6 +1352,65 @@ for review_fault in timer-enable stage-identity trap-restore; do pass "review $review_fault injection reached and failure preserved" done +for phase in disable-systemctl disable-backup disable-delete; do + for fault in HUP INT TERM zero failure; do + ( + new_case + trap - EXIT + configure_repository || fail "disable fixture" + write_auto_update_files || fail "disable files" + rc=0 + ( + trap - EXIT + updater_transaction_hook() { + [[ "$1" == "$phase" ]] || return 0 + case "$fault" in zero) exit 0 ;; failure) return 1 ;; *) kill -s "$fault" "$BASHPID" ;; esac + } + disable_auto_update --confirmed + ) > "$CASE_DIR/disable.log" 2>&1 || rc=$? + case "$fault" in HUP) expected=129 ;; INT) expected=130 ;; TERM) expected=143 ;; *) expected=1 ;; esac + [[ "$rc" == "$expected" ]] || fail "disable exit $rc" + [[ -f "$AUTO_UPDATE_SCRIPT" && -f "$AUTO_UPDATE_SERVICE" && -f "$AUTO_UPDATE_TIMER" ]] || fail "premature file deletion" + compgen -G "$REPOSITORY_STATE_DIR/pending-updater-*" >/dev/null || fail "disable pending missing" + grep -q '自动更新操作未完成' "$CASE_DIR/disable.log" || fail "disable diagnostic missing" + acquire_repository_lock || fail "disable lock retained" + release_repository_lock || fail "disable lock release" + ) > "$TEST_DIR/disable-$phase-$fault.log" 2>&1 || { + cat "$TEST_DIR/disable-$phase-$fault.log" + fail "disable $phase $fault" + } + pass "independent disable $phase $fault preserves pending and exit contract" + done +done + +for phase in enable-validate enable-write enable-reload enable-timer disable-systemctl; do + ( + new_case + trap - EXIT + configure_repository || fail "barrier configure" + write_auto_update_files || fail "barrier files" + mkfifo "$CASE_DIR/ready" "$CASE_DIR/release" + ( + trap - EXIT + dpkg-query() { printf installed; } + updater_transaction_hook() { + [[ "$1" == "$phase" ]] || return 0 + printf ready > "$CASE_DIR/ready" + read -r _ < "$CASE_DIR/release" + } + if [[ "$phase" == disable-* ]]; then disable_auto_update --confirmed; else enable_auto_update; fi + ) > "$CASE_DIR/holder.log" 2>&1 & + holder=$! + read -r ready < "$CASE_DIR/ready" || [[ "$ready" == ready ]] || fail "barrier readiness" + for contender in enable_auto_update disable_auto_update uninstall_cloudflared; do + if ( "$contender" --confirmed ) > "$CASE_DIR/contender.log" 2>&1; then fail "contender bypassed lock"; fi + done + printf 'release\n' > "$CASE_DIR/release" + wait "$holder" || { cat "$CASE_DIR/holder.log"; fail "holder failed"; } + ) > "$TEST_DIR/barrier-$phase.log" 2>&1 || { cat "$TEST_DIR/barrier-$phase.log"; fail "barrier $phase"; } + pass "real updater barrier $phase excludes all competing mutation entrypoints" +done + entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" pass "bash -c entrypoint remains compatible" diff --git a/tools/cloudflare_tunnel.sh b/tools/cloudflare_tunnel.sh index 3a0de2c..8d62766 100644 --- a/tools/cloudflare_tunnel.sh +++ b/tools/cloudflare_tunnel.sh @@ -784,6 +784,7 @@ finish_repository_transaction() { fi REPOSITORY_TRANSACTION_DIR="$history_dir" REPOSITORY_TRANSACTION_ACTIVE=false + if [[ "${1:-}" == --keep-lock ]]; then return 0; fi trap - HUP INT TERM EXIT if ! release_repository_lock; then restore_repository_traps @@ -1080,7 +1081,46 @@ auto_update_file_is_managed() { [[ "$actual" == "$expected" ]] } -enable_auto_update() { +updater_transaction_hook() { :; } + +updater_abort() { + local status="$1" + [[ "${UPDATER_ACTIVE:-false}" == true ]] || return "$status" + UPDATER_ACTIVE=false + release_repository_lock || error "自动更新锁释放失败: $REPOSITORY_OWNED_LOCK" + restore_repository_traps || error "自动更新 traps 恢复失败" + error "自动更新操作未完成;不自动恢复服务状态,请人工检查文件、备份与 pending: ${UPDATER_PENDING:-unknown}" + (( status == 0 )) && status=1 + return "$status" +} + +updater_guard() { + UPDATER_ACTIVE=true + trap 'updater_abort 129; exit 129' HUP + trap 'updater_abort 130; exit 130' INT + trap 'updater_abort 143; exit 143' TERM + trap 'status=$?; updater_abort "$status"; exit $?' EXIT +} + +updater_start_pending() { + UPDATER_PENDING="$REPOSITORY_STATE_DIR/pending-updater-$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM" + (set -C; umask 077; printf 'updater operation pending\n' > "$UPDATER_PENDING") +} + +updater_finish() { + # A failed lock release must leave durable evidence. Remove pending while + # locked, recreate on release failure; competing entrypoints still see lock. + validate_secure_file "$UPDATER_PENDING" 600 && rm -- "$UPDATER_PENDING" || { updater_abort 1; return 1; } + if ! release_repository_lock; then + (umask 077; printf 'lock release failed\n' > "$UPDATER_PENDING") || true + updater_abort 1 + return 1 + fi + UPDATER_ACTIVE=false + restore_repository_traps || return 1 +} + +enable_auto_update_locked() { local path backup_dir require_root check_platform || return $? @@ -1088,11 +1128,11 @@ enable_auto_update() { error "请先安装 cloudflared APT 包" return 1 } - configure_repository || return $? command -v flock >/dev/null || { - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y util-linux + apt-get update || return $? + DEBIAN_FRONTEND=noninteractive apt-get install -y util-linux || return $? } + updater_transaction_hook enable-validate || return $? for path in "$AUTO_UPDATE_SCRIPT" "$AUTO_UPDATE_SERVICE" "$AUTO_UPDATE_TIMER"; do [[ -e "$path" || -L "$path" ]] || continue auto_update_file_is_managed "$path" || { @@ -1104,12 +1144,28 @@ enable_auto_update() { for path in "$AUTO_UPDATE_SCRIPT" "$AUTO_UPDATE_SERVICE" "$AUTO_UPDATE_TIMER"; do backup_path "$path" "$backup_dir" || return $? done + updater_transaction_hook enable-write || return $? write_auto_update_files || return $? + updater_transaction_hook enable-reload || return $? systemctl daemon-reload || return $? + updater_transaction_hook enable-timer || return $? systemctl enable --now cloudflared-apt-update.timer || return $? info "已启用每日 APT 更新检查;更新时仅重启原本正在运行的 cloudflared 服务。" } +enable_auto_update() { + init_runtime_config + require_root + check_platform || return $? + begin_repository_transaction || return 1 + # Archive repository changes without releasing the shared lock or traps. + finish_repository_transaction --keep-lock || return 1 + updater_guard + updater_start_pending || { updater_abort 1; return 1; } + enable_auto_update_locked || { local status=$?; updater_abort "$status"; return "$status"; } + updater_finish +} + disable_auto_update_locked() { local confirmed="${1:-}" path backup_dir require_root @@ -1125,13 +1181,16 @@ disable_auto_update_locked() { confirm "禁用并删除 cloudflared APT 自动更新组件?" || { info "已取消"; return 0; } fi backup_dir="$STATE_DIR/auto-update-$(date +%Y%m%d_%H%M%S)" + updater_transaction_hook disable-systemctl || return $? systemctl disable --now cloudflared-apt-update.timer >/dev/null 2>&1 || true systemctl stop cloudflared-apt-update.service >/dev/null 2>&1 || true for path in "$AUTO_UPDATE_SCRIPT" "$AUTO_UPDATE_SERVICE" "$AUTO_UPDATE_TIMER"; do [[ -e "$path" || -L "$path" ]] || continue if [[ "${UNINSTALL_TRANSACTION_STATE:-NONE}" != ACTIVE ]]; then + updater_transaction_hook disable-backup || return $? backup_path "$path" "$backup_dir" || return 1 fi + updater_transaction_hook disable-delete || return $? rm -f "$path" || return 1 done systemctl daemon-reload || return $? @@ -1140,13 +1199,14 @@ disable_auto_update_locked() { disable_auto_update() { init_runtime_config - validate_directory_chain "$(dirname -- "$REPOSITORY_LOCK_DIR")" || return 1 acquire_repository_lock || return 1 - if ! prepare_repository_state || ! disable_auto_update_locked "$@"; then - release_repository_lock || return 1 - return 1 - fi - release_repository_lock + save_repository_traps + updater_guard + UPDATER_PENDING="" + prepare_repository_state || { updater_abort 1; return 1; } + updater_start_pending || { updater_abort 1; return 1; } + disable_auto_update_locked "$@" || { local status=$?; updater_abort "$status"; return "$status"; } + updater_finish } show_auto_update_status() { From cbea6520927f909c9b4fe4d98b0acc55aa56bb53 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 9 Sep 2026 03:36:58 +0800 Subject: [PATCH 31/32] test: verify partial disable failures and lock release evidence --- tests/test-cloudflare-tunnel.sh | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index 139496c..c042684 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -1411,6 +1411,39 @@ for phase in enable-validate enable-write enable-reload enable-timer disable-sys pass "real updater barrier $phase excludes all competing mutation entrypoints" done +for fault in backup delete release; do + ( + new_case + trap - EXIT + configure_repository || fail "file fault fixture" + write_auto_update_files || fail "file fault files" + hit=false + cp() { + if [[ "$fault" == backup && "$*" == *auto-update-* ]]; then hit=true; return 1; fi + command cp "$@" + } + rm() { + if [[ "$fault" == delete && "$*" == *"$AUTO_UPDATE_SERVICE"* ]]; then hit=true; return 1; fi + command rm "$@" + } + rmdir() { + if [[ "$fault" == release ]]; then hit=true; return 1; fi + command rmdir "$@" + } + before=$(trap -p HUP INT TERM EXIT) + if disable_auto_update --confirmed > "$CASE_DIR/error.log" 2>&1; then fail "disable file fault accepted"; fi + [[ "$hit" == true ]] || fail "file fault not hit" + [[ "$(trap -p HUP INT TERM EXIT)" == "$before" ]] || fail "disable traps not restored" + compgen -G "$REPOSITORY_STATE_DIR/pending-updater-*" >/dev/null || fail "file fault pending missing" + case "$fault" in + backup) [[ -f "$AUTO_UPDATE_SCRIPT" && -f "$AUTO_UPDATE_SERVICE" && -f "$AUTO_UPDATE_TIMER" ]] || fail "backup failure deleted files" ;; + delete) [[ ! -e "$AUTO_UPDATE_SCRIPT" && -f "$AUTO_UPDATE_SERVICE" && -f "$AUTO_UPDATE_TIMER" ]] || fail "partial deletion group wrong" ;; + release) [[ -d "$CLOUDFLARED_STATE_DIR.lock" ]] || fail "failed release lost lock" ;; + esac + ) > "$TEST_DIR/disable-file-$fault.log" 2>&1 || { cat "$TEST_DIR/disable-file-$fault.log"; fail "disable file $fault"; } + pass "independent disable actual $fault failure retains group evidence and traps" +done + entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" pass "bash -c entrypoint remains compatible" From 40c17d88e7cf02b2aecb8bf82fd5a87be447bc3b Mon Sep 17 00:00:00 2001 From: root Date: Wed, 9 Sep 2026 04:04:22 +0800 Subject: [PATCH 32/32] test: cover uninstall lock contention and util-linux dependency failures --- tests/test-cloudflare-tunnel.sh | 93 +++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/tests/test-cloudflare-tunnel.sh b/tests/test-cloudflare-tunnel.sh index c042684..c23689a 100644 --- a/tests/test-cloudflare-tunnel.sh +++ b/tests/test-cloudflare-tunnel.sh @@ -1444,6 +1444,99 @@ for fault in backup delete release; do pass "independent disable actual $fault failure retains group evidence and traps" done +# Separate shells load the implementation independently; only file/FIFO state is shared. +( + new_case + trap - EXIT + configure_repository || fail "uninstall barrier configure" + write_auto_update_files || fail "uninstall barrier updater" + mkfifo "$CASE_DIR/uninstall-ready" "$CASE_DIR/uninstall-release" + bash -c ' + source "$1" + check_platform() { :; } + uninstall_transaction_hook() { + [[ "$1" == after-snapshot-active ]] || return 0 + printf "%s\n" "$UNINSTALL_SNAPSHOT_DIR" > "$CASE_DIR/snapshot-path" + printf "ready\n" > "$CASE_DIR/uninstall-ready" + read -r _ < "$CASE_DIR/uninstall-release" + } + uninstall_cloudflared --confirmed + ' _ "$ROOT_DIR/tools/cloudflare_tunnel.sh" > "$CASE_DIR/holder.log" 2>&1 & + holder=$! + # Timeout bounds a broken readiness handshake; no scheduling sleeps. + timeout 30 bash -c 'read -r ready < "$CASE_DIR/uninstall-ready"; [[ "$ready" == ready ]]' || fail "uninstall never reached active snapshot" + snapshot=$(cat "$CASE_DIR/snapshot-path") + [[ -f "$snapshot/manifest" ]] || fail "snapshot not ready" + lock_before=$(stat -c '%d:%i:%u:%g:%a' "$CLOUDFLARED_STATE_DIR.lock") + find "$REPOSITORY_STATE_DIR" -type f -exec sha256sum {} + | sort > "$CASE_DIR/state-before" + sha256sum "$AUTO_UPDATE_SCRIPT" "$AUTO_UPDATE_SERVICE" "$AUTO_UPDATE_TIMER" > "$CASE_DIR/updater-before" + for contender in enable_auto_update disable_auto_update; do + : > "$CASE_DIR/contender-external.log" + rc=0 + FAKE_LOG="$CASE_DIR/contender-external.log" bash -c ' + source "$1" + check_platform() { :; } + "$2" --confirmed + ' _ "$ROOT_DIR/tools/cloudflare_tunnel.sh" "$contender" > "$CASE_DIR/contender.log" 2>&1 || rc=$? + [[ "$rc" != 0 ]] || fail "contender entered uninstall lock" + grep -q '无法取得 Cloudflare 仓库事务锁' "$CASE_DIR/contender.log" || fail "competition not reached" + [[ ! -s "$CASE_DIR/contender-external.log" ]] || fail "contender executed external command" + [[ "$(stat -c '%d:%i:%u:%g:%a' "$CLOUDFLARED_STATE_DIR.lock")" == "$lock_before" ]] || fail "holder lock changed" + find "$REPOSITORY_STATE_DIR" -type f -exec sha256sum {} + | sort > "$CASE_DIR/state-after" + cmp "$CASE_DIR/state-before" "$CASE_DIR/state-after" || fail "snapshot or pending changed" + sha256sum -c "$CASE_DIR/updater-before" >/dev/null || fail "contender changed updater" + done + printf 'release\n' > "$CASE_DIR/uninstall-release" + wait "$holder" || { cat "$CASE_DIR/holder.log"; fail "uninstall holder failed"; } + [[ ! -e "$SOURCE_FILE" && ! -e "$AUTO_UPDATE_TIMER" ]] || fail "uninstall did not complete" + [[ ! -d "$CLOUDFLARED_STATE_DIR.lock" ]] || fail "uninstall retained lock" +) > "$TEST_DIR/uninstall-holder.log" 2>&1 || { cat "$TEST_DIR/uninstall-holder.log"; fail "uninstall holder barrier"; } +pass "independent uninstall holder excludes enable and disable without changing snapshot pending or lock" + +for dependency_fault in update install; do + ( + new_case + trap - EXIT + write_auto_update_files || fail "dependency updater fixture" + sha256sum "$AUTO_UPDATE_SCRIPT" "$AUTO_UPDATE_SERVICE" "$AUTO_UPDATE_TIMER" > "$CASE_DIR/updater-before" + command() { + if [[ "$*" == '-v flock' ]]; then + printf 'flock-missing\n' >> "$CASE_DIR/dependency-hits" + return 1 + fi + builtin command "$@" + } + dpkg-query() { printf installed; } + apt-get() { + printf 'apt-get:%s\n' "$*" >> "$FAKE_LOG" + if [[ "$1" == update && "$dependency_fault" == update ]]; then return 41; fi + if [[ "$*" == 'install -y util-linux' ]]; then return 42; fi + return 0 + } + trap ':' HUP + before_traps=$(trap -p HUP INT TERM EXIT) + rc=0 + enable_auto_update > "$CASE_DIR/dependency.log" 2>&1 || rc=$? + case "$dependency_fault" in update) expected=41 ;; install) expected=42 ;; esac + [[ "$rc" == "$expected" ]] || fail "dependency status $rc" + grep -Fxq flock-missing "$CASE_DIR/dependency-hits" || fail "flock branch not reached" + grep -Fxq apt-get:update "$FAKE_LOG" || fail "dependency update not reached" + if [[ "$dependency_fault" == update ]]; then + if grep -q 'apt-get:install' "$FAKE_LOG"; then fail "install continued after update failure"; fi + else + grep -Fxq 'apt-get:install -y util-linux' "$FAKE_LOG" || fail "util install not reached" + fi + if grep -Eq 'systemctl:.*(reload|enable)' "$FAKE_LOG"; then fail "dependency failure continued timer work"; fi + sha256sum -c "$CASE_DIR/updater-before" >/dev/null || fail "dependency failure changed updater" + validate_current_repository_manifest || fail "prior repository configuration not recorded" + compgen -G "$REPOSITORY_STATE_DIR/pending-updater-*" >/dev/null || fail "dependency pending missing" + [[ ! -d "$CLOUDFLARED_STATE_DIR.lock" ]] || fail "dependency lock retained" + [[ "$(trap -p HUP INT TERM EXIT)" == "$before_traps" ]] || fail "dependency traps changed" + grep -q '自动更新操作未完成' "$CASE_DIR/dependency.log" || fail "dependency diagnostic missing" + ) > "$TEST_DIR/dependency-$dependency_fault.log" 2>&1 || { cat "$TEST_DIR/dependency-$dependency_fault.log"; fail "dependency $dependency_fault"; } + pass "missing flock $dependency_fault failure stops updater after recorded repository commit" +done + entrypoint_output=$(bash -c "$(cat "$ROOT_DIR/tools/cloudflare_tunnel.sh")" cloudflare_tunnel.sh help) grep -Fq 'cloudflare_tunnel.sh install' <<< "$entrypoint_output" || fail "bash -c entrypoint broken" pass "bash -c entrypoint remains compatible"