]> git.99rst.org Git - openwrt-packages.git/commitdiff
uneighbord: add uneighbord
authorNick Hainke <redacted>
Wed, 19 Aug 2026 10:04:17 +0000 (12:04 +0200)
committerNick Hainke <redacted>
Sun, 30 Aug 2026 09:50:14 +0000 (11:50 +0200)
uneighbord synchronizes hostapd 802.11k Neighbor Reports between local
`hostapd.*` BSSes and other OpenWrt access points on the same LAN, over
IPv6 link-local UDP multicast. Reports are only shared between BSSes with
the same SSID, and installed through hostapd's ubus API.

Signed-off-by: Nick Hainke <redacted>
net/uneighbord/Makefile [new file with mode: 0644]
net/uneighbord/README.md [new file with mode: 0644]
net/uneighbord/files/etc/config/uneighbord [new file with mode: 0644]
net/uneighbord/files/etc/init.d/uneighbord [new file with mode: 0755]
net/uneighbord/files/usr/sbin/uneighbord [new file with mode: 0755]
net/uneighbord/test-version.sh [new file with mode: 0755]

diff --git a/net/uneighbord/Makefile b/net/uneighbord/Makefile
new file mode 100644 (file)
index 0000000..abf3d89
--- /dev/null
@@ -0,0 +1,39 @@
+include $(TOPDIR)/rules.mk
+
+PKG_NAME:=uneighbord
+PKG_VERSION:=1
+PKG_RELEASE:=1
+
+PKG_LICENSE:=GPL-2.0-only
+PKG_MAINTAINER:=Nick Hainke <vincent@systemli.org>
+
+include $(INCLUDE_DIR)/package.mk
+
+define Package/uneighbord
+  SECTION:=net
+  CATEGORY:=Network
+  TITLE:=802.11k Neighbor Report synchronizer
+  DEPENDS:=+ucode +ucode-mod-ubus +ucode-mod-uloop +ucode-mod-socket +ucode-mod-uci +ucode-mod-log
+  PKGARCH:=all
+endef
+
+define Package/uneighbord/description
+  Synchronize hostapd 802.11k Neighbor Reports between local BSSes and
+  OpenWrt access points on the same LAN.
+endef
+
+define Build/Compile
+endef
+
+define Package/uneighbord/conffiles
+/etc/config/uneighbord
+endef
+
+define Package/uneighbord/install
+       $(INSTALL_DIR) $(1)/etc/config $(1)/etc/init.d $(1)/usr/sbin
+       $(INSTALL_CONF) ./files/etc/config/uneighbord $(1)/etc/config/uneighbord
+       $(INSTALL_BIN) ./files/etc/init.d/uneighbord $(1)/etc/init.d/uneighbord
+       $(INSTALL_BIN) ./files/usr/sbin/uneighbord $(1)/usr/sbin/uneighbord
+endef
+
+$(eval $(call BuildPackage,uneighbord))
diff --git a/net/uneighbord/README.md b/net/uneighbord/README.md
new file mode 100644 (file)
index 0000000..56a54a5
--- /dev/null
@@ -0,0 +1,58 @@
+# uneighbord
+
+uneighbord synchronizes hostapd 802.11k Neighbor Reports between local
+`hostapd.*` BSSes and other OpenWrt access points on the same LAN, over
+IPv6 link-local UDP multicast. Reports are only shared between BSSes with
+the same SSID, and installed through hostapd's ubus API.
+
+## Requirements
+
+`ucode`, `ucode-mod-ubus`, `ucode-mod-uloop`, `ucode-mod-socket`, `ucode-mod-uci`, `ucode-mod-log`.
+
+## Build and install
+
+```sh
+make menuconfig                  # select Network -> uneighbord
+make package/uneighbord/compile V=s
+```
+
+Install the resulting package, then:
+
+```sh
+/etc/init.d/uneighbord enable
+/etc/init.d/uneighbord start
+```
+
+## Configuration
+
+```uci
+config uneighbord 'main'
+        option enabled '1'
+        option network 'lan'
+```
+
+`network` is an OpenWrt logical network name (not a device name); its
+`l3_device` is used for multicast membership and scope.
+
+## How it works
+
+Every 30 seconds each AP announces its local Neighbor Reports (from
+`rrm_nr_get_own`) as a small JSON packet to a fixed IPv6 multicast group on
+port 32027. For each local BSS, same-SSID reports from other local BSSes
+and remote peers are merged, excluding the BSS's own entry, and installed
+via `rrm_nr_set`. Remote peers are dropped after 90 seconds without a
+fresh announcement.
+
+uneighbord assumes a trusted L2/backhaul network: announcements are
+unauthenticated, so any host able to reach the multicast group can
+influence the installed Neighbor Report list.
+
+## Debugging
+
+```sh
+ubus list 'hostapd.*'
+ubus call hostapd.<interface> rrm_nr_list
+logread -e uneighbord
+ss -6 -u -a -n | grep 32027
+ip -6 maddr show dev br-lan
+```
diff --git a/net/uneighbord/files/etc/config/uneighbord b/net/uneighbord/files/etc/config/uneighbord
new file mode 100644 (file)
index 0000000..b655c2d
--- /dev/null
@@ -0,0 +1,3 @@
+config uneighbord 'main'
+       option enabled '1'
+       option network 'lan'
diff --git a/net/uneighbord/files/etc/init.d/uneighbord b/net/uneighbord/files/etc/init.d/uneighbord
new file mode 100755 (executable)
index 0000000..9a5880d
--- /dev/null
@@ -0,0 +1,27 @@
+#!/bin/sh /etc/rc.common
+
+START=50
+STOP=10
+USE_PROCD=1
+
+PROG=/usr/sbin/uneighbord
+
+start_service() {
+       local enabled
+
+       config_load uneighbord
+       config_get_bool enabled main enabled 1
+       [ "$enabled" -eq 1 ] || return 0
+
+       procd_open_instance
+       procd_set_param command "$PROG"
+       procd_set_param file /etc/config/uneighbord
+       procd_set_param respawn
+       procd_set_param stdout 1
+       procd_set_param stderr 1
+       procd_close_instance
+}
+
+service_triggers() {
+       procd_add_reload_trigger uneighbord
+}
diff --git a/net/uneighbord/files/usr/sbin/uneighbord b/net/uneighbord/files/usr/sbin/uneighbord
new file mode 100755 (executable)
index 0000000..5acd60b
--- /dev/null
@@ -0,0 +1,475 @@
+#!/usr/bin/ucode
+
+/* SPDX-License-Identifier: GPL-2.0-only */
+
+import { ulog_open, ulog, ulog_close, ULOG_SYSLOG, LOG_DAEMON, LOG_INFO, LOG_WARNING, LOG_ERR } from "log";
+
+const VERSION = 1;
+const GROUP = "ff12::6e65:6967:6862:6f72:64";
+const PORT = 32027;
+const MAX_PACKET = 8192;
+const MAX_SEND = 1232;
+const MAX_REPORTS = 64;
+const MAX_PEERS = 64;
+const MAX_NR_HEX = 1024;
+const RECONCILE_MS = 15000;
+const ANNOUNCE_MS = 30000;
+const PEER_TIMEOUT_S = 90;
+
+let libubus, uloop, socket, libuci;
+let bus, network;
+let udp, udp_handle, socket_device, socket_failure, send_failure;
+let local_bss = {};
+let remote_peers = {};
+let last_installed = {};
+let node_id, node_id_failure;
+let reconcile_timer, announce_timer, expire_timer, event_timer, object_listener, network_listener;
+
+function canonical(value) {
+       return sprintf("%J", value);
+}
+
+function report_cmp(a, b) {
+       if (a.ssid < b.ssid)
+               return -1;
+       if (a.ssid > b.ssid)
+               return 1;
+       if (a.bssid < b.bssid)
+               return -1;
+       if (a.bssid > b.bssid)
+               return 1;
+       let av = canonical(a.value);
+       let bv = canonical(b.value);
+       return av < bv ? -1 : (av > bv ? 1 : 0);
+}
+
+/* Keep the hostapd value array intact; normalized fields are only indexes. */
+function validate_report(value) {
+       if (type(value) != "array" || length(value) != 3)
+               return null;
+
+       if (type(value[0]) != "string" ||
+           type(value[1]) != "string" ||
+           type(value[2]) != "string")
+               return null;
+
+       let bssid = lc(value[0]);
+       let ssid = value[1];
+       let nr = value[2];
+
+       if (!match(bssid, /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/))
+               return null;
+
+       if (length(ssid) < 1 || length(ssid) > 32)
+               return null;
+
+       if (length(nr) < 26 || length(nr) > MAX_NR_HEX ||
+           length(nr) % 2 || !match(nr, /^[0-9A-Fa-f]+$/))
+               return null;
+
+       /* The first six bytes in the Neighbor Report body are its BSSID. */
+       if (lc(substr(nr, 0, 12)) != replace(bssid, /:/g, ""))
+               return null;
+
+       return { bssid, ssid, value };
+}
+
+function normalize_reports(values) {
+       if (type(values) != "array" || length(values) > MAX_REPORTS)
+               return null;
+
+       let reports = [];
+
+       for (let value in values) {
+               let report = validate_report(value);
+               if (!report)
+                       return null;
+
+               let duplicate = -1;
+               for (let i = 0; i < length(reports); i++) {
+                       if (reports[i].ssid == report.ssid && reports[i].bssid == report.bssid) {
+                               duplicate = i;
+                               break;
+                       }
+               }
+
+               if (duplicate < 0)
+                       push(reports, report);
+               else if (canonical(report.value) < canonical(reports[duplicate].value))
+                       reports[duplicate] = report;
+       }
+
+       return sort(reports, report_cmp);
+}
+
+function decode_packet(data) {
+       if (type(data) != "string" || length(data) > MAX_PACKET)
+               return null;
+
+       let message;
+       try {
+               message = json(data);
+       }
+       catch (e) {
+               return null;
+       }
+
+       if (type(message) != "object" || message.version != VERSION ||
+           type(message.node) != "string" ||
+           !match(lc(message.node), /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/))
+               return null;
+
+       let reports = normalize_reports(message.reports);
+       if (!reports)
+               return null;
+
+       return { node: lc(message.node), reports };
+}
+
+function local_reports(state) {
+       let reports = [];
+       for (let name in sort(keys(state)))
+               push(reports, state[name].report);
+
+       return sort(reports, report_cmp);
+}
+
+/* Local reports always survive MAX_REPORTS truncation; an untrusted peer
+   flooding low BSSIDs must not be able to evict them. */
+function neighbor_list(target, locals, peers) {
+       let seen = {};
+       let list = [];
+
+       for (let name in sort(keys(locals))) {
+               if (length(list) >= MAX_REPORTS)
+                       return list;
+               let report = locals[name].report;
+               if (report.ssid == target.report.ssid && report.bssid != target.report.bssid &&
+                   !seen[report.bssid]) {
+                       seen[report.bssid] = true;
+                       push(list, report.value);
+               }
+       }
+
+       for (let peer_name in sort(keys(peers))) {
+               for (let report in peers[peer_name].reports) {
+                       if (length(list) >= MAX_REPORTS)
+                               return list;
+                       if (report.ssid == target.report.ssid && report.bssid != target.report.bssid &&
+                           !seen[report.bssid]) {
+                               seen[report.bssid] = true;
+                               push(list, report.value);
+                       }
+               }
+       }
+
+       return list;
+}
+
+function prune_peers(peers, now, timeout) {
+       let expired = [];
+       for (let name in keys(peers)) {
+               if (now - peers[name].seen >= timeout) {
+                       push(expired, name);
+                       delete peers[name];
+               }
+       }
+       return sort(expired);
+}
+
+libubus = require("ubus");
+uloop = require("uloop");
+socket = require("socket");
+libuci = require("uci");
+
+function monotime() {
+       return clock(true)[0];
+}
+
+function socket_close() {
+       if (udp_handle) {
+               udp_handle.delete();
+               udp_handle = null;
+       }
+       if (udp) {
+               udp.close();
+               udp = null;
+       }
+       socket_device = null;
+}
+
+function socket_fail(message) {
+       if (socket_failure != message) {
+               ulog(LOG_ERR, message);
+               socket_failure = message;
+       }
+       socket_close();
+}
+
+function update_hostapd() {
+       for (let object in sort(keys(local_bss))) {
+               let list = neighbor_list(local_bss[object], local_bss, remote_peers);
+               let signature = canonical(list);
+               if (last_installed[object] == signature)
+                       continue;
+
+               bus.call(object, "rrm_nr_set", { list });
+               let error = libubus.error();
+               if (error) {
+                       ulog(LOG_ERR, "failed to update " + object + ": " + error);
+                       continue;
+               }
+
+               last_installed[object] = signature;
+               ulog(LOG_INFO, sprintf("updated neighbor list for %s: %d entries",
+                                      local_bss[object].interface, length(list)));
+       }
+}
+
+function receive_packet(data, address) {
+       let packet = decode_packet(data);
+       if (!packet)
+               return;
+
+       if (packet.node == node_id)
+               return;
+
+       let previous = remote_peers[packet.node];
+       if (!previous && length(keys(remote_peers)) >= MAX_PEERS)
+               return;
+
+       let changed = !previous || canonical(previous.reports) != canonical(packet.reports);
+       remote_peers[packet.node] = { seen: monotime(), reports: packet.reports };
+
+       if (changed) {
+               ulog(LOG_INFO, "learned peer " + packet.node);
+               update_hostapd();
+       }
+}
+
+function socket_readable(events) {
+       if (!(events & uloop.ULOOP_READ) || !udp)
+               return;
+
+       while (true) {
+               let address = {};
+               let data = udp.recv(MAX_PACKET + 1, socket.MSG_DONTWAIT, address);
+               if (data == null)
+                       break;
+               if (length(data) > MAX_PACKET)
+                       continue;
+               receive_packet(data, address);
+       }
+}
+
+/* The node identity is the network device's own MAC, not a BSSID: it must
+   stay the same AP even as individual BSSes come and go. */
+function device_node_id(device) {
+       let status = bus.call("network.device", "status", { name: device });
+       let mac = status?.macaddr;
+       if (type(mac) != "string" || !match(lc(mac), /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/))
+               return null;
+
+       return lc(mac);
+}
+
+function ensure_socket() {
+       let status = bus.call("network.interface." + network, "status", {});
+       if (type(status) != "object" || !status.up || type(status.l3_device) != "string") {
+               socket_fail("network " + network + " has no usable IPv6 interface; retrying");
+               return false;
+       }
+       let device = status.l3_device;
+
+       if (!node_id) {
+               let mac = device_node_id(device);
+               if (mac) {
+                       node_id = mac;
+                       node_id_failure = null;
+               }
+               else if (node_id_failure != device) {
+                       ulog(LOG_WARNING, "unable to determine node id from " + device + "; not announcing");
+                       node_id_failure = device;
+               }
+       }
+
+       if (udp && socket_device == device)
+               return true;
+
+       socket_close();
+       let sk = socket.create(socket.AF_INET6, socket.SOCK_DGRAM | socket.SOCK_NONBLOCK, 0);
+       if (!sk ||
+           !sk.setopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, true) ||
+           !sk.setopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, device) ||
+           !sk.setopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, true) ||
+           /* sock.bind() takes a single address argument, not (host, port). */
+           !sk.bind({ address: "::", port: PORT }) ||
+           /* ucode resolves the device name to the membership interface index. */
+           !sk.setopt(socket.IPPROTO_IPV6, socket.IPV6_ADD_MEMBERSHIP,
+                      { multiaddr: GROUP, interface: device }) ||
+           !sk.setopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, device) ||
+           !sk.setopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 1) ||
+           !sk.setopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, false)) {
+               let error = socket.error() ?? "unknown socket error";
+               if (sk)
+                       sk.close();
+               socket_fail("unable to open IPv6 multicast socket on " + device + ": " + error);
+               return false;
+       }
+
+       udp = sk;
+       udp_handle = uloop.handle(udp, socket_readable, uloop.ULOOP_READ);
+       if (!udp_handle) {
+               socket_fail("unable to register multicast socket with uloop");
+               return false;
+       }
+
+       socket_device = device;
+       socket_failure = null;
+       return true;
+}
+
+function discover_hostapd() {
+       let fresh = {};
+       let objects = bus.list() ?? [];
+
+       for (let object in sort(objects)) {
+               if (substr(object, 0, 8) != "hostapd.")
+                       continue;
+
+               let status = bus.call(object, "get_status", {});
+               if (type(status) != "object" || status.status != "ENABLED" ||
+                   type(status.bssid) != "string" || type(status.ssid) != "string")
+                       continue;
+
+               let own = bus.call(object, "rrm_nr_get_own", {});
+               let report = validate_report(own?.value);
+               if (!report || report.bssid != lc(status.bssid) || report.ssid != status.ssid)
+                       continue;
+
+               fresh[object] = {
+                       object,
+                       interface: substr(object, 8),
+                       report
+               };
+       }
+
+       let old_signature = canonical(map(local_reports(local_bss), (r) => r.value));
+       let new_signature = canonical(map(local_reports(fresh), (r) => r.value));
+
+       for (let object in keys(fresh)) {
+               if (!local_bss[object])
+                       ulog(LOG_INFO, "discovered " + object);
+       }
+       for (let object in keys(local_bss)) {
+               if (!fresh[object])
+                       delete last_installed[object];
+       }
+
+       local_bss = fresh;
+       return old_signature != new_signature;
+}
+
+/* An empty local_bss (e.g. mid wifi reload) is still announced under the
+   same node_id, so peers clear our reports immediately instead of waiting
+   for PEER_TIMEOUT_S. */
+function announce_local_reports() {
+       if (!node_id || !udp)
+               return;
+       if (length(keys(local_bss)) > MAX_REPORTS)
+               return;
+
+       let reports = map(local_reports(local_bss), (report) => report.value);
+       let data = canonical({ version: VERSION, node: node_id, reports });
+       if (length(data) > MAX_SEND) {
+               if (send_failure != length(data)) {
+                       ulog(LOG_WARNING, "local announcement (" + length(data) + " bytes) exceeds " +
+                                         MAX_SEND + " byte send limit; not sending");
+                       send_failure = length(data);
+               }
+               return;
+       }
+       send_failure = null;
+
+       if (udp.send(data, 0, {
+               family: socket.AF_INET6,
+               address: GROUP,
+               port: PORT,
+               interface: socket_device
+       }) == null)
+               socket_fail("IPv6 multicast send failed on " + socket_device + ": " +
+                           (socket.error() ?? "unknown socket error"));
+}
+
+function expire_remote_reports() {
+       let expired = prune_peers(remote_peers, monotime(), PEER_TIMEOUT_S);
+       if (!length(expired))
+               return;
+
+       for (let peer in expired)
+               ulog(LOG_INFO, "peer " + peer + " expired");
+       update_hostapd();
+}
+
+function reconcile() {
+       ensure_socket();
+       let changed = discover_hostapd();
+       update_hostapd();
+       if (changed)
+               announce_local_reports();
+}
+
+ulog_open(ULOG_SYSLOG, LOG_DAEMON, "uneighbord");
+
+let section = libuci.cursor().get_all("uneighbord", "main");
+network = section?.network ?? "lan";
+if (type(network) != "string" || !length(network)) {
+       ulog(LOG_ERR, "invalid UCI network");
+       exit(1);
+}
+
+uloop.init();
+bus = libubus.connect(null, 3);
+if (!bus) {
+       ulog(LOG_ERR, "unable to connect to ubus");
+       exit(1);
+}
+
+event_timer = uloop.timer(-1, reconcile);
+object_listener = bus.listener("ubus.object.add", (event, message) => {
+       if (type(message?.path) != "string" || substr(message.path, 0, 8) != "hostapd.")
+               return;
+
+       /* A recreated object has an empty hostapd NR DB even if its name is reused. */
+       delete last_installed[message.path];
+       event_timer.set(500);
+});
+
+/* A device recreated under the same name gets a new kernel interface index,
+   which the name match in ensure_socket() alone would never notice. */
+network_listener = bus.listener("network.interface", (event, message) => {
+       if (message?.interface != network)
+               return;
+
+       socket_close();
+       event_timer.set(500);
+});
+
+reconcile_timer = uloop.timer(RECONCILE_MS, () => {
+       reconcile();
+       reconcile_timer.set(RECONCILE_MS);
+});
+announce_timer = uloop.timer(ANNOUNCE_MS, () => {
+       announce_local_reports();
+       announce_timer.set(ANNOUNCE_MS);
+});
+expire_timer = uloop.timer(5000, () => {
+       expire_remote_reports();
+       expire_timer.set(5000);
+});
+
+reconcile();
+uloop.run();
+socket_close();
+uloop.done();
+ulog_close();
diff --git a/net/uneighbord/test-version.sh b/net/uneighbord/test-version.sh
new file mode 100755 (executable)
index 0000000..d2969e9
--- /dev/null
@@ -0,0 +1,15 @@
+#!/bin/sh
+
+# shellcheck shell=busybox
+
+# uneighbord is a daemon and does not support a --version flag.
+case "$PKG_NAME" in
+uneighbord)
+       exit 0
+       ;;
+
+*)
+       echo "Untested package: $PKG_NAME" >&2
+       exit 1
+       ;;
+esac
git clone https://git.99rst.org/PROJECT