diff --git a/net/bird-babel-collector/Makefile b/net/bird-babel-collector/Makefile new file mode 100644 index 0000000000000..0b7fcc6c2ed64 --- /dev/null +++ b/net/bird-babel-collector/Makefile @@ -0,0 +1,49 @@ +# SPDX-License-Identifier: Apache-2.0 + +include $(TOPDIR)/rules.mk + +PKG_NAME:=bird-babel-collector +PKG_VERSION:=0.1 +PKG_RELEASE:=1 + +PKG_MAINTAINER:=Robert Foss +PKG_LICENSE:=Apache-2.0 +PKG_LICENSE_FILES:= + +include $(INCLUDE_DIR)/package.mk + +define Package/bird-babel-collector + SECTION:=net + CATEGORY:=Network + SUBMENU:=Routing and Redirection + TITLE:=History collector for BIRD Babel neighbours + DEPENDS:=+ucode +ucode-mod-fs +ucode-mod-log +ucode-mod-math +ucode-mod-resolv \ + +ucode-mod-rtnl +ucode-mod-socket +ucode-mod-struct +ucode-mod-ubus +ucode-mod-uci \ + +ucode-mod-uloop +uclient-fetch + PKGARCH:=all +endef + +define Package/bird-babel-collector/description + Samples the Babel neighbours and default route selection of BIRD over its + control socket (read-only) into an in-memory history of up to 24 hours and + serves it on ubus as "bird-babel". The sampling interval follows the + available memory and is never shorter than one second. +endef + +define Package/bird-babel-collector/conffiles +/etc/config/bird-babel +endef + +define Build/Compile +endef + +define Package/bird-babel-collector/install + $(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/init.d $(1)/etc/config $(1)/lib/upgrade/keep.d + $(INSTALL_BIN) ./files/bird-babel-collector $(1)/usr/sbin/bird-babel-collector + $(INSTALL_BIN) ./files/bird-babel-collector.init $(1)/etc/init.d/bird-babel-collector + $(INSTALL_CONF) ./files/bird-babel.config $(1)/etc/config/bird-babel + $(INSTALL_DATA) ./files/bird-babel-collector.keep $(1)/lib/upgrade/keep.d/bird-babel-collector + $(INSTALL_DATA) ./files/bird-babel-collector.upgrade $(1)/lib/upgrade/bird-babel-collector.sh +endef + +$(eval $(call BuildPackage,bird-babel-collector)) diff --git a/net/bird-babel-collector/README.md b/net/bird-babel-collector/README.md new file mode 100644 index 0000000000000..5fc074c9a1ada --- /dev/null +++ b/net/bird-babel-collector/README.md @@ -0,0 +1,152 @@ +# bird-babel-collector + +Samples the Babel neighbours and default route selection of BIRD into an +in-memory history of up to 24 hours and serves it on ubus as `bird-babel`. +It is the backend of luci-app-bird-babel. A single ucode script +(`files/bird-babel-collector`), run by procd. + +These notes explain how it works and why, for whoever changes it next. + +## Data sources + +- BIRD's control socket, opened in restricted (read-only) mode, without + forking birdc: `show babel neighbors`, `show babel interfaces`, the + default routes of the Babel tables (`show route ... for 0.0.0.0/0 all`, + sadr tables for IPv6), and rarely `show babel routes` for standby + routes (stock BIRD only puts the best route into the table). +- Names: the hosts files (`/etc/hosts`, `/tmp/hosts/*`), reverse DNS via + the local resolver, and the IPv4 address in Babel router IDs + ("randomize router id" puts it in the last four bytes). Found names are + kept per device (MAC) for a week. +- Vendor and model: the neighbour's `luci-rpc getBoardJSON`, where it is + open to anonymous sessions (over IPv4 or link-local IPv6). + +## Sampling + +The interval follows the RAM (`SAMPLE_TIERS`): 60 s below 96 MB, 30 s +below 192 MB, 15 s below 384 MB, else 4 s. 4 s is BIRD's default Babel +hello interval; link cost and RTT only change with hellos, sampling faster +would only repeat them. `option interval` overrides it. + +## History storage + +Per neighbour, one string per 15 minutes (`BLOCK`, on multiples of 900 s): + + header start (u32), seconds covered (u16), records (u16), histogram bytes (u16) + 15 x minute summary (MIN_FMT, 22 bytes): seconds present, cost + sum/min/max, seconds with an RTT and its sum/min/max, role4, + role6 (ROLE_MIXED unless the same for the whole minute) + histogram sparse (bin u8, seconds u16) pairs, cost then RTT after a 255; + 8 bins per octave, for the 99th percentiles + records 8 bytes each (REC_FMT): seconds into the block, cost, RTT + (0.1 ms), role4, role6; one whenever any value changes + +- A record lasts until the next one. A neighbour that disappears gets a + record with role ROLE_ABSENT. The block being filled keeps its records + in a growing string (`hist[key].open`) and gets its summaries when it + closes. +- `state.cover` lists the stretches the collector sampled; records are + clipped to it, and its gaps are reported as "no data". A gap of up to + two sampling intervals still counts as continuous. +- Everything is weighted by seconds, not samples, so averages and + percentiles do not depend on the sampling interval. +- Data older than 24 hours ages out by whole blocks. `memory_budget` + (default 1/64 of RAM, 256 KiB to 16 MiB) only shortens the history if + it is ever reached. + +Why change records: on a real router 94% of 1 s samples repeated the +previous one, and ucode spends far more on object bookkeeping than on the +data. Measured for 8 neighbours over 24 hours: + +| layout | memory | +|-------------------------------------------|-------------| +| 6 byte sample per interval, 60-sample chunks with summary objects (the first version) | ~39 MB | +| change records in 15 minute blocks, stable links | 0.7 MB (1.5 MB for the whole process) | +| change records, RTT changing on every hello | 1.8 MB (2.6 MB for the whole process) | + +Run length encoding per field was 13-32% smaller but needs three streams +merged per query. Delta, delta-of-delta and Gorilla style XOR encodings +were no smaller in practice: they decode every sample, so they need dense +summaries to keep queries fast, and bit operations are slow in ucode. zlib +and xz need modules and cannot seek. A 6 byte sample per interval in +packed strings (no compression) would take ~6 MB. + +## Queries + +`history` buckets the series of each neighbour: + +- Buckets are at least the sampling interval, whole minutes from a minute + up and a divisor of a minute below, and start on multiples of their + width. So they line up with the minute summaries and the blocks, and a + refresh does not reshuffle samples. +- Whole minutes inside a bucket come from the minute summaries, when the + neighbour was present all minute with the same roles; everything else + is replayed from the records, clipped to the coverage. Ranges that do + not start on a whole minute with buckets of a minute or more would + replay everything; the bucket choice above avoids that. +- With `since`, only buckets from there on are computed, without + percentiles: the UI polls with it every 30 s and keeps what it has. + Merging the histograms of all blocks is most of a full query's cost. +- Percentiles come from the block histograms (block granularity at the + range edges, ~9% resolution). They cannot be exact client side, the UI + only gets bucket averages, minimums and maximums. +- Results are cached briefly (a few entries). Calls without a login (the + all-zero session, for a public status page) are rounded so viewers share + cached results, and those that need computing are rate limited; polls + for the last few minutes are not. + +Measured for 8 neighbours over 24 hours (x86-64): a full 24 h query +~200-240 ms, a poll ~20-35 ms. + +## Route to the gateway + +`status` has `route4` and `route6`: the next hop and the originator of the +selected default route from BIRD (its address from the router ID, looked up +only if private), whether the next hop is the gateway, and the traced path. + +Babel is distance-vector, so the routers in between are found with +traceroute (`TRACE_*`): only when the selected route switches to one +without a path from the last hour (a route flapping between two known paths +needs none), and again while the gateway was not reached; at most every +5 minutes per family. Paths are kept per route. Until a path is known, +`trace_next` says when the next trace runs. The IPv4 target is the address +in the router ID; the IPv6 target an address in a prefix the originator +announces, and the first hop inside it is the gateway. + +## Saving to flash + +Off by default (`option persist`). History is saved gzip compressed only +when the collector stops (reboot, shutdown, restart; procd allows 60 s), +right before sysupgrade or a config backup takes the file (the +`/lib/upgrade` hook calls `save`), and on `ubus call bird-babel save`. +Written to a temporary file and renamed. The format (`version: 4`) is JSON +with the blocks base64 encoded; other versions are not read. + +## ubus + +- `status`: live data (refreshed at most once a second): neighbours with + names, roles and link quality, interfaces, default routes, the routes to + the gateways, the collector's interval, budget, history size and oldest + data. +- `history { range, points, end, since }`: see above. +- `events { since }`: neighbour and default route changes. +- `save`: save now (local only, not in the LuCI ACL). + +## ucode notes + +- Under `-S` a function must be declared before any function using it is + compiled; later declarations give "access to undeclared variable" at + runtime. +- Integer division truncates: `int(x) / 100` is an integer, use `100.0`. +- A `'\x8b'` string literal is UTF-8 encoded; compare raw bytes as hex. +- Build `struct` formats once per size and reuse them (`unpacker()`), + creating them is slow. +- Many small objects cost far more than the data in them; keep bulk data + in packed strings. + +## Tests + +`tests/bird-babel-collector` in the development repository: recorded BIRD +conversations replayed to the collector in an OpenWrt container (`run.sh`), +and a history test with changing replies, checking the queries and a save +and restart (`history-test.sh`). diff --git a/net/bird-babel-collector/files/bird-babel-collector b/net/bird-babel-collector/files/bird-babel-collector new file mode 100755 index 0000000000000..bd5f5bbae47ed --- /dev/null +++ b/net/bird-babel-collector/files/bird-babel-collector @@ -0,0 +1,1580 @@ +#!/usr/bin/ucode -S +// SPDX-License-Identifier: Apache-2.0 +// +// Samples BIRD's Babel neighbours and default route selection into an +// in-memory ring buffer and serves it on ubus as "bird-babel". +// BIRD is only ever queried in restricted (read-only) mode. + +'use strict'; + +import * as socket from 'socket'; +import * as struct from 'struct'; +import * as uloop from 'uloop'; +import * as ubus from 'ubus'; +import { cursor } from 'uci'; +import { readfile, writefile, rename, stat, mkdir, unlink, glob, popen } from 'fs'; +import * as rtnl from 'rtnl'; +import { log as ln } from 'math'; +import { query as resolv_query } from 'resolv'; +import { openlog, syslog, LOG_PID, LOG_DAEMON, LOG_INFO } from 'log'; + +const HISTORY_SECONDS = 86400; +// History per neighbour in blocks of BLOCK seconds on multiples of BLOCK: a +// header, a summary per minute, a histogram, then a record per change +const BLOCK = 900, MINUTE = 60; +const BLOCK_HDR = ' its tables carry all its default routes + babel_defaults: null, + bird_version: null, + sock: null, + buf: '', + hist: {}, // key -> { blocks: [ packed ], open: { t0, recs, n }, last: [ cost, rtt, role4, role6 ] } + hist_bytes: 0, // size of all closed blocks + cover: [], // [ [ start, end ] ] while the collector was sampling + neighbors: {}, // key -> meta + events: [], + live: null, + live_ts: 0, + names: {}, + names_mtime: {}, + local_ll: {}, + ifaddr4: {}, // interface -> its (first) IPv4 address + ifll: {}, // interface -> its link-local address + traces: {}, // route key -> { time, hops: [{ addr, name }], reached, src } + trace_last: {}, // v4/v6 -> when a trace was last started + trace_busy: {}, + trace_key: {}, // v4/v6 -> the route being traced + ifnames: {}, + mac4: {}, + mac6: {}, // "iface|link-local" -> MAC, from the IPv6 neighbour table + via4: {}, // neighbour key -> its IPv4 next hop, once known + rid4: {}, // IPv4 next hop -> { time, router_id } + macany: {}, + ifmac: {}, + rid: {}, // key -> { time, router_id, ip4 } + idents: {}, // device (MAC, else link-local address) -> what is known about its name + ptr: {}, // address -> { time, name } from reverse DNS + name_routes: null, // { time, ifaces, vias: { iface: [ IPv4 next hops ] } } + boards: {}, // ip4 -> { time, ok, id, name } + board_queue: [], + board_busy: false, + hcache: [], // [ { key, at, res } ], most recent first + anon_tokens: ANON_BURST, anon_ts: 0, + last_sel: { v4: null, v6: null }, + hostname: null, + domain: null, + mesh_hints: {}, + error: null, +}; + +let ubus_conn = null; +let tick_timer = null; + +function log(msg) { + syslog(LOG_INFO, '%s', msg); +} + +// Optional hints which device and radio serve a mesh network, e.g. a mesh AP +// bridging its 802.11s interface into a VLAN of this router: +// config mesh +// option network 'mesh_hof_s' +// option host 'gub37-hof-s' +// option band '5 GHz' +// option ifname 'wlan5-mesh' +// option vendor 'Cudy' # used when the device does not report its model +// option model 'AP3000 Outdoor (v1)' +function load_mesh_hints() { + let hints = {}; + cursor().foreach('bird-babel', 'mesh', s => { + if (s.network) hints[s.network] = { host: s.host, band: s.band, ifname: s.ifname, vendor: s.vendor, model: s.model }; + }); + return hints; +} + + +// The values config_get_bool takes as true, as for "enabled" in the init script +function uci_bool(v) { + return v in [ '1', 'true', 'yes', 'on', 'enabled' ]; +} + +function load_config() { + let s = cursor().get_all('bird-babel', 'collector'); + if (!s) return; + for (let k in [ 'socket', 'protocol', 'persist_path' ]) + if (s[k]) conf[k] = s[k]; + for (let k in [ 'interval', 'memory_budget' ]) + if (s[k] != null) conf[k] = +s[k]; + conf.persist = uci_bool(s.persist); +} + +// ---- BIRD control socket ------------------------------------------------- + +function bird_close() { + if (state.sock) state.sock.close(); + state.sock = null; + state.buf = ''; +} + +// Read one reply: "NNNN-text" continues, " text" continues the previous +// code, "NNNN text" ends the reply. Received data is split into lines once, +// so long replies take linear time. With keep, only the text lines matching +// it are kept (and the final line). +function bird_reply(keep) { + let lines = [], code = 0; + for (;;) { + let ls = split(state.buf, '\n'); + state.buf = pop(ls); + for (let i = 0; i < length(ls); i++) { + let m = match(ls[i], /^([0-9]{4})([ -])(.*)$/); + let text = m ? m[3] : substr(ls[i], 1); + if (m) code = +m[1]; + if (m && m[2] == ' ') { + push(lines, { code, text }); + state.buf = join('\n', [ ...slice(ls, i + 1), state.buf ]); + return lines; + } + if (!keep || match(text, keep)) push(lines, { code, text }); + } + let chunk = state.sock.recv(65536); + if (chunk == null || chunk == '') { + bird_close(); + return null; + } + state.buf += chunk; + } +} + +function bird_cmd_raw(cmd, keep) { + if (!state.sock.send(cmd + '\n')) { + bird_close(); + return null; + } + return bird_reply(keep); +} + +function bird_open() { + if (!stat(conf.socket)) { + state.error = `BIRD is not running (no control socket at ${conf.socket})`; + return false; + } + let s = socket.connect({ path: conf.socket }, null, null, 2000); + if (!s) { + state.error = `BIRD is not running (cannot connect to ${conf.socket})`; + return false; + } + state.sock = s; + state.buf = ''; + let hello = bird_reply(); + state.bird_version = match(hello?.[0]?.text ?? '', /^BIRD (\S+) ready/)?.[1]; + let r = hello ? bird_cmd_raw('restrict') : null; + if (!r || r[-1].code != 16) { + state.error = 'BIRD refused restricted mode'; + bird_close(); + return false; + } + return true; +} + +// Returns the text lines of a reply (only those matching keep, if given), or +// null on error. +function bird(cmd, keep) { + for (let attempt = 0; attempt < 2; attempt++) { + if (!state.sock && !bird_open()) return null; + let r = bird_cmd_raw(cmd, keep); + if (r == null) continue; + if (r[-1].code >= 8000) { + state.error = `${cmd}: ${r[-1].text}`; + return null; + } + state.error = null; + return map(filter(r, l => l.code != 0), l => l.text); + } + return null; +} + +// ---- discovery and parsing ----------------------------------------------- + +// All Babel protocols (or the configured one) and the tables of their channels +function discover() { + let protos = []; + for (let l in bird('show protocols') ?? []) { + let f = split(trim(l), /\s+/); + if (f[1] == 'Babel' && (!conf.protocol || f[0] == conf.protocol)) + push(protos, { name: f[0], table4: null, table6: null }); + } + for (let p in protos) { + let chan = null; + for (let l in bird(`show protocols all ${p.name}`) ?? []) { + let m = match(l, /^\s+Channel (\S+)/); + if (m) { chan = m[1]; continue; } + m = match(l, /^\s+Table:\s+(\S+)/); + if (m && chan == 'ipv4') p.table4 = m[1]; + else if (m && index(chan, 'ipv6') == 0) p.table6 = { name: m[1], sadr: chan == 'ipv6-sadr' }; + } + } + state.protos = length(protos) ? protos : null; + return state.protos != null; +} + +function parse_neighbors() { + let res = []; + for (let p in state.protos) { + let lines = bird(`show babel neighbors ${p.name}`); + if (lines == null) return null; + for (let l in lines) { + let f = split(trim(l), /\s+/); + if (length(f) < 7 || !match(f[0], /^[0-9a-f]*:[0-9a-f:]*$/i)) continue; + push(res, { + addr: f[0], iface: f[1], metric: +f[2], routes: +f[3], + hellos: +f[4], expires: +f[5], auth: f[6] == 'Yes', + rtt: (length(f) > 7 && +f[7] > 0) ? +f[7] : null, + protocol: p.name, + }); + } + } + return res; +} + +function parse_interfaces() { + let res = []; + for (let p in state.protos) + for (let l in bird(`show babel interfaces ${p.name}`) ?? []) { + let f = split(trim(l), /\s+/); + if (length(f) < 6 || f[0] == 'Interface' || f[0] == `${p.name}:`) continue; + push(res, { + iface: f[0], network: state.ifnames[f[0]], up: f[1] == 'Up', auth: f[2] == 'Yes', rxcost: +f[3], + neighbors: +f[4], nexthop4: f[6], nexthop6: f[7], protocol: p.name, + wireless: stat(`/sys/class/net/${f[0]}/phy80211`) != null, + }); + } + return res; +} + +// Default route candidates: [{ proto, selected, metric, router_id, via, iface, from }] +function parse_defaults(lines) { + let res = [], cur = null; + for (let l in lines ?? []) { + // POSIX regex: "unicast [proto time from addr] * (pref/metric) [router-id]" + let m = match(l, /unicast \[([^] ]+)([^]]*)\] *(\*)? *\(([0-9]+)(\/([0-9]+))?\)( *\[([0-9a-fA-F:]+)\])?/); + if (m) { + cur = { + proto: m[1], from: match(m[2], / from ([^ ]+)/)?.[1], selected: m[3] == '*', + metric: (m[6] != null) ? +m[6] : null, router_id: m[8], + }; + push(res, cur); + continue; + } + if (!cur) continue; + m = match(l, /^\s+via (\S+) on (\S+)/); + if (m) { cur.via = m[1]; cur.iface = m[2]; continue; } + m = match(l, /^\s+dev (\S+)/); + if (m) { cur.iface = m[1]; continue; } + m = match(l, /^\s+from: (\S+)/); + if (m) { cur.from = m[1]; continue; } + // BIRD 3 "babel_metric: N", BIRD 2 "Babel.metric: N" + m = match(l, /^\s+[Bb]abel[._]metric: ([0-9]+)/); + if (m) cur.metric = +m[1]; + } + return res; +} + +function query_defaults() { + let d = { v4: [], v6: [] }, seen = {}; + for (let p in state.protos) { + if (p.table4 && !seen['4|' + p.table4]) { + seen['4|' + p.table4] = true; + push(d.v4, ...parse_defaults(bird(`show route table ${p.table4} for 0.0.0.0/0 all`))); + } + if (p.table6 && !seen['6|' + p.table6.name]) { + seen['6|' + p.table6.name] = true; + push(d.v6, ...parse_defaults(bird(p.table6.sadr + ? `show route table ${p.table6.name} where net.dst = ::/0 all` + : `show route table ${p.table6.name} for ::/0 all`))); + } + } + return d; +} + +// Stock BIRD only puts the best Babel route of a prefix into the table, so +// the default routes other neighbours offer (standby) are only visible in +// Babel's own route list. That list holds every route of the mesh and can be +// large, so it is read every BABEL_ROUTES_EVERY seconds or 10 samples at +// most, and rarely enough to cost at most about 1% CPU, and not at all for a +// protocol once a table carries two of its default routes for one family (as +// patched BIRD builds do). +// "::/0 from 2001:bf7::/32 fe80::1 eth0 640 * 2095 55.165": prefix, +// optional source prefix, next hop, interface, metric, feasibility (* selected, +// + feasible, blank unfeasible), seqno, expiry. +function parse_babel_defaults(lines, proto) { + let res = []; + for (let l in lines ?? []) { + let m = match(l, /^\s*(0\.0\.0\.0\/0|::\/0)( from \S+)?\s+(\S+)\s+(\S+)\s+([0-9]+)\s+([*+]?)\s*[0-9]+\s+[0-9.]+\s*$/); + if (m && m[6] != '') + push(res, { family: (m[1] == '::/0') ? 'v6' : 'v4', via: m[3], iface: m[4], metric: +m[5], selected: m[6] == '*', proto }); + } + return res; +} + +function query_babel_defaults(def) { + for (let fam in [ 'v4', 'v6' ]) { + let n = {}; + for (let d in def[fam]) { + n[d.proto] = (n[d.proto] ?? 0) + 1; + if (n[d.proto] > 1) state.complete[d.proto] = true; + } + } + let todo = filter(state.protos, p => !state.complete[p.name]); + if (!length(todo)) return []; + if (state.babel_defaults && time() < state.babel_defaults.next) + return state.babel_defaults.list; + let t0 = clock(true), list = []; + for (let p in todo) + push(list, ...parse_babel_defaults(bird(`show babel routes ${p.name}`, /^\s*(0\.0\.0\.0|::)\/0\s/), p.name)); + let t1 = clock(true), took = (t1[0] - t0[0]) + (t1[1] - t0[1]) / 1e9; + state.babel_defaults = { next: time() + max(BABEL_ROUTES_EVERY, 10 * state.interval, int(100 * took)), list }; + return list; +} + +// ---- names --------------------------------------------------------------- + +function hosts_files() { + return [ '/etc/hosts', ...(glob('/tmp/hosts/*') ?? []) ]; +} + +function load_names() { + let files = hosts_files(), changed = (length(files) != length(keys(state.names_mtime))); + for (let f in files) { + let mt = stat(f)?.mtime ?? 0; + if (state.names_mtime[f] !== mt) changed = true; + state.names_mtime[f] = mt; + } + if (!changed) return; + state.names_mtime = {}; + for (let f in files) state.names_mtime[f] = stat(f)?.mtime ?? 0; + let names = {}; + for (let f in files) { + let data = readfile(f); + if (!data) continue; + for (let l in split(data, '\n')) { + let fl = split(trim(split(l, '#')[0]), /\s+/); + if (length(fl) < 2 || names[fl[0]]) continue; + // prefer the plain node name over interface-prefixed aliases + let best = fl[1]; + for (let i = 2; i < length(fl); i++) + if (length(split(fl[i], '.')) < length(split(best, '.'))) best = fl[i]; + names[fl[0]] = best; + } + } + state.names = names; +} + +// Node name without the local domain and interface prefixes, e.g. with the +// local domain "ff": "mid9.emma-core.ff" / "switch0_11.gub37-core.ff" -> "emma-core" +function short_name(fqdn) { + if (!fqdn) return null; + let d = state.domain; + if (!d || substr(fqdn, -(length(d) + 1)) != '.' + d) return fqdn; + return split(substr(fqdn, 0, -(length(d) + 1)), '.')[-1]; +} + +// fe80::97:dff:fe08:3064 -> 02:97:0d:08:30:64 (EUI-64 with the U/L bit flipped) +function ll_to_mac(addr) { + let g = split(addr, ':'); + if (length(g) < 4) return null; + let w = map(slice(g, -4), x => hex(x || '0')); + if ((w[1] & 0xff) != 0xff || (w[2] >> 8) != 0xfe) return null; + let b = [ (w[0] >> 8) ^ 2, w[0] & 0xff, w[1] >> 8, w[2] & 0xff, w[3] >> 8, w[3] & 0xff ]; + return join(':', map(b, x => sprintf('%02x', x))); +} + +const RT_SCOPE_LINK = 253; + +// Interface MACs, the router's own IPv6 link-local addresses and the IPv4 +// neighbour table, straight from the kernel +function load_links() { + let C = rtnl.const; + let ifnames = {}, local_ll = {}, ifll = {}, ifaddr4 = {}, mac4 = {}, mac6 = {}, macany = {}, ifmac = {}; + for (let l in rtnl.request(C.RTM_GETLINK, C.NLM_F_DUMP, {}) ?? []) + if (l.address) ifmac[l.ifname] = l.address; + for (let i in ubus_conn.call('network.interface', 'dump')?.interface ?? []) + if (i.l3_device && !ifnames[i.l3_device]) ifnames[i.l3_device] = i.interface; + for (let a in rtnl.request(C.RTM_GETADDR, C.NLM_F_DUMP, { family: C.AF_INET6 }) ?? []) + if (a.scope == RT_SCOPE_LINK && a.address) { + local_ll[split(a.address, '/')[0]] = a.dev; + ifll[a.dev] ??= split(a.address, '/')[0]; + } + for (let a in rtnl.request(C.RTM_GETADDR, C.NLM_F_DUMP, { family: C.AF_INET }) ?? []) + if (a.address && a.dev) ifaddr4[a.dev] ??= split(a.address, '/')[0]; + for (let e in rtnl.request(C.RTM_GETNEIGH, C.NLM_F_DUMP, { family: C.AF_INET }) ?? []) + if (e.lladdr && e.dev && e.dst) { + mac4[`${e.dev}|${e.lladdr}`] = e.dst; + macany[e.lladdr] ??= e.dst; + } + for (let e in rtnl.request(C.RTM_GETNEIGH, C.NLM_F_DUMP, { family: C.AF_INET6 }) ?? []) + if (e.lladdr && e.dev && e.dst && index(e.dst, 'fe80:') == 0) + mac6[`${e.dev}|${e.dst}`] = e.lladdr; + state.ifnames = ifnames; + state.local_ll = local_ll; + state.ifll = ifll; + state.ifaddr4 = ifaddr4; + state.mac4 = mac4; + state.mac6 = mac6; + state.macany = macany; + state.ifmac = ifmac; +} + +// ---- sampling ------------------------------------------------------------ + +// Is "via" (a route's next hop on nb's interface) this neighbour? IPv6 next +// hops are its link-local address, IPv4 ones its IPv4 address; on a link +// with a single neighbour any next hop is that neighbour. +function is_via(nb, via, nbs) { + if (via == nb.addr || (nb.ip4 && via == nb.ip4)) return true; + return length(filter(nbs, n => n.iface == nb.iface)) == 1; +} + +// The role of the default route through a neighbour, from the tables, plus +// standby routes only Babel's own route list shows (alt, maybe 30 s old, so +// it never decides which route is selected) +function role_for(defaults, alt, nb, nbs, family) { + let r = ROLE_LEAF, m = INF; + for (let d in defaults) { + if (((family == 4) ? d.from : d.via) != nb.addr || d.iface != nb.iface) continue; + if (d.selected) r = ROLE_SELECTED; + else if (r != ROLE_SELECTED) r = ROLE_STANDBY; + if (d.metric != null && d.metric < m) m = d.metric; + } + for (let a in alt) { + if (r != ROLE_LEAF) break; + if (a.family != `v${family}` || a.iface != nb.iface || !is_via(nb, a.via, nbs)) continue; + r = ROLE_STANDBY; + m = a.metric; + } + return [ r, m ]; +} + +function add_event(type, data) { + data.type = type; + data.time = time(); + push(state.events, data); + if (length(state.events) > MAX_EVENTS) + state.events = slice(state.events, -MAX_EVENTS); +} + +// Babel router IDs from "randomize router id" carry the node's IPv4 address +// in their last four bytes +function rid_ip4(rid) { + let b = rid ? split(rid, ':') : []; + return (length(b) >= 4) ? join('.', map(slice(b, -4), x => hex(x))) : null; +} + +// The lowest-metric route via a neighbour is the neighbour's own prefix +function router_id_of(nb) { + let c = state.rid[nb.key]; + if (c && time() - c.time < (c.router_id ? RID_TTL : NAME_RETRY_MIN)) return c; + c = state.rid[nb.key] = { time: time() }; + let t6 = filter(state.protos ?? [], p => p.name == nb.protocol)[0]?.table6; + if (!t6) return c; + let best = INF; + for (let l in bird(`show route table ${t6.name} where gw = ${nb.addr}`) ?? []) { + let m = match(l, /\([0-9]+\/([0-9]+)\) \[([0-9a-fA-F:]+)\]/); + if (m && +m[1] < best) { best = +m[1]; c.router_id = m[2]; } + } + c.ip4 = rid_ip4(c.router_id); + return c; +} + +// Model and manufacturer from the neighbour's LuCI ubus (luci-rpc getBoardJSON). +// Only nodes that allow it for anonymous sessions answer, e.g. those with +// luci-app-olsr, whose "unauthenticated" ACL grants luci-rpc; stock OpenWrt +// refuses and other devices may not run LuCI at all. Fetched in the +// background, one at a time; refusals are retried after BOARD_DENIED, no +// answer after BOARD_RETRY, and without a model the neighbour is shown without. +function board_next() { + if (state.board_busy || !length(state.board_queue)) return; + let q = shift(state.board_queue), key = q[0]; + let out = `${TMPDIR}/board-` + replace(key, /[^0-9a-zA-Z.]/g, '_') + '.json'; + state.board_busy = true; + let body = '{"jsonrpc":"2.0","id":1,"method":"call","params":["00000000000000000000000000000000","luci-rpc","getBoardJSON",{}]}'; + // uclient-fetch reports TLS and connection errors on stderr even with -q + uloop.process('/bin/sh', [ '-c', 'exec /bin/uclient-fetch -q -T 5 --no-check-certificate -O "$1" --post-data "$2" "$3" 2>/dev/null', + 'sh', out, body, `https://${q[1]}/ubus` ], {}, (code) => { + let raw = readfile(out), d = null; + unlink(out); + try { d = json(raw ?? 'null'); } catch (e) { d = null; } + let model = d?.result?.[1]?.model; + state.boards[key] = model?.name + ? { time: time(), ok: true, id: model.id, name: model.name } + : { time: time(), ok: false, denied: d != null }; + state.board_busy = false; + board_next(); + }); +} + +// key names the device, host is how to reach it: an IPv4 address or a +// link-local one with its interface, "[fe80::1%eth0]" +function board_of(key, host) { + if (!key || !host) return null; + let b = state.boards[key]; + let ttl = b?.ok ? BOARD_TTL : b?.denied ? BOARD_DENIED : BOARD_RETRY; + if ((!b || time() - b.time > ttl) && !length(filter(state.board_queue, q => q[0] == key))) { + state.boards[key] ??= { time: time(), ok: false }; + state.boards[key].time = time(); + push(state.board_queue, [ key, host ]); + board_next(); + } + return b?.ok ? b : null; +} + +// "Linksys E8450 (UBI)" -> { vendor: "Linksys", model: "E8450" }. Flash +// layout variants like "(UBI)" are dropped, hardware versions like "(v2)" kept. +function clean_model(name) { + return trim(replace(name, /[ ]*\(([^)]*(UBI|NAND|NOR|eMMC|layout|U-Boot|OpenWrt)[^)]*)\)/gi, '')); +} + +function split_board(b) { + if (!b?.name) return null; + let name = clean_model(b.name); + let m = match(name, /^([^ ]+) (.+)$/); + return m ? { vendor: m[1], model: m[2] } : { vendor: null, model: name }; +} + +// Address and model of the devices named in the hints, for the graph +let host_ips = {}; +function mesh_hints_live() { + let out = {}; + for (let net, h in state.mesh_hints) { + let c = host_ips[h.host]; + if (h.host && (!c || time() - c.time > RID_TTL)) { + let fqdn = state.domain ? `${h.host}.${state.domain}` : h.host; + let r = resolv_query(uniq([ h.host, fqdn ]), { type: [ 'A' ], nameserver: [ '127.0.0.1' ], timeout: 500 }); + c = host_ips[h.host] = { time: time(), ip: r?.[h.host]?.A?.[0] ?? r?.[fqdn]?.A?.[0] }; + } + let b = split_board(board_of(c?.ip, c?.ip)); + out[net] = { ...h, ip: c?.ip, vendor: b?.vendor ?? h.vendor, model: b?.model ?? h.model }; + } + return out; +} + +// Router ID of the lowest-metric route through an IPv4 next hop +function via4_router_id(via, proto) { + let c = state.rid4[via]; + if (c && time() - c.time < RID_TTL) return c.router_id; + c = state.rid4[via] = { time: time() }; + let t4 = filter(state.protos ?? [], p => p.name == proto)[0]?.table4; + let best = INF; + for (let l in (t4 ? bird(`show route table ${t4} where gw = ${via}`) : null) ?? []) { + let m = match(l, /\([0-9]+\/([0-9]+)\) \[([0-9a-fA-F:]+)\]/); + if (m && +m[1] < best) { best = +m[1]; c.router_id = m[2]; } + } + return c.router_id; +} + +// IPv4 next hops of standby routes that neither the tables nor the kernel's +// neighbour tables tie to a neighbour: on a link where only one neighbour has +// no known IPv4 address it is that one's, else the one with the same router ID +function resolve_via4(nbs, alt) { + for (let a in alt) { + if (a.family != 'v4' || index(a.via, ':') >= 0) continue; + if (length(filter(nbs, n => n.iface == a.iface && n.ip4 == a.via))) continue; + let cand = filter(nbs, n => n.iface == a.iface && !n.ip4 && !n.self); + let pick = (length(cand) == 1) ? cand[0] : null; + if (length(cand) > 1) { + let rid = via4_router_id(a.via, a.proto); + if (rid) pick = filter(cand, n => router_id_of(n).router_id == rid)[0]; + } + if (pick) pick.ip4 = state.via4[pick.key] = a.via; + } +} + +// ---- names --------------------------------------------------------------- +// +// A neighbour is named from the hosts files, or reverse DNS, via one of its +// addresses. Every sample tries the cheap sources: its IPv4 address from a +// default route or the kernel's neighbour tables. When they give no name, the +// expensive ones are tried on a schedule per device: the IPv4 address in its +// router ID, its IPv4 address from Babel's route list (which also keeps the +// routes of neighbours too poor to use, which the tables never see) and +// reverse DNS. An unnamed device is tried again after NAME_RETRY_MIN seconds, +// doubling up to NAME_RETRY_MAX, and at once when it reappears; a named one +// only after NAME_RECHECK. What is found is remembered per device, by MAC +// address (the same on all its interfaces), for NAME_KEEP and saved with the +// history, so a device that is only heard now and then keeps its name. + +// Name for an address from the hosts files, else (with lookup) reverse DNS +function addr_name(addr, lookup) { + if (!addr) return null; + if (state.names[addr]) return state.names[addr]; + if (!lookup) return null; + let c = state.ptr[addr]; + if (!c || time() - c.time > (c.name ? NAME_RECHECK : NAME_RETRY_MAX)) { + let r = resolv_query([ addr ], { type: [ 'PTR' ], timeout: 500 }); + let name = null; + for (let k, v in r ?? {}) + name ??= v?.PTR?.[0]; + c = state.ptr[addr] = { time: time(), name }; + } + return c.name; +} + +// IPv4 next hops per interface from Babel's route list, only for the given +// interfaces, read at most every NAME_ROUTES_EVERY seconds +function babel_vias4(ifaces) { + let c = state.name_routes; + if (c && time() - c.time < NAME_ROUTES_EVERY && !length(filter(ifaces, i => index(c.ifaces, i) < 0))) + return c.vias; + let keep = regexp('^\\s*[0-9.]+/[0-9]+( from [^ ]+)?\\s+[0-9.]+\\s+(' + + join('|', map(ifaces, i => replace(i, /[^a-zA-Z0-9_-]/g, '.'))) + ')\\s'); + let vias = {}; + for (let p in state.protos) + for (let l in bird(`show babel routes ${p.name}`, keep) ?? []) { + let m = match(l, /^\s*[0-9.]+\/[0-9]+( from [^ ]+)?\s+([0-9.]+)\s+([^ ]+)\s/); + if (!m) continue; + vias[m[3]] ??= []; + if (index(vias[m[3]], m[2]) < 0) push(vias[m[3]], m[2]); + } + state.name_routes = { time: time(), ifaces, vias }; + return vias; +} + +// The IPv4 address of an unnamed neighbour from Babel's route list: on its +// link, the one next hop that belongs to no other neighbour, if it is the +// only neighbour there without a known IPv4 address +function babel_ip4(nb, nbs) { + let unknown = filter(nbs, n => n.iface == nb.iface && !n.self && !n.ip4); + if (length(unknown) != 1) return null; + let vias = babel_vias4(uniq(map(filter(nbs, n => !n.self && !n.ip4), n => n.iface)))[nb.iface] ?? []; + let taken = map(filter(nbs, n => n.iface == nb.iface && n.ip4), n => n.ip4); + let free = filter(vias, v => index(taken, v) < 0); + return (length(free) == 1) ? free[0] : null; +} + +// The expensive lookups for an unnamed neighbour; fills in what it learns +function name_search(nb, id, nbs) { + let rid = router_id_of(nb); + if (rid.router_id) id.router_id = rid.router_id; + if (rid.ip4) id.node_ip4 = rid.ip4; + let host = addr_name(id.node_ip4, true); + if (!host && !nb.ip4) { + let ip4 = babel_ip4(nb, nbs); + if (ip4) { + nb.ip4 = id.ip4 = state.via4[nb.key] = ip4; + host = addr_name(ip4, true); + } + } + return host ?? addr_name(nb.ip4, true); +} + +function resolve_name(nb, nbs) { + let now = time(); + let id = state.idents[nb.mac ?? nb.addr] ??= { fails: 0, next: 0 }; + id.seen = now; + if (nb.ip4) id.ip4 = nb.ip4; + nb.ip4 ??= id.ip4; + + let host = addr_name(nb.ip4) ?? addr_name(id.node_ip4); + if (!host && now >= id.next) + host = name_search(nb, id, nbs); + if (host) { + id.hostname = host; + id.confirmed = now; + id.fails = 0; + if (id.next < now) id.next = now + NAME_RECHECK; + } + else if (now >= id.next) { + id.fails++; + id.next = now + min(NAME_RETRY_MAX, NAME_RETRY_MIN * (1 << min(10, id.fails - 1))); + } + + nb.hostname = host ?? id.hostname; + // remembered from before the collector started, not confirmed since + nb.name_cached = !host && id.hostname != null && id.confirmed < state.started; + nb.router_id = id.router_id; + nb.node_ip4 = id.node_ip4; +} + +// An unnamed device that reappears is looked up again at once +function name_retry_now(nb) { + let id = state.idents[nb.mac ?? nb.addr]; + if (id && !id.hostname) id.next = 0; +} + +function prune_idents() { + let cutoff = time() - NAME_KEEP; + for (let k, id in state.idents) + if (id.seen < cutoff) delete state.idents[k]; + for (let k, c in state.ptr) + if (time() - c.time > NAME_RECHECK) delete state.ptr[k]; +} + +// Identity of a neighbour: key, network, loop detection and IPv4 address +function annotate(nb, def) { + nb.key = `${nb.iface}|${nb.addr}`; + nb.network = state.ifnames[nb.iface]; + nb.self = state.local_ll[nb.addr] != null; + if (nb.self) nb.self_iface = state.local_ll[nb.addr]; + + // IPv4 address of the neighbour: the next hop of an IPv4 default route + // through it (remembered, as it stays the same), else the neighbour tables + let ip4 = null; + for (let d in def.v4) + if (d.from == nb.addr && d.iface == nb.iface && d.via) { ip4 = state.via4[nb.key] = d.via; break; } + ip4 ??= state.via4[nb.key]; + let mac = state.mac6[nb.key] ?? ll_to_mac(nb.addr); + if (!ip4 && mac) ip4 = state.mac4[`${nb.iface}|${mac}`] ?? state.macany[mac]; + nb.mac = mac; + nb.ip4 = ip4; +} + +// Name, default route roles and board of a neighbour +function annotate_rest(nb, def, alt, nbs) { + let d4 = role_for(def.v4, alt, nb, nbs, 4), d6 = role_for(def.v6, alt, nb, nbs, 6); + nb.role4 = d4[0]; nb.metric4 = d4[1]; + nb.role6 = d6[0]; nb.metric6 = d6[1]; + + if (nb.self) { + nb.hostname = null; + nb.name = nb.addr; + nb.mac_rx = state.ifmac[nb.iface]; + nb.mac_tx = state.ifmac[nb.self_iface]; + return; + } + resolve_name(nb, nbs); + nb.name = short_name(nb.hostname) ?? nb.ip4 ?? nb.node_ip4 ?? nb.addr; + // the board answers on the link-local address too, without any IPv4 + let b = split_board(board_of(nb.mac ?? nb.addr, nb.ip4 ?? `[${nb.addr}%${nb.iface}]`)); + if (b) { nb.vendor = b.vendor; nb.model = b.model; } +} + +// Unpacker for n repetitions of a format, cached +let unpackers = {}; +function unpacker(fmt1, n) { + let k = fmt1 + n; + if (!unpackers[k]) { + let fmt = '<'; + for (let i = 0; i < n; i++) fmt += substr(fmt1, 1); + unpackers[k] = struct.new(fmt); + } + return unpackers[k]; +} + +// ---- route to the gateway ------------------------------------------------- +// +// BIRD knows the selected default route's next hop (a neighbour) and its +// originator, whose router ID ("randomize router id") carries its IPv4 +// address; Babel is distance-vector, the routers in between are found with +// traceroute. Only when the route switches to one without a path from the +// last TRACE_KEEP seconds (a route flapping between two known paths needs +// none), and while the gateway was not reached; at most every TRACE_CAP +// seconds per family, a flapping route waits for that. +const TRACE_CAP = 300, TRACE_KEEP = 3600, TRACE_MAX_HOPS = 16; + +// Without "randomize router id" the bytes are no address; mesh addresses are +// private, so nothing else is looked up +function private_ip4(a) { + let b = map(split(a, '.'), x => +x); + return b[0] == 10 || (b[0] == 172 && (b[1] & 0xf0) == 16) || (b[0] == 192 && b[1] == 168) || + (b[0] == 100 && (b[1] & 0xc0) == 64); +} + +// IPv6 address as 8 numbers +function ip6_words(a) { + let halves = split(a, '::'); + let head = length(halves[0]) ? split(halves[0], ':') : []; + let tail = (length(halves) > 1 && length(halves[1])) ? split(halves[1], ':') : []; + let w = map(head, x => hex(x)); + for (let i = length(head) + length(tail); i < 8; i++) push(w, 0); + push(w, ...map(tail, x => hex(x))); + return w; +} + +function in_prefix6(a, prefix) { + let p = split(prefix, '/'), bits = +p[1], wa = ip6_words(a), wp = ip6_words(p[0]); + for (let i = 0; bits > 0; i++, bits -= 16) { + let mask = (bits >= 16) ? 0xffff : (0xffff << (16 - bits)) & 0xffff; + if ((wa[i] & mask) != (wp[i] & mask)) return false; + } + return true; +} + +// A prefix the router with this ID announces, other than the default route +function announced_prefix6(proto, rid) { + let t6 = filter(state.protos ?? [], p => p.name == proto)[0]?.table6; + if (!t6) return null; + for (let l in bird(`show route table ${t6.name}`) ?? []) { + let m = match(l, /^([0-9a-fA-F:]+\/[0-9]+)( from [^ ]+)? +unicast .*\[([0-9a-fA-F:]+)\]\s*$/); + if (m && m[3] == rid && m[1] != '::/0' && match(m[1], /::\/[0-9]+$/)) return m[1]; + } + return null; +} + +// Gateway and traceroute target for the selected default route of a family +function trace_target(fam, sel) { + let gw4 = rid_ip4(sel?.router_id); + if (fam == 'v4') + return (gw4 && private_ip4(gw4)) ? { addr: gw4, dst: gw4 } : null; + let prefix = sel ? announced_prefix6(sel.proto, sel.router_id) : null; + return prefix ? { prefix, dst: split(prefix, '/')[0] + '1', name4: gw4 } : null; +} + +function trace_parse(fam, out, target) { + let hops = [], reached = false; + for (let l in split(readfile(out) ?? '', '\n')) { + // " 1 10.31.11.43 0.854 ms", no reply: " 3 *" + let m = match(l, /^\s*[0-9]+\s+(\*|[0-9a-fA-F.:]+)/); + if (!m) continue; + let addr = (m[1] == '*') ? null : m[1]; + push(hops, { addr, name: short_name(addr_name(addr, true)) }); + if (addr && ((fam == 'v4') ? addr == target.addr : in_prefix6(addr, target.prefix))) { + reached = true; + break; + } + } + return { hops, reached }; +} + +// Trace the route with this key in the background; the path is kept per +// route, whichever is selected when it is done +function trace_start(fam, key, sel) { + let target = trace_target(fam, sel); + if (!target) { + state.traces[key] = { time: time(), hops: [], reached: false }; + return; + } + state.trace_busy[fam] = true; + state.trace_key[fam] = key; + state.trace_last[fam] = time(); + let out = `${TMPDIR}/trace-${fam}.txt`, v = (fam == 'v4') ? '4' : '6'; + // the source address this router uses, then the hops + uloop.process('/bin/sh', [ '-c', 'ip -$4 route get "$1" 2>/dev/null | sed -n "s/.* src \\([^ ]*\\).*/\\1/p" | head -n1 >"$2.src"; ' + + 'exec $5 -n -q 1 -w 1 -m "$3" "$1" >"$2" 2>/dev/null', + 'sh', target.dst, out, '' + TRACE_MAX_HOPS, v, (fam == 'v4') ? 'traceroute' : 'traceroute6' ], {}, (code) => { + state.trace_busy[fam] = false; + let r = trace_parse(fam, out, target); + let src = trim(readfile(out + '.src') ?? '') || null; + unlink(out); unlink(out + '.src'); + // the gateway's IPv6 interface has no name of its own, it has its IPv4 one + if (r.reached && !r.hops[-1].name) + r.hops[-1].name = short_name(addr_name(target.addr ?? target.name4, true)); + state.traces[key] = { time: time(), hops: r.hops, reached: r.reached, src }; + for (let k, t in state.traces) + if (time() - t.time > 2 * TRACE_KEEP) delete state.traces[k]; + }); +} + +function gateway_route(fam, sel, nbs) { + if (!sel?.router_id) return null; + let gw = rid_ip4(sel.router_id); + if (gw && !private_ip4(gw)) gw = null; + let key = `${sel.iface}|${(fam == 'v4') ? (sel.from ?? sel.via) : sel.via}`; + let nb = filter(nbs, n => n.key == key)[0]; + let r = { + self: { name: state.hostname, addr: (fam == 'v4') ? state.ifaddr4[sel.iface] : state.ifll[sel.iface] }, + via: { key, name: nb?.name, addr: (fam == 'v4') ? (nb?.ip4 ?? sel.via) : sel.via }, + gateway: { router_id: sel.router_id, addr: gw, name: gw ? short_name(addr_name(gw, true)) : null }, + // the next hop is the gateway itself + direct: !!(nb?.router_id && nb.router_id == sel.router_id), + metric: sel.metric, trace_cap: TRACE_CAP, + }; + if (r.direct) return r; + let tkey = `${sel.router_id}|${sel.iface}|${sel.via}`, now = time(), t = state.traces[tkey]; + let fresh = t && now - t.time < TRACE_KEEP; + if ((!fresh || (!t.reached && now - t.time >= TRACE_CAP)) && !state.trace_busy[fam] && + now - (state.trace_last[fam] ?? 0) >= TRACE_CAP) + trace_start(fam, tkey, sel); + t = state.traces[tkey]; + fresh = t && now - t.time < TRACE_KEEP; + if (fresh) r.trace = { time: t.time, hops: t.hops, reached: t.reached, src: t.src }; + // without the whole path: when it is traced (now if it is being traced) + if (!fresh || !t.reached) + r.trace_next = (state.trace_busy[fam] && state.trace_key[fam] == tkey) ? now : + max(now, fresh ? t.time + TRACE_CAP : 0, (state.trace_last[fam] ?? 0) + TRACE_CAP); + return r; +} + +function collect() { + if (!state.protos && !discover()) { + state.live = { error: state.error ?? 'no Babel protocol found in BIRD', time: time() }; + state.live_ts = time(); + return null; + } + let nbs = parse_neighbors(); + if (nbs == null) { + state.protos = null; // rediscover next time, BIRD may have restarted + state.complete = {}; + state.babel_defaults = null; + state.live = { error: state.error, time: time() }; + state.live_ts = time(); + return null; + } + let ifs = parse_interfaces(); + let def = query_defaults(); + let alt = query_babel_defaults(def); + let now = time(); + + let present = {}; + for (let nb in nbs) { + annotate(nb, def); + let meta = state.neighbors[nb.key]; + if (!meta || meta.gone) name_retry_now(nb); + } + resolve_via4(nbs, alt); + for (let nb in nbs) { + annotate_rest(nb, def, alt, nbs); + present[nb.key] = true; + let meta = state.neighbors[nb.key]; + // the router hearing its own hellos is a loop, not a neighbour event + if ((!meta || meta.gone) && !nb.self) + add_event('neighbor_up', { key: nb.key, name: nb.name, iface: nb.iface }); + meta ??= state.neighbors[nb.key] = { first_seen: now }; + meta.gone = false; + meta.last_seen = now; + for (let k in [ 'addr', 'iface', 'network', 'ip4', 'hostname', 'name', 'self', 'vendor', 'model', 'router_id' ]) + meta[k] = nb[k]; + } + for (let k, meta in state.neighbors) + if (!present[k] && !meta.gone) { + meta.gone = true; + if (!meta.self) + add_event('neighbor_down', { key: k, name: meta.name, iface: meta.iface }); + } + + for (let fam in [ 'v4', 'v6' ]) { + let sel = null; + for (let d in def[fam]) if (d.selected) sel = d; + let key = sel ? `${sel.iface}|${(fam == 'v4') ? (sel.from ?? sel.via) : sel.via}` : null; + if (key !== state.last_sel[fam]) { + let prev = state.last_sel[fam]; + add_event('gateway_change', { + family: fam, from: prev, to: key, + from_name: state.neighbors[prev]?.name, + to_name: state.neighbors[key]?.name ?? sel?.proto, + metric: sel?.metric, + }); + state.last_sel[fam] = key; + } + } + + let route = {}; + for (let fam in [ 'v4', 'v6' ]) + route[fam] = gateway_route(fam, filter(def[fam], d => d.selected)[0], nbs); + + state.live = { + time: now, hostname: state.hostname, mesh_hints: mesh_hints_live(), bird_version: state.bird_version, + route4: route.v4, route6: route.v6, + protocols: map(state.protos, p => ({ name: p.name, table4: p.table4, table6: p.table6?.name, sadr: p.table6?.sadr ?? false })), + neighbors: nbs, interfaces: ifs, defaults: def, + }; + state.live_ts = now; + return nbs; +} + +// ---- history storage ------------------------------------------------------- +// +// Per neighbour, one string per BLOCK seconds (on multiples of BLOCK): header, +// a summary per minute, a histogram, then a record whenever any of its values +// change (D in the design notes: memory follows how often links change, not +// the sampling interval). The open block keeps its records in a growing +// string and gets its summaries when it closes. A record lasts until the next +// one; a neighbour that disappears gets an absent record, and times the +// collector did not sample (state.cover) count as no data. + +function merge_run(runs, start, end, role, gap) { + let last = length(runs) ? runs[-1] : null; + if (last && last[2] == role && start <= last[1] + gap) { + if (end > last[1]) last[1] = end; + } + else { + push(runs, [ start, end, role ]); + } +} + +function hist_bin(v) { + return (v < 1) ? 0 : min(HIST_BINS - 1, int(ln(v) * HIST_SCALE)); +} + +// Note a sample at t: extend the coverage, or start a new stretch after a gap +function cover_add(t) { + let c = length(state.cover) ? state.cover[-1] : null; + if (c && t <= c[1] + 2 * state.interval) c[1] = t + state.interval; + else push(state.cover, [ t, t + state.interval ]); +} + +// The covered parts of a..b +function cover_in(a, b) { + let res = []; + for (let c in state.cover) { + if (c[1] <= a) continue; + if (c[0] >= b) break; + push(res, [ max(a, c[0]), min(b, c[1]) ]); + } + return res; +} + +// Pack a block: minute summaries and the histogram from its records, clipped +// to the coverage; `len` is how many seconds of the block it covers +function block_pack(t0, len, recs, n) { + let r = unpacker(REC_FMT, n).unpack(recs), end = t0 + len; + let m = [], hm = {}, hr = {}; + for (let i = 0; i < MINUTES; i++) + push(m, [ 0, 0, INF, 0, 0, 0, INF, 0, null, null ]); + for (let i = 0, o = 0; i < n; i++, o += 5) { + if (r[o + 3] == ROLE_ABSENT) continue; + let s = t0 + r[o], e = (i + 1 < n) ? t0 + r[o + 5] : end; + let cost = r[o + 1], rtt = r[o + 2], r4 = r[o + 3], r6 = r[o + 4]; + for (let c in cover_in(s, e)) { + for (let p = c[0]; p < c[1];) { + let mi = int((p - t0) / MINUTE), q = min(c[1], t0 + (mi + 1) * MINUTE), secs = q - p, x = m[mi]; + x[0] += secs; x[1] += cost * secs; + if (cost < x[2]) x[2] = cost; + if (cost > x[3]) x[3] = cost; + hm[hist_bin(cost)] = (hm[hist_bin(cost)] ?? 0) + secs; + if (rtt != INF && rtt > 0) { + x[4] += secs; x[5] += rtt * secs; + if (rtt < x[6]) x[6] = rtt; + if (rtt > x[7]) x[7] = rtt; + hr[hist_bin(rtt)] = (hr[hist_bin(rtt)] ?? 0) + secs; + } + x[8] = (x[8] == null || x[8] == r4) ? r4 : ROLE_MIXED; + x[9] = (x[9] == null || x[9] == r6) ? r6 : ROLE_MIXED; + p = q; + } + } + } + let mins = ''; + for (let x in m) { + // roles only count for a minute the neighbour was present all of + let full = (x[0] == MINUTE); + mins += struct.pack(MIN_FMT, x[0], x[1], x[2], x[3], x[4], x[5], x[6], x[7], + full ? x[8] : ROLE_MIXED, full ? x[9] : ROLE_MIXED); + } + // sparse (bin, seconds) pairs, cost then RTT after a 255 + let hist = ''; + for (let b, v in hm) hist += struct.pack(' state.budget) { + let oldest = null; + for (let k, h in state.hist) + if (length(h.blocks) && (!oldest || block_t0(h.blocks[0]) < block_t0(state.hist[oldest].blocks[0]))) + oldest = k; + if (!oldest) break; + state.hist_bytes -= length(shift(state.hist[oldest].blocks)); + } + while (length(state.cover) && state.cover[0][1] <= cutoff) shift(state.cover); + if (length(state.cover) && state.cover[0][0] < cutoff) state.cover[0][0] = cutoff; + for (let k, meta in state.neighbors) + if (meta.gone && meta.last_seen < cutoff) delete state.neighbors[k]; +} + +function record(nbs) { + let now = time(), t0 = now - now % BLOCK, seen = {}, closed = false; + cover_add(now); + for (let k, h in state.hist) + if (h.open && h.open.t0 != t0) { block_close(h); closed = true; } + for (let nb in nbs) { + seen[nb.key] = true; + let rtt = (nb.rtt == null) ? INF : min(INF - 1, int(nb.rtt * 10 + 0.5)); + hist_put(state.hist[nb.key] ??= { blocks: [], open: null, last: null }, now, t0, + [ min(INF, nb.metric), rtt, nb.role4, nb.role6 ]); + } + for (let k, h in state.hist) + if (!seen[k] && h.last && h.last[2] != ROLE_ABSENT) + hist_put(h, now, t0, [ INF, INF, ROLE_ABSENT, ROLE_ABSENT ]); + if (closed) hist_prune(now); +} + +function oldest() { + return length(state.cover) ? state.cover[0][0] : time(); +} + +// ---- sampling interval ----------------------------------------------------- + +function mem_total() { + let m = match(readfile('/proc/meminfo') ?? '', /MemTotal:\s+([0-9]+)/); + return m ? +m[1] * 1024 : 64 * 1024 * 1024; +} + +function sample_interval() { + if (conf.interval > 0) return max(1, conf.interval); + let mb = mem_total() / 1048576; + for (let t in SAMPLE_TIERS) + if (mb < t[0]) return t[1]; + return SAMPLE_MIN; +} + +// ---- persistence --------------------------------------------------------- +// +// History lives in RAM. Saving it to flash is optional and only done when the +// collector stops (so on reboot and shutdown), right before sysupgrade or a +// config backup takes the file (/lib/upgrade hook calling the "save" method), +// and when asked to with "ubus call bird-babel save". gzip compressed, written +// to a temporary file and renamed, so a power cut leaves the previous copy. A +// file without compression is read as well. + +function shell_quote(s) { + return "'" + replace(s, "'", "'\\''") + "'"; +} + +function persist_save() { + if (!conf.persist) return false; + let dir = match(conf.persist_path, /^(.*)\//)?.[1]; + if (dir) mkdir(dir); + let blocks = {}, now = time(); + for (let k, h in state.hist) { + let list = map(h.blocks, b => b64enc(b)); + // the open block as covered so far, without closing it + if (h.open?.n) + push(list, b64enc(block_pack(h.open.t0, min(BLOCK, now - h.open.t0), h.open.recs, h.open.n))); + if (length(list)) blocks[k] = list; + } + let out = { + version: 4, saved: now, neighbors: state.neighbors, events: state.events, idents: state.idents, + cover: map(state.cover, c => [ c[0], min(c[1], now) ]), blocks, + }; + let tmp = conf.persist_path + '.tmp'; + let gz = popen('exec gzip -c > ' + shell_quote(tmp), 'w'); + let ok = gz && gz.write(sprintf('%J', out)) != null; + ok = (gz?.close() === 0) && ok && rename(tmp, conf.persist_path); + if (!ok) { + unlink(tmp); + log(`saving history to ${conf.persist_path} failed`); + return false; + } + return true; +} + +function persist_read(path) { + if (!stat(path)) return null; + // gzip magic; compared as hex, a '\x8b' literal would be UTF-8 encoded + if (hexenc(readfile(path, 2) ?? '') != '1f8b') + return readfile(path); + let gz = popen('exec gzip -dc ' + shell_quote(path), 'r'); + let raw = gz?.read('all'); + return (gz?.close() === 0) ? raw : null; +} + +function persist_load() { + if (!conf.persist) return; + let d = null; + try { d = json(persist_read(conf.persist_path) ?? 'null'); } catch (e) { d = null; } + if (d?.version != 4) return; + for (let k, list in d.blocks ?? {}) { + let h = state.hist[k] = { blocks: [], open: null, last: null }; + for (let b in list) { + b = b64dec(b); + push(h.blocks, b); + state.hist_bytes += length(b); + } + } + state.cover = d.cover ?? []; + // keep the saved presence and gateway state so a restart does not + // replay "appeared" and "default route" events + state.neighbors = d.neighbors ?? {}; + state.events = d.events ?? []; + // names found before; unnamed devices are looked up again right away + state.idents = d.idents ?? {}; + for (let k, id in state.idents) + if (!id.hostname) id.next = 0; + for (let e in state.events) + if (e.type == 'gateway_change') state.last_sel[e.family] = e.to; + hist_prune(time()); +} + +// ---- history queries ----------------------------------------------------- + +// Per-neighbour bucket accumulators as flat numeric arrays (no per-sample +// allocations, ucode is slow at those). Values are weighted by seconds. +function acc_new(points) { + let a = { n: [], ms: [], mmin: [], mmax: [], rn: [], rs: [], rmin: [], rmax: [], r4: [], r6: [], hm: [], hr: [] }; + for (let i = 0; i < HIST_BINS; i++) { push(a.hm, 0); push(a.hr, 0); } + for (let i = 0; i < points; i++) { + push(a.n, 0); push(a.ms, 0); push(a.mmin, INF); push(a.mmax, 0); + push(a.rn, 0); push(a.rs, 0); push(a.rmin, INF); push(a.rmax, 0); + } + return a; +} + +function hist_add(a, blk, off, len) { + let acc = a.hm, end = off + len; + for (let i = off; i < end;) { + if (ord(blk, i) == 255) { acc = a.hr; i++; continue; } + acc[ord(blk, i)] += ord(blk, i + 1) | (ord(blk, i + 2) << 8); + i += 3; + } +} + +// A constant stretch s..e of one record into the buckets of q; hist adds it +// to the histograms too (the open block has none yet), HIST_ONLY only there +const HIST_ONLY = 2; +function add_stretch(a, q, s, e, r, o, hist) { + let r4 = r[o + 3]; + if (r4 == ROLE_ABSENT) return; + let cost = r[o + 1], rtt = r[o + 2], rok = (rtt != INF && rtt > 0); + if (hist) { + a.hm[hist_bin(cost)] += e - s; + if (rok) a.hr[hist_bin(rtt)] += e - s; + if (hist == HIST_ONLY) return; + } + merge_run(a.r4, s, e, r4, q.gap); + merge_run(a.r6, s, e, r[o + 4], q.gap); + while (s < e) { + let b = int((s - q.from) / q.bucket), next = min(e, q.from + (b + 1) * q.bucket), secs = next - s; + a.n[b] += secs; a.ms[b] += cost * secs; + if (cost < a.mmin[b]) a.mmin[b] = cost; + if (cost > a.mmax[b]) a.mmax[b] = cost; + if (rok) { + a.rn[b] += secs; a.rs[b] += rtt * secs; + if (rtt < a.rmin[b]) a.rmin[b] = rtt; + if (rtt > a.rmax[b]) a.rmax[b] = rtt; + } + s = next; + } +} + +// Replay the records r (n of them, block t0..end) over the covered parts of +// a..b; i is where to start looking, returns where the next span can +function replay(a, q, r, n, t0, end, i, from, to, hist) { + for (let c in cover_in(from, to)) { + while (i + 1 < n && t0 + r[(i + 1) * 5] <= c[0]) i++; + for (let j = i, o = i * 5; j < n; j++, o += 5) { + let s = t0 + r[o], e = (j + 1 < n) ? t0 + r[o + 5] : end; + if (s >= c[1]) break; + if (e > c[0]) add_stretch(a, q, max(s, c[0]), min(e, c[1]), r, o, hist); + } + } + return i; +} + +// One neighbour over q.qfrom..q.to: whole minutes inside a bucket from the +// summaries, the rest replayed from the records +function query_neighbor(h, a, q) { + let min_unpacker = unpacker(MIN_FMT, MINUTES); + for (let blk in h.blocks) { + let hd = struct.unpack(BLOCK_HDR, blk), t0 = hd[0], end = t0 + hd[1]; + if (end <= q.from || t0 >= q.to) continue; + let moff = BLOCK_HDR_SIZE, hoff = moff + MINUTES * MIN_SIZE, roff = hoff + hd[3]; + if (q.p99) hist_add(a, blk, hoff, hd[3]); + if (t0 + BLOCK <= q.qfrom) continue; // only for the percentiles + let r = null, ri = 0, sv = (q.bucket >= MINUTE) ? min_unpacker.unpack(blk, moff) : null; + let spans = sv ? [] : [ [ max(t0, q.qfrom), min(end, q.to) ] ]; + for (let m = 0; sv && m < MINUTES; m++) { + let ms = t0 + m * MINUTE, me = ms + MINUTE, o = m * 10; + if (me <= q.qfrom || ms >= q.to || ms >= end || !sv[o]) continue; + if (ms >= q.qfrom && me <= q.to && sv[o] == MINUTE && sv[o + 8] != ROLE_MIXED && sv[o + 9] != ROLE_MIXED) { + let b = int((ms - q.from) / q.bucket); + a.n[b] += sv[o]; a.ms[b] += sv[o + 1]; + if (sv[o + 2] < a.mmin[b]) a.mmin[b] = sv[o + 2]; + if (sv[o + 3] > a.mmax[b]) a.mmax[b] = sv[o + 3]; + if (sv[o + 4]) { + a.rn[b] += sv[o + 4]; a.rs[b] += sv[o + 5]; + if (sv[o + 6] < a.rmin[b]) a.rmin[b] = sv[o + 6]; + if (sv[o + 7] > a.rmax[b]) a.rmax[b] = sv[o + 7]; + } + merge_run(a.r4, ms, me, sv[o + 8], q.gap); + merge_run(a.r6, ms, me, sv[o + 9], q.gap); + continue; + } + // in order with the summaries, so the role runs merge + r ??= unpacker(REC_FMT, hd[2]).unpack(blk, roff); + ri = replay(a, q, r, hd[2], t0, end, ri, max(ms, q.qfrom), min(me, end, q.to), false); + } + for (let sp in spans) { + r ??= unpacker(REC_FMT, hd[2]).unpack(blk, roff); + replay(a, q, r, hd[2], t0, end, 0, sp[0], sp[1], false); + } + } + let o = h.open; + if (o?.n && o.t0 < q.to) { + let end = min(q.to, time() + state.interval), r = unpacker(REC_FMT, o.n).unpack(o.recs); + // before qfrom only for the percentiles + if (q.p99 && o.t0 < q.qfrom) + replay(a, q, r, o.n, o.t0, end, 0, max(o.t0, q.from), q.qfrom, HIST_ONLY); + replay(a, q, r, o.n, o.t0, end, 0, max(o.t0, q.qfrom), end, q.p99); + } +} + +// Stretches of from..to without samples (before the history begins, and +// while the collector was not running), longer than 2 sample intervals +function no_data(from, to) { + let gaps = [], at = from; + for (let c in cover_in(from, to)) { + if (c[0] - at > 2 * state.interval) push(gaps, [ at, c[0] ]); + at = c[1]; + } + if (to - at > 2 * state.interval) push(gaps, [ at, to ]); + return gaps; +} + +// Bucket width for a requested one: at least the sampling interval, whole +// minutes from a minute up, below that a divisor of a minute, so buckets line +// up with the minute summaries +function pick_bucket(want) { + let b = max(state.interval, want); + if (b >= MINUTE) return max(1, int(b / MINUTE + 0.5)) * MINUTE; + let best = 1; + for (let d in [ 1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30 ]) + if (d <= b) best = d; + return best; +} + +// Percentile from a histogram, interpolated within the bin and clamped to +// the exact range of the data. +function hist_percentile(h, q, lo, hi) { + let total = 0; + for (let n in h) total += n; + if (!total) return null; + let target = q * total, cum = 0; + for (let b = 0; b < HIST_BINS; b++) { + if (!h[b]) continue; + if (cum + h[b] >= target) { + let v = 2 ** ((b + (target - cum) / h[b]) / 8); + return max(lo, min(hi, v)); + } + cum += h[b]; + } + return hi; +} + +// Per-neighbour series bucketed to about `points` buckets, plus exact +// run-length role segments for the timeline. The range ends now, or at `end` +// for a zoomed in window of the past; `now` in the result is that end. With +// `since`, only buckets from there on are computed and no percentiles (the +// UI polling for new data, it keeps the ones it has). +function history_compute(range, points, end, since) { + range = max(60, min(HISTORY_SECONDS, +(range || HISTORY_SECONDS))); + points = max(10, min(2000, +(points || 720))); + let now = time(); + if (+end > 0 && +end < now) now = int(+end); + let bucket = pick_bucket(range / points); + // buckets on multiples of their width, so a refresh puts samples into the + // same buckets as before instead of reshuffling them (lines would wobble) + let from = int((now - range) / bucket) * bucket; + points = int((now - from + bucket - 1) / bucket); + let qfrom = from; + if (+since > from) qfrom = min(from + int((+since - from) / bucket) * bucket, now); + // percentiles of the whole range barely move between polls, which leave + // them out (merging the histograms of all blocks is most of their cost) + let q = { from, qfrom, to: now, bucket, gap: 2 * state.interval, p99: (qfrom == from) }; + + let out = {}; + for (let k, h in state.hist) { + let a = acc_new(points); + query_neighbor(h, a, q); + let m = [], r = [], hn = 0; + for (let b = int((qfrom - from) / bucket); b < points; b++) { + if (!a.n[b]) continue; + let t = from + int((b + 0.5) * bucket); + push(m, [ t, int(a.ms[b] / a.n[b] + 0.5), a.mmin[b], a.mmax[b] ]); + // RTT is stored in 0.1 ms units, keep that precision + if (a.rn[b]) push(r, [ t, int(a.rs[b] / a.rn[b] + 0.5) / 10.0, a.rmin[b] / 10.0, a.rmax[b] / 10.0 ]); + } + for (let v in a.hm) hn += v; + if (!length(m) && !length(a.r4) && !hn) continue; + out[k] = { meta: state.neighbors[k], metric: m, rtt: r, roles4: a.r4, roles6: a.r6 }; + if (q.p99) { + let mlo = INF, mhi = 0, rlo = INF, rhi = 0; + for (let b = 0; b < points; b++) { + if (a.n[b]) { mlo = min(mlo, a.mmin[b]); mhi = max(mhi, a.mmax[b]); } + if (a.rn[b]) { rlo = min(rlo, a.rmin[b]); rhi = max(rhi, a.rmax[b]); } + } + let pm = (mlo <= mhi) ? hist_percentile(a.hm, 0.99, mlo, mhi) : null; + let pr = (rlo <= rhi) ? hist_percentile(a.hr, 0.99, rlo, rhi) : null; + out[k].p99 = { metric: (pm != null) ? int(pm + 0.5) : null, rtt: (pr != null) ? int(pr + 0.5) / 10.0 : null }; + } + } + return { now, from, range, bucket, since: (qfrom > from) ? qfrom : null, oldest: oldest(), interval: state.interval, + neighbors: out, nodata: no_data(qfrom, now) }; +} + +// Take a token for an anonymous history computation, false when out of them +function anon_allow() { + let now = time(); + state.anon_tokens = min(ANON_BURST, state.anon_tokens + (now - state.anon_ts) / ANON_REFILL); + state.anon_ts = now; + if (state.anon_tokens < 1) return false; + state.anon_tokens--; + return true; +} + +// Results are cached briefly, the UI polls and several browsers may be open. +// Polls for the last few minutes are cheap and not rate limited. +function history(range, points, end, since, session) { + let anon = (session == ANON_SESSION); + range = +range || 0; points = +points || 0; end = +end || 0; since = +since || 0; + if (anon) { + let round = v => int((v + ANON_ROUND / 2) / ANON_ROUND) * ANON_ROUND; + points = max(ANON_POINTS, min(600, int(points / ANON_POINTS) * ANON_POINTS)); + if (range) range = max(ANON_ROUND, round(range)); + if (end) end = round(end); + } + let ck = `${range}|${points}|${end}|${since}`, now = time(); + for (let i = 0; i < length(state.hcache); i++) { + let c = state.hcache[i]; + if (c.key == ck && now - c.at < max(5, min(30, c.res.bucket))) + return c.res; + } + if (anon && !(since && since >= now - 300) && !anon_allow()) + return UBUS_STATUS_TIMEOUT; + let res = history_compute(range, points, end, since); + state.hcache = [ { key: ck, at: now, res }, ...filter(state.hcache, c => c.key != ck) ]; + if (length(state.hcache) > HCACHE_SIZE) + state.hcache = slice(state.hcache, 0, HCACHE_SIZE); + return res; +} + +// ---- main ---------------------------------------------------------------- + +function tick() { + let nbs = collect(); + if (nbs != null) record(nbs); + tick_timer.set(state.interval * 1000); +} + +openlog('bird-babel-collector', LOG_PID, LOG_DAEMON); +load_config(); +state.hostname = cursor().get_first('system', 'system', 'hostname') ?? (trim(readfile('/proc/sys/kernel/hostname') ?? '') || null); +state.domain = cursor().get_first('dhcp', 'dnsmasq', 'domain'); +state.mesh_hints = load_mesh_hints(); +// a cap the history should never reach (~2 MB for 8 neighbours at most) +state.budget = (conf.memory_budget > 0) ? conf.memory_budget : + max(256 * 1024, min(16 * 1024 * 1024, int(mem_total() / 64))); +state.interval = sample_interval(); + +uloop.init(); +mkdir(TMPDIR); +ubus_conn = ubus.connect(); +if (!ubus_conn) die('cannot connect to ubus\n'); + +load_names(); +load_links(); +persist_load(); + +ubus_conn.publish('bird-babel', { + status: { + call: function(req) { + // refresh live data at most once per second for fast UI polling + if (time() - state.live_ts >= 1) collect(); + return { + ...state.live, + collector: { + started: state.started, interval: state.interval, + budget: state.budget, history_bytes: state.hist_bytes, oldest: oldest(), + history_seconds: HISTORY_SECONDS, persist: conf.persist, error: state.error, + }, + }; + }, + args: { ubus_rpc_session: '' }, + }, + history: { + call: req => history(req.args.range, req.args.points, req.args.end, req.args.since, req.args.ubus_rpc_session), + args: { range: 0, points: 0, end: 0, since: 0, ubus_rpc_session: '' }, + }, + // Save the history now (sysupgrade hook, and for development); not in + // the LuCI ACL, local only + save: { + call: () => conf.persist ? { saved: persist_save(), path: conf.persist_path } : { saved: false }, + args: {}, + }, + events: { + call: function(req) { + let since = +(req.args.since || 0); + return { events: filter(state.events, e => e.time >= since) }; + }, + args: { since: 0, ubus_rpc_session: '' }, + }, +}); + +tick_timer = uloop.timer(10, tick); +uloop.interval(30000, () => { load_names(); load_links(); prune_idents(); }); + +let restored = 0; +for (let k, h in state.hist) restored += length(h.blocks); +add_event('collector_start', { interval: state.interval, budget: state.budget, restored }); +log(`started: budget ${state.budget} bytes, interval ${state.interval}s`); + +uloop.run(); +persist_save(); +bird_close(); diff --git a/net/bird-babel-collector/files/bird-babel-collector.init b/net/bird-babel-collector/files/bird-babel-collector.init new file mode 100755 index 0000000000000..c14aaa7533b69 --- /dev/null +++ b/net/bird-babel-collector/files/bird-babel-collector.init @@ -0,0 +1,26 @@ +#!/bin/sh /etc/rc.common + +START=95 +STOP=10 +USE_PROCD=1 + +PROG=/usr/sbin/bird-babel-collector + +start_service() { + config_load bird-babel + local enabled + config_get_bool enabled collector enabled 1 + [ "$enabled" -eq 1 ] || return 0 + + procd_open_instance + procd_set_param command "$PROG" + procd_set_param respawn 3600 5 0 + # the history is saved on stop, which can take a while on slow targets + procd_set_param term_timeout 60 + procd_set_param stderr 1 + procd_close_instance +} + +service_triggers() { + procd_add_reload_trigger bird-babel +} diff --git a/net/bird-babel-collector/files/bird-babel-collector.keep b/net/bird-babel-collector/files/bird-babel-collector.keep new file mode 100644 index 0000000000000..555114cb8f30d --- /dev/null +++ b/net/bird-babel-collector/files/bird-babel-collector.keep @@ -0,0 +1 @@ +/etc/bird-babel/ diff --git a/net/bird-babel-collector/files/bird-babel-collector.upgrade b/net/bird-babel-collector/files/bird-babel-collector.upgrade new file mode 100644 index 0000000000000..17f170f73ef17 --- /dev/null +++ b/net/bird-babel-collector/files/bird-babel-collector.upgrade @@ -0,0 +1,12 @@ +# Sourced by sysupgrade: save the history right before the config backup +# takes it; otherwise the history is only saved when the collector stops + +add_bird_babel_conffiles() { + local filelist="$1" path + + path="$(ubus -t 60 call bird-babel save 2>/dev/null | jsonfilter -q -e '@.path')" + [ -n "$path" ] && [ -f "$path" ] || return 0 + grep -qxF "$path" "$filelist" || echo "$path" >> "$filelist" +} + +sysupgrade_init_conffiles="$sysupgrade_init_conffiles add_bird_babel_conffiles" diff --git a/net/bird-babel-collector/files/bird-babel.config b/net/bird-babel-collector/files/bird-babel.config new file mode 100644 index 0000000000000..670e1d8a217fe --- /dev/null +++ b/net/bird-babel-collector/files/bird-babel.config @@ -0,0 +1,29 @@ + +config collector 'collector' + option enabled '1' + # BIRD control socket, and the Babel protocol to show (empty: all of them) + option socket '/var/run/bird.ctl' + option protocol '' + # Sampling interval in seconds; 0 picks one by RAM: 60 s below 96 MB, + # 30 s below 192 MB, 15 s below 384 MB, else 4 s (BIRD's default Babel + # hello interval; values only change with hellos) + option interval '0' + # Memory cap for the history in bytes; 0 uses 1/64 of RAM, between + # 256 KiB and 16 MiB. History only shrinks below 24 h if it is reached. + option memory_budget '0' + # Keep history across reboots and sysupgrade by saving it to flash, + # gzip compressed: only when the collector stops (reboot, shutdown, + # restart) and before sysupgrade; "ubus call bird-babel save" saves now + option persist '0' + option persist_path '/etc/bird-babel/history.json.gz' + +# Optional: the device and radio that serve a mesh network, e.g. a mesh AP +# bridging its 802.11s interface into a VLAN of this router. Shown in the +# graph; vendor and model are used when the device does not report them. +#config mesh +# option network 'mesh_hof_s' +# option host 'gub37-hof-s' +# option band '5 GHz' +# option ifname 'wlan5-mesh' +# option vendor 'Cudy' +# option model 'AP3000 Outdoor (v1)' diff --git a/net/bird-babel-collector/test-version.sh b/net/bird-babel-collector/test-version.sh new file mode 100644 index 0000000000000..0bab6311a7f2c --- /dev/null +++ b/net/bird-babel-collector/test-version.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +# shellcheck shell=busybox + +# The collector is a daemon without a version option, which the generic +# version check would start instead: check that it compiles. +case "$1" in +bird-babel-collector) + ucode -c -o /dev/null /usr/sbin/bird-babel-collector && echo "$1 $2 compiles" + ;; +*) + echo "Untested package: $1" >&2 + exit 1 + ;; +esac