-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathconfig.sh
More file actions
866 lines (814 loc) · 34.4 KB
/
Copy pathconfig.sh
File metadata and controls
866 lines (814 loc) · 34.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
#!/usr/bin/env bash
# `apl-feed config sync` — push the local feed.env snapshot to
# airplanes.live and apply the server's merged response. Invoked every
# ~60s by airplanes-config-sync.timer (with 30s jitter). Operators can
# trigger a one-shot run manually.
#
# Wire shape, gates, and merge semantics in
# infrastructure/docs/feeder.md#remote-configuration. Authentication is
# the standard Authorization: Bearer alv1.<uuid>.<secret> token. The
# library's metadata-LWW gate (APL_APPLY_INCOMING_META_*) protects this
# call site against a concurrent operator/webconfig write that lands
# between the snapshot read and the apply step.
#
# Exit codes (intentional minimal surface — mirrors
# airplanes-diagnostics.sh):
# 0 applied / no_change / unowned heartbeat / any transient or
# recoverable failure (401, 423, 426, 429, 4xx body, 5xx,
# network error). The structured log line carries the reason;
# the operator reads journalctl -u airplanes-config-sync.
# 64 hard local config error — missing Feeder ID or claim secret,
# broken installation. systemctl marks the unit failed so the
# failure surfaces in `apl-feed status` and `systemctl status`.
# Legacy fallback tuple matches the migration in
# scripts/lib/update-migrations.sh:migrate_seed_feed_meta_json so a
# feeder whose sidecar is missing or corrupt converges to the server's
# state rather than forging fresh feeder-stamped tuples.
CONFIG_SYNC_LEGACY_EDITED_AT="2020-01-01T00:00:00Z"
CONFIG_SYNC_LEGACY_EDITED_BY="legacy"
CONFIG_SYNC_EXIT_OK=0
CONFIG_SYNC_EXIT_BAD_CONFIG=64
# parse_opt_in RAW
# echoes one of: enabled, disabled, invalid, empty
# Mirrors parse_report_status in airplanes-diagnostics.sh.
# Callers map "empty" to disabled — REMOTE_CONFIG_ENABLED is opt-in,
# so absence means "not consented" rather than "default on".
_config_sync_parse_opt_in() {
local raw="$1"
if [[ -z "$raw" ]]; then
printf '%s' 'empty'
return
fi
local lower
lower="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
lower="${lower#"${lower%%[![:space:]]*}"}"
lower="${lower%"${lower##*[![:space:]]}"}"
case "$lower" in
true|yes|1|on) printf '%s' 'enabled' ;;
false|no|0|off) printf '%s' 'disabled' ;;
*) printf '%s' 'invalid' ;;
esac
}
# Sentinel mtime path. Overridable for tests + chroot smokes.
CONFIG_SYNC_LAST_SUCCESS_FILE="${AIRPLANES_CONFIG_SYNC_LAST_SUCCESS:-/var/lib/airplanes/config-sync-last-success}"
# Structured logger. Mirrors airplanes-diagnostics.sh's `log` so the two
# timers produce a uniform journal stream.
_config_sync_log() {
local level="$1"
shift
printf 'apl-feed-config-sync level=%s %s\n' "$level" "$*" >&2
}
# True if feed.env has a (non-comment) line matching `^[[:space:]]*KEY=`.
# Distinguishes "absent" (omit field from payload) from "present-empty"
# (emit tombstone where the schema accepts null).
_config_sync_has_key() {
local feed_env="$1" key="$2"
[[ -f "$feed_env" ]] || return 1
grep -qE "^[[:space:]]*${key}=" "$feed_env" 2>/dev/null
}
# Normalize a feed.env bool ("true"/"yes"/"1"/"on" or "false"/"no"/"0"/
# "off") to the canonical "true"/"false" the API expects as a JSON bool.
# Returns 1 (and emits nothing) on unparseable / empty.
_config_sync_read_bool() {
local raw="${1:-}"
case "${raw,,}" in
true|yes|1|on)
printf 'true'
return 0
;;
false|no|0|off)
printf 'false'
return 0
;;
esac
return 1
}
# Load /etc/airplanes/feed.meta.json into per-key (edited_at, edited_by)
# associative arrays via nameref. Missing / corrupt / non-v1 schema all
# leave the maps empty without erroring — callers fall back to the
# legacy tuple per-key.
#
# Per-entry shape is filtered to enforce:
# - edited_at matches the RFC 3339 UTC regex (mirrors the apply lib's
# APL_FEED_APPLY_EDITED_AT_RE)
# - edited_by ∈ {feeder, website, legacy}
# Invalid entries are dropped silently. The next call to apl_feed_apply
# overwrites the sidecar from clean state, so a one-off corrupt entry
# heals itself; meanwhile we never propagate the bad metadata to the
# server and stay out of a 400 validation_failed loop.
_config_sync_load_meta() {
local meta_path="$1"
local -n at_out="$2"
local -n by_out="$3"
at_out=()
by_out=()
[[ -f "$meta_path" ]] || return 0
local entries
if ! entries="$(jq -r '
if (type == "object") and (.schema_version == 1) and (.fields | type == "object") then
.fields | to_entries[] |
select(.value | type == "object") |
select(.value.edited_at | type == "string") |
select(.value.edited_by | type == "string") |
select(.value.edited_at | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}([.][0-9]+)?Z$")) |
select(.value.edited_by | IN("feeder", "website", "legacy")) |
"\(.key)\t\(.value.edited_at)\t\(.value.edited_by)"
else empty end
' "$meta_path" 2>/dev/null)"; then
return 0
fi
local key at by
while IFS=$'\t' read -r key at by; do
[[ -z "$key" ]] && continue
at_out[$key]="$at"
by_out[$key]="$by"
done <<< "$entries"
}
# Resolve per-field outgoing (edited_at, edited_by). Sets the
# top-level _PAY_AT_<key> / _PAY_BY_<key> locals in the caller's scope.
# Default falls back to the legacy tuple when the sidecar has no entry.
_config_sync_resolve_field_meta() {
local key="$1"
local fallback_by="${4:-$CONFIG_SYNC_LEGACY_EDITED_BY}"
local -n at_map="$2"
local -n by_map="$3"
if [[ -n "${at_map[$key]+set}" ]]; then
printf '%s\t%s' "${at_map[$key]}" "${by_map[$key]:-$fallback_by}"
else
printf '%s\t%s' "$CONFIG_SYNC_LEGACY_EDITED_AT" "$CONFIG_SYNC_LEGACY_EDITED_BY"
fi
}
# Build the outgoing payload JSON. Reads feed.env + feed.meta.json and
# emits the body the API expects on stdout. Returns 0 on success, 1 on
# unrecoverable build error.
_config_sync_build_payload() {
local feed_env="$1" meta_path="$2" feeder_time="$3"
local -A meta_at=() meta_by=()
_config_sync_load_meta "$meta_path" meta_at meta_by
local lat lon alt mlat_user mlat_enabled mlat_private geo_configured
lat="$(feed_env_get LATITUDE 2>/dev/null || true)"
lon="$(feed_env_get LONGITUDE 2>/dev/null || true)"
alt="$(feed_env_get ALTITUDE 2>/dev/null || true)"
mlat_user="$(feed_env_get MLAT_USER 2>/dev/null || true)"
mlat_enabled="$(feed_env_get MLAT_ENABLED 2>/dev/null || true)"
mlat_private="$(feed_env_get MLAT_PRIVATE 2>/dev/null || true)"
geo_configured="$(feed_env_get GEO_CONFIGURED 2>/dev/null || true)"
# Position: tombstone unless GEO_CONFIGURED=true AND both axes
# parse as numbers. Atomic-pair edited_at = min(lat.at, lon.at)
# so a freshly-stamped axis doesn't promote a stale other-axis to
# "newer than server."
local position_value='null'
local position_at="$CONFIG_SYNC_LEGACY_EDITED_AT"
local position_by="$CONFIG_SYNC_LEGACY_EDITED_BY"
local geo_lower="${geo_configured,,}"
if [[ "$geo_lower" == "true" && -n "$lat" && -n "$lon" ]]; then
local pos_candidate
if pos_candidate="$(jq -nc \
--arg lat "$lat" --arg lon "$lon" \
'{lat: ($lat | tonumber), lon: ($lon | tonumber)}' 2>/dev/null)"; then
position_value="$pos_candidate"
fi
fi
local lat_at="${meta_at[LATITUDE]:-}"
local lon_at="${meta_at[LONGITUDE]:-}"
if [[ -n "$lat_at" && -n "$lon_at" ]]; then
# Atomic-group edited_at = MAX of the two axes' stamps. apl_feed_apply
# writes both axes in a single locked transaction with the same
# stamp, so MIN and MAX are equal in the normal case. In the
# divergent-stamps edge case (legacy seed + a partial hand-edit
# that touched only one axis), MAX reflects when the position
# *state* was last changed, not the older lagging stamp. The
# symmetric LWW gate in _config_sync_apply_response uses MAX too.
if [[ "$lat_at" > "$lon_at" ]]; then
position_at="$lat_at"
else
position_at="$lon_at"
fi
# On-disk position is feeder-originated by definition (the only
# path that mutates LAT/LON is operator-driven writes through
# apl_feed_apply, all of which stamp edited_by=feeder when not
# told otherwise). Force the wire value rather than echoing one
# axis' stamp arbitrarily.
position_by="feeder"
elif [[ -n "$lat_at" ]]; then
position_at="$lat_at"
position_by="${meta_by[LATITUDE]:-feeder}"
elif [[ -n "$lon_at" ]]; then
position_at="$lon_at"
position_by="${meta_by[LONGITUDE]:-feeder}"
fi
# Build via jq with per-field --arg streams so values pass through
# JSON-safely. `--argjson position_value` lets us emit either the
# object `{lat,lon}` or the literal `null` based on the variable.
local filter='{schema_version: 1, feeder_time: $feeder_time, fields: {}}'
local -a jq_args=(
--arg feeder_time "$feeder_time"
--argjson pos_value "$position_value"
--arg pos_at "$position_at"
--arg pos_by "$position_by"
)
filter+=' | .fields.position = {value: $pos_value, edited_at: $pos_at, edited_by: $pos_by}'
# alt — nullable string.
if _config_sync_has_key "$feed_env" ALTITUDE; then
local alt_at alt_by alt_meta
alt_meta="$(_config_sync_resolve_field_meta ALTITUDE meta_at meta_by)"
alt_at="${alt_meta%%$'\t'*}"
alt_by="${alt_meta##*$'\t'}"
jq_args+=(--arg alt_at "$alt_at" --arg alt_by "$alt_by")
if [[ -z "$alt" ]]; then
filter+=' | .fields.alt = {value: null, edited_at: $alt_at, edited_by: $alt_by}'
else
jq_args+=(--arg alt_v "$alt")
filter+=' | .fields.alt = {value: $alt_v, edited_at: $alt_at, edited_by: $alt_by}'
fi
fi
# mlat_user — nullable string.
if _config_sync_has_key "$feed_env" MLAT_USER; then
local mu_at mu_by mu_meta
mu_meta="$(_config_sync_resolve_field_meta MLAT_USER meta_at meta_by)"
mu_at="${mu_meta%%$'\t'*}"
mu_by="${mu_meta##*$'\t'}"
jq_args+=(--arg mu_at "$mu_at" --arg mu_by "$mu_by")
if [[ -z "$mlat_user" ]]; then
filter+=' | .fields.mlat_user = {value: null, edited_at: $mu_at, edited_by: $mu_by}'
else
jq_args+=(--arg mu_v "$mlat_user")
filter+=' | .fields.mlat_user = {value: $mu_v, edited_at: $mu_at, edited_by: $mu_by}'
fi
fi
# mlat_enabled — non-nullable bool. Omit the field entirely when
# the key is absent or unparseable (the API rejects null booleans).
if _config_sync_has_key "$feed_env" MLAT_ENABLED; then
local me_norm
if me_norm="$(_config_sync_read_bool "$mlat_enabled")"; then
local me_at me_by me_meta
me_meta="$(_config_sync_resolve_field_meta MLAT_ENABLED meta_at meta_by)"
me_at="${me_meta%%$'\t'*}"
me_by="${me_meta##*$'\t'}"
jq_args+=(
--argjson me_v "$me_norm"
--arg me_at "$me_at"
--arg me_by "$me_by"
)
filter+=' | .fields.mlat_enabled = {value: $me_v, edited_at: $me_at, edited_by: $me_by}'
fi
fi
# mlat_private — non-nullable bool. Same omission rules as
# mlat_enabled.
if _config_sync_has_key "$feed_env" MLAT_PRIVATE; then
local mp_norm
if mp_norm="$(_config_sync_read_bool "$mlat_private")"; then
local mp_at mp_by mp_meta
mp_meta="$(_config_sync_resolve_field_meta MLAT_PRIVATE meta_at meta_by)"
mp_at="${mp_meta%%$'\t'*}"
mp_by="${mp_meta##*$'\t'}"
jq_args+=(
--argjson mp_v "$mp_norm"
--arg mp_at "$mp_at"
--arg mp_by "$mp_by"
)
filter+=' | .fields.mlat_private = {value: $mp_v, edited_at: $mp_at, edited_by: $mp_by}'
fi
fi
jq -nc "${jq_args[@]}" "$filter"
}
# Update the success-mtime sentinel. Failure to touch is non-fatal —
# the next successful sync will retry. Path is taken straight from the
# AIRPLANES_CONFIG_SYNC_LAST_SUCCESS env var (or the production default);
# chroot / test callers override the env var rather than relying on
# --root translation so the sentinel lands exactly where they expect.
_config_sync_touch_sentinel() {
local file="$CONFIG_SYNC_LAST_SUCCESS_FILE"
local dir
dir="$(dirname "$file")"
mkdir -p "$dir" 2>/dev/null || true
: > "$file" 2>/dev/null || true
}
# Translate the server's `fields` response into per-key arguments for
# apl_feed_apply. Populates the caller-passed arrays:
# APL_APPLY_INCOMING_META_EDITED_AT, _EDITED_BY (global, by contract
# with the lib) and the local arg-list array `out_args` (nameref).
#
# Tombstones (value: null) emit empty-string values for nullable feed.env
# keys (LATITUDE, LONGITUDE, ALTITUDE, MLAT_USER); the bool keys never
# receive a tombstone (the API serializer rejects null booleans).
#
# The rejected_fields response array is processed by adopting the
# server's tuple for each rejected key — that heals a bogus on-disk
# edited_at and ends the rejection loop on the next tick.
_config_sync_translate_response() {
local response_file="$1"
local -n out_args="$2"
out_args=()
APL_APPLY_INCOMING_META_EDITED_AT=()
APL_APPLY_INCOMING_META_EDITED_BY=()
# jq extracts one TAB-separated line per API field with: name,
# is_tombstone, value, edited_at, edited_by. Position's value is
# rendered as `lat|lon` so a single line carries both axes; bool
# values come out as `true`/`false`; null values render as empty
# string in the `value` column with `is_tombstone=1`.
local entries
if ! entries="$(jq -r '
.fields | to_entries[] |
[.key,
(if .value.value == null then "1" else "0" end),
(if .value.value == null then ""
elif .key == "position" then "\(.value.value.lat)|\(.value.value.lon)"
elif (.value.value | type) == "boolean" then (.value.value | tostring)
else (.value.value | tostring) end),
.value.edited_at,
.value.edited_by] |
@tsv
' "$response_file" 2>/dev/null)"; then
return 1
fi
local api_field is_null value edited_at edited_by
while IFS=$'\t' read -r api_field is_null value edited_at edited_by; do
[[ -z "$api_field" ]] && continue
# The server-side serializer enforces this allowlist on inbound
# writes; mirror it on the response so a misbehaving server cannot
# smuggle an unknown actor label into feed.meta.json. Drop the
# field entirely (no out_args entry, no incoming-meta entry) and
# continue — other fields in the same response still apply.
case "$edited_by" in
feeder|website|legacy) ;;
*)
# Quote the value so an attacker-controlled string can't
# forge extra key=value pairs in the structured log line.
# jq @tsv already escapes tabs/newlines; this guards
# against spaces and embedded `=`.
local _bad_value="${edited_by:0:64}"
_bad_value="${_bad_value//\"/\\\"}"
_config_sync_log warn "reason=bad_edited_by field=$api_field value=\"$_bad_value\""
continue
;;
esac
case "$api_field" in
position)
if [[ "$is_null" == "1" ]]; then
out_args+=("LATITUDE=" "LONGITUDE=")
else
local pos_lat="${value%|*}" pos_lon="${value#*|}"
out_args+=("LATITUDE=$pos_lat" "LONGITUDE=$pos_lon")
fi
APL_APPLY_INCOMING_META_EDITED_AT[LATITUDE]="$edited_at"
APL_APPLY_INCOMING_META_EDITED_AT[LONGITUDE]="$edited_at"
APL_APPLY_INCOMING_META_EDITED_BY[LATITUDE]="$edited_by"
APL_APPLY_INCOMING_META_EDITED_BY[LONGITUDE]="$edited_by"
;;
alt)
if [[ "$is_null" == "1" ]]; then
out_args+=("ALTITUDE=")
else
out_args+=("ALTITUDE=$value")
fi
APL_APPLY_INCOMING_META_EDITED_AT[ALTITUDE]="$edited_at"
APL_APPLY_INCOMING_META_EDITED_BY[ALTITUDE]="$edited_by"
;;
mlat_user)
if [[ "$is_null" == "1" ]]; then
out_args+=("MLAT_USER=")
else
out_args+=("MLAT_USER=$value")
fi
APL_APPLY_INCOMING_META_EDITED_AT[MLAT_USER]="$edited_at"
APL_APPLY_INCOMING_META_EDITED_BY[MLAT_USER]="$edited_by"
;;
mlat_enabled)
out_args+=("MLAT_ENABLED=$value")
APL_APPLY_INCOMING_META_EDITED_AT[MLAT_ENABLED]="$edited_at"
APL_APPLY_INCOMING_META_EDITED_BY[MLAT_ENABLED]="$edited_by"
;;
mlat_private)
out_args+=("MLAT_PRIVATE=$value")
APL_APPLY_INCOMING_META_EDITED_AT[MLAT_PRIVATE]="$edited_at"
APL_APPLY_INCOMING_META_EDITED_BY[MLAT_PRIVATE]="$edited_by"
;;
*)
# Unknown server field — ignore. A schema_version=1
# server should never emit one; future versions are
# caught by the 426 path before we ever reach this.
;;
esac
done <<< "$entries"
return 0
}
# Drive the apply step for a 200 APPLIED response. Returns 0 on success
# (applied / no_change), 1 on any apply-side failure (logged + treated
# as transient by the caller).
_config_sync_apply_response() {
local response_file="$1"
local skip_restart="$2"
local -a apply_args=()
if ! _config_sync_translate_response "$response_file" apply_args; then
_config_sync_log error "reason=apply_translate body=$(body_preview "$response_file")"
APL_APPLY_INCOMING_META_EDITED_AT=()
APL_APPLY_INCOMING_META_EDITED_BY=()
APL_APPLY_INCOMING_SERVER_TIME=""
return 1
fi
if (( ${#apply_args[@]} == 0 )); then
# Server returned `fields: {}` — nothing to apply. Treat as
# success but log so an unexpected empty response is visible.
_config_sync_log info "reason=empty_fields_payload"
APL_APPLY_INCOMING_META_EDITED_AT=()
APL_APPLY_INCOMING_META_EDITED_BY=()
APL_APPLY_INCOMING_SERVER_TIME=""
return 0
fi
# Pass the server's authoritative `server_time` to the apply lib so
# its bogus-future-heal threshold is computed against trusted time
# rather than a possibly-fast local clock.
APL_APPLY_INCOMING_SERVER_TIME="$(parse_field_from "$response_file" '.server_time')"
# Atomic position-group LWW decision. The server treats position as
# a single field, but the apply lib gates per feed.env key. Without
# this pre-check, a feeder whose on-disk LATITUDE/LONGITUDE stamps
# have somehow diverged could apply one axis from the server tuple
# and skip the other — producing a hybrid position. Compute the
# group decision against the OLDER of the two on-disk stamps so a
# stale half can't masquerade as the whole.
local _have_lat_arg=0 _have_lon_arg=0
local _arg
for _arg in "${apply_args[@]}"; do
case "$_arg" in
LATITUDE=*) _have_lat_arg=1 ;;
LONGITUDE=*) _have_lon_arg=1 ;;
esac
done
if (( _have_lat_arg == 1 && _have_lon_arg == 1 )); then
local _meta_path
_meta_path="$(feed_env_write_path)"
_meta_path="${_meta_path%/feed.env}/feed.meta.json"
local -A _on_at=() _on_by=()
_config_sync_load_meta "$_meta_path" _on_at _on_by
local _lat_at="${_on_at[LATITUDE]:-}"
local _lon_at="${_on_at[LONGITUDE]:-}"
local _pos_group_at=""
if [[ -n "$_lat_at" && -n "$_lon_at" ]]; then
# MAX-of-pair: see comment in _config_sync_build_payload.
# If LAT was edited fresh but LON's stamp is stale, the
# position state was effectively newly edited at the LAT
# time — incoming must beat MAX to apply atomically.
if [[ "$_lat_at" > "$_lon_at" ]]; then
_pos_group_at="$_lat_at"
else
_pos_group_at="$_lon_at"
fi
elif [[ -n "$_lat_at" ]]; then
_pos_group_at="$_lat_at"
elif [[ -n "$_lon_at" ]]; then
_pos_group_at="$_lon_at"
fi
local _incoming_pos_at="${APL_APPLY_INCOMING_META_EDITED_AT[LATITUDE]:-}"
if [[ -n "$_pos_group_at" && -n "$_incoming_pos_at" ]]; then
# Use the same bogus-future-heal carve-out the lib applies.
# If on-disk is wildly in server-future, the heal path must
# run — keep both axes in the payload.
local _heal_threshold
if [[ -n "$APL_APPLY_INCOMING_SERVER_TIME" \
&& "$APL_APPLY_INCOMING_SERVER_TIME" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}([.][0-9]+)?Z$ ]]; then
_heal_threshold="$(date -u -d "$APL_APPLY_INCOMING_SERVER_TIME +300 seconds" \
+%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true)"
fi
local _is_bogus_future=0
if [[ -n "$_heal_threshold" && "$_pos_group_at" > "$_heal_threshold" ]]; then
_is_bogus_future=1
fi
if (( _is_bogus_future == 0 )) && [[ ! "$_incoming_pos_at" > "$_pos_group_at" ]]; then
# Drop both LATITUDE and LONGITUDE from apply_args and
# the incoming-meta arrays so the lib's gate has nothing
# to decide for position. Other fields apply as usual.
local -a _filtered=()
for _arg in "${apply_args[@]}"; do
case "$_arg" in
LATITUDE=*|LONGITUDE=*) ;;
*) _filtered+=("$_arg") ;;
esac
done
apply_args=("${_filtered[@]}")
unset 'APL_APPLY_INCOMING_META_EDITED_AT[LATITUDE]'
unset 'APL_APPLY_INCOMING_META_EDITED_AT[LONGITUDE]'
unset 'APL_APPLY_INCOMING_META_EDITED_BY[LATITUDE]'
unset 'APL_APPLY_INCOMING_META_EDITED_BY[LONGITUDE]'
_config_sync_log info "reason=position_group_skipped_by_lww"
fi
fi
if (( ${#apply_args[@]} == 0 )); then
_config_sync_log info "reason=all_keys_skipped_after_pos_group"
APL_APPLY_INCOMING_META_EDITED_AT=()
APL_APPLY_INCOMING_META_EDITED_BY=()
APL_APPLY_INCOMING_SERVER_TIME=""
return 0
fi
fi
feed_env_ensure_canonical_for_write
local -a lib_args=(--feed-env "$(feed_env_write_path)"
--lock-file "$(feed_env_lock_path)")
if (( skip_restart )); then
lib_args+=(--no-restart)
fi
if [[ "$ROOT" != "/" ]]; then
lib_args+=(--no-restart --no-audit)
fi
local rc=0
apl_feed_apply "${lib_args[@]}" "${apply_args[@]}" || rc=$?
# Always clear the globals so a subsequent call (e.g. retry from
# the timer's next tick) starts from a clean slate.
APL_APPLY_INCOMING_META_EDITED_AT=()
APL_APPLY_INCOMING_META_EDITED_BY=()
APL_APPLY_INCOMING_SERVER_TIME=""
case "$APL_APPLY_STATUS" in
applied|no_change)
local skipped="${APL_APPLY_SKIPPED_BY_LWW[*]:-}"
local changed="${APL_APPLY_CHANGED[*]:-}"
_config_sync_log info \
"reason=applied status=$APL_APPLY_STATUS changed=[${changed}] lww_skipped=[${skipped}]"
return 0
;;
rejected)
local k errs=""
for k in "${!APL_APPLY_ERRORS[@]}"; do
errs+="${k}=\"${APL_APPLY_ERRORS[$k]}\" "
done
_config_sync_log error "reason=apply_rejected errors=[${errs% }]"
return 1
;;
lock_timeout|filesystem_error|usage_error)
_config_sync_log warn \
"reason=apply_${APL_APPLY_STATUS} message=\"${APL_APPLY_ERROR_MESSAGE}\""
return 1
;;
*)
_config_sync_log warn "reason=apply_unknown status=${APL_APPLY_STATUS:-empty} rc=$rc"
return 1
;;
esac
}
# Print the outgoing payload to stdout (for --dry-run). Returns 0 on
# success, 1 on local read error.
_config_sync_dry_run() {
local feed_env feeder_time payload
feed_env="$(root_path '/etc/airplanes/feed.env')"
local meta_path="${feed_env%/feed.env}/feed.meta.json"
feeder_time="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [[ ! -f "$feed_env" ]]; then
_config_sync_log error "reason=feed_env_missing path=$feed_env"
return 1
fi
if ! payload="$(_config_sync_build_payload "$feed_env" "$meta_path" "$feeder_time")"; then
_config_sync_log error "reason=payload_build_failed"
return 1
fi
printf '%s\n' "$payload"
return 0
}
apl_feed_config_sync() {
local dry_run=0
local skip_restart=0
local opt_rc
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run)
dry_run=1
shift
;;
--no-restart)
skip_restart=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
if parse_common_option "$@"; then opt_rc=0; else opt_rc=$?; fi
case "$opt_rc" in
1) shift ;;
2) shift 2 ;;
0) die "unknown flag for config sync: $1" ;;
esac
;;
esac
done
# Opt-in gate. REMOTE_CONFIG_ENABLED must be explicitly true before
# this CLI contacts the website. Absent / empty / false all short-
# circuit silently with exit 0 — the timer keeps ticking, the
# collector exits silently each tick, no payload leaves the feeder.
# An unparseable value surfaces as a hard config error (exit 64) so
# operators see it in `apl-feed status` / `systemctl status`.
local opt_in opt_in_state
opt_in="$(feed_env_get REMOTE_CONFIG_ENABLED 2>/dev/null || true)"
opt_in_state="$(_config_sync_parse_opt_in "$opt_in")"
case "$opt_in_state" in
disabled|empty)
_config_sync_log info "status=disabled reason=opt_in_required"
return "$CONFIG_SYNC_EXIT_OK"
;;
invalid)
_config_sync_log error "status=bad_config key=REMOTE_CONFIG_ENABLED value=${opt_in}"
return "$CONFIG_SYNC_EXIT_BAD_CONFIG"
;;
enabled) ;;
esac
require_jq
if (( dry_run )); then
if _config_sync_dry_run; then
return "$CONFIG_SYNC_EXIT_OK"
fi
return 1
fi
# Identity prerequisites. Failure here is a hard config error —
# exit 64 so systemd marks the unit failed.
local uuid secret_path secret
if ! uuid="$(read_uuid 2>/dev/null)"; then
_config_sync_log error "reason=missing_uuid"
return "$CONFIG_SYNC_EXIT_BAD_CONFIG"
fi
secret_path="$(secret_final_path)"
if [[ ! -f "$secret_path" ]]; then
_config_sync_log error "reason=missing_claim_secret path=$secret_path"
return "$CONFIG_SYNC_EXIT_BAD_CONFIG"
fi
if [[ ! -r "$secret_path" ]]; then
_config_sync_log error "reason=unreadable_claim_secret path=$secret_path"
return "$CONFIG_SYNC_EXIT_BAD_CONFIG"
fi
# read_secret_file calls `die` (exit 1) on a malformed secret.
# Without the `||` capture, set -e would propagate that exit 1 out
# of this function — masking it as a generic transient failure
# instead of the hard-config error it actually is.
local _secret_rc=0
secret="$(read_secret_file "$secret_path" 2>/dev/null)" || _secret_rc=$?
if (( _secret_rc != 0 )) || [[ -z "$secret" ]]; then
_config_sync_log error "reason=invalid_claim_secret path=$secret_path"
return "$CONFIG_SYNC_EXIT_BAD_CONFIG"
fi
local feed_env meta_path feeder_time payload response_file
feed_env="$(root_path '/etc/airplanes/feed.env')"
meta_path="${feed_env%/feed.env}/feed.meta.json"
feeder_time="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [[ ! -f "$feed_env" ]]; then
_config_sync_log error "reason=feed_env_missing path=$feed_env"
return "$CONFIG_SYNC_EXIT_BAD_CONFIG"
fi
if ! payload="$(_config_sync_build_payload "$feed_env" "$meta_path" "$feeder_time")"; then
_config_sync_log error "reason=payload_build_failed"
return "$CONFIG_SYNC_EXIT_OK"
fi
response_file="$(new_tmp_file)"
local status curl_rc token
token="$(apl_auth_token "$uuid" "$secret")"
set +e
status="$(post_json_bearer "$token" '/api/feeders/config/sync' "$payload" "$response_file")"
curl_rc=$?
set -e
if [[ "$curl_rc" -ne 0 ]]; then
_config_sync_log warn "reason=transport curl_rc=$curl_rc"
return "$CONFIG_SYNC_EXIT_OK"
fi
local error_field body_preview owned
error_field="$(parse_field_from "$response_file" '.error')"
body_preview="$(body_preview "$response_file")"
case "$status" in
200)
owned="$(parse_field_from "$response_file" '.owned')"
case "$owned" in
true)
local rejected
rejected="$(jq -r '.rejected_fields // [] | join(",")' "$response_file" 2>/dev/null || true)"
if [[ -n "$rejected" ]]; then
_config_sync_log warn "reason=rejected_fields fields=[$rejected]"
fi
if _config_sync_apply_response "$response_file" "$skip_restart"; then
_config_sync_touch_sentinel
fi
;;
false)
_config_sync_log info "reason=unowned"
_config_sync_touch_sentinel
;;
*)
_config_sync_log warn "reason=malformed_response body=$body_preview"
;;
esac
;;
400)
_config_sync_log error "reason=validation_failed body=$body_preview"
;;
401)
_config_sync_log error "reason=unauthorized body=$body_preview"
;;
423)
local block_reason
block_reason="$(parse_field_from "$response_file" '.reason')"
_config_sync_log warn "reason=blocked block_reason=${block_reason:-unknown}"
;;
426)
local supported
supported="$(jq -r '.supported // [] | tostring' "$response_file" 2>/dev/null || true)"
_config_sync_log error "reason=schema_version_unsupported server_supported=${supported:-[]}"
;;
429)
_config_sync_log warn "reason=rate_limited body=$body_preview"
;;
5*)
_config_sync_log warn "reason=server_${status} body=$body_preview"
;;
*)
_config_sync_log warn "reason=unexpected_status status=$status error=${error_field:-} body=$body_preview"
;;
esac
return "$CONFIG_SYNC_EXIT_OK"
}
# Operator-facing toggle for the REMOTE_CONFIG_ENABLED opt-in. Mirrors
# _diagnostics_apply / _diagnostics_emit_result in apl-feed/diagnostics.sh:
# routes the sparse update through apl_feed_apply so the canonical
# privileged writer handles locking, validation, and atomic rewrite.
# REMOTE_CONFIG_ENABLED is registered as a no-restart key in
# feed-env-keys.sh — the next sync tick (within ~60s) reads the new
# value at the top-of-function gate.
_config_toggle_apply() {
feed_env_ensure_canonical_for_write
local -a args=()
args+=(--feed-env "$(feed_env_write_path)")
args+=(--lock-file "$(feed_env_lock_path)")
if [[ "$ROOT" != "/" ]]; then
args+=(--no-restart --no-audit)
echo "Skipping service restart (--root=$ROOT, not the host root)" >&2
fi
CONFIG_TOGGLE_APPLY_RC=0
apl_feed_apply "${args[@]}" "$@" || CONFIG_TOGGLE_APPLY_RC=$?
}
_config_toggle_emit_result() {
local success_msg="$1"
case "$APL_APPLY_STATUS" in
applied)
echo "$success_msg"
if (( ${#APL_APPLY_PENDING_RESTART[@]} > 0 )); then
echo "Warning: failed to restart ${APL_APPLY_PENDING_RESTART[*]} — re-run: sudo systemctl restart ${APL_APPLY_PENDING_RESTART[*]}" >&2
fi
apl_feed_apply_emit_meta_warning
return 0
;;
no_change)
return 0
;;
rejected)
local k
for k in "${!APL_APPLY_ERRORS[@]}"; do
echo "ERROR: $k: ${APL_APPLY_ERRORS[$k]}" >&2
done
return 1
;;
lock_timeout)
echo "ERROR: could not acquire feed.env lock: $APL_APPLY_ERROR_MESSAGE" >&2
return 1
;;
filesystem_error)
echo "ERROR: $APL_APPLY_ERROR_MESSAGE" >&2
return 1
;;
*)
echo "ERROR: ${APL_APPLY_ERROR_MESSAGE:-apply failed with status ${APL_APPLY_STATUS:-<unset>}}" >&2
return 1
;;
esac
}
apl_feed_config_enable() {
local opt_rc
while [[ $# -gt 0 ]]; do
if parse_common_option "$@"; then opt_rc=0; else opt_rc=$?; fi
case "$opt_rc" in
1) shift ;;
2) shift 2 ;;
0) die "unknown flag for config enable: $1" ;;
esac
done
_config_toggle_apply REMOTE_CONFIG_ENABLED=true
_config_toggle_emit_result "REMOTE_CONFIG_ENABLED set to true (remote config sync enabled; next tick within ~60s will contact the website)"
}
apl_feed_config_disable() {
local opt_rc
while [[ $# -gt 0 ]]; do
if parse_common_option "$@"; then opt_rc=0; else opt_rc=$?; fi
case "$opt_rc" in
1) shift ;;
2) shift 2 ;;
0) die "unknown flag for config disable: $1" ;;
esac
done
_config_toggle_apply REMOTE_CONFIG_ENABLED=false
_config_toggle_emit_result "REMOTE_CONFIG_ENABLED set to false (remote config sync disabled; the timer stays armed but the sync CLI exits silently each tick)"
}
dispatch_config() {
local sub="${1:-}"
[[ -n "$sub" ]] || die "config requires a subcommand (enable|disable|sync)"
shift || true
case "$sub" in
enable) apl_feed_config_enable "$@" ;;
disable) apl_feed_config_disable "$@" ;;
sync) apl_feed_config_sync "$@" ;;
-h|--help) usage ;;
*) die "unknown config subcommand: $sub" ;;
esac
}