From: Nick Hainke Date: Wed, 19 Aug 2026 10:04:17 +0000 (+0200) Subject: uneighbord: add uneighbord X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=d5c4e00d9c2d81f5fcc4709428a0a5c273226780;p=openwrt-packages.git uneighbord: add 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. Signed-off-by: Nick Hainke --- diff --git a/net/uneighbord/Makefile b/net/uneighbord/Makefile new file mode 100644 index 000000000..abf3d895f --- /dev/null +++ b/net/uneighbord/Makefile @@ -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 + +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 index 000000000..56a54a5af --- /dev/null +++ b/net/uneighbord/README.md @@ -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. 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 index 000000000..b655c2ddf --- /dev/null +++ b/net/uneighbord/files/etc/config/uneighbord @@ -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 index 000000000..9a5880d06 --- /dev/null +++ b/net/uneighbord/files/etc/init.d/uneighbord @@ -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 index 000000000..5acd60b04 --- /dev/null +++ b/net/uneighbord/files/usr/sbin/uneighbord @@ -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 index 000000000..d2969e9e1 --- /dev/null +++ b/net/uneighbord/test-version.sh @@ -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