From f22b3f905d4b6dc04f4d0382d94aaf189af41e05 Mon Sep 17 00:00:00 2001 From: Josh Poole Date: Mon, 14 Sep 2026 09:05:55 +0100 Subject: [PATCH] 20260914 - Point the ADS-B truth feed at the node's own tar1090 The shipped default named sfo1.retnode.com, one machine that every node read its truth from. It stopped answering: it returns HTTP 530 from every node on the estate and from off it, so every node that never had this set by hand has been labelling nothing. Three of the four nodes checked were blind. localhost:8078 is node-invariant, which is what makes it defaultable at all. Every node already runs its own tar1090 container on that port, fed by adsb.lol and by a local receiver where tar1090.adsb_source names one. The per-site part stays in adsb_source, so attaching a receiver is three fields in one place and never touches the truth feed. That separation is also the fix for how this went wrong on fairforest B, where the receiver's BEAST address was put in the truth field. A raw feed port is not an HTTP endpoint, so blah2-api logged a parse error every cycle for 15 hours and every detection's adsb came back null. Changing default.yml alone reaches no deployed node, because first boot seeds user.yml with a whole copy of it. migrate_adsb_truth_server() drops the key when it still holds the legacy host, on the same reasoning as the Doppler span and tracker_forward migrations: nothing is lost by moving a node off a host that answers 530. Any other value is a real choice and survives, including a receiver address, which is wrong in a way this cannot safely judge. Co-Authored-By: Claude Opus 5 (1M context) --- config-merger/script/merge_config.py | 38 ++++++++++ config-merger/test/test_merge_config.py | 93 +++++++++++++++++++++++++ config/default.yml | 7 +- 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/config-merger/script/merge_config.py b/config-merger/script/merge_config.py index 69ae48d..b6dc328 100755 --- a/config-merger/script/merge_config.py +++ b/config-merger/script/merge_config.py @@ -47,6 +47,11 @@ 'port': 3012, } +# The ADS-B truth server default.yml shipped before the truth feed was pointed at +# the node's own tar1090. A user.yml holding exactly this is the merger's own +# first-boot copy rather than anyone's choice. See migrate_adsb_truth_server(). +LEGACY_ADSB_TAR1090 = 'sfo1.retnode.com' + def get_node_id_from_mender(): """Read node_id from Mender device identity file (generated by mender-device-identity)""" @@ -245,6 +250,38 @@ def migrate_tracker_forward(user): del network['tracker_forward'] +def migrate_adsb_truth_server(user): + """Drop an ADS-B truth server the user never chose, so default.yml can change it. + + The same first-boot copy problem as migrate_tracker_forward(): every node on + the estate persists whatever truth server shipped when it booted, so changing + default.yml alone reaches none of them. + + Narrow for the same reason that one is. 'sfo1.retnode.com' names a single + machine that no second node has any reason to read its truth from, and it + stopped answering: it returns HTTP 530 from every node and from off-estate, + so a node still pointed there is getting no ADS-B at all and has nothing to + lose. Any other value is a real choice and is left alone, including a + receiver's own address, which is wrong in a different way this cannot judge. + + Dropping the key rather than rewriting it means whatever default.yml ships + applies, including a later change of host or port. user.yml on disk is + untouched, so this stays load-bearing rather than a one-shot fixup. + """ + try: + adsb = user['truth']['adsb'] + except (KeyError, TypeError): + return + + if not isinstance(adsb, dict): + return + + if adsb.get('tar1090') == LEGACY_ADSB_TAR1090: + print(f"Dropping first-boot ADS-B truth server ({LEGACY_ADSB_TAR1090}) " + "from user config so the shipped default applies") + del adsb['tar1090'] + + def ensure_node_id(user_config_path): """Add/update node_id in user config from Mender device identity""" try: @@ -343,6 +380,7 @@ def main(): print("Applying user overrides...") migrate_doppler_span(user) migrate_tracker_forward(user) + migrate_adsb_truth_server(user) merge(config, user) else: print("User config is empty, using defaults") diff --git a/config-merger/test/test_merge_config.py b/config-merger/test/test_merge_config.py index 1ed65aa..2ff5be3 100755 --- a/config-merger/test/test_merge_config.py +++ b/config-merger/test/test_merge_config.py @@ -831,6 +831,99 @@ def test_tracker_forward_absent_from_user_config(self): self.assertTrue(forward['enabled']) self.assertEqual(forward['port'], 30100) + # --- ADS-B truth server migration ------------------------------------- + # Same first-boot-copy problem again: the shipped truth server persists in + # every overlay, so default.yml alone cannot move a node off a dead host. + + def default_with_truth(self, tar1090='localhost:8078'): + """A default.yml carrying the shipped ADS-B truth block.""" + return { + 'truth': { + 'adsb': { + 'enabled': True, + 'tar1090': tar1090, + 'adsb2dd': 'localhost:49155', + } + } + } + + def write_truth_configs(self, user_adsb, forced_config=None): + """Write a default/user/forced set differing only in the truth block.""" + self.write_yaml(os.path.join(self.defaults_dir, 'default.yml'), self.default_with_truth()) + self.write_yaml(os.path.join(self.defaults_dir, 'forced.yml'), forced_config or {}) + self.write_yaml(os.path.join(self.config_dir, 'user.yml'), {'truth': {'adsb': user_adsb}}) + + def test_adsb_truth_first_boot_copy_migrated(self): + """The remote host that shipped gives way to the node's own tar1090""" + self.write_truth_configs({'enabled': True, 'tar1090': 'sfo1.retnode.com'}) + + adsb = self.read_yaml(self.run_merge())['truth']['adsb'] + + self.assertEqual(adsb['tar1090'], 'localhost:8078') + + def test_adsb_truth_migration_leaves_the_rest_of_adsb_alone(self): + """Only the truth server moves; every other setting is still the user's""" + self.write_truth_configs( + {'enabled': True, 'tar1090': 'sfo1.retnode.com', + 'adsb2dd': 'localhost:49155', 'delay_tolerance': 4.5}) + + adsb = self.read_yaml(self.run_merge())['truth']['adsb'] + + self.assertEqual(adsb['tar1090'], 'localhost:8078') + self.assertEqual(adsb['delay_tolerance'], 4.5) + + def test_adsb_truth_deliberate_server_kept(self): + """A node aimed at some other host is a choice, not a first-boot copy""" + self.write_truth_configs({'tar1090': 'adsb.example.internal:8080'}) + + adsb = self.read_yaml(self.run_merge())['truth']['adsb'] + + self.assertEqual(adsb['tar1090'], 'adsb.example.internal:8080') + + def test_adsb_truth_receiver_address_kept(self): + """A receiver's own address is wrong differently, and not ours to rewrite""" + self.write_truth_configs({'tar1090': '192.168.1.143:30005'}) + + adsb = self.read_yaml(self.run_merge())['truth']['adsb'] + + self.assertEqual(adsb['tar1090'], '192.168.1.143:30005') + + def test_adsb_truth_legacy_host_with_a_port_kept(self): + """The legacy host named with a port is not the string we shipped""" + self.write_truth_configs({'tar1090': 'sfo1.retnode.com:8078'}) + + adsb = self.read_yaml(self.run_merge())['truth']['adsb'] + + self.assertEqual(adsb['tar1090'], 'sfo1.retnode.com:8078') + + def test_adsb_truth_forced_still_wins(self): + """forced.yml keeps the last word over a migrated truth server""" + self.write_truth_configs( + {'tar1090': 'sfo1.retnode.com'}, + forced_config={'truth': {'adsb': {'tar1090': 'forced.example:8078'}}}, + ) + + adsb = self.read_yaml(self.run_merge())['truth']['adsb'] + + self.assertEqual(adsb['tar1090'], 'forced.example:8078') + + def test_adsb_truth_migration_does_not_rewrite_user_yml(self): + """The overlay on disk is untouched, so the migration has to stay in place""" + self.write_truth_configs({'tar1090': 'sfo1.retnode.com'}) + + self.run_merge() + + user = self.read_yaml(os.path.join(self.config_dir, 'user.yml')) + self.assertEqual(user['truth']['adsb']['tar1090'], 'sfo1.retnode.com') + + def test_adsb_truth_absent_from_user_config(self): + """A user.yml with no truth server just takes the default""" + self.write_truth_configs({'enabled': True}) + + adsb = self.read_yaml(self.run_merge())['truth']['adsb'] + + self.assertEqual(adsb['tar1090'], 'localhost:8078') + if __name__ == '__main__': unittest.main() diff --git a/config/default.yml b/config/default.yml index 2ff9448..5d96cbc 100644 --- a/config/default.yml +++ b/config/default.yml @@ -83,7 +83,12 @@ network: truth: adsb: enabled: true - tar1090: 'sfo1.retnode.com' + # The node's own tar1090 container, which every node runs on the same port. + # Node-invariant on purpose: a local receiver is described by tar1090.adsb_source + # below and merged in there, so adding one never means editing this. Pointing + # this at a receiver directly is the mistake it is shaped to prevent, because + # a receiver's raw feed port speaks BEAST and this is fetched over HTTP. + tar1090: 'localhost:8078' adsb2dd: 'localhost:49155' delay_tolerance: 2.0 doppler_tolerance: 5.0