From 5c89a6fd3ccabbf2eccade95b800faba45ae58cd Mon Sep 17 00:00:00 2001 From: darken Date: Mon, 11 May 2026 14:53:00 +0200 Subject: [PATCH] Translate MLAT_MARKER to MLAT_PRIVATE on save; co-emit, preserve marker PHP webconfig's still-shipping yes/no dropdown writes MLAT_MARKER (inverted polarity, no=privacy on). The new feed daemons read MLAT_PRIVATE instead. Without translation at save time, toggling privacy in webconfig would silently no-op on already-updated feeders. MLAT_MARKER is preserved on disk because PHP rebuilds the config from POST only and only knows about MLAT_MARKER. Stripping it would cause the next PHP save to drop both keys. MLAT_PRIVATE is co-emitted on the line after MLAT_MARKER, re-derived on every save, mirroring the USER -> MLAT_USER re-derivation already in this migrator. MLAT_MARKER wins when both keys are present; stale MLAT_PRIVATE is overwritten. Value parsing is case-insensitive. Unrecognized MLAT_MARKER values exit 2 rather than silently defaulting to public; for a privacy key, fail-loud beats fail-open. Refactors do_migrate into a chain of single-purpose migration functions. Adds AIRPLANES_CONFIG_BACKUP_BASE env var: install-adsbconfig.sh sets it to the canonical boot config path so .pre-X-split backups land there even when the migrator runs against a random temp file (previously orphaned one backup per save under random temp suffixes). 26 test cases including a two-save PHP round-trip verifying privacy survives PHP's rebuild-from-POST. --- helpers/install-adsbconfig.sh | 9 +- helpers/migrate-config.sh | 207 ++++++++++++++++++++++++++++---- test/migrate-config-test.sh | 220 ++++++++++++++++++++++++++++++++++ 3 files changed, 410 insertions(+), 26 deletions(-) diff --git a/helpers/install-adsbconfig.sh b/helpers/install-adsbconfig.sh index a545167..5d49dc1 100755 --- a/helpers/install-adsbconfig.sh +++ b/helpers/install-adsbconfig.sh @@ -5,7 +5,14 @@ # adjacent to /boot/airplanes-config.txt so readers never see a # legacy-only intermediate state at the canonical path. set -euo pipefail -exec flock /var/lock/airplanes-config.lock env AIRPLANES_CONFIG_LOCK_HELD=1 bash -c ' +# AIRPLANES_CONFIG_BACKUP_BASE points backups at the canonical path. Without +# it, migrate-config.sh would write `.pre-mlat-split` / `.pre-marker-split` +# next to the random temp file; mv-to-canonical leaves those backups +# orphaned in /boot under random suffixes, accumulating one per save. +exec flock /var/lock/airplanes-config.lock env \ + AIRPLANES_CONFIG_LOCK_HELD=1 \ + AIRPLANES_CONFIG_BACKUP_BASE=/boot/airplanes-config.txt \ + bash -c ' set -euo pipefail tmp="$(mktemp /boot/airplanes-config.txt.XXXXXX)" trap "rm -f \"$tmp\"" EXIT diff --git a/helpers/migrate-config.sh b/helpers/migrate-config.sh index 020175f..946b248 100755 --- a/helpers/migrate-config.sh +++ b/helpers/migrate-config.sh @@ -1,7 +1,6 @@ #!/usr/bin/env bash -# migrate-config.sh - Migrate legacy USER= schema in airplanes-config.txt -# to the split MLAT_USER + MLAT_ENABLED schema expected by the new feed -# daemons in airplanes-live/feed. +# migrate-config.sh - Migrate legacy boot-config keys to the canonical +# schema expected by the new feed daemons in airplanes-live/feed. # # Canonical home: airplanes-live/airplanes-webconfig:helpers/migrate-config.sh # Vendored byte-equivalent copy: airplanes-live/airplanes-update: @@ -9,34 +8,64 @@ # Drift between the two is enforced by a CI test in airplanes-update. # # Usage: -# migrate-config.sh apply migration in-place +# migrate-config.sh apply migrations in-place # migrate-config.sh --version print MIGRATOR_VERSION and exit # -# Behavior: -# - Idempotent. Byte-compares the rendered output against the input; -# only mv's if different (mtime unchanged on no-op). +# Migrations run, in order: +# +# 1. USER → MLAT_USER + MLAT_ENABLED (do_migrate calls _migrate_user_to_split) +# +# - USER="0" or USER="disable" → MLAT_USER="", MLAT_ENABLED=false +# - USER="" → MLAT_USER="Anonymous", MLAT_ENABLED=true +# (matches feed/configure.sh's default; +# avoids the daemon's empty-MLAT_USER fail) +# - USER= → MLAT_USER=, MLAT_ENABLED=true +# - USER absent → no-op +# +# USER is preserved on disk in normalized `USER=""` form so +# legacy consumers that still key off USER keep working. Backup at +# `.pre-mlat-split` written once on first transition out of +# pure-legacy state. +# +# 2. MLAT_MARKER → MLAT_PRIVATE (do_migrate calls _migrate_marker_to_private) +# +# - MLAT_MARKER="no" → MLAT_PRIVATE=true (inverted polarity) +# - MLAT_MARKER="yes" → MLAT_PRIVATE=false +# - MLAT_MARKER= → exit 2 (strict-fail; privacy keys +# must not silently default to public) +# - MLAT_MARKER absent → no-op +# +# Case-insensitive (PHP webconfig's yes/no dropdown matches uppercase +# values too). MLAT_MARKER is PRESERVED on the file alongside the +# newly emitted MLAT_PRIVATE: PHP webconfig (deprecating; archived +# after MVP soak) rebuilds airplanes-config.txt from $_POST and would +# strip MLAT_PRIVATE on the next save without a corresponding form +# control. Keeping MLAT_MARKER lets the legacy writer keep working; +# the migrator re-derives MLAT_PRIVATE from MLAT_MARKER on every save, +# mirroring USER → MLAT_USER re-derivation above. MLAT_MARKER wins +# when both keys are present. Backup at `.pre-marker-split`. +# +# Behavior shared by all migrations: +# - Idempotent. Each migration byte-compares its rendered output against +# the input and only mv's if different (mtime unchanged on no-op). # - Never sources the file. Sourcing would execute user-supplied shell. # - Atomic via mktemp adjacent + mv. -# - On first migration of a legacy file (USER present, MLAT_USER absent) -# writes a `.pre-mlat-split` backup. Idempotent: never overwritten. -# - When USER is present it is authoritative: any stale MLAT_USER / -# MLAT_ENABLED lines are stripped and re-derived. The USER= line is -# re-emitted in `USER=""` form for shell-meta safety -# (defense for hand-edited files; PHP webconfig sanitize already -# restricts to [A-Za-z0-9_.-]). -# - When USER is absent, the file is treated as already on the new -# schema and not touched. -# - Empty USER (USER="") is treated as "opted in, no name" and -# produces MLAT_USER="Anonymous" / MLAT_ENABLED=true, matching the -# defaults in feed/configure.sh and feed/scripts/apl-feed/mlat.sh. +# - Single flock around the chain. Each migration assumes the caller +# holds the lock; nested invocations re-enter via AIRPLANES_CONFIG_LOCK_HELD. # # Env vars: -# AIRPLANES_CONFIG_LOCK_HELD=1 Skip flock (caller already holds it). -# Used by install-adsbconfig.sh wrapper. +# AIRPLANES_CONFIG_LOCK_HELD=1 Skip flock (caller already holds it). +# Used by install-adsbconfig.sh wrapper. +# AIRPLANES_CONFIG_BACKUP_BASE Override path prefix for `.pre-X-split` +# backups. Defaults to the migrated file's +# path. install-adsbconfig.sh sets this to +# /boot/airplanes-config.txt so backups land +# at the canonical location even when the +# migrator runs on a random temp file. set -euo pipefail -MIGRATOR_VERSION=1 +MIGRATOR_VERSION=2 LOCK_FILE="/var/lock/airplanes-config.lock" # Extract the last-wins value of KEY from FILE without sourcing it. @@ -147,10 +176,28 @@ derive_mlat_from_user() { esac } -# Run the migration on $path. Caller is responsible for the lock. -do_migrate() { +# Decide MLAT_PRIVATE from a MLAT_MARKER value per migration rules. +# Sets caller-scope variable MLAT_PRIVATE_OUT. Inverted polarity: legacy +# MLAT_MARKER="no" meant privacy ON. Case-insensitive: PHP webconfig's +# dropdown handles uppercase, so the migrator does too. Strict-fail on +# anything else — for a privacy key, silently defaulting to public on +# unrecognized input is the wrong failure mode. +derive_private_from_marker() { + local marker="${1,,}" + case "$marker" in + no) MLAT_PRIVATE_OUT="true" ;; + yes) MLAT_PRIVATE_OUT="false" ;; + *) + printf 'migrate-config.sh: unrecognized MLAT_MARKER value: %s\n' "$1" >&2 + exit 2 + ;; + esac +} + +# Migration 1: USER → MLAT_USER + MLAT_ENABLED. Caller holds the lock. +_migrate_user_to_split() { local path="$1" - local backup_path="${path}.pre-mlat-split" + local backup_path="${AIRPLANES_CONFIG_BACKUP_BASE:-$path}.pre-mlat-split" local user_value="" user_present=0 mlat_user_present=0 rc @@ -259,6 +306,116 @@ do_migrate() { trap - EXIT } +# Migration 2: MLAT_MARKER → MLAT_PRIVATE. Caller holds the lock. +# +# MLAT_MARKER is preserved on disk because PHP webconfig is still the only +# writer of /boot/airplanes-config.txt and only knows about MLAT_MARKER; +# stripping it would cause the next PHP save (which rebuilds from $_POST) +# to drop both keys. So this migration co-emits MLAT_PRIVATE alongside +# MLAT_MARKER and re-derives MLAT_PRIVATE on every save — mirroring the +# USER → MLAT_USER re-derivation pattern above. +# +# Conflict rule: MLAT_MARKER wins when both keys are present (PHP is the +# active writer; any existing MLAT_PRIVATE is re-derived). +_migrate_marker_to_private() { + local path="$1" + local backup_path="${AIRPLANES_CONFIG_BACKUP_BASE:-$path}.pre-marker-split" + + local marker_value="" marker_present=0 + local mlat_private_present=0 rc + + if marker_value="$(extract_key "$path" MLAT_MARKER)"; then + marker_present=1 + else + rc=$? + case "$rc" in + 1) marker_present=0 ;; + 2) exit 2 ;; + *) exit "$rc" ;; + esac + fi + + if extract_key "$path" MLAT_PRIVATE >/dev/null; then + mlat_private_present=1 + else + rc=$? + case "$rc" in + 1) mlat_private_present=0 ;; + 2) exit 2 ;; + *) exit "$rc" ;; + esac + fi + + # MLAT_MARKER absent → nothing to translate. MLAT_PRIVATE (if present) + # is left alone so feeders without PHP webconfig keep whatever + # canonical value was written by another writer. + if (( ! marker_present )); then + return 0 + fi + + # MLAT_MARKER is authoritative: any stale MLAT_PRIVATE is stripped + # and re-derived from MLAT_MARKER. + local MLAT_PRIVATE_OUT="" + derive_private_from_marker "$marker_value" + + # Backup once on first transition out of pure-legacy (MLAT_MARKER + # present, MLAT_PRIVATE absent). Never overwritten. + if [[ ! -f "$backup_path" ]] && (( ! mlat_private_present )); then + cp -fp "$path" "$backup_path" + fi + + # Render new output. The first MLAT_MARKER line becomes the anchor: + # the line itself is preserved (legacy writer keeps working), and + # MLAT_PRIVATE is emitted on the line immediately after. Subsequent + # MLAT_MARKER= duplicates and any existing MLAT_PRIVATE= lines are + # dropped. + local tmp + tmp="$(mktemp "${path}.XXXXXX")" + # shellcheck disable=SC2064 + trap "rm -f '$tmp'" EXIT + + { + local line trimmed marker_seen=0 + while IFS= read -r line || [[ -n "$line" ]]; do + trimmed="${line%$'\r'}" + case "$trimmed" in + 'MLAT_MARKER='*) + if (( ! marker_seen )); then + printf '%s\n' "$line" + printf 'MLAT_PRIVATE=%s\n' "$MLAT_PRIVATE_OUT" + marker_seen=1 + fi + ;; + 'MLAT_PRIVATE='*) + : # drop; re-emitted at the MLAT_MARKER anchor above + ;; + *) + printf '%s\n' "$line" + ;; + esac + done < "$path" + } > "$tmp" + + chmod --reference="$path" "$tmp" 2>/dev/null || chmod 0644 "$tmp" + chown --reference="$path" "$tmp" 2>/dev/null || true + + if cmp -s "$path" "$tmp"; then + rm -f "$tmp" + trap - EXIT + return 0 + fi + + mv -f "$tmp" "$path" + trap - EXIT +} + +# Run the chain of migrations on $path. Caller is responsible for the lock. +do_migrate() { + local path="$1" + _migrate_user_to_split "$path" + _migrate_marker_to_private "$path" +} + main() { if [[ "${1:-}" == "--version" ]]; then printf 'migrate-config.sh version=%d\n' "$MIGRATOR_VERSION" diff --git a/test/migrate-config-test.sh b/test/migrate-config-test.sh index e4c85d3..c45a8d8 100755 --- a/test/migrate-config-test.sh +++ b/test/migrate-config-test.sh @@ -220,6 +220,214 @@ test_crlf_tolerated() { echo "PASS: CRLF tolerated" } +# --- MLAT_MARKER → MLAT_PRIVATE migration (co-emit, MLAT_MARKER preserved) --- + +test_marker_no_emits_private_true_alongside() { + local f + f="$(with_file "case_marker_no" 'LATITUDE=51.5 +MLAT_MARKER="no" +DUMP1090=yes +')" + run_migrator "$f" + assert_file_eq 'LATITUDE=51.5 +MLAT_MARKER="no" +MLAT_PRIVATE=true +DUMP1090=yes' "$f" "MLAT_MARKER=no → MLAT_PRIVATE=true (marker preserved)" + [[ -f "$f.pre-marker-split" ]] || fail "marker backup not created" + echo "PASS: MLAT_MARKER=no → MLAT_PRIVATE=true (co-emit)" +} + +test_marker_yes_emits_private_false_alongside() { + local f + f="$(with_file "case_marker_yes" 'MLAT_MARKER="yes" +')" + run_migrator "$f" + assert_file_eq 'MLAT_MARKER="yes" +MLAT_PRIVATE=false' "$f" "MLAT_MARKER=yes → MLAT_PRIVATE=false (marker preserved)" + echo "PASS: MLAT_MARKER=yes → MLAT_PRIVATE=false (co-emit)" +} + +test_marker_uppercase_tolerated() { + # PHP's reader treats values case-insensitively; the migrator follows. + local f + f="$(with_file "case_marker_upper" 'MLAT_MARKER="NO" +')" + run_migrator "$f" + assert_file_eq 'MLAT_MARKER="NO" +MLAT_PRIVATE=true' "$f" "MLAT_MARKER=NO (uppercase) → MLAT_PRIVATE=true" + echo "PASS: MLAT_MARKER uppercase tolerated" +} + +test_marker_garbage_strict_fails() { + local f rc=0 + f="$(with_file "case_marker_garbage" 'MLAT_MARKER="maybe" +')" + local before + before="$(cat "$f")" + run_migrator "$f" 2>/dev/null || rc=$? + [[ "$rc" -ne 0 ]] || fail "unrecognized MLAT_MARKER not rejected" + assert_file_eq "$before" "$f" "malformed file unchanged" + echo "PASS: unrecognized MLAT_MARKER value rejected, file unchanged" +} + +test_marker_absent_is_noop() { + local f + f="$(with_file "case_no_marker" 'LATITUDE=51.5 +DUMP1090=yes +')" + local before_mtime after_mtime + before_mtime="$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f")" + sleep 1 + run_migrator "$f" + after_mtime="$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f")" + assert_eq "$before_mtime" "$after_mtime" "absent-MLAT_MARKER: mtime preserved" + [[ ! -f "$f.pre-marker-split" ]] || fail "marker backup created on no-op" + echo "PASS: MLAT_MARKER absent is no-op" +} + +test_marker_wins_over_stale_private() { + # MLAT_MARKER is the active writer (PHP webconfig); MLAT_PRIVATE in the + # file is treated as stale and re-derived. Parallels USER → MLAT_USER + # re-derivation in the existing migration. + local f + f="$(with_file "case_marker_both" 'MLAT_PRIVATE=false +MLAT_MARKER="no" +')" + run_migrator "$f" + assert_file_eq 'MLAT_MARKER="no" +MLAT_PRIVATE=true' "$f" "MLAT_MARKER wins, MLAT_PRIVATE re-derived to true" + echo "PASS: MLAT_MARKER wins, stale MLAT_PRIVATE re-derived" +} + +test_marker_private_absent_only_marker() { + # Pure legacy state: only MLAT_MARKER present, no MLAT_PRIVATE yet. + # Migration creates the canonical alongside. + local f + f="$(with_file "case_marker_only" 'LATITUDE=51.5 +MLAT_MARKER="no" +DUMP1090=yes +GRAPHS1090=yes +')" + run_migrator "$f" + assert_file_eq 'LATITUDE=51.5 +MLAT_MARKER="no" +MLAT_PRIVATE=true +DUMP1090=yes +GRAPHS1090=yes' "$f" "MLAT_PRIVATE inserted immediately after MLAT_MARKER" + echo "PASS: MLAT_PRIVATE emitted on the line after MLAT_MARKER" +} + +test_user_and_marker_both_migrate_in_chain() { + # Chain: USER migration emits MLAT_USER+MLAT_ENABLED at the USER anchor; + # MLAT_MARKER migration emits MLAT_PRIVATE at the MLAT_MARKER anchor. + # Both legacy keys are preserved in their original positions. + local f + f="$(with_file "case_chain" 'LATITUDE=51.5 +USER="alice" +MLAT_MARKER="no" +DUMP1090=yes +')" + run_migrator "$f" + assert_file_eq 'LATITUDE=51.5 +USER="alice" +MLAT_USER="alice" +MLAT_ENABLED=true +MLAT_MARKER="no" +MLAT_PRIVATE=true +DUMP1090=yes' "$f" "chain: USER → split AND MLAT_MARKER → MLAT_PRIVATE" + [[ -f "$f.pre-mlat-split" ]] || fail "USER backup missing" + [[ -f "$f.pre-marker-split" ]] || fail "MARKER backup missing" + echo "PASS: USER+MLAT_MARKER both migrate in chain" +} + +test_marker_backup_not_overwritten() { + local f + f="$(with_file "case_marker_backup" 'MLAT_MARKER="no" +')" + run_migrator "$f" + local backup_content_1 + backup_content_1="$(cat "$f.pre-marker-split")" + # Re-migrate after a no-op change. Backup must NOT be touched. + sleep 1 + run_migrator "$f" + local backup_content_2 + backup_content_2="$(cat "$f.pre-marker-split")" + assert_eq "$backup_content_1" "$backup_content_2" "marker backup unchanged on subsequent runs" + echo "PASS: marker backup written once, never overwritten" +} + +test_marker_idempotent() { + local f + f="$(with_file "case_marker_idempotent" 'MLAT_MARKER="no" +')" + run_migrator "$f" + local first_content first_mtime + first_content="$(cat "$f")" + first_mtime="$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f")" + sleep 1 + run_migrator "$f" + local second_content second_mtime + second_content="$(cat "$f")" + second_mtime="$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f")" + assert_eq "$first_content" "$second_content" "marker idempotent: content" + assert_eq "$first_mtime" "$second_mtime" "marker idempotent: mtime preserved" + echo "PASS: MLAT_MARKER migration idempotent" +} + +test_backup_base_env_var_redirects_backups() { + # install-adsbconfig.sh migrates a random temp file but wants backups + # at the canonical /boot/airplanes-config.txt location. Without + # AIRPLANES_CONFIG_BACKUP_BASE, backups would orphan under the temp + # suffix every save. + local tmp canonical + tmp="$WORK_DIR/airplanes-config.txt.XYZ123" + canonical="$WORK_DIR/airplanes-config.txt" + printf 'USER="alice"\nMLAT_MARKER="no"\n' > "$tmp" + + AIRPLANES_CONFIG_LOCK_HELD=1 \ + AIRPLANES_CONFIG_BACKUP_BASE="$canonical" \ + "$MIGRATOR" "$tmp" + + [[ -f "$canonical.pre-mlat-split" ]] || fail "USER backup not at canonical base" + [[ -f "$canonical.pre-marker-split" ]] || fail "MARKER backup not at canonical base" + [[ ! -f "$tmp.pre-mlat-split" ]] || fail "USER backup leaked to temp path" + [[ ! -f "$tmp.pre-marker-split" ]] || fail "MARKER backup leaked to temp path" + echo "PASS: AIRPLANES_CONFIG_BACKUP_BASE redirects backups to canonical path" +} + +test_php_two_save_roundtrip() { + # Codex-flagged scenario: simulate two consecutive PHP webconfig saves. + # First save: legacy file with MLAT_MARKER, migrator writes MLAT_PRIVATE + # alongside. Second save: PHP rebuilds from $_POST (it still only writes + # MLAT_MARKER, no MLAT_PRIVATE), migrator re-derives MLAT_PRIVATE from + # the rewritten MLAT_MARKER. Privacy posture must survive the round-trip. + local f + f="$(with_file "case_php_roundtrip" 'LATITUDE=51.5 +USER="alice" +MLAT_MARKER="no" +DUMP1090=yes +')" + # First PHP save → migrator runs. + run_migrator "$f" + grep -qx 'MLAT_PRIVATE=true' "$f" || fail "first save: MLAT_PRIVATE missing" + grep -qx 'MLAT_MARKER="no"' "$f" || fail "first save: MLAT_MARKER stripped" + + # Simulate PHP's second save: it rebuilds the file from $_POST. The + # MLAT_PRIVATE line PHP can't see is dropped. MLAT_MARKER survives + # because the form still writes it. (Order of keys is what PHP would + # produce after iterating $_POST.) + cat > "$f" <<'EOF' +LATITUDE=51.5 +USER="alice" +MLAT_MARKER=no +DUMP1090=yes +EOF + run_migrator "$f" + grep -qx 'MLAT_PRIVATE=true' "$f" || fail "second save: privacy lost after PHP rebuild" + grep -qx 'MLAT_MARKER=no' "$f" || fail "second save: MLAT_MARKER lost" + echo "PASS: PHP two-save round-trip preserves privacy via re-derivation" +} + main() { test_version_flag test_simple_user_migrates @@ -235,6 +443,18 @@ main() { test_missing_file_is_noop test_backup_not_overwritten test_crlf_tolerated + test_marker_no_emits_private_true_alongside + test_marker_yes_emits_private_false_alongside + test_marker_uppercase_tolerated + test_marker_garbage_strict_fails + test_marker_absent_is_noop + test_marker_wins_over_stale_private + test_marker_private_absent_only_marker + test_user_and_marker_both_migrate_in_chain + test_marker_backup_not_overwritten + test_marker_idempotent + test_backup_base_env_var_redirects_backups + test_php_two_save_roundtrip echo "All migrate-config tests passed" }