From 7045cc4d409175e96f49614ec6e068464cb4016e Mon Sep 17 00:00:00 2001 From: Josh Poole Date: Fri, 21 Aug 2026 17:51:09 +0100 Subject: [PATCH 1/2] 20260821 - Give every node its own mDNS name, and share owl.local between them All nodes ship with /etc/hostname set to "owl", so on a LAN with more than one of them Avahi arbitrated by boot race: first to boot got owl.local, the rest got owl-2.local, and it reshuffled on every reboot. Nothing had an address worth writing down, and when the holder of owl.local was unplugged the name died with it, because Avahi never reclaims a name it lost. New mdns_identity role, two names published two different ways: ret.local unique, probed, Avahi's own host-name. Derived from the Mender node_id, so it is the same for the life of the board. Nodes no longer contend, so owl-2.local is gone. owl.local shared, unprobed, published by every node at once. All of them answer, the client picks one, and a node dying is a non-event because there was never a single holder to fail over from. No election, no reclaim, no split brain. /etc/hostname stays "owl". Docker, log tags, the shell prompt and Mender all want the generic name; only what Avahi puts on the wire changes. Nothing here depends on how many nodes exist: every node does the same thing on every boot. The node count is consulted in exactly one place, retina-gui, deciding whether to show the fleet page or redirect to the only node it can see. ## Why the alias talks DBus avahi-publish -a cannot make a shared record and neither can the AddAddress call under it: avahi_server_add_address() ORs in AVAHI_PUBLISH_UNIQUE regardless of what the caller passed (avahi-core/entry.c, 0.8), so the record is always probed and always collides. ALLOW_MULTIPLE is not the way out either. AddAddress rejects it as invalid and forces it on internally, and it means "multiple local records" for a multi-homed host, not "let other hosts answer this name". EntryGroup.AddRecord passes flags through untouched, so omitting UNIQUE gives a genuinely shared record, and it takes an explicit TTL. Adds python3-dbus. ## Retires avahi-alias-retina That published retina.local the broken way, from every node, and had been failing on both test nodes for a long time: 826 and 1438 restarts. It does not even fail cleanly, it aborts on an assertion after trying to pick an alternative name for an address record and getting NULL. owl.local covers what it was for. ## Also fixes a pre-existing bug Avahi advertises on every interface by default, and a node running the radar stack has half a dozen Docker bridges. Their 172.x addresses went out under the node's name, so `avahi-resolve -n owl.local` on a live node answered 172.18.0.1, which is reachable from nowhere. Avahi is now restricted to interfaces with a backing device in /sys, which keeps end0 and wlan0 and drops every bridge and veth. Names are not used for the test: the br- ones are generated per compose network. ## Verified on two nodes Both answer owl.local at once (confirmed with a RecordBrowser enumerating every A record on the wire, from each node independently). Taking one fully off the network left owl.local resolving with no gap and the survivor back to redirecting; restoring it brought both back. owl-2.local and retina.local are gone. A cold boot puts the name in place before avahi-daemon starts. Co-Authored-By: Claude Opus 5 --- README.md | 16 +- plugins/playbooks/os_setup/main.yml | 3 + .../roles/mdns_identity/files/owl-mdns-alias | 256 ++++++++++++++++++ .../mdns_identity/files/owl-mdns-identity | 181 +++++++++++++ .../roles/mdns_identity/tasks/main.yml | 109 ++++++++ .../roles/radar_packages/tasks/main.yml | 38 +-- 6 files changed, 575 insertions(+), 28 deletions(-) create mode 100644 plugins/playbooks/os_setup/roles/mdns_identity/files/owl-mdns-alias create mode 100644 plugins/playbooks/os_setup/roles/mdns_identity/files/owl-mdns-identity create mode 100644 plugins/playbooks/os_setup/roles/mdns_identity/tasks/main.yml diff --git a/README.md b/README.md index 81fc748..b08ffb1 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Mender-enabled OS images for Raspberry Pi 5 radar nodes. The image comes pre-loa - **SDRconnect v1.0.5** for standalone SDR analysis - **Chrony** for NTP clock disciplining - **Cloudflared** for secure tunneling -- **Avahi mDNS** for `.local` discovery +- **Avahi mDNS** for `ret.local` per-node discovery, plus a shared `owl.local` - **WiFi Connect** captive portal for network setup - **Mender client** for OTA updates @@ -50,6 +50,16 @@ During a retina-node install, retina-gui holds an `install.lock` in `/data/retin After deploying retina-node, visit `http://owl.local` to configure capture settings, location, ADS-B truth source, and tar1090. See [retina-node](https://github.com/offworldlabs/retina-node) for details. +### Addressing nodes + +Each node has a permanent name of its own, `ret.local`, derived from the Mender node_id and unchanged for the life of the board. It is shown on the node's home page and is the address to bookmark. + +`owl.local` is a shared entry point published by *every* node at once, so it reaches whichever one answers first. With a single node on the network it redirects to that node. With more than one it shows a list of every node found, each linking to its own `ret.local`. A node dropping off the network does not take `owl.local` with it — the others were already answering it. + +Use `owl.local` to find a node, and `ret.local` to work with one. In particular **do not use `owl.local` for SSH**: it can resolve to a different node between connections, which will trip `REMOTE HOST IDENTIFICATION HAS CHANGED`. + +Nodes can be given a friendly name from the home page. It is only a label for the node list — nothing is addressed by it, so renaming never breaks a bookmark or an SSH config. + ### Cloudflare Tunnel (Optional) To enable Cloudflare tunnel forwarding, create a token file on the node: @@ -67,11 +77,13 @@ The token persists across OTA updates. **End users:** Add your SSH key via the web GUI at `http://owl.local` after boot. Once added, connect with: ```bash -ssh node@owl.local +ssh node@ret.local # or by IP ssh node@ ``` +Always SSH to the node's own `ret.local`, never to `owl.local` — that name is answered by every node on the network, so which host you land on can change between connections and SSH will refuse on the host key mismatch. The node's page shows its name. + Keys persist across reboots and OTA updates. **Developers:** Public keys can be baked into the image at build time by adding them to `ssh_pub_keys/`: diff --git a/plugins/playbooks/os_setup/main.yml b/plugins/playbooks/os_setup/main.yml index 0802fe6..fb1619e 100644 --- a/plugins/playbooks/os_setup/main.yml +++ b/plugins/playbooks/os_setup/main.yml @@ -87,6 +87,9 @@ - role: wifi_reconnect # Rejoin the saved network after an outage become: True become_user: root + - role: mdns_identity # Per-node ret.local, shared owl.local + become: True + become_user: root - role: cpu_governor # Set CPU governor to schedutil for better performance become: True become_user: root diff --git a/plugins/playbooks/os_setup/roles/mdns_identity/files/owl-mdns-alias b/plugins/playbooks/os_setup/roles/mdns_identity/files/owl-mdns-alias new file mode 100644 index 0000000..5971bf5 --- /dev/null +++ b/plugins/playbooks/os_setup/roles/mdns_identity/files/owl-mdns-alias @@ -0,0 +1,256 @@ +#!/usr/bin/python3 + +"""Publish owl.local as a *shared* mDNS address record. + +owl.local is the fleet's entry point: whichever node answers it serves the +landing page, and since every node serves the same page it does not matter +which one that is. What does matter is that the name never goes away while any +node is up. + +Avahi's own conflict resolution cannot give us that. A host name is a unique +record, so the first node to claim owl.local keeps it, every other node backs +off to owl-2.local, and when the holder is unplugged the name simply dies — +Avahi has no logic to reclaim a name it lost. The obvious workaround, an +election between the nodes, means a watcher loop, claim/backoff state, and a +split-brain case when multicast drops briefly and two nodes each believe the +name is vacant. + +None of that is necessary, because mDNS already allows several hosts to answer +one name: a *shared* record is never probed and never conflicts. All nodes +answer owl.local at once, the client picks one, and a node dying is a non-event +because there was never a single holder to fail over from. + +## Why this talks DBus instead of running avahi-publish + +avahi-publish -a cannot produce a shared record, and neither can the +AddAddress call underneath it. avahi_server_add_address() forces the flags, +discarding what the caller asked for (avahi-core/entry.c in 0.8): + + entry = server_add_internal(s, g, interface, protocol, + (flags & ~AVAHI_PUBLISH_NO_REVERSE) + | AVAHI_PUBLISH_UNIQUE | AVAHI_PUBLISH_ALLOW_MULTIPLE, r); + +UNIQUE is always set, so the record is always probed and always collides. That +is exactly the bug the old avahi-alias-retina.service had: every node published +retina.local this way and each collided with the others. avahi-publish does not +even fail cleanly: it tries to pick an alternative name, gets NULL because +there is no alternative for an address record, and aborts on an assertion. +Observed on a live node, 1438 restarts deep: + + Name collision, picking new name '(null)'. + avahi-publish: entrygroup.c:738: avahi_entry_group_add_address: + Assertion `name' failed. + Main process exited, code=killed, status=6/ABRT + +Restart=always turned that into a permanent ten-second flap. + +ALLOW_MULTIPLE is not the escape hatch it sounds like. AddAddress rejects it +outright as an invalid flag, and forces it on internally regardless — and it +would not have helped either way, because it means "allow multiple *local* +records of this type" (for a multi-homed host), not "let other hosts answer +this name too". + +EntryGroup.AddRecord is the one path that works: avahi_server_add() passes +flags through untouched, so omitting AVAHI_PUBLISH_UNIQUE gives a genuinely +shared record. It also takes an explicit TTL, which AddAddress hardcodes. + +## Why it holds its addresses under review + +The record is the node's own IP, so it goes stale when that changes — a DHCP +renewal onto a new address, or a switch between Ethernet and WiFi. The old +alias captured the address once at service start and never looked again, which +is why a node could end up advertising an address it no longer had. + +This re-checks on a short interval and republishes on change, which covers the +DHCP and interface cases together and needs no NetworkManager dispatcher hook +to do it. +""" + +import json +import os +import signal +import socket +import subprocess +import sys +import time + +import dbus + +ALIAS = "owl.local" + +# Deliberately shorter than Avahi's 120s default for host addresses. A node +# powered off at the wall sends no goodbye packet, so every client that cached +# its address for owl.local keeps trying it until the TTL runs out. That window +# is the one real cost of publishing this as a shared record, and it is the +# only knob on it. A clean shutdown withdraws the record immediately and is +# unaffected either way. +TTL_SECONDS = 30 + +# How often to re-read the node's addresses. One `ip` invocation, so the cost +# is nil; short enough that a DHCP change is corrected before anyone notices. +POLL_SECONDS = 15 + +# From avahi-common/defs.h. +PROTO_INET = 0 +DNS_CLASS_IN = 1 +DNS_TYPE_A = 1 +# The whole point: no AVAHI_PUBLISH_UNIQUE (1). Without it the record is +# shared — not probed, and free to coexist with the same name on other hosts. +PUBLISH_FLAGS_SHARED = 0 + + +def is_physical(name): + """Is this a real network interface, rather than a virtual one? + + Docker is the reason this matters. A node running the radar stack has half + a dozen bridges — docker0 and a br- per compose network — each with a + 172.x address that means nothing outside the node. Publishing those under + owl.local hands clients addresses they can never reach, and it is not + hypothetical: before this existed, `avahi-resolve -n owl.local` on a live + node answered 172.18.0.1. + + The test is whether the kernel gives the interface a backing device. Real + NICs (end0, wlan0) have one; bridges, veths and tunnels do not. That beats + matching on names, because the br- ones are generated per network and + there is no pattern worth trusting. + """ + return os.path.exists(f"/sys/class/net/{name}/device") + + +def current_addresses(): + """Every global IPv4 address on a physical interface, as {ifindex: [...]}. + + Published per interface rather than on AVAHI_IF_UNSPEC so a multi-homed + node does not announce its WiFi address to the Ethernet segment, where + nothing can reach it. + + IPv4 only for now. retina-gui listens dual-stack, so adding AAAA later is + possible, but IPv6 on these LANs is mostly link-local and needs scope + handling that buys nothing while every client reaches the node over v4. + """ + try: + output = subprocess.run( + ["ip", "-j", "-4", "addr", "show", "scope", "global"], + capture_output=True, text=True, timeout=10, check=True).stdout + interfaces = json.loads(output) + except (subprocess.SubprocessError, ValueError) as e: + print(f"owl-mdns-alias: could not read addresses: {e}", file=sys.stderr) + return {} + + found = {} + for interface in interfaces: + index = interface.get("ifindex") + name = interface.get("ifname", "") + if not index or not is_physical(name): + continue + addresses = [a["local"] for a in interface.get("addr_info", []) + if a.get("family") == "inet" and a.get("local")] + if addresses: + found[index] = sorted(addresses) + return found + + +class Publisher: + """Holds one Avahi entry group carrying the alias, and keeps it current.""" + + def __init__(self): + self.bus = dbus.SystemBus() + server = dbus.Interface( + self.bus.get_object("org.freedesktop.Avahi", "/"), + "org.freedesktop.Avahi.Server") + self.group = dbus.Interface( + self.bus.get_object("org.freedesktop.Avahi", server.EntryGroupNew()), + "org.freedesktop.Avahi.EntryGroup") + self.published = {} + + def publish(self, addresses): + # Reset rather than update in place: the set of interfaces can change, + # not just the addresses on them, and a reset withdraws whatever was + # there before with goodbye packets rather than leaving an orphan. + self.group.Reset() + + for index, on_interface in addresses.items(): + for address in on_interface: + self.group.AddRecord( + dbus.Int32(index), + dbus.Int32(PROTO_INET), + dbus.UInt32(PUBLISH_FLAGS_SHARED), + ALIAS, + dbus.UInt16(DNS_CLASS_IN), + dbus.UInt16(DNS_TYPE_A), + dbus.UInt32(TTL_SECONDS), + dbus.ByteArray(socket.inet_aton(address))) + + self.group.Commit() + self.published = addresses + flat = ", ".join(a for on_if in addresses.values() for a in on_if) + print(f"owl-mdns-alias: {ALIAS} -> {flat or '(nothing)'}", flush=True) + + def check(self): + """Raise if the entry group is no longer ours to hold. + + The poll below only touches DBus when an address actually changes, so + on a node with a stable IP nothing would otherwise ever notice that + avahi-daemon had gone away and taken the entry group with it. The + service would sit there looking healthy while publishing nothing. + + That is not a hypothetical: avahi-daemon is socket-activated, so it can + be stopped and brought back by something else entirely, without this + unit being restarted by systemd. + """ + state = self.group.GetState() + # 3 = AVAHI_ENTRY_GROUP_COLLISION, 4 = FAILURE. Neither should happen + # for a shared record, but if one does, a clean rebuild beats staying + # up in a state that publishes nothing. + if int(state) in (3, 4): + raise dbus.DBusException(f"entry group in state {state}") + + def withdraw(self): + """Take the alias off the network now, rather than at TTL expiry.""" + try: + self.group.Reset() + except dbus.DBusException: + pass + + +def main(): + try: + publisher = Publisher() + except dbus.DBusException as e: + # avahi-daemon not up yet, or gone. systemd restarts us; there is + # nothing useful to do from in here. + print(f"owl-mdns-alias: cannot reach avahi: {e}", file=sys.stderr) + return 1 + + running = True + + def stop(_signum, _frame): + nonlocal running + running = False + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + + try: + publisher.publish(current_addresses()) + while running: + time.sleep(POLL_SECONDS) + if not running: + break + publisher.check() + addresses = current_addresses() + if addresses != publisher.published: + publisher.publish(addresses) + except dbus.DBusException as e: + # Most likely avahi-daemon restarted under us and took the entry group + # with it. Exiting lets systemd rebuild the whole thing cleanly. + print(f"owl-mdns-alias: lost avahi: {e}", file=sys.stderr) + return 1 + finally: + publisher.withdraw() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/playbooks/os_setup/roles/mdns_identity/files/owl-mdns-identity b/plugins/playbooks/os_setup/roles/mdns_identity/files/owl-mdns-identity new file mode 100644 index 0000000..9499c97 --- /dev/null +++ b/plugins/playbooks/os_setup/roles/mdns_identity/files/owl-mdns-identity @@ -0,0 +1,181 @@ +#!/bin/bash + +# ---------------------------------------------------------------------------- +# Give this node an mDNS name of its own, and advertise it for discovery. +# ---------------------------------------------------------------------------- +# +# Every node ships with /etc/hostname set to "owl", so on a LAN with more than +# one of them Avahi's conflict resolution decides who is owl.local and who +# becomes owl-2.local. The winner is whoever booted first, and it reshuffles on +# every reboot, so no node has an address you can write down. +# +# This sets Avahi's advertised host name to the node's Mender node_id instead +# (ret4c844c20.local), which is derived from the Pi's serial and therefore the +# same on every boot for the life of the board. Nodes stop contending for a +# name, so owl-2.local never occurs again. +# +# /etc/hostname is deliberately left alone. It stays "owl" for Docker, the +# shell prompt, log tags and Mender, none of which want a per-device name; only +# what Avahi puts on the wire changes. +# +# Runs on every boot rather than once, so it is self-healing after a manual +# edit and picks up a friendly name set through the GUI. It is ordered before +# avahi-daemon so the name is in place when the daemon first probes. + +set -o nounset +set -o errexit +set -o pipefail + +AVAHI_CONF=/etc/avahi/avahi-daemon.conf +SERVICE_FILE=/etc/avahi/services/owl-node.service +NODE_ID_FILE=/data/mender/node_id +NODE_NAME_FILE=/data/retina-gui/node-name + +log() { echo "owl-mdns-identity: $*"; } + +# ---------------------------------------------------------------- the node_id +# +# Must agree exactly with configuration/mender/identity/mender-device-identity. +# Two formats are live in the fleet and both are valid: ret + 16 hex (current, +# the whole serial) and ret + 8 hex (legacy, the last 8 only). The persisted +# file is checked first and wins, which is what keeps an enrolled legacy node on +# the identity it already has — the same reason that script reads it first. +derive_node_id() { + if [ -f "${NODE_ID_FILE}" ]; then + local persisted + persisted=$(tr -d '[:space:]' < "${NODE_ID_FILE}") + if [ -n "${persisted}" ]; then + echo "${persisted}" + return 0 + fi + fi + + if ! grep -q "Raspberry Pi\|BCM" /proc/cpuinfo 2>/dev/null; then + return 1 + fi + + local serial + serial=$(grep "^Serial" /proc/cpuinfo | cut -d: -f2 | tr -d ' ' | tr '[:upper:]' '[:lower:]') + if [ -z "${serial}" ] || [ "${serial}" = "0000000000000000" ]; then + return 1 + fi + + if printf '%s' "${serial}" | grep -qE '^[0-9a-f]{16}$'; then + echo "ret${serial}" + elif printf '%s' "${serial: -8}" | grep -qE '^[0-9a-f]{8}$'; then + echo "ret${serial: -8}" + else + return 1 + fi +} + +# A name we cannot derive is not a name we should invent. Falling back to the +# stock "owl" leaves the node exactly as it behaves today — findable, with +# Avahi's suffixing to sort out collisions — whereas a made-up identity could +# collide with a real one, and would follow the board around forever. +if NODE_ID=$(derive_node_id); then + log "mDNS host name: ${NODE_ID}" +else + NODE_ID=$(cat /etc/hostname) + log "warning: no node_id available; leaving mDNS host name as ${NODE_ID}" +fi + +# ------------------------------------------------------------ the host name +# +# Avahi has no drop-in directory, so the shipped config file has to be edited +# in place. Idempotent: matches the setting whether it is currently commented +# out (as Debian ships it), already set to something else, or already correct. +if grep -qE '^[#[:space:]]*host-name=' "${AVAHI_CONF}"; then + sed -i -E "s|^[#[:space:]]*host-name=.*|host-name=${NODE_ID}|" "${AVAHI_CONF}" +else + sed -i "/^\[server\]/a host-name=${NODE_ID}" "${AVAHI_CONF}" +fi + +# ------------------------------------------------------- the interfaces to use +# +# A node running the radar stack has half a dozen Docker bridges — docker0 plus +# a br- per compose network — each with a 172.x address that is +# meaningless outside the node. Avahi advertises on every interface by default, +# so those addresses go out under the node's name and clients get handed +# routes to nowhere. Observed on a live node before this was added: +# +# $ avahi-resolve -n owl.local +# owl.local 172.18.0.1 +# +# Restricting Avahi to interfaces with a backing device in /sys leaves the real +# NICs (end0, wlan0) and drops every bridge and veth. Names are not used for +# the test: the br- ones are generated per network, so there is no +# pattern worth trusting. +PHYSICAL="" +for interface in /sys/class/net/*; do + name=$(basename "${interface}") + if [ -e "${interface}/device" ]; then + PHYSICAL="${PHYSICAL}${PHYSICAL:+,}${name}" + fi +done + +# An empty list would tell Avahi to publish on nothing at all, which is a worse +# failure than publishing too much — so if the probe finds nothing, leave the +# setting alone and let Avahi use every interface as before. +if [ -n "${PHYSICAL}" ]; then + log "publishing on: ${PHYSICAL}" + if grep -qE '^[#[:space:]]*allow-interfaces=' "${AVAHI_CONF}"; then + sed -i -E "s|^[#[:space:]]*allow-interfaces=.*|allow-interfaces=${PHYSICAL}|" "${AVAHI_CONF}" + else + sed -i "/^\[server\]/a allow-interfaces=${PHYSICAL}" "${AVAHI_CONF}" + fi +else + log "warning: no physical interfaces found; leaving allow-interfaces unset" +fi + +# ------------------------------------------------------------ the friendly name +# +# Optional, set through the GUI and stored on /data so it survives an OS +# update. ret4c844c20 is stable but unreadable, and a landing page listing +# several of them is not something anyone can navigate. +FRIENDLY_NAME="" +if [ -f "${NODE_NAME_FILE}" ]; then + FRIENDLY_NAME=$(head -c 64 "${NODE_NAME_FILE}" | tr -d '\n\r') +fi + +# The value lands inside an XML attribute-free text node, so the five XML +# entities have to go. The GUI validates on the way in as well; this is the +# second line of defence, and covers a file edited by hand over SSH. +xml_escape() { + printf '%s' "$1" \ + | sed -e 's/&/\&/g' \ + -e 's//\>/g' \ + -e 's/"/\"/g' \ + -e "s/'/\'/g" +} + +# ------------------------------------------------------- the DNS-SD advertisement +# +# This is how peers find each other. A service type rather than a host name +# because DNS-SD is multi-instance by design — every node advertising +# _owl-node._tcp is normal, so there is nothing to collide over. The SRV record +# it generates points at the host *name*, not an address, so it can never go +# stale the way the old retina.local alias did. +# +# %h expands to Avahi's host name, so the instance tracks whatever was set +# above without this file having to repeat it. avahi-daemon watches this +# directory and reloads on its own; no restart is needed when only the friendly +# name changes, which is what lets the GUI rename a node live. +mkdir -p "$(dirname "${SERVICE_FILE}")" +cat > "${SERVICE_FILE}" < + + + + %h + + _owl-node._tcp + 80 + node_id=$(xml_escape "${NODE_ID}") + name=$(xml_escape "${FRIENDLY_NAME}") + + +EOF + +log "advertised _owl-node._tcp as ${NODE_ID}${FRIENDLY_NAME:+ (${FRIENDLY_NAME})}" diff --git a/plugins/playbooks/os_setup/roles/mdns_identity/tasks/main.yml b/plugins/playbooks/os_setup/roles/mdns_identity/tasks/main.yml new file mode 100644 index 0000000..41f3a25 --- /dev/null +++ b/plugins/playbooks/os_setup/roles/mdns_identity/tasks/main.yml @@ -0,0 +1,109 @@ +--- +# Playbook: mdns_identity +# Version: 0.1.0 +# +# Purpose: Make every node individually addressable on a shared LAN, and make +# owl.local a fleet entry point that any live node can answer. +# +# The problem: all nodes ship with /etc/hostname set to "owl", so Avahi decides +# by boot race who gets owl.local and who gets owl-2.local, and the answer +# changes on every reboot. Nothing has a stable address, and when the node +# holding owl.local goes away the name dies with it — Avahi never reclaims a +# name it lost. +# +# Two names, published two different ways: +# +# ret4c844c20.local unique, probed, Avahi's own host-name. One node only. +# Derived from the Mender node_id, so it is the same for +# the life of the board. Nodes no longer contend for a +# name, so owl-2.local stops occurring at all. +# +# owl.local shared, unprobed. Published by every node, always. All +# of them answer, the client picks one, and a node dying +# is a non-event because there was never a single holder. +# +# Nothing here depends on how many nodes exist: every node does exactly the +# same thing on every boot. The only place the node count is consulted is +# retina-gui, deciding whether to serve the landing page or redirect to the one +# node it can see. +# +# Discovery is DNS-SD (_owl-node._tcp), which is multi-instance by design and +# so has no uniqueness problem to work around. +# +# Replaces avahi-alias-retina.service, removed from the radar_packages role. +# That published retina.local via avahi-publish -a, which cannot make a shared +# record — see the long comment in owl-mdns-alias for why, and why this talks +# to Avahi over DBus instead. + +- name: Install owl-mdns-identity + copy: + src: owl-mdns-identity + dest: /usr/local/sbin/owl-mdns-identity + mode: '0755' + +- name: Install owl-mdns-alias + copy: + src: owl-mdns-alias + dest: /usr/local/sbin/owl-mdns-alias + mode: '0755' + +- name: Create owl-mdns-identity systemd service + copy: + dest: /etc/systemd/system/owl-mdns-identity.service + mode: '0644' + content: | + [Unit] + Description=Set this node's mDNS host name and advertise it for discovery + # The host name has to be in avahi-daemon.conf before the daemon reads it, + # and the daemon only reads it at startup. + Before=avahi-daemon.service + # The node_id is persisted on /data, which is its own partition. + RequiresMountsFor=/data + + [Service] + Type=oneshot + RemainAfterExit=yes + ExecStart=/usr/local/sbin/owl-mdns-identity + + [Install] + WantedBy=multi-user.target + +- name: Enable owl-mdns-identity for boot + file: + src: /etc/systemd/system/owl-mdns-identity.service + dest: /etc/systemd/system/multi-user.target.wants/owl-mdns-identity.service + state: link + +- name: Create owl-mdns-alias systemd service + copy: + dest: /etc/systemd/system/owl-mdns-alias.service + mode: '0644' + content: | + [Unit] + Description=Publish owl.local as a shared mDNS alias for this node + Wants=avahi-daemon.service + After=avahi-daemon.service + + [Service] + Type=simple + ExecStart=/usr/local/sbin/owl-mdns-alias + # Deliberately not bound to avahi-daemon's lifecycle. PartOf= was tried + # and is wrong: it stops this unit when avahi stops but cannot start it + # again, and avahi-daemon is socket-activated, so it comes back on its + # own without systemd restarting anything that depends on it. A node was + # left advertising its own name but not owl.local that way. + # + # Restart=always plus the script's own bus check covers every case + # instead. While avahi is down the script exits and is retried every 5s; + # once it is back the alias republishes on the next attempt. + Restart=always + RestartSec=5 + + [Install] + WantedBy=multi-user.target + +- name: Enable owl-mdns-alias for boot + file: + src: /etc/systemd/system/owl-mdns-alias.service + dest: /etc/systemd/system/multi-user.target.wants/owl-mdns-alias.service + state: link diff --git a/plugins/playbooks/os_setup/roles/radar_packages/tasks/main.yml b/plugins/playbooks/os_setup/roles/radar_packages/tasks/main.yml index 4da22d9..cc99850 100644 --- a/plugins/playbooks/os_setup/roles/radar_packages/tasks/main.yml +++ b/plugins/playbooks/os_setup/roles/radar_packages/tasks/main.yml @@ -116,12 +116,23 @@ state: link # 2b. Install Avahi for mDNS .local hostname resolution +# +# The names themselves are set up by the mdns_identity role, which runs later: +# ret.local per node, plus owl.local shared across all of them. This +# task only puts the daemon and its dependencies in place. +# +# retina.local used to be published here by an avahi-alias-retina.service. It +# has been retired — it could never work on a LAN with more than one node +# (avahi-publish -a cannot make a shared record, so every node collided with +# every other and flapped on a ten-second restart loop), and owl.local now +# covers what it was for. - name: Install Avahi mDNS daemon apt: name: - avahi-daemon # mDNS service for .local hostname advertisement - - avahi-utils # Provides avahi-publish for mDNS alias publishing + - avahi-utils # avahi-browse, used by retina-gui to discover peers - libnss-mdns # Name service switch module for mDNS resolution + - python3-dbus # owl-mdns-alias publishes the shared alias over DBus state: present update_cache: yes @@ -131,31 +142,6 @@ dest: /etc/systemd/system/multi-user.target.wants/avahi-daemon.service state: link -# 2b. Install retina.local mDNS alias (backwards compatibility for existing nodes) -- name: Install retina.local avahi alias service - copy: - dest: /etc/systemd/system/avahi-alias-retina.service - content: | - [Unit] - Description=Publish retina.local as mDNS alias for this device - Requires=avahi-daemon.service - After=avahi-daemon.service network-online.target - - [Service] - Type=simple - ExecStart=/bin/bash -c "/usr/bin/avahi-publish -a -R retina.local $(ip route get 1 | awk '{print $7;exit}')" - Restart=always - RestartSec=10 - - [Install] - WantedBy=multi-user.target - -- name: Enable retina.local alias service on boot - file: - src: /etc/systemd/system/avahi-alias-retina.service - dest: /etc/systemd/system/multi-user.target.wants/avahi-alias-retina.service - state: link - # 3. Install SDRplay API # 3a) Create directory - name: Create SDRplay lib directory From 614fb63181412d044b46f2e9f2b6a8da863ab7c7 Mon Sep 17 00:00:00 2001 From: Josh Poole Date: Fri, 21 Aug 2026 18:00:28 +0100 Subject: [PATCH 2/2] 20260821 - Point the docs at Configuration > This node Renaming and the node's own address moved off the home page onto the config page, so three references here were stale. Co-Authored-By: Claude Opus 5 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b08ffb1..f009421 100644 --- a/README.md +++ b/README.md @@ -52,13 +52,13 @@ After deploying retina-node, visit `http://owl.local` to configure capture setti ### Addressing nodes -Each node has a permanent name of its own, `ret.local`, derived from the Mender node_id and unchanged for the life of the board. It is shown on the node's home page and is the address to bookmark. +Each node has a permanent name of its own, `ret.local`, derived from the Mender node_id and unchanged for the life of the board. It is shown under **Configuration > This node** and is the address to bookmark. `owl.local` is a shared entry point published by *every* node at once, so it reaches whichever one answers first. With a single node on the network it redirects to that node. With more than one it shows a list of every node found, each linking to its own `ret.local`. A node dropping off the network does not take `owl.local` with it — the others were already answering it. Use `owl.local` to find a node, and `ret.local` to work with one. In particular **do not use `owl.local` for SSH**: it can resolve to a different node between connections, which will trip `REMOTE HOST IDENTIFICATION HAS CHANGED`. -Nodes can be given a friendly name from the home page. It is only a label for the node list — nothing is addressed by it, so renaming never breaks a bookmark or an SSH config. +Nodes can be given a friendly name under **Configuration > This node**. It is only a label for the node list, so renaming never breaks a bookmark or an SSH config. That section also shows the node's own `ret.local` address. ### Cloudflare Tunnel (Optional) @@ -82,7 +82,7 @@ ssh node@ret.local ssh node@ ``` -Always SSH to the node's own `ret.local`, never to `owl.local` — that name is answered by every node on the network, so which host you land on can change between connections and SSH will refuse on the host key mismatch. The node's page shows its name. +Always SSH to the node's own `ret.local`, never to `owl.local` — that name is answered by every node on the network, so which host you land on can change between connections and SSH will refuse on the host key mismatch. **Configuration > This node** shows its address. Keys persist across reboots and OTA updates.