From b2b1556544db47c45009af31dc6a891d673974d2 Mon Sep 17 00:00:00 2001 From: boburning Date: Mon, 31 Aug 2026 05:27:11 -0400 Subject: [PATCH 1/2] fix(network): serialize developer SSH reconciliation --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 8 +-- static/build/.tmp_update/bin/bloom-dev-ssh | 51 ++++++++++++++++++- .../script/network/update_networking.sh | 17 ++----- test/shell/developer_ssh.bats | 48 ++++++++++++++++- 5 files changed, 106 insertions(+), 20 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index dc11658f7..a6ae26705 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -209,7 +209,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 326 file_count = 1967 -byte_count = 260659274 +byte_count = 260660770 source_build_components = 232 unresolved_components = 94 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index 9250a4eba..d1fdb221f 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -475,8 +475,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-dev-ssh", "file_count": 1, - "byte_count": 13539, - "tree_sha256": "967940060ee432fe115d726afef2ad59ce07d07bcf1e11db5e3e7a71e5514b9f", + "byte_count": 15073, + "tree_sha256": "70773fe6f7eaad77eda356ba3ee3b0a37fcd4d8a4d42c9c2c1af2cab8f15bc66", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -2256,8 +2256,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/script/network", "file_count": 5, - "byte_count": 20018, - "tree_sha256": "bd82793eb547e1e1d3a922e39040ce631fb27c0dd3a6d1efe6e90152e6a8db0d", + "byte_count": 19980, + "tree_sha256": "7ba394cd30b420febe2d14de13f0cbc3e4aa053ab643c06137904faa8c7d8ba9", "source": "https://github.com/OnionUI/Onion", "source_revision": "07505ea58c7bba698d6b9220ff43946a43cac76b", "license": "GPL-3.0-only", diff --git a/static/build/.tmp_update/bin/bloom-dev-ssh b/static/build/.tmp_update/bin/bloom-dev-ssh index 11d048499..e25623491 100755 --- a/static/build/.tmp_update/bin/bloom-dev-ssh +++ b/static/build/.tmp_update/bin/bloom-dev-ssh @@ -15,6 +15,8 @@ STATE_FILE="$ROOT/tmp/bloom-dev-ssh.state" PID_FILE="$ROOT/tmp/bloom-dev-ssh.pid" SHADOW_STATE_FILE="$ROOT/tmp/bloom-dev-ssh-shadow.state" SHADOW_PID_FILE="$ROOT/tmp/bloom-dev-ssh-shadow.pid" +LOCK_DIR="${BLOOM_DEV_SSH_LOCK_DIR:-$ROOT/tmp/bloom-dev-ssh.lock}" +LOCK_OWNER="$LOCK_DIR/owner" PORT="${BLOOM_DEV_SSH_PORT:-22}" SHADOW_PORT="${BLOOM_DEV_SSH_SHADOW_PORT:-2222}" DROPBEAR="${BLOOM_DROPBEAR:-$SYS_DIR/bin/bloom-dropbearmulti}" @@ -25,6 +27,49 @@ SFTP_CHROOT="$ROOT/tmp/bloom-sftp-root" TRANSFER_POLICY=${BLOOM_TRANSFER_POLICY_BIN:-$SYS_DIR/bin/bloom-transfer-policy} DEV_NULL=${BLOOM_DEV_NULL:-$ROOT/dev/null} NETWORK_TRANSACTION=${BLOOM_NETWORK_TRANSACTION_BIN:-$SYS_DIR/bin/bloom-network-transaction} +LOCK_HELD=0 + +lock_cleanup() { + [ "$LOCK_HELD" -eq 1 ] || return 0 + rm -f "$LOCK_OWNER" 2>/dev/null || true + rmdir "$LOCK_DIR" 2>/dev/null || true + LOCK_HELD=0 +} + +lock_acquire() { + lock_parent=${LOCK_DIR%/*} + [ -d "$lock_parent" ] && [ ! -L "$lock_parent" ] || return 1 + lock_attempt=0 + while ! mkdir "$LOCK_DIR" 2>/dev/null; do + [ -d "$LOCK_DIR" ] && [ ! -L "$LOCK_DIR" ] || return 1 + lock_pid='' + [ -f "$LOCK_OWNER" ] && [ ! -L "$LOCK_OWNER" ] && + IFS= read -r lock_pid <"$LOCK_OWNER" || true + case "$lock_pid" in + ''|*[!0-9]*) ;; + *) + if [ -d "${BLOOM_PROC_ROOT:-$ROOT/proc}/$lock_pid" ]; then + [ "$lock_attempt" -lt 100 ] || return 1 + lock_attempt=$((lock_attempt + 1)) + sleep 0.05 + continue + fi + ;; + esac + rm -f "$LOCK_OWNER" 2>/dev/null || return 1 + rmdir "$LOCK_DIR" 2>/dev/null || return 1 + done + chmod 700 "$LOCK_DIR" || { rmdir "$LOCK_DIR" 2>/dev/null || true; return 1; } + (umask 077; printf '%s\n' "$$" >"$LOCK_OWNER") || { + rmdir "$LOCK_DIR" 2>/dev/null || true + return 1 + } + LOCK_HELD=1 + trap 'lock_cleanup' EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM +} state() { mkdir -p "$(dirname "$STATE_FILE")" @@ -345,8 +390,12 @@ start_ssh() { } case "${1:-}" in - start) start_ssh ;; + start) + lock_acquire || exit 1 + start_ssh + ;; stop) + lock_acquire || exit 1 stop_dropbear state stopped ;; diff --git a/static/build/.tmp_update/script/network/update_networking.sh b/static/build/.tmp_update/script/network/update_networking.sh index 96e5189dd..c3728c772 100644 --- a/static/build/.tmp_update/script/network/update_networking.sh +++ b/static/build/.tmp_update/script/network/update_networking.sh @@ -77,7 +77,11 @@ check() { check_services() { local has_wifi=$(wifi_enabled && echo 1 || echo 0) lockdown_legacy_listeners - check_sshstate & + + # bloom-network-services is the sole SSH/SFTP lifecycle owner. Callers of + # this compatibility path reconcile it after Wi-Fi compatibility work is + # complete; starting bloom-dev-ssh here as well races its /home fallback + # mount and can leave a live listener without its authorized_keys path. if [ "$is_booting" -eq 1 ] && [ "$has_wifi" -eq 1 ]; then bootScreen Boot "Waiting for network..." @@ -201,17 +205,6 @@ wifi_off() { /customer/app/axp_test wifioff } -# Starts only Bloom's explicit developer-mode, key-only SSH service. -check_sshstate() { - if $sysdir/bin/bloom-dev-ssh start; then - log "SSH: Bloom key-only service checked" - else - log "SSH: Bloom key-only service refused its configuration" - fi - disable_flag sshState - disable_flag authsshState -} - # Utility functions # create/pop the json file for remembering states diff --git a/test/shell/developer_ssh.bats b/test/shell/developer_ssh.bats index 631cb6754..385495b37 100755 --- a/test/shell/developer_ssh.bats +++ b/test/shell/developer_ssh.bats @@ -201,8 +201,10 @@ EOF ! grep -Fq dropbear "$MOCK_LOG" } -@test "network initialization delegates developer mode to the secure helper" { - grep -F '$sysdir/bin/bloom-dev-ssh start' /workspace/static/build/.tmp_update/script/network/update_networking.sh +@test "network initialization has one canonical SSH lifecycle owner" { + ! grep -F 'bloom-dev-ssh start' /workspace/static/build/.tmp_update/script/network/update_networking.sh + ! grep -F 'check_sshstate' /workspace/static/build/.tmp_update/script/network/update_networking.sh + grep -F 'reconcile_services' /workspace/static/build/.tmp_update/bin/bloom-network grep -F 'service_requested' /workspace/static/build/.tmp_update/bin/bloom-dev-ssh } @@ -244,3 +246,45 @@ EOF [ "$(cat "$BLOOM_TEST_ROOT/tmp/bloom-dev-ssh.state")" = startup_failed ] [ ! -e "$BLOOM_TEST_ROOT/tmp/bloom-dev-ssh.pid" ] } + +@test "concurrent reconciles serialize listener and runtime-key ownership" { + enable_flip + install_valid_key + cat >"$MOCK_BIN/dropbear" <<'EOF' +#!/bin/sh +printf '%s\n' "$0 $*" >>"$MOCK_LOG" +if [ "${1:-}" = dropbearkey ]; then + printf '%s\n' fixture-host-key >"$SDCARD/.bloom/ssh/dropbear_ed25519_host_key" + exit 0 +fi +if [ "${1:-}" = dropbear ]; then + port_lock="$BLOOM_TEST_ROOT/tmp/mock-dropbear-port" + mkdir "$port_lock" 2>/dev/null || exit 98 + cleanup() { rmdir "$port_lock" 2>/dev/null || true; } + trap 'cleanup; exit 0' HUP INT TERM + trap cleanup EXIT + sleep 30 & + wait +fi +EOF + chmod +x "$MOCK_BIN/dropbear" + + "$SSH_HELPER" start >"$BATS_TEST_TMPDIR/first.out" 2>&1 & + first=$! + attempt=0 + while [ ! -d "$BLOOM_TEST_ROOT/tmp/bloom-dev-ssh.lock" ] && [ "$attempt" -lt 40 ]; do + sleep 0.05 + attempt=$((attempt + 1)) + done + [ -d "$BLOOM_TEST_ROOT/tmp/bloom-dev-ssh.lock" ] + "$SSH_HELPER" start >"$BATS_TEST_TMPDIR/second.out" 2>&1 & + second=$! + + wait "$first" + wait "$second" + server_pid="$(cat "$BLOOM_TEST_ROOT/tmp/bloom-dev-ssh.pid")" + kill -0 "$server_pid" + [ "$(cat "$BLOOM_TEST_ROOT/tmp/bloom-dev-ssh.state")" = running_key_only ] + [ ! -e "$BLOOM_TEST_ROOT/tmp/bloom-dev-ssh.lock" ] + [ "$(grep -Fc ' dropbear -F ' "$MOCK_LOG")" -eq 2 ] +} From 0b3b5d96609723724c3d5449e4f65628aa8d3246 Mon Sep 17 00:00:00 2001 From: boburning Date: Mon, 31 Aug 2026 05:35:00 -0400 Subject: [PATCH 2/2] fix(update): allow explicit candidate rejection --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 8 +++---- .../.tmp_update/bin/bloom-update-rollback | 10 ++++++++ .../build/.tmp_update/bin/bloom-update-state | 19 ++++++++++++++- test/shell/update_rollback.bats | 21 +++++++++++++++++ test/shell/update_state.bats | 23 +++++++++++++++++++ 6 files changed, 77 insertions(+), 6 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index a6ae26705..c8083fb66 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -209,7 +209,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 326 file_count = 1967 -byte_count = 260660770 +byte_count = 260662316 source_build_components = 232 unresolved_components = 94 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index d1fdb221f..82b37a9fd 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -943,8 +943,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-update-rollback", "file_count": 1, - "byte_count": 3745, - "tree_sha256": "d7be9a6cb1fe4617fa41d6c5f2fce733a117123e4e449614c58ac00f4b8f9da8", + "byte_count": 4117, + "tree_sha256": "0803729354c5b2938547023186619fb40e83270ebbcd376457ce68b22e769889", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -969,8 +969,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-update-state", "file_count": 1, - "byte_count": 11295, - "tree_sha256": "282a8c937578e14b76a50e396450447f8a4939aeae4365bde1d110466aea9c3c", + "byte_count": 12469, + "tree_sha256": "41ff306f6421230260a3e2f197d8c7c492b6b6ba90d1179767a104d613a6fde8", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/static/build/.tmp_update/bin/bloom-update-rollback b/static/build/.tmp_update/bin/bloom-update-rollback index c1813e6fc..570c21404 100755 --- a/static/build/.tmp_update/bin/bloom-update-rollback +++ b/static/build/.tmp_update/bin/bloom-update-rollback @@ -18,6 +18,16 @@ fail() { printf 'bloom-update-rollback: %s\n' "$*" >&2; exit 1; } [ -x "$SNAPSHOT_BIN" ] || fail "save snapshot service is unavailable" [ -x "$JQ" ] || fail "jq is unavailable" +current="$($STATE_BIN status)" || fail "update state is unavailable" +phase="$(printf '%s\n' "$current" | "$JQ" -er '.phase')" || fail "update state is invalid" +case "$phase" in + testing) + $STATE_BIN reject >/dev/null || fail "cannot reject the unconfirmed update" + ;; + recovery_required) ;; + *) fail "no booted update is awaiting rollback" ;; +esac + recovery="$($STATE_BIN recovery)" || fail "signed recovery is unavailable" version="$(printf '%s\n' "$recovery" | "$JQ" -er '.version')" || fail "recovery version is invalid" case "$version" in ''|*[!A-Za-z0-9._+-]*) fail "recovery version is unsafe" ;; esac diff --git a/static/build/.tmp_update/bin/bloom-update-state b/static/build/.tmp_update/bin/bloom-update-state index 1fb635910..309b88726 100755 --- a/static/build/.tmp_update/bin/bloom-update-state +++ b/static/build/.tmp_update/bin/bloom-update-state @@ -123,6 +123,23 @@ case "${1:-}" in '.phase=$phase | .boot_attempts=$attempts' "$STATE" | atomic_json "$STATE" "$JQ" -c . "$STATE" ;; + reject) + [ "$#" -eq 1 ] || fail "reject takes no arguments" + [ -f "$STATE" ] && [ ! -L "$STATE" ] || fail "no update is awaiting validation" + phase="$(read_field "$STATE" '.phase')" || fail "current state is invalid" + [ "$phase" = testing ] || fail "no booted update is awaiting rejection" + operation="$("$JQ" -r '.operation // "update"' "$STATE")" + [ "$operation" = update ] || fail "rollback validation cannot be rejected recursively" + pending="$(read_field "$STATE" '.pending_version')" || fail "pending version is unavailable" + safe_version "$pending" || fail "pending version is unsafe" + [ -f "$KNOWN_GOOD" ] && [ ! -L "$KNOWN_GOOD" ] || fail "no known-good update is retained" + recovery_version="$(read_field "$KNOWN_GOOD" '.version')" || fail "known-good state is invalid" + safe_version "$recovery_version" && [ "$recovery_version" != "$pending" ] || + fail "known-good recovery target is invalid" + "$JQ" -c '.phase="recovery_required" | .recovery_reason="operator_requested"' \ + "$STATE" | atomic_json "$STATE" + "$JQ" -c . "$STATE" + ;; activation-start) [ "$#" -eq 3 ] || fail "activation-start expects VERSION SNAPSHOT_ID" version="$2" @@ -178,5 +195,5 @@ case "${1:-}" in [ -d "$path" ] && [ ! -L "$path" ] || fail "known-good payload is unavailable" "$JQ" -c --arg status recovery_available '. + {status:$status}' "$KNOWN_GOOD" ;; - *) fail "expected {status|bootstrap VERSION|arm VERSION|activation-start VERSION SNAPSHOT_ID|rollback-start VERSION SNAPSHOT_ID|boot-attempt|mark-good VERSION|recovery}" ;; + *) fail "expected {status|bootstrap VERSION|arm VERSION|activation-start VERSION SNAPSHOT_ID|rollback-start VERSION SNAPSHOT_ID|boot-attempt|reject|mark-good VERSION|recovery}" ;; esac diff --git a/test/shell/update_rollback.bats b/test/shell/update_rollback.bats index a8858d06a..e11247314 100644 --- a/test/shell/update_rollback.bats +++ b/test/shell/update_rollback.bats @@ -16,6 +16,12 @@ setup() { cat >"$BLOOM_UPDATE_STATE_BIN" <<'EOF' #!/bin/sh case "$1" in + status) + printf '{"schema":1,"phase":"%s"}\n' "${ROLLBACK_PHASE:-recovery_required}" + ;; + reject) + printf 'state:reject\n' >>"$BLOOM_TEST_ROOT/calls" + ;; recovery) [ "${RECOVERY_AVAILABLE:-1}" = 1 ] || exit 1 printf '{"schema":1,"version":"1.2.2","status":"recovery_available"}\n' @@ -49,6 +55,21 @@ EOF chmod +x "$BLOOM_UPDATE_STATE_BIN" "$BLOOM_UPDATE_PREPARE_BIN" "$BLOOM_SAVE_SNAPSHOT_BIN" "$BLOOM_MOUNT_BIN" "$BLOOM_UMOUNT_BIN" } +@test "operator-requested rollback rejects testing before publishing signed recovery" { + export ROLLBACK_PHASE=testing + mkdir -p "$SDCARD/miyoo/app" + printf 'installed MainUI binary\n' >"$SDCARD/miyoo/app/MainUI" + + run "$ROLLBACK" + + [ "$status" -eq 0 ] + [ "$(sed -n '1p' "$BLOOM_TEST_ROOT/calls")" = 'state:reject' ] + [ "$(sed -n '2p' "$BLOOM_TEST_ROOT/calls")" = 'prepare:--replace:1.2.2' ] + [ "$(sed -n '3p' "$BLOOM_TEST_ROOT/calls")" = 'snapshot:create:pre-rollback' ] + [ "$(sed -n '4p' "$BLOOM_TEST_ROOT/calls")" = 'state:rollback-start:1.2.2:snapshot-rollback' ] + grep -Fx fresh-core "$SDCARD/miyoo/app/.tmp_update/onion.pak" +} + teardown() { teardown_bloom_fixture; } @test "rebuilds signed known-good payload before publishing rollback trigger" { diff --git a/test/shell/update_state.bats b/test/shell/update_state.bats index 506a4c6c1..4a374ca46 100644 --- a/test/shell/update_state.bats +++ b/test/shell/update_state.bats @@ -134,6 +134,29 @@ PY printf '%s' "$output" | grep -F '"version":"1.2.3"' } +@test "operator rejection enters signed recovery without repeated failed boots" { + stage_release 1.2.3 + "$STATE" arm 1.2.3 >/dev/null + "$STATE" mark-good 1.2.3 >/dev/null + known_good_before="$(sha256sum "$BLOOM_UPDATE_ROOT/known-good.json" | awk '{print $1}')" + stage_release 1.2.4 + "$STATE" arm 1.2.4 >/dev/null + "$STATE" activation-start 1.2.4 snapshot-update >/dev/null + "$STATE" boot-attempt >/dev/null + + run "$STATE" reject + [ "$status" -eq 0 ] + printf '%s' "$output" | grep -F '"phase":"recovery_required"' + printf '%s' "$output" | grep -F '"recovery_reason":"operator_requested"' + printf '%s' "$output" | grep -F '"boot_attempts":1' + [ "$(sha256sum "$BLOOM_UPDATE_ROOT/known-good.json" | awk '{print $1}')" = "$known_good_before" ] + run "$STATE" recovery + [ "$status" -eq 0 ] + printf '%s' "$output" | grep -F '"version":"1.2.3"' + run "$STATE" reject + [ "$status" -eq 1 ] +} + @test "tracks rollback separately and preserves the retained known-good record" { stage_release 1.2.3 "$STATE" arm 1.2.3 >/dev/null