|
| 1 | +#!/usr/bin/env bash |
| 2 | +# migrate-config.sh - Migrate legacy USER= schema in airplanes-config.txt |
| 3 | +# to the split MLAT_USER + MLAT_ENABLED schema expected by the new feed |
| 4 | +# daemons in airplanes-live/feed. |
| 5 | +# |
| 6 | +# Canonical home: airplanes-live/airplanes-webconfig:helpers/migrate-config.sh |
| 7 | +# Vendored byte-equivalent copy: airplanes-live/airplanes-update: |
| 8 | +# skeleton/usr/local/lib/airplanes-update/migrate-config.sh |
| 9 | +# Drift between the two is enforced by a CI test in airplanes-update. |
| 10 | +# |
| 11 | +# Usage: |
| 12 | +# migrate-config.sh <path> apply migration in-place |
| 13 | +# migrate-config.sh --version print MIGRATOR_VERSION and exit |
| 14 | +# |
| 15 | +# Behavior: |
| 16 | +# - Idempotent. Byte-compares the rendered output against the input; |
| 17 | +# only mv's if different (mtime unchanged on no-op). |
| 18 | +# - Never sources the file. Sourcing would execute user-supplied shell. |
| 19 | +# - Atomic via mktemp adjacent + mv. |
| 20 | +# - On first migration of a legacy file (USER present, MLAT_USER absent) |
| 21 | +# writes a `.pre-mlat-split` backup. Idempotent: never overwritten. |
| 22 | +# - When USER is present it is authoritative: any stale MLAT_USER / |
| 23 | +# MLAT_ENABLED lines are stripped and re-derived. The USER= line is |
| 24 | +# re-emitted in `USER="<escaped>"` form for shell-meta safety |
| 25 | +# (defense for hand-edited files; PHP webconfig sanitize already |
| 26 | +# restricts to [A-Za-z0-9_.-]). |
| 27 | +# - When USER is absent, the file is treated as already on the new |
| 28 | +# schema and not touched. |
| 29 | +# - Empty USER (USER="") is treated as "opted in, no name" and |
| 30 | +# produces MLAT_USER="Anonymous" / MLAT_ENABLED=true, matching the |
| 31 | +# defaults in feed/configure.sh and feed/scripts/apl-feed/mlat.sh. |
| 32 | +# |
| 33 | +# Env vars: |
| 34 | +# AIRPLANES_CONFIG_LOCK_HELD=1 Skip flock (caller already holds it). |
| 35 | +# Used by install-adsbconfig.sh wrapper. |
| 36 | + |
| 37 | +set -euo pipefail |
| 38 | + |
| 39 | +MIGRATOR_VERSION=1 |
| 40 | +LOCK_FILE="/var/lock/airplanes-config.lock" |
| 41 | + |
| 42 | +# Extract the last-wins value of KEY from FILE without sourcing it. |
| 43 | +# |
| 44 | +# Returns: |
| 45 | +# 0 KEY present (value printed to stdout; may be empty) |
| 46 | +# 1 KEY absent |
| 47 | +# 2 KEY present but malformed (e.g. unterminated quote) |
| 48 | +extract_key() { |
| 49 | + local file="$1" key="$2" |
| 50 | + local line raw found=0 |
| 51 | + raw="" |
| 52 | + while IFS= read -r line || [[ -n "$line" ]]; do |
| 53 | + line="${line%$'\r'}" |
| 54 | + [[ -z "$line" || "${line:0:1}" == "#" ]] && continue |
| 55 | + case "$line" in |
| 56 | + "${key}="*) |
| 57 | + raw="${line#"${key}="}" |
| 58 | + found=1 |
| 59 | + ;; |
| 60 | + esac |
| 61 | + done < "$file" |
| 62 | + |
| 63 | + if (( ! found )); then |
| 64 | + return 1 |
| 65 | + fi |
| 66 | + |
| 67 | + case "$raw" in |
| 68 | + '"'*) |
| 69 | + # Double-quoted: handle \\, \", \$, \` escapes; reject if |
| 70 | + # closing quote is missing. |
| 71 | + local result="" i len=${#raw} escaped=0 closed=0 c |
| 72 | + for (( i=1; i<len; i++ )); do |
| 73 | + c="${raw:$i:1}" |
| 74 | + if (( escaped )); then |
| 75 | + result+="$c" |
| 76 | + escaped=0 |
| 77 | + elif [[ "$c" == '\' ]]; then |
| 78 | + escaped=1 |
| 79 | + elif [[ "$c" == '"' ]]; then |
| 80 | + closed=1 |
| 81 | + break |
| 82 | + else |
| 83 | + result+="$c" |
| 84 | + fi |
| 85 | + done |
| 86 | + if (( ! closed )); then |
| 87 | + printf 'migrate-config.sh: %s: malformed double-quoted value for %s=\n' "$file" "$key" >&2 |
| 88 | + return 2 |
| 89 | + fi |
| 90 | + printf '%s' "$result" |
| 91 | + ;; |
| 92 | + "'"*) |
| 93 | + # Single-quoted: no escapes; reject if closing quote missing. |
| 94 | + local body="${raw#\'}" |
| 95 | + case "$body" in |
| 96 | + *"'"*) |
| 97 | + body="${body%%\'*}" |
| 98 | + printf '%s' "$body" |
| 99 | + ;; |
| 100 | + *) |
| 101 | + printf 'migrate-config.sh: %s: malformed single-quoted value for %s=\n' "$file" "$key" >&2 |
| 102 | + return 2 |
| 103 | + ;; |
| 104 | + esac |
| 105 | + ;; |
| 106 | + *) |
| 107 | + # Unquoted: ends at first whitespace (trailing comments must |
| 108 | + # be space-separated per shell parsing). |
| 109 | + printf '%s' "${raw%%[[:space:]]*}" |
| 110 | + ;; |
| 111 | + esac |
| 112 | + return 0 |
| 113 | +} |
| 114 | + |
| 115 | +# Escape a literal value for safe inclusion in `KEY="<value>"` form. |
| 116 | +# Order matters: backslash first so subsequent escapes don't double-up. |
| 117 | +escape_for_double_quoted() { |
| 118 | + local v="$1" |
| 119 | + v="${v//\\/\\\\}" |
| 120 | + v="${v//\$/\\\$}" |
| 121 | + v="${v//\`/\\\`}" |
| 122 | + v="${v//\"/\\\"}" |
| 123 | + printf '%s' "$v" |
| 124 | +} |
| 125 | + |
| 126 | +# Decide MLAT_USER / MLAT_ENABLED from a USER value per migration rules. |
| 127 | +# Sets caller-scope variables MLAT_USER_OUT and MLAT_ENABLED_OUT. |
| 128 | +derive_mlat_from_user() { |
| 129 | + local user="$1" |
| 130 | + case "$user" in |
| 131 | + 0|disable) |
| 132 | + MLAT_USER_OUT="" |
| 133 | + MLAT_ENABLED_OUT="false" |
| 134 | + ;; |
| 135 | + '') |
| 136 | + # Empty USER → "Anonymous" so the daemon's strict-fail on |
| 137 | + # empty MLAT_USER + MLAT_ENABLED=true never fires. Mirrors |
| 138 | + # the writer-side defaults in feed/configure.sh and |
| 139 | + # feed/scripts/apl-feed/mlat.sh. |
| 140 | + MLAT_USER_OUT="Anonymous" |
| 141 | + MLAT_ENABLED_OUT="true" |
| 142 | + ;; |
| 143 | + *) |
| 144 | + MLAT_USER_OUT="$user" |
| 145 | + MLAT_ENABLED_OUT="true" |
| 146 | + ;; |
| 147 | + esac |
| 148 | +} |
| 149 | + |
| 150 | +# Run the migration on $path. Caller is responsible for the lock. |
| 151 | +do_migrate() { |
| 152 | + local path="$1" |
| 153 | + local backup_path="${path}.pre-mlat-split" |
| 154 | + |
| 155 | + local user_value="" user_present=0 mlat_user_present=0 rc |
| 156 | + |
| 157 | + if user_value="$(extract_key "$path" USER)"; then |
| 158 | + user_present=1 |
| 159 | + else |
| 160 | + rc=$? |
| 161 | + case "$rc" in |
| 162 | + 1) user_present=0 ;; |
| 163 | + 2) exit 2 ;; |
| 164 | + *) exit "$rc" ;; |
| 165 | + esac |
| 166 | + fi |
| 167 | + |
| 168 | + if extract_key "$path" MLAT_USER >/dev/null; then |
| 169 | + mlat_user_present=1 |
| 170 | + else |
| 171 | + rc=$? |
| 172 | + case "$rc" in |
| 173 | + 1) mlat_user_present=0 ;; |
| 174 | + 2) exit 2 ;; |
| 175 | + *) exit "$rc" ;; |
| 176 | + esac |
| 177 | + fi |
| 178 | + |
| 179 | + # MLAT_ENABLED malformed is also a hard fail, even though we don't |
| 180 | + # use its value here (it gets re-derived). |
| 181 | + if extract_key "$path" MLAT_ENABLED >/dev/null; then |
| 182 | + : |
| 183 | + else |
| 184 | + rc=$? |
| 185 | + case "$rc" in |
| 186 | + 1) : ;; |
| 187 | + 2) exit 2 ;; |
| 188 | + *) exit "$rc" ;; |
| 189 | + esac |
| 190 | + fi |
| 191 | + |
| 192 | + # USER absent → file is already on new schema (or has no schema). |
| 193 | + # Don't touch it. mtime stays unchanged. |
| 194 | + if (( ! user_present )); then |
| 195 | + return 0 |
| 196 | + fi |
| 197 | + |
| 198 | + # USER present: migrate. USER is authoritative; any stale MLAT_USER |
| 199 | + # / MLAT_ENABLED is stripped and re-derived. |
| 200 | + local MLAT_USER_OUT="" MLAT_ENABLED_OUT="" |
| 201 | + derive_mlat_from_user "$user_value" |
| 202 | + |
| 203 | + local user_escaped mlat_user_escaped |
| 204 | + user_escaped="$(escape_for_double_quoted "$user_value")" |
| 205 | + mlat_user_escaped="$(escape_for_double_quoted "$MLAT_USER_OUT")" |
| 206 | + |
| 207 | + # Backup once on first transition out of pure-legacy (USER present, |
| 208 | + # MLAT_USER absent). Never overwritten. |
| 209 | + if [[ ! -f "$backup_path" ]] && (( ! mlat_user_present )); then |
| 210 | + cp -fp "$path" "$backup_path" |
| 211 | + fi |
| 212 | + |
| 213 | + # Render desired output to an adjacent temp file. The first USER= |
| 214 | + # line becomes the anchor: re-emitted in normalized form, immediately |
| 215 | + # followed by MLAT_USER and MLAT_ENABLED. Subsequent USER= lines and |
| 216 | + # any existing MLAT_USER= / MLAT_ENABLED= lines are dropped. |
| 217 | + local tmp |
| 218 | + tmp="$(mktemp "${path}.XXXXXX")" |
| 219 | + # shellcheck disable=SC2064 |
| 220 | + trap "rm -f '$tmp'" EXIT |
| 221 | + |
| 222 | + { |
| 223 | + local line trimmed user_seen=0 |
| 224 | + while IFS= read -r line || [[ -n "$line" ]]; do |
| 225 | + trimmed="${line%$'\r'}" |
| 226 | + case "$trimmed" in |
| 227 | + 'USER='*) |
| 228 | + if (( ! user_seen )); then |
| 229 | + printf 'USER="%s"\n' "$user_escaped" |
| 230 | + printf 'MLAT_USER="%s"\n' "$mlat_user_escaped" |
| 231 | + printf 'MLAT_ENABLED=%s\n' "$MLAT_ENABLED_OUT" |
| 232 | + user_seen=1 |
| 233 | + fi |
| 234 | + ;; |
| 235 | + 'MLAT_USER='*|'MLAT_ENABLED='*) |
| 236 | + : # drop; will be re-emitted by the USER anchor |
| 237 | + ;; |
| 238 | + *) |
| 239 | + printf '%s\n' "$line" |
| 240 | + ;; |
| 241 | + esac |
| 242 | + done < "$path" |
| 243 | + } > "$tmp" |
| 244 | + |
| 245 | + # Preserve mode and ownership. On vfat /boot ownership is meaningless, |
| 246 | + # so chown failure is tolerated; chmod falls back to 0644. |
| 247 | + chmod --reference="$path" "$tmp" 2>/dev/null || chmod 0644 "$tmp" |
| 248 | + chown --reference="$path" "$tmp" 2>/dev/null || true |
| 249 | + |
| 250 | + # Byte-compare. No-op when output is identical to input so mtime is |
| 251 | + # preserved across idempotent re-runs. |
| 252 | + if cmp -s "$path" "$tmp"; then |
| 253 | + rm -f "$tmp" |
| 254 | + trap - EXIT |
| 255 | + return 0 |
| 256 | + fi |
| 257 | + |
| 258 | + mv -f "$tmp" "$path" |
| 259 | + trap - EXIT |
| 260 | +} |
| 261 | + |
| 262 | +main() { |
| 263 | + if [[ "${1:-}" == "--version" ]]; then |
| 264 | + printf 'migrate-config.sh version=%d\n' "$MIGRATOR_VERSION" |
| 265 | + return 0 |
| 266 | + fi |
| 267 | + |
| 268 | + local path="${1:-}" |
| 269 | + if [[ -z "$path" ]]; then |
| 270 | + echo "usage: migrate-config.sh <path>" >&2 |
| 271 | + return 2 |
| 272 | + fi |
| 273 | + |
| 274 | + if [[ ! -f "$path" ]]; then |
| 275 | + # Nothing to migrate. Don't fail — could be a fresh install |
| 276 | + # where airplanes-config.txt hasn't been created yet. |
| 277 | + return 0 |
| 278 | + fi |
| 279 | + |
| 280 | + if [[ "${AIRPLANES_CONFIG_LOCK_HELD:-}" == "1" ]]; then |
| 281 | + do_migrate "$path" |
| 282 | + return |
| 283 | + fi |
| 284 | + |
| 285 | + mkdir -p "$(dirname "$LOCK_FILE")" |
| 286 | + exec 9>"$LOCK_FILE" |
| 287 | + flock 9 |
| 288 | + do_migrate "$path" |
| 289 | +} |
| 290 | + |
| 291 | +main "$@" |
0 commit comments