--- /dev/null
+#!/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();