Files
op-packages/luci-app-appflow/root/usr/sbin/appflowd
T

2546 lines
91 KiB
Plaintext
Executable File

#!/usr/bin/ucode
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 VolanticSystems
//
// appflowd — aggregate Netify Agent (netifyd) DPI flow events into live
// per-application / per-device / per-category traffic statistics and serve
// them over ubus. See docs/DESIGN.md; section references below point there.
//
// Data path (§3):
// netifyd --unix stream, length-framed JSON--> appflowd --ubus--> LuCI
//
// Everything that counts a byte goes through account() (§7.1), which is the
// single seam historical accounting will tap into.
import * as fs from "fs";
import * as uloop from "uloop";
import * as ubus from "ubus";
import * as socket from "socket";
import { cursor } from "uci";
/* ------------------------------------------------------------------ *
* Constants — the memory bounds of §3.3 live here. Every table in the
* daemon is capped; nothing grows with uptime or traffic volume.
* ------------------------------------------------------------------ */
const NAME = "appflowd";
const VERSION = "1.0";
const RX_CHUNK = 16384; // bytes per recv()
const RX_MAX = 262144; // partial-line buffer ceiling; beyond this the
// peer is not speaking our protocol -> resync
const RX_DRAIN_MAX = 64; // recv() calls per readable event (fairness)
const AGG_MAX = 384; // max aggregates per class (apps/devices/cats)
const APP_DEV_MAX = 64; // devices tracked per application (LRU)
const APP_HOST_MAX = 16; // hostnames tracked per application (LRU)
const NAME_MAX = 8192; // id->tag map ceiling (nDPI catalogue is ~2.5k)
const TAG_MAX = 64; // max stored length of any detection tag
const UNKNOWN_TYPE_MAX = 16; // distinct unhandled event types remembered
const EVENT_TYPE_MAX = 24; // distinct event types counted in status
const FLOWS_REPLY_MAX = 200; // hard ceiling for the flows[] ubus reply
const LIST_REPLY_MAX = 200; // hard ceiling for apps[]/devices[] replies
// Trailing-hour time series (§7.2 buckets, GL-parity `stats` method):
// 12 slots x 5 minutes. Slot numbers are absolute (epoch_ms / SERIES_MS), so
// a slot is self-describing and stale slots need no eager clearing.
const SERIES_SLOTS = 12;
const SERIES_MS = 300000;
// Per (app x device) we only need trailing-hour *totals*, not a series, so a
// coarser 4 x 15 min ring is used: same code path, quarter the memory. The
// window it reports is therefore 45-60 minutes rather than exactly 60.
const DEVSER_SLOTS = 4;
const DEVSER_MS = 900000;
const PRUNE_MS = 15000; // idle-flow / aggregate sweep interval
const LEASE_TTL_MS = 30000; // DHCP lease file re-read throttle (§4)
const LOG_REPEAT_MS = 60000; // identical-message log suppression window
const RECONNECT_MIN_MS = 1000;
const RECONNECT_MAX_MS = 30000;
const CATEGORY_FILE = "/etc/netify.d/netify-categories.json";
const LEASE_FILE = "/tmp/dhcp.leases";
const STATUS_FILE = "/var/run/netifyd/status.json";
const CONNTRACK_FILE = "/proc/net/nf_conntrack";
const CT_TTL_MS = 2000; // reuse one conntrack snapshot this long (3.2)
const CT_RETRY_MS = 60000; // re-probe an unreadable conntrack this often
const CT_FORCE_MIN_MS = 250; // floor between miss-driven refreshes (3.2)
const CT_MAX = 8192; // entries kept from one snapshot (memory bound, 3.3)
const CFG_DEFAULTS = {
socket_path: "/var/run/netifyd/netifyd.sock",
conntrack_path: "/proc/net/nf_conntrack",
flow_max: 4096,
rate_window: 30,
top_n: 10,
idle_timeout: 300,
ai_breakout: 1
};
// Conntrack snapshot used to resolve NAT twins (3.2). `map` is keyed on the
// REPLY tuple, which is the view netifyd's external capture reports, and holds
// true when that flow was source-translated. `avail` is null until first
// probed, then true/false; false is re-probed every CT_RETRY_MS so a conntrack
// module loaded after us is picked up without needing a restart.
let conntrack = { at: 0, probed: 0, avail: null, map: {}, entries: 0 };
// Any event type matching this and carrying flow.digest finalises a flow.
// netifyd 4.4.7 emits "flow_purge"; the regex keeps a renamed or additional
// event working without a code change (the dispatcher below is data-driven).
const PURGE_RE = regexp("(purge|expire|delete|remove|terminat|close)", "i");
/* ------------------------------------------------------------------ *
* State
* ------------------------------------------------------------------ */
let cfg = {};
let sock = null; // socket handle to netifyd
let sock_fd = null; // uloop fd handle
let rxbuf = ""; // partial line carry-over
let connected = false;
let ever_connected = false; // distinguishes the first connect from reconnects
let backoff_ms = RECONNECT_MIN_MS;
let retry_timer = null;
let flows = {}; // digest -> flow record
let nflows = 0;
// True once any internal (LAN-bridge) capture has been seen. The single-count
// shadow rule only trusts "the internal capture already counted this" when
// internal capture is actually active; on an external-only netifyd setup it
// must not shadow client flows or the dashboard would read near-zero.
let saw_internal = false;
let apps = {}, devs = {}, cats = {};
let totals = null; // aggregate covering everything
let names = { app: {}, proto: {} }; // id -> netifyd tag (definitions event)
let catnames = { app: {}, proto: {} }; // id -> category tag (categories file)
let leases = { at: 0, mtime: -1, by_mac: {}, by_ip: {} };
let router = { macs: {}, ips: {} }; // this router's own L2/L3 identities
let agent = { version: null, uptime: 0, flow_count: 0, ts: 0 };
let stats = {
events: 0, frames: 0, malformed: 0, unknown: 0, unknown_types: {},
by_type: {}, reconnects: 0, connect_fail: 0, purges: 0,
flows_seen: 0, flows_late: 0, flows_pruned: 0, flows_dropped: 0,
// Flows relabelled by the AI hostname table. Observable so a test can
// assert the feature fired rather than inferring it from a label.
ai_labeled: 0,
// Flows evicted because the table was AT ITS CAP, as opposed to evicted for
// going idle. The two used to share flows_pruned, where cap pressure was
// invisible underneath ordinary housekeeping.
//
// This exists because flows_dropped, which reads like the counter for
// "flows we could not keep", CANNOT BE MOVED BY ANY INPUT. flow_new()
// increments it only if the table is still full after prune(now, true), and
// that prune unconditionally sheds int(flow_max/10)+1 entries, so it always
// frees space and the fall-through never happens. Measured 2026-08-26 by
// tests/protocol-suite.sh: 200 distinct digests against flow_max=64 gave
// tracked=60 pruned=143 dropped=0. An operator reading `dropped: 0`
// concluded nothing was being lost while flows were being shed constantly.
// Same shape as aggs_refused below, and found the same way.
//
// flows_dropped is kept as the genuine last-resort backstop it always was.
// flows_shed is the number an operator should actually watch.
flows_shed: 0,
shadowed: 0, aggs_pruned: 0, rx_bytes: 0, resyncs: 0, sweeps: 0,
// Times an aggregate table was full of LIVE entries and a new key was
// therefore not tracked at all. Distinct from aggs_pruned, which counts
// evictions of idle entries and is normal housekeeping. A non-zero value
// here means the dashboard is silently incomplete.
aggs_refused: 0,
no_total: 0, last_event_ms: 0, last_reset_ms: 0,
// NAT twin resolution outcomes (3.2): twins shadowed on conntrack evidence,
// router-originated flows kept, and flows conntrack could not decide, which
// are shadowed on the safe default.
ct_twins: 0, ct_local: 0, ct_unresolved: 0,
// Purge-only flows rescued (see flow_update). Expected to stay 0 on
// netifyd 4.4.7; a non-zero value means that assumption has changed.
purge_only: 0,
// Byte conservation (§2.5): reported == attributed + shadowed, or
// something is leaking. Both silent byte-loss defects found in this
// daemon would have been visible here from the start.
bytes_reported: 0, bytes_attributed: 0, bytes_shadowed: 0
};
let start_ms = 0;
let start_mono = 0;
let log_seen = {};
// The published ubus object must stay referenced: dropping the last reference
// lets ucode collect it, which unregisters the object from ubus.
let ubus_obj = null;
// Forward declarations. ucode binds an identifier at compile time: one that
// is still undeclared resolves to a global, while `function f()` further down
// declares a local, so the call would find null. connect/schedule_reconnect
// are mutually recursive and prune() is used by the flow table above its own
// definition, so those four are declared here and assigned below.
let prune, connect, disconnect, schedule_reconnect;
/* ------------------------------------------------------------------ *
* Small helpers
* ------------------------------------------------------------------ */
function now_ms() {
let c = clock();
return c[0] * 1000 + int(c[1] / 1000000);
}
function mono_ms() {
let c = clock(true);
return c[0] * 1000 + int(c[1] / 1000000);
}
function num(v, def) {
if (v == null)
return def;
let n = int(v);
return (type(n) == "int") ? n : def;
}
function clampi(v, lo, hi) {
return (v < lo) ? lo : ((v > hi) ? hi : v);
}
// procd feeds our stdout to logd as daemon.info and our stderr as daemon.err
// (see the init script), so route by severity and flush explicitly -- stdout
// is block buffered when it is a pipe.
function logmsg(level, fmt, ...args) {
let out = (level == "error") ? fs.stderr : fs.stdout;
out.write(sprintf("[%s] %s\n", level, sprintf(fmt, ...args)));
out.flush();
}
// Log at most one identical message per LOG_REPEAT_MS. Reconnect churn while
// netifyd is down must not turn into log spam.
function logonce(key, level, fmt, ...args) {
let now = mono_ms();
if (log_seen[key] && (now - log_seen[key]) < LOG_REPEAT_MS)
return;
if (length(log_seen) > 32)
log_seen = {};
log_seen[key] = now;
logmsg(level, fmt, ...args);
}
/* ------------------------------------------------------------------ *
* Configuration (uci: /etc/config/appflow)
* ------------------------------------------------------------------ */
function cfg_load() {
let s = {};
try {
s = cursor().get_all("appflow", "main") ?? {};
} catch (e) {
logmsg("warn", "uci: %s — using defaults", e);
}
/* User-supplied hostname mappings, from `config hostmap` sections.
*
* Each adds or OVERRIDES one entry in the built-in table, so this is both
* the way to teach appflow a service it does not know and the way to
* correct one it gets wrong. Consulted before the built-in table.
*
* config hostmap
* option suffix 'torproject.org'
* option name 'Tor'
* option category 'Privacy'
* option strong '0'
*
* A malformed section is skipped and named in the log rather than
* silently ignored: a typo in a suffix produces a rule that matches
* nothing, which looks identical to the feature not working.
*/
let hostmap = {};
let bad = 0;
try {
cursor().foreach("appflow", "hostmap", function(h) {
let suf = lc(trim(h.suffix ?? ""));
let nm = trim(h.name ?? "");
/* A suffix must contain a dot, or it can never match: the matcher
* rebuilds the last N labels of a hostname and compares, so a
* single label matches nothing. */
if (!length(suf) || !length(nm) || index(suf, ".") < 0) {
bad++;
logmsg("warn", "hostmap: skipping entry with suffix %s name %s",
suf ?? "(empty)", nm ?? "(empty)");
return;
}
hostmap[suf] = [ nm,
length(trim(h.category ?? "")) ? trim(h.category) : "other",
num(h.strong, 0) != 0 ];
});
} catch (e) {
logmsg("warn", "hostmap: %s", e);
}
if (length(hostmap))
logmsg("info", "hostmap: %d user entr%s loaded%s",
length(hostmap), length(hostmap) == 1 ? "y" : "ies",
bad ? sprintf(", %d skipped", bad) : "");
cfg = {
socket_path: length(s.socket_path ?? "") ? s.socket_path : CFG_DEFAULTS.socket_path,
conntrack_path: length(s.conntrack_path ?? "") ? s.conntrack_path : CFG_DEFAULTS.conntrack_path,
flow_max: clampi(num(s.flow_max, CFG_DEFAULTS.flow_max), 64, 65536),
rate_window: clampi(num(s.rate_window, CFG_DEFAULTS.rate_window), 5, 600),
top_n: clampi(num(s.top_n, CFG_DEFAULTS.top_n), 1, 100),
idle_timeout: clampi(num(s.idle_timeout, CFG_DEFAULTS.idle_timeout), 15, 86400),
// Absent means ON. A dashboard feature that defaults to off is a
// feature nobody sees, and this one only ever relabels flows netifyd
// had no answer for.
ai_breakout: num(s.ai_breakout, CFG_DEFAULTS.ai_breakout) != 0,
hostmap: hostmap
};
cfg.rate_ms = cfg.rate_window * 1000;
cfg.idle_ms = cfg.idle_timeout * 1000;
}
/* ------------------------------------------------------------------ *
* Time-bucketed accumulators (§7.2)
*
* A ring of `n` slots, each tagged with its absolute slot number. Writes are
* O(1) and self-invalidating: a slot whose tag is not in the trailing window
* is simply not counted, so no eager rotation is needed for correctness. The
* periodic sweep below still clears stale slots so their bytes cannot be
* resurrected by a clock step and so status output stays cheap.
* ------------------------------------------------------------------ */
function ring_new(n) {
let r = { s: [], d: [], u: [] };
for (let i = 0; i < n; i++) {
push(r.s, -1);
push(r.d, 0);
push(r.u, 0);
}
return r;
}
function ring_add(r, slot, n, up, down) {
let i = slot % n;
if (r.s[i] != slot) {
r.s[i] = slot;
r.d[i] = 0;
r.u[i] = 0;
}
r.d[i] += down;
r.u[i] += up;
}
function ring_total(r, slot, n) {
let d = 0, u = 0;
for (let i = 0; i < n; i++) {
if (r.s[i] > (slot - n) && r.s[i] <= slot) {
d += r.d[i];
u += r.u[i];
}
}
return [ d, u ];
}
function ring_series(r, slot, n, slot_ms) {
let out = [];
for (let k = n - 1; k >= 0; k--) {
let s = slot - k, i = s % n, ok = (r.s[i] == s);
push(out, {
ts: s * slot_ms,
download: ok ? r.d[i] : 0,
upload: ok ? r.u[i] : 0
});
}
return out;
}
function ring_sweep(r, slot, n) {
for (let i = 0; i < n; i++)
if (r.s[i] >= 0 && (r.s[i] <= (slot - n) || r.s[i] > slot)) {
r.s[i] = -1;
r.d[i] = 0;
r.u[i] = 0;
}
}
function ring_clear(r, n) {
for (let i = 0; i < n; i++) {
r.s[i] = -1;
r.d[i] = 0;
r.u[i] = 0;
}
}
/* ------------------------------------------------------------------ *
* Aggregates
*
* Shape is {cur, buckets} per §7.2: `cur` is the session accumulator plus a
* two-bucket sliding window used for the live rate, `buckets` is the hour-long
* time series. History support (§7) extends `buckets`, nothing else.
* ------------------------------------------------------------------ */
function agg_new(kind, key, name, now) {
return {
kind, key, name,
cur: { up: 0, down: 0, flows: 0, first: now, last: 0 },
// Sliding rate window: bytes land in the open bucket; when it ages past
// rate_window it becomes the closed bucket and a fresh one opens. The
// reported rate spans closed+open, i.e. 1x-2x the window. Chosen over
// an EWMA because netifyd delivers flow_stats in ~15 s bursts: an EWMA
// tuned tight enough to be live would oscillate between spike and zero,
// while this integrates each burst over a known interval. Cost is O(1)
// memory and no per-second timer.
rate: { t0: now, up: 0, down: 0, pdt: 0, pup: 0, pdown: 0 },
buckets: ring_new(SERIES_SLOTS)
};
}
function agg_roll(a, now) {
let span = now - a.rate.t0;
if (span >= cfg.rate_ms) {
a.rate.pdt = span;
a.rate.pup = a.rate.up;
a.rate.pdown = a.rate.down;
a.rate.t0 = now;
a.rate.up = 0;
a.rate.down = 0;
}
}
// Pure: computes the rate WITHOUT rolling the window, so a read never mutates
// state. Two clients polling at different intervals (or a second browser tab)
// must see identical numbers for identical traffic; the window is advanced only
// by agg_bump and housekeeping. Rolls are applied virtually here.
function agg_rate(a, now) {
let span = now - a.rate.t0;
let pdt, pup, pdown, cspan, cup, cdown;
if (span >= cfg.rate_ms) { // window would have rolled
pdt = span; pup = a.rate.up; pdown = a.rate.down;
cspan = 0; cup = 0; cdown = 0;
} else {
pdt = a.rate.pdt; pup = a.rate.pup; pdown = a.rate.pdown;
cspan = span; cup = a.rate.up; cdown = a.rate.down;
}
// Floor the denominator at one window so a single burst cannot be reported
// as an impossibly high instantaneous rate.
let denom = pdt + cspan;
if (denom < cfg.rate_ms)
denom = cfg.rate_ms;
return [
int((pup + cup) * 1000 / denom),
int((pdown + cdown) * 1000 / denom)
];
}
function agg_bump(a, up, down, now, slot) {
agg_roll(a, now);
a.cur.up += up;
a.cur.down += down;
a.cur.last = now;
a.rate.up += up;
a.rate.down += down;
ring_add(a.buckets, slot, SERIES_SLOTS, up, down);
}
function agg_reset(a, now) {
a.cur.up = 0;
a.cur.down = 0;
a.cur.first = now;
a.cur.last = 0;
a.rate = { t0: now, up: 0, down: 0, pdt: 0, pup: 0, pdown: 0 };
ring_clear(a.buckets, SERIES_SLOTS);
}
// Evict the least recently active zero-flow aggregate when a class is full.
function agg_make(tbl, kind, key, name, now) {
let a = tbl[key];
if (a)
return a;
if (length(tbl) >= AGG_MAX) {
let victim = null, oldest = now + 1;
for (let k in tbl) {
let c = tbl[k];
if (c.cur.flows > 0)
continue;
if (c.cur.last < oldest) {
oldest = c.cur.last;
victim = k;
}
}
if (victim == null) {
// Every slot is backed by a live flow: refuse to grow (§3.3).
// Bytes still land in the totals, so the summary stays truthful.
// Counted, not just logged. logonce() fires ONCE for the life of
// the process, so without this an operator polling status sees
// `devices: 384` and no sign that anything is being dropped -- the
// table looks healthy precisely when it has stopped accepting the
// device the user is looking for. A security panel on 2026-08-25
// converged on this table being fillable by a LAN attacker
// (confirmed: six spoofed MACs produced six aggregates on
// hardware). Evicting live data is already refused above, which is
// the right call; what was missing was any way to SEE the refusal.
stats.aggs_refused++;
logonce("aggfull", "warn", "%s table full (%d), not tracking %s",
kind, AGG_MAX, key);
return null;
}
delete tbl[victim];
stats.aggs_pruned++;
}
a = agg_new(kind, key, name, now);
tbl[key] = a;
return a;
}
/* ------------------------------------------------------------------ *
* Per-application breakdowns (bounded): device totals and hostnames.
* ------------------------------------------------------------------ */
function detail_dev(a, fr, up, down, now, dslot) {
if (!a.devs)
a.devs = {};
let e = a.devs[fr.dev_key];
if (!e) {
if (length(a.devs) >= APP_DEV_MAX) {
// Seed from the first candidate, not from `now`: a backwards clock
// step (routine on an RTC-less router after its first NTP sync) can
// push every stored `last` past now, which would leave victim null
// and grow the map unbounded.
let victim = null, oldest = null;
for (let k in a.devs)
if (oldest == null || a.devs[k].last < oldest) {
oldest = a.devs[k].last;
victim = k;
}
if (victim == null)
return; // cannot make room; do not exceed the cap
delete a.devs[victim];
}
e = { mac: fr.dev_mac, ip: fr.dev_ip, last: now, ring: ring_new(DEVSER_SLOTS) };
a.devs[fr.dev_key] = e;
}
if (fr.dev_ip)
e.ip = fr.dev_ip;
e.last = now;
ring_add(e.ring, dslot, DEVSER_SLOTS, up, down);
}
function detail_host(a, fr, up, down, now) {
if (!fr.host)
return;
if (!a.hosts)
a.hosts = {};
let e = a.hosts[fr.host];
if (!e) {
if (length(a.hosts) >= APP_HOST_MAX) {
let victim = null, oldest = null;
for (let k in a.hosts)
if (oldest == null || a.hosts[k].last < oldest) {
oldest = a.hosts[k].last;
victim = k;
}
if (victim == null)
return;
delete a.hosts[victim];
}
// Same trailing-window ring as the device breakdown, so the two tables
// in the detail drawer report over the same interval and stay comparable.
e = { last: now, ring: ring_new(DEVSER_SLOTS) };
a.hosts[fr.host] = e;
}
e.last = now;
ring_add(e.ring, int(now / DEVSER_MS), DEVSER_SLOTS, up, down);
}
/* ------------------------------------------------------------------ *
* Persistence interface (§7.3)
*
* v1 ships this dormant on purpose. A history implementation (RAM-rotating
* file, collectd exec, sqlite on USB) plugs in here without touching the
* accounting path. Flash-wear rule: coarse buckets only, never per sample.
* ------------------------------------------------------------------ */
const store = {
flush: function(_aggregates, _slot) {
// no-op in v1
}
};
/* ------------------------------------------------------------------ *
* THE accounting choke point (§7.1)
*
* Every byte appflowd ever counts passes through here, exactly once.
* `d_local` / `d_other` are netifyd's per-update directional deltas; the flow
* record knows which of the two endpoints is the attributed device, so:
* upload = bytes sent BY the device (device side -> peer)
* download = bytes sent TO the device (peer -> device side)
* ------------------------------------------------------------------ */
function account(fr, d_local, d_other, ts) {
if (fr.shadow) { // deduplicated NAT twin (§3.2)
// Deliberately dropped, not lost. Counted so that
// bytes_reported == bytes_attributed + bytes_shadowed holds, and any
// residual is a real leak rather than the dedup doing its job.
let s = d_local + d_other;
if (s > 0)
stats.bytes_shadowed += s;
return;
}
let up = fr.dev_local ? d_local : d_other;
let down = fr.dev_local ? d_other : d_local;
if (up < 0) up = 0;
if (down < 0) down = 0;
stats.bytes_attributed += up + down;
fr.up += up;
fr.down += down;
fr.seen = ts;
if (up == 0 && down == 0)
return;
let slot = int(ts / SERIES_MS);
let dslot = int(ts / DEVSER_MS);
agg_bump(totals, up, down, ts, slot);
if (fr.a) {
agg_bump(fr.a, up, down, ts, slot);
detail_dev(fr.a, fr, up, down, ts, dslot);
detail_host(fr.a, fr, up, down, ts);
}
if (fr.d)
agg_bump(fr.d, up, down, ts, slot);
if (fr.c)
agg_bump(fr.c, up, down, ts, slot);
// History (§7) taps in exactly here: the buckets these bumps fill are what
// store.flush() drains once a persistent implementation exists.
}
/* ------------------------------------------------------------------ *
* Naming (§2.3)
* ------------------------------------------------------------------ */
// Post-capitalization fixes for tokens where naive Title Case reads wrong.
const BRAND_CAPS = {
"Openwrt": "OpenWrt", "Dns": "DNS", "Http": "HTTP", "Https": "HTTPS",
"Ssl": "SSL", "Tls": "TLS", "Ssh": "SSH", "Ntp": "NTP", "Vpn": "VPN",
"Cdn": "CDN", "Voip": "VoIP", "Tv": "TV", "Api": "API", "Ip": "IP",
"Iot": "IoT", "Ai": "AI", "Ads": "Ads",
// Brands whose own capitalization is word-internal, which single-letter
// Title Case cannot produce.
"Youtube": "YouTube", "Github": "GitHub", "Gitlab": "GitLab",
"Whatsapp": "WhatsApp", "Tiktok": "TikTok", "Paypal": "PayPal",
"Icloud": "iCloud", "Iphone": "iPhone", "Deepl": "DeepL",
"Openai": "OpenAI", "Openvpn": "OpenVPN", "Wireguard": "WireGuard",
"Bittorrent": "BitTorrent", "Speedtest": "Speedtest", "Steamos": "SteamOS"
};
function title_case(s) {
let out = [];
for (let w in split(s, " ")) {
let t = length(w) ? (uc(substr(w, 0, 1)) + substr(w, 1)) : w;
push(out, BRAND_CAPS[t] ?? t);
}
return join(" ", out);
}
// Netify application tags look like "netify.google-ads"; nDPI protocol tags
// are already display-styled ("HTTP/S", "ZOOM", "WireGuard") and are kept
// verbatim.
function display_name(tag) {
if (!length(tag ?? ""))
return null;
if (substr(tag, 0, 7) != "netify.")
return tag;
let slug = substr(tag, 7);
slug = replace(slug, /[-_.]/g, " ");
return title_case(slug);
}
function load_categories() {
let raw;
try {
raw = fs.readfile(CATEGORY_FILE);
} catch (e) {
raw = null;
}
if (!raw) {
logonce("cats", "warn", "category names unavailable: %s", CATEGORY_FILE);
return;
}
let obj;
try {
obj = json(raw);
} catch (e) {
logmsg("warn", "%s: parse error: %s", CATEGORY_FILE, e);
return;
}
// The file maps tag -> id; we need id -> tag. The far larger
// application_index / protocol_index member lists are deliberately not
// retained: flow events carry the category id directly.
for (let t in obj.application_tag_index ?? {})
catnames.app[obj.application_tag_index[t]] = t;
for (let t in obj.protocol_tag_index ?? {})
catnames.proto[obj.protocol_tag_index[t]] = t;
}
function load_leases(now) {
if (leases.at && (now - leases.at) < LEASE_TTL_MS)
return;
leases.at = now;
let st;
try {
st = fs.stat(LEASE_FILE);
} catch (e) {
st = null;
}
if (!st) {
leases.mtime = -1;
return;
}
if (st.mtime == leases.mtime)
return;
leases.mtime = st.mtime;
let raw;
try {
raw = fs.readfile(LEASE_FILE);
} catch (e) {
return;
}
if (!raw)
return;
let by_mac = {}, by_ip = {};
for (let line in split(raw, "\n")) {
let f = split(trim(line), /[ \t]+/);
// <expiry> <mac> <ip> <hostname> <clientid>
if (length(f) < 4 || f[3] == "*")
continue;
by_mac[lc(f[1])] = f[3];
by_ip[f[2]] = f[3];
}
leases.by_mac = by_mac;
leases.by_ip = by_ip;
}
function device_name(key, mac, ip, now) {
if (key == "router")
return "Router";
if (key == "unknown")
return "Unknown";
if (key == "multicast")
return "Multicast / Broadcast";
load_leases(now);
return leases.by_mac[mac ?? ""] ?? leases.by_ip[ip ?? ""] ?? (mac ?? ip ?? key);
}
/* ------------------------------------------------------------------ *
* Router identity — needed to tell "this box" from a client (§3.2).
* ------------------------------------------------------------------ */
// Rebuild the router-identity maps wholesale from an interfaces block. An
// agent_status frame that carries `interfaces` is complete and authoritative,
// so replacing rather than merging keeps these maps bounded (IPv6 privacy
// addresses and WAN re-leases would otherwise accumulate stale entries for the
// daemon's whole lifetime). The reduced agent_status frame omits `interfaces`
// entirely — guarded by the caller — and must not wipe what we know.
function router_learn(ifaces) {
if (type(ifaces) != "object")
return;
let macs = {}, ips = {};
for (let ifname in ifaces) {
let i = ifaces[ifname];
if (length(i?.mac ?? ""))
macs[lc(i.mac)] = ifname;
for (let a in i?.addr ?? [])
ips[a] = ifname;
}
router.macs = macs;
router.ips = ips;
}
// netifyd publishes the same interface block in its status file; read it once
// at start-up so attribution is correct before the first agent_status arrives.
function router_bootstrap() {
let raw;
try {
raw = fs.readfile(STATUS_FILE);
} catch (e) {
return;
}
if (!raw)
return;
try {
router_learn(json(raw).interfaces);
} catch (e) {
// status.json is rewritten in place by netifyd; a torn read is normal
}
}
function is_router(mac, ip) {
return (length(mac ?? "") && router.macs[mac] != null) ||
(length(ip ?? "") && router.ips[ip] != null);
}
// IEEE 802 group addresses: IPv4 multicast (01:00:5e:*), IPv6 multicast
// (33:33:*) and broadcast. These are not devices — attributing them per
// address would let any LAN host mint unbounded device aggregates by looping
// over multicast groups, so they collapse into one pseudo-device.
function is_group_mac(mac) {
if (!length(mac ?? ""))
return false;
return substr(mac, 0, 8) == "01:00:5e" ||
substr(mac, 0, 5) == "33:33" ||
mac == "ff:ff:ff:ff:ff:ff";
}
/* ------------------------------------------------------------------ *
* Flow table (§3.2, §3.3)
* ------------------------------------------------------------------ */
function flow_detach(fr) {
if (fr.shadow)
return;
if (fr.a && fr.a.cur.flows > 0) fr.a.cur.flows--;
if (fr.d && fr.d.cur.flows > 0) fr.d.cur.flows--;
if (fr.c && fr.c.cur.flows > 0) fr.c.cur.flows--;
}
function flow_attach(fr) {
if (fr.shadow)
return;
if (fr.a) fr.a.cur.flows++;
if (fr.d) fr.d.cur.flows++;
if (fr.c) fr.c.cur.flows++;
}
function flow_drop(digest) {
let fr = flows[digest];
if (!fr)
return;
flow_detach(fr);
delete flows[digest];
nflows--;
}
// Identify (or re-identify, on a detection update) an existing flow record.
/* ------------------------------------------------------------------ *
* NAT twin resolution (3.2)
* ------------------------------------------------------------------ */
// On the EXTERNAL (WAN) capture of a NAT-ed flow, netifyd 4.4.7 reports the
// POST-translation identity: local_ip and local_mac are this router's own WAN
// address and MAC. That is indistinguishable from a flow the router itself
// originated, so the capture cannot be classified from the event alone.
//
// netifyd's own ip_nat field is exactly the question being asked, and it is
// carried on the record as fr.nat, but 4.4.7 does not set it on this path:
// measured 0 true out of 24 external captures of genuinely NAT-ed client
// flows, and 0 out of 24 on the matching internal captures. It is therefore
// read but never trusted as evidence.
//
// conntrack does know, because it holds both halves of the translation. For a
// masqueraded flow the ORIGINAL source is the LAN client while the REPLY
// destination is this router's WAN address; for router-originated traffic the
// two are the same address:
//
// src=192.168.1.50 dst=P ... src=P dst=192.168.72.10 <- client, NAT-ed
// src=192.168.72.10 dst=Q ... src=Q dst=192.168.72.10 <- router's own
//
// Reading a snapshot is not free, so it is reused for CT_TTL_MS: a burst of new
// flows shares one read, and an idle router never reads at all, because loading
// is demand-driven from ct_nat_twin() below.
function ct_load(now, force) {
if (conntrack.avail == false && (now - conntrack.probed) < CT_RETRY_MS)
return;
// A forced reload comes from a lookup miss, which is the ordinary case for a
// flow younger than the current snapshot. Honour it, but never more often
// than CT_FORCE_MIN_MS, so a burst of new flows cannot turn into a burst of
// reads of a file that is large on a busy router.
if (force) {
if ((now - conntrack.at) < CT_FORCE_MIN_MS)
return;
} else if (conntrack.at && (now - conntrack.at) < CT_TTL_MS) {
// Throttle on "we read recently", NOT on "we read recently and got
// entries". Gating this on conntrack.entries meant a snapshot that
// parsed to zero rows while the file was readable disabled the throttle
// entirely, and the unforced path has no CT_FORCE_MIN_MS floor, so every
// external flow identification re-read the whole file.
return;
}
conntrack.at = now;
conntrack.probed = now;
// Read INCREMENTALLY, one line at a time. This used to be fs.readfile()
// followed by split(raw, "\n"), which materialised the entire conntrack
// table and then the entire line array before CT_MAX was ever consulted --
// so CT_MAX bounded the retained map but not peak allocation, and the
// comment on the constant calling it a memory bound was wrong. On a busy
// router that is megabytes of transient garbage per read, on a device that
// may have 64 MB. Reading a line at a time makes the peak one line, and
// the CT_MAX break now stops the work as well as the retention.
let fd;
try {
fd = fs.open(cfg.conntrack_path ?? CONNTRACK_FILE, "r");
} catch (e) {
fd = null;
}
if (!fd) {
if (conntrack.avail != false) {
conntrack.avail = false;
conntrack.map = {};
conntrack.entries = 0;
logonce("ct-missing", "warn",
"%s unreadable: cannot distinguish a NAT twin from router-originated traffic, so external captures are shadowed while an internal capture runs. Client accounting stays correct; the router's own traffic is not attributed.",
cfg.conntrack_path ?? CONNTRACK_FILE);
}
return;
}
conntrack.avail = true;
let map = {}, n = 0;
while (true) {
if (n >= CT_MAX)
break;
let line = fd.read("line");
if (line == null || !length(line))
break;
// Each line carries two tuples in order, original then reply. Collect the
// fields positionally rather than matching the whole line: the optional
// state word (TIME_WAIT, [UNREPLIED], ...) shifts the columns and differs
// between protocols.
let src = [], dst = [], sport = [], dport = [];
for (let tok in split(line, " ")) {
if (substr(tok, 0, 4) == "src=") push(src, substr(tok, 4));
else if (substr(tok, 0, 4) == "dst=") push(dst, substr(tok, 4));
else if (substr(tok, 0, 6) == "sport=") push(sport, substr(tok, 6));
else if (substr(tok, 0, 6) == "dport=") push(dport, substr(tok, 6));
}
if (length(src) < 2 || length(dst) < 2 || length(sport) < 2 || length(dport) < 2)
continue;
// Key on the reply tuple; value is whether the source was translated.
map[src[1] + "|" + dst[1] + "|" + sport[1] + "|" + dport[1]] = (src[0] != dst[1]);
n++;
}
try { fd.close(); } catch (e) {}
conntrack.map = map;
conntrack.entries = n;
}
// true = this external capture is the WAN-side twin of a NAT-ed LAN flow
// false = conntrack positively says the router originated it
// null = conntrack cannot say (absent, unreadable, or the entry has expired)
function ct_nat_twin(f, now, fr) {
// THE PORTS COME FROM THE FLOW RECORD, NOT THE EVENT, AND THAT IS THE WHOLE
// FIX. netifyd puts local_port/other_port on the initial `flow` event and
// on nothing afterwards: a `flow_stats` payload carries addresses and byte
// counters only. This key was built straight off the event, so every call
// made from an update produced "...|93.184.216.34|192.168.72.10||" with two
// empty fields, matched nothing, and returned null.
//
// Nothing detected that. reshadow() treats null as grounds to discard, so
// EVERY router-originated flow was shadowed on its first stats update, and
// the "Router" device counted zero bytes on every install since 1.0.x. The
// dashboard then correctly hides a zero-byte device, so the feature was
// absent rather than visibly broken. Production had logged 107,476
// positive identifications against a row reading zero for 2.6 days.
//
// Found 2026-08-31 by instrumenting the daemon after the row was noticed
// missing on the bench. See tests/protocol-suite.sh `router`.
let lp = (fr != null && fr.local_port != null) ? fr.local_port : f.local_port;
let op = (fr != null && fr.other_port != null) ? fr.other_port : f.other_port;
let key = (f.other_ip ?? "") + "|" + (f.local_ip ?? "") + "|" +
(op ?? "") + "|" + (lp ?? "");
ct_load(now, false);
let v = conntrack.entries ? conntrack.map[key] : null;
// A miss usually means the flow is newer than the snapshot, because netifyd
// identifies a flow within moments of it starting. Refresh once and retry
// before giving up: without this the common case resolves to "unknown" and
// every router-originated flow is shadowed, which silently degrades to the
// no-conntrack behaviour even though conntrack is available.
if (v == null && conntrack.avail != false) {
ct_load(now, true);
v = conntrack.entries ? conntrack.map[key] : null;
}
return (v == null) ? null : v;
}
/* ------------------------------------------------------------------ *
* AI service breakout (§10)
*
* WHY THIS EXISTS, measured rather than assumed.
*
* netifyd cannot classify AI traffic and will not soon. Its signature set,
* /etc/netify.d/netify-apps.conf, is dated 10 August 2023 on both routers
* checked, carries 199 application signatures, and contains ZERO entries for
* anthropic, claude, openai, chatgpt, huggingface, perplexity, mistral,
* copilot, bard or gemini. So this traffic arrives with detected_application 0
* and lands in generic HTTP/S alongside everything else.
*
* But the identifying data is already here. netifyd reports the TLS SNI as
* host_server_name, and flows to the real endpoints carry api.anthropic.com,
* claude.ai and api.openai.com with no code change on our side. The daemon is
* therefore the right layer: aggregation happens here, and the browser only
* ever sees summarised rows.
*
* THIS IS HOSTNAME MATCHING, NOT DPI, AND MUST BE DESCRIBED THAT WAY. It
* asserts a classification netifyd did not make. The technique is legitimate
* -- matching the SNI is the same mechanism netifyd itself uses for HTTP/S --
* but the honest framing is "we recognise the name", not "we inspected the
* protocol".
*
* Three limitations, stated here so they are not discovered later:
*
* - The list rots. New services appear constantly and nothing here updates
* itself. `ai_breakout '0'` turns the whole thing off.
* - ECH (Encrypted Client Hello) encrypts the SNI and would end this
* outright. netifyd has the identical exposure, so this is not worse than
* the status quo, but it is not durable either.
* - Region-encoded hostnames under a generic cloud domain cannot be matched
* by suffix without false positives. bedrock-runtime.us-east-1.amazonaws.com
* is the obvious example and is deliberately absent: no suffix of it is
* specific to Bedrock, and matching amazonaws.com would label every S3
* bucket on the network as AI.
*
* ACCOUNTING IS NOT AFFECTED. This changes an identity, never a byte count.
* account() never sees it, so byte conservation is exactly as it was and the
* existing conservation checks still hold.
*/
/* Keyed on a domain suffix, matched against the last two, three or four labels
* of the SNI. Value is [ app label, category, strong ].
*
* THE CATEGORY IS A SLUG, NOT A DISPLAY STRING, and it must stay one. Until
* 1.1.1 this column held "AI assistants" and its three siblings, which read
* correctly in English and could therefore never be translated: the browser
* renders a category by looking the slug up in a table of `_()` literals, and
* a string that arrives already rendered has no key to look up. Every netifyd
* category reaches the frontend as a lowercase slug; these four were the only
* ones that did not, and they were ours. See catLabel() in common.js, which
* carries the matching `_('AI assistants')`.
*
* `strong` means: override even when netifyd DID identify an application. It
* is set only where netifyd is known to recognise the PARENT brand and would
* otherwise swallow the AI service inside it -- Gemini reported as "Google",
* Copilot as "GitHub". Everywhere else the rule is conservative: netifyd's own
* answer wins, so the day Netify ships AI signatures this table goes quiet by
* itself instead of permanently shadowing better data.
*/
const AI_HOSTS = {
// ---- assistants and model vendors --------------------------------
"anthropic.com": [ "Anthropic (Claude)", "ai-assistants", false ],
"claude.ai": [ "Anthropic (Claude)", "ai-assistants", false ],
"openai.com": [ "OpenAI", "ai-assistants", false ],
"chatgpt.com": [ "OpenAI (ChatGPT)", "ai-assistants", false ],
"oaistatic.com": [ "OpenAI", "ai-assistants", false ],
"oaiusercontent.com": [ "OpenAI", "ai-assistants", false ],
"gemini.google.com": [ "Google Gemini", "ai-assistants", true ],
"aistudio.google.com": [ "Google Gemini", "ai-assistants", true ],
"generativelanguage.googleapis.com":
[ "Google Gemini", "ai-assistants", true ],
"x.ai": [ "xAI (Grok)", "ai-assistants", false ],
"grok.com": [ "xAI (Grok)", "ai-assistants", false ],
"mistral.ai": [ "Mistral", "ai-assistants", false ],
"cohere.com": [ "Cohere", "ai-assistants", false ],
"cohere.ai": [ "Cohere", "ai-assistants", false ],
"deepseek.com": [ "DeepSeek", "ai-assistants", false ],
"perplexity.ai": [ "Perplexity", "ai-assistants", false ],
"moonshot.cn": [ "Moonshot (Kimi)", "ai-assistants", false ],
"bigmodel.cn": [ "Zhipu", "ai-assistants", false ],
"dashscope.aliyuncs.com":
[ "Alibaba Qwen", "ai-assistants", true ],
"ai21.com": [ "AI21", "ai-assistants", false ],
"meta.ai": [ "Meta AI", "ai-assistants", false ],
"reka.ai": [ "Reka", "ai-assistants", false ],
"character.ai": [ "Character.AI", "ai-assistants", false ],
"poe.com": [ "Poe", "ai-assistants", false ],
"phind.com": [ "Phind", "ai-assistants", false ],
// ---- media generation --------------------------------------------
"midjourney.com": [ "Midjourney", "ai-media", false ],
"stability.ai": [ "Stability AI", "ai-media", false ],
"runwayml.com": [ "Runway", "ai-media", false ],
"elevenlabs.io": [ "ElevenLabs", "ai-media", false ],
"suno.com": [ "Suno", "ai-media", false ],
"suno.ai": [ "Suno", "ai-media", false ],
"udio.com": [ "Udio", "ai-media", false ],
"lumalabs.ai": [ "Luma", "ai-media", false ],
"bfl.ai": [ "Black Forest Labs", "ai-media", false ],
"ideogram.ai": [ "Ideogram", "ai-media", false ],
"leonardo.ai": [ "Leonardo", "ai-media", false ],
"pika.art": [ "Pika", "ai-media", false ],
"heygen.com": [ "HeyGen", "ai-media", false ],
"synthesia.io": [ "Synthesia", "ai-media", false ],
"descript.com": [ "Descript", "ai-media", false ],
// ---- coding assistants and dev tools -----------------------------
"githubcopilot.com": [ "GitHub Copilot", "ai-developer", true ],
"copilot.microsoft.com":[ "Microsoft Copilot", "ai-developer", true ],
"cursor.sh": [ "Cursor", "ai-developer", false ],
"cursor.com": [ "Cursor", "ai-developer", false ],
"codeium.com": [ "Codeium", "ai-developer", false ],
"windsurf.com": [ "Windsurf", "ai-developer", false ],
"tabnine.com": [ "Tabnine", "ai-developer", false ],
"sourcegraph.com": [ "Sourcegraph Cody", "ai-developer", false ],
"v0.dev": [ "v0", "ai-developer", false ],
"bolt.new": [ "Bolt", "ai-developer", false ],
"lovable.dev": [ "Lovable", "ai-developer", false ],
"replit.com": [ "Replit", "ai-developer", false ],
// ---- aggregators, inference APIs, GPU rental, AI infrastructure ---
"openrouter.ai": [ "OpenRouter", "ai-infrastructure", false ],
"together.ai": [ "Together AI", "ai-infrastructure", false ],
"together.xyz": [ "Together AI", "ai-infrastructure", false ],
"replicate.com": [ "Replicate", "ai-infrastructure", false ],
"replicate.delivery": [ "Replicate", "ai-infrastructure", false ],
"fireworks.ai": [ "Fireworks AI", "ai-infrastructure", false ],
"groq.com": [ "Groq", "ai-infrastructure", false ],
"deepinfra.com": [ "DeepInfra", "ai-infrastructure", false ],
"novita.ai": [ "Novita", "ai-infrastructure", false ],
"hyperbolic.xyz": [ "Hyperbolic", "ai-infrastructure", false ],
"anyscale.com": [ "Anyscale", "ai-infrastructure", false ],
"sambanova.ai": [ "SambaNova", "ai-infrastructure", false ],
"cerebras.ai": [ "Cerebras", "ai-infrastructure", false ],
"huggingface.co": [ "Hugging Face", "ai-infrastructure", false ],
"hf.co": [ "Hugging Face", "ai-infrastructure", false ],
"vast.ai": [ "Vast.ai", "ai-infrastructure", false ],
"runpod.io": [ "RunPod", "ai-infrastructure", false ],
"runpod.ai": [ "RunPod", "ai-infrastructure", false ],
"lambdalabs.com": [ "Lambda", "ai-infrastructure", false ],
"paperspace.com": [ "Paperspace", "ai-infrastructure", false ],
"coreweave.com": [ "CoreWeave", "ai-infrastructure", false ],
"modal.com": [ "Modal", "ai-infrastructure", false ],
"baseten.co": [ "Baseten", "ai-infrastructure", false ],
"beam.cloud": [ "Beam", "ai-infrastructure", false ],
"salad.com": [ "Salad", "ai-infrastructure", false ],
"tensordock.com": [ "TensorDock", "ai-infrastructure", false ],
"fly.io": [ "Fly.io", "ai-infrastructure", false ],
"wandb.ai": [ "Weights & Biases", "ai-infrastructure", false ],
"langchain.com": [ "LangChain", "ai-infrastructure", false ],
"pinecone.io": [ "Pinecone", "ai-infrastructure", false ],
"weaviate.io": [ "Weaviate", "ai-infrastructure", false ],
"qdrant.tech": [ "Qdrant", "ai-infrastructure", false ],
"trychroma.com": [ "Chroma", "ai-infrastructure", false ]
};
/* Match the SNI against AI_HOSTS by domain suffix.
*
* SUFFIX, ANCHORED ON A LABEL BOUNDARY -- never a substring test. A substring
* match on "anthropic.com" also matches "anthropic.com.attacker.example", which
* is a hostname anyone can register and point at anything. Splitting on labels
* and rebuilding the last N makes that impossible by construction rather than
* by care.
*
* Tries the last two labels first, then three, then four, so a specific entry
* (gemini.google.com) is reachable without a linear scan of the table and a
* generic parent cannot shadow it.
*/
function ai_match(host) {
let h = lc(trim(host ?? ""));
if (!length(h))
return null;
/* A trailing dot is legal in a FQDN and would otherwise produce an empty
* final label, shifting every suffix by one and matching nothing. */
if (substr(h, -1) == ".")
h = substr(h, 0, length(h) - 1);
let parts = split(h, ".");
let n = length(parts);
if (n < 2)
return null;
for (let take = 2; take <= 4 && take <= n; take++) {
let suffix = join(".", slice(parts, n - take));
/* User entries first, so `config hostmap` can override a built-in that
* is wrong as well as add one that is missing. */
let hit = (cfg?.hostmap ?? {})[suffix] ?? AI_HOSTS[suffix];
/* Type-checked rather than merely truthy.
*
* In a BROWSER this would be load-bearing: `{}["constructor"]` returns
* a function, so a hostname of "constructor" would read as a hit. In
* ucode it is not, because ucode objects have no prototype chain and
* that lookup is null. Verified by sabotage: replacing this with a
* truthiness test changes no test result.
*
* It stays because what it actually guards is a malformed entry in the
* table above -- a two-element row, or a string where an array belongs
* -- which would otherwise be indexed as if it were well formed and
* produce a null label on a live router. It is NOT a defence against a
* prototype-key hostname, and should not be described as one. */
if (type(hit) == "array" && length(hit) == 3)
return { app: hit[0], cat: hit[1], strong: hit[2], via: suffix };
}
return null;
}
function flow_identify(fr, ev, f, now) {
let app_id = num(f.detected_application, 0);
let proto_id = num(f.detected_protocol, 0);
// Cap any tag taken straight off the wire (detected_*_name) so a single
// flow record cannot hold an unbounded string.
let cap = (s) => (length(s ?? "") > TAG_MAX) ? substr(s, 0, TAG_MAX) : s;
let key, label;
if (app_id > 0) {
key = "a" + app_id;
fr.tag = cap(f.detected_application_name ?? names.app[app_id]);
label = display_name(fr.tag);
} else if (proto_id > 0) {
key = "p" + proto_id;
fr.tag = cap(f.detected_protocol_name ?? names.proto[proto_id]);
label = display_name(fr.tag);
} else {
key = "unknown";
label = "Unknown";
fr.tag = "unknown";
}
if (!length(label ?? "")) {
label = display_name(f.detected_protocol_name ?? names.proto[proto_id]) ?? "Unknown";
if (!length(fr.tag ?? ""))
fr.tag = "unknown";
}
let cat = catnames.app[num(f.category?.application, -1)] ??
catnames.proto[num(f.category?.protocol, -1)] ?? "unclassified";
if (length(f.host_server_name ?? ""))
fr.host = substr(f.host_server_name, 0, 63);
// AI service breakout (10). Runs AFTER the netifyd identity is resolved and
// after `cat`, because it replaces both, and BEFORE attribution, because
// nothing below this point depends on the app identity.
//
// The gate is deliberately conservative: netifyd's own answer wins unless
// the entry is marked `strong`, which is set only where netifyd is known to
// recognise the parent brand and would swallow the AI service inside it.
// So the day Netify ships AI signatures, this table falls silent by itself
// rather than permanently shadowing better data.
//
// Identity only. No byte count is touched here, account() never sees this,
// and conservation is unaffected.
if (cfg.ai_breakout && length(fr.host ?? "")) {
let ai = ai_match(fr.host);
if (ai && (app_id == 0 || ai.strong)) {
// Key on the LABEL, not on the matched suffix: anthropic.com and
// claude.ai are one vendor and must aggregate into one row, not two
// identically-named ones.
let slug = lc(replace(ai.app, /[^a-zA-Z0-9]+/g, "-"));
slug = replace(slug, /^-+/, "");
slug = replace(slug, /-+$/, "");
key = "ai:" + slug;
label = ai.app;
// The icon pack is keyed on this tag. An `appflow.` prefix keeps the
// namespace honest: nothing here pretends to have come from netifyd.
fr.tag = "appflow." + slug;
cat = ai.cat;
stats.ai_labeled++;
}
}
// Attribution (§3.2). netifyd's "local"/"other" labels follow its own
// topology view, not ours, so decide which endpoint is *our* device:
// - on an internal (LAN bridge) capture the device is the non-router side;
// - on an external (WAN) capture our own box is the device and the peer is
// upstream infrastructure, so the device is the router-owned side.
// Ports only ever arrive on the initial `flow` event. Keep them on the
// record so conntrack can still be asked about this flow later; see
// ct_nat_twin().
if (f.local_port != null) fr.local_port = f.local_port;
if (f.other_port != null) fr.other_port = f.other_port;
let lm = lc(f.local_mac ?? ""), om = lc(f.other_mac ?? "");
let rl = is_router(lm, f.local_ip), ro = is_router(om, f.other_ip);
let dev_local = ev.internal ? !(rl && !ro) : !(ro && !rl);
let dev_is_router = dev_local ? rl : ro;
let mac = dev_local ? lm : om;
let ip = dev_local ? f.local_ip : f.other_ip;
// Honour netifyd's other_type when it names a group endpoint. netifyd's
// enum documents "broadcast"/"multicast", though only "local"/"remote" were
// seen on this bench, so the group-bit MAC test below is the path that
// actually fires today; the other_type check is a forward-compatible
// preference that costs nothing and needs no re-verification to be safe (it
// only ever adds a positive on a documented value).
let ot = f.other_type;
let group = (ot == "multicast" || ot == "broadcast") ||
is_group_mac(mac) || is_group_mac(dev_local ? om : lm);
let dkey = group ? "multicast"
: (dev_is_router ? "router" : (length(mac) ? mac : (ip ?? "unknown")));
// Single-count each flow. The SHIPPING rule is documented in DESIGN §3.2
// (which this implements; the earlier ip_nat-pairing design was replaced):
// with netifyd watching the LAN bridge (internal) and WAN (external) at once,
// a client's traffic is captured twice. Rather than pair the two captures by
// a fragile remote-endpoint key, count deterministically -- an internal
// capture is always the device's own copy, while an external capture is
// counted only when the device is the router itself (router-originated
// traffic never crosses the bridge); an external client-flow capture is
// therefore a NAT twin and is shadowed.
//
// NOT order-independent in the strict sense: saw_internal is a process-wide
// capability latch ("is any internal capture running"), not per-flow evidence
// of a specific twin. A WAN-side flow identified BEFORE the first internal
// event is counted; the same flow after is shadowed. Left there, a
// pre-existing external stub identified while saw_internal was still false
// would stay counted alongside its internal twin for its whole life (a real
// double-count of the central total, not a brief startup blip) -- reshadow()
// re-applies this rule on later stats updates to close that window. The
// per-flow twin question, and the exact contents of local_mac/other_mac when
// ip_nat is set, remain the open bench-test item in DESIGN §3.2 / §8.
if (fr.internal)
saw_internal = true;
// Single-count decision for an EXTERNAL capture, once an internal capture is
// known to be running. Two cases, and they are not symmetric:
//
// device is not the router -> a LAN client seen on the WAN side, which only
// happens as the twin of its own internal
// capture. Shadow it.
// device IS the router -> ambiguous by construction, because netifyd
// reports the post-NAT identity. Ask conntrack.
//
// When conntrack cannot answer, shadow. The two possible errors are not
// equal: counting a twin double-counts a client's entire traffic, measured at
// 163-199% of wire truth, while shadowing router-originated traffic omits the
// router's own DNS, NTP and updates. Default to the smaller error.
let want_shadow = false;
if (saw_internal && !fr.internal && !group) {
if (!dev_is_router)
want_shadow = true;
else {
let twin = ct_nat_twin(f, now, fr);
want_shadow = (twin != false);
if (twin == false)
fr.ct_local = true; // a positive finding, remembered
if (twin == null)
stats.ct_unresolved++;
else if (twin)
stats.ct_twins++;
else
stats.ct_local++;
}
}
if (fr.app_key == key && fr.cat_key == cat && fr.dev_key == dkey &&
fr.shadow == want_shadow) {
if (!fr.dev_ip)
fr.dev_ip = ip;
return; // nothing structural changed
}
// Re-pointing an already-counted flow: bytes already attributed stay where
// they were (the accounting seam is append-only by design); only future
// bytes and the active-flow counts follow the new identity.
flow_detach(fr); // uses the OLD shadow state
if (want_shadow && !fr.shadow)
stats.shadowed++;
fr.shadow = want_shadow;
if (fr.shadow) { // a shadowed twin tracks nothing
fr.app_key = key; fr.cat_key = cat; fr.dev_key = dkey;
fr.dev_mac = length(mac) ? mac : null; fr.dev_ip = ip;
fr.dev_local = dev_local;
fr.a = null; fr.d = null; fr.c = null;
return;
}
fr.app_key = key;
fr.app_name = label ?? "Unknown";
fr.cat_key = cat;
fr.dev_key = dkey;
fr.dev_mac = length(mac) ? mac : null;
fr.dev_ip = ip;
fr.dev_local = dev_local;
fr.a = agg_make(apps, "app", key, fr.app_name, now);
fr.d = agg_make(devs, "device", dkey, device_name(dkey, fr.dev_mac, ip, now), now);
fr.c = agg_make(cats, "category", cat, cat, now);
if (fr.d) {
fr.d.mac = fr.dev_mac;
// Keep the friendliest address for display: an interface usually has
// both, and an IPv4 address means more to a user than a link-local v6.
if (ip && (!fr.d.ip || (index(ip, ".") >= 0 && index(fr.d.ip, ".") < 0)))
fr.d.ip = ip;
fr.d.router = (dkey == "router");
}
if (fr.a) {
fr.a.tag = fr.tag;
fr.a.cat = cat;
}
flow_attach(fr);
}
// Re-apply the single-count rule to a flow whose shadow decision was frozen at
// creation. flow_identify runs once per flow (at flow_new) and again only on a
// `flow` event; a pre-existing external stub, created from flow_stats before any
// internal event, is therefore identified with saw_internal still false, counted,
// and never revisited -- so it double-counts against its internal twin for its
// whole life. Once saw_internal latches, such a flow is an external non-router
// capture like any other and the shipping rule already says to shadow it; this
// just enforces that rule for flows that predated the latch, closing the window
// from "entire flow life" to "at most one stats interval". It only ever tightens
// (unshadowed -> shadowed), never resurrects. The residual sign question for an
// unidentifiable stub (a genuine NAT twin vs. pre-existing router-origin traffic,
// both landing in the "unknown" bucket) is the same §3.2 bench-test item.
function reshadow(fr, f, now) {
if (fr.shadow || !saw_internal || fr.internal)
return;
if (fr.dev_key == "multicast")
return;
if (fr.dev_key == "router") {
// A WAN-side capture of a NAT-ed client flow is classified "router",
// because netifyd reports the post-translation identity (§3.2). That is
// precisely the class this function exists to correct, so the blanket
// `return` that stood here until 2026-08-24 exempted the only flows that
// needed it: after a restart, an established flow's external copy stayed
// counted alongside its internal twin for the flow's whole remaining
// life, and stats.shadowed never moved because there was no transition.
// Ask conntrack, exactly as flow_identify does, and default the same way.
let twin = ct_nat_twin(f, now, fr);
if (twin == false) {
stats.ct_local++;
return; // conntrack says the router originated it
}
if (twin == null) {
stats.ct_unresolved++;
// ABSENT EVIDENCE IS NOT POSITIVE EVIDENCE, the same rule
// statusStrip() carries a comment about. If conntrack already told
// us this flow was router-originated, a later lookup that cannot
// answer -- because the connection closed and the entry aged out --
// is not a reason to throw the flow away. Only a positive "this is
// a NAT twin" below may overturn it.
if (fr.ct_local)
return;
} else {
stats.ct_twins++;
}
}
flow_detach(fr);
fr.shadow = true;
fr.a = null; fr.d = null; fr.c = null;
stats.shadowed++;
}
function flow_new(digest, ev, f, now, late) {
if (nflows >= cfg.flow_max) {
prune(now, true);
if (nflows >= cfg.flow_max) {
stats.flows_dropped++;
return null;
}
}
let fr = {
digest,
iface: ev.interface ?? "?",
internal: (ev.internal == true),
nat: (f.ip_nat == true),
proto: num(f.ip_protocol, 0),
host: null, tag: null,
app_key: null, app_name: "Unknown", cat_key: "unclassified",
dev_key: "unknown", dev_mac: null, dev_ip: null, dev_local: true,
a: null, d: null, c: null,
up: 0, down: 0, total: 0,
start: num(f.first_seen_at, now), seen: now,
shadow: false, late: (late == true)
};
flows[digest] = fr;
nflows++;
stats.flows_seen++;
if (late)
stats.flows_late++;
// Attribution and single-count shadowing are both decided in flow_identify,
// deterministically from this flow alone (no cross-flow pairing state).
flow_identify(fr, ev, f, now);
return fr;
}
/* ------------------------------------------------------------------ *
* Event handlers. Dispatch is table-driven: adding or renaming an event
* type is a one-line change.
* ------------------------------------------------------------------ */
function ev_hello(ev) {
agent.version = ev.agent_version ?? agent.version;
logmsg("info", "netify agent %s connected (%s)",
agent.version ?? "?", ev.build_version ?? "");
}
// In normal operation `names` is bounded by nDPI's catalogue (a few thousand
// ids from the definitions event). These caps make that bound explicit rather
// than incidental, so a buggy or hostile agent cannot grow the maps without
// limit: entries beyond NAME_MAX are dropped and every stored tag is truncated.
function store_name(tbl, id, tag) {
if (tbl[id] == null && length(tbl) >= NAME_MAX)
return;
tbl[id] = (length(tag ?? "") > TAG_MAX) ? substr(tag, 0, TAG_MAX) : tag;
}
function ev_definitions(ev) {
let na = 0, np = 0;
for (let e in ev.applications ?? []) {
if (e?.id == null) continue;
store_name(names.app, e.id, e.tag);
na++;
}
for (let e in ev.protocols ?? []) {
if (e?.id == null) continue;
store_name(names.proto, e.id, e.tag);
np++;
}
logmsg("info", "definitions: %d applications, %d protocols", na, np);
}
function ev_agent_status(ev) {
agent.version = ev.agent_version ?? agent.version;
agent.uptime = num(ev.uptime, agent.uptime);
agent.flow_count = num(ev.flow_count, 0);
agent.rss_kb = num(ev.maxrss_kb, 0);
agent.ts = num(ev.timestamp, 0) * 1000;
router_learn(ev.interfaces);
// ev.devices is netifyd's MAC -> IP[] map; used only to fill in an address
// for devices whose flows we have not seen an IP for yet.
for (let mac in ev.devices ?? {}) {
let d = devs[lc(mac)];
if (d && !d.ip)
d.ip = (ev.devices[mac] ?? [])[0];
}
}
function ev_flow(ev, f, now) {
let fr = flows[f.digest];
if (!fr) {
flow_new(f.digest, ev, f, now, false);
return;
}
fr.late = false;
flow_identify(fr, ev, f, now);
}
// local_bytes/other_bytes are *per-update deltas* (verified on 4.4.7: they
// return to zero in the final flow_purge while total_bytes, cumulative, does
// not). This shared helper accounts one such update, bounding the directional
// deltas by the growth of total_bytes so a self-inconsistent counter can never
// inflate the totals. Both flow_stats and flow_purge go through it, so the
// clamp guarantee in DESIGN §2.2 holds on every accounting path — including the
// purge path, whose payload shape is the one the interface doc could not
// capture live.
function flow_update(fr, f, now, final) {
let dl = num(f.local_bytes, 0), do_ = num(f.other_bytes, 0);
let tb = num(f.total_bytes, 0);
// Byte conservation (§2.5). What netifyd says this flow grew by, recorded
// before any clamping, so `bytes_reported` can be compared against what
// actually reached an aggregate. A late stub's first update is skipped:
// its total_bytes is the flow's whole pre-adoption history, which this
// daemon deliberately does not attribute, and counting it would show as
// permanent false drift rather than as the connect-blindness it is.
// Byte conservation (§2.5). netifyd's own statement of what moved, recorded
// BEFORE any clamp or shadow decision, so `leaked` in status exposes bytes
// that were reported to us and never reached an aggregate. It has to mirror
// the basis attribution actually uses: where a baseline exists the
// cumulative growth is authoritative, otherwise the directional deltas are
// all there is. A late stub's pre-adoption history is excluded by
// construction, because it lives in total_bytes and never in the deltas.
if (tb > 0 && fr.total > 0) {
let grew = tb - fr.total;
stats.bytes_reported += (grew < 0) ? tb : grew;
} else {
stats.bytes_reported += (dl + do_);
}
// Bound the deltas by total_bytes growth ONLY when total_bytes is usable
// (present and moving forward). A missing or reset counter must not scale
// the real directional deltas to zero (that would silently and permanently
// stop accounting a flow); in that case trust the deltas as given.
if (tb > 0 && fr.total > 0) {
let dt = tb - fr.total;
if (dt < 0)
dt = tb; // netifyd counter reset: treat tb as the delta
let sum = dl + do_;
if (sum > dt) {
// Directional deltas exceed the cumulative growth: clamp them down.
dl = (sum > 0) ? int(dl * dt / sum) : 0;
do_ = dt - dl;
} else if (sum == 0 && dt > 0) {
// The cumulative counter advanced but this update carries no
// directional bytes. This is the flow_purge case on 4.4.7:
// local_bytes/other_bytes are always 0 there while total_bytes is
// final, so the bytes since the last flow_stats would otherwise be
// lost (a few percent of every flow). Attribute dt in the same
// up/down proportion the flow has shown so far (account() maps
// local/other -> up/down by dev_local, so split in that frame),
// defaulting to download when the direction is not yet known.
let up_share = (fr.up + fr.down > 0) ? int(dt * fr.up / (fr.up + fr.down)) : 0;
let down_share = dt - up_share;
if (fr.up + fr.down == 0)
down_share = dt; // unknown: count as download
// local == up when dev_local, else local == down.
dl = fr.dev_local ? up_share : down_share;
do_ = fr.dev_local ? down_share : up_share;
}
} else if (final && tb > 0 && fr.total == 0 && !fr.late && (dl + do_) == 0) {
// PURGE-ONLY FLOW: a `flow` event created this record, no flow_stats ever
// arrived, and now the purge carries the final cumulative counter with
// both directional fields zero. The branch above cannot run because it
// requires fr.total > 0, so without this the flow's entire byte count
// would be silently discarded -- and stats.no_total would not move
// either, because tb > 0.
//
// MEASURED 2026-08-24 (EA8500, netifyd 4.4.7): this does NOT fire on
// this netifyd. Tapping the export socket for 170 s, 64 of 64 flows
// whose full lifecycle was observed carried a flow_stats before their
// purge, UDP DNS queries included. DESIGN §2.5 reached the same
// conclusion from byte totals; this reached it from the events.
//
// It is here because that behaviour is undocumented and the daemon had
// no defence if it ever changed. Late stubs are excluded: for those tb
// is pre-adoption history and injecting it would overcount.
stats.purge_only++;
dl = fr.dev_local ? 0 : tb; // account() maps local/other -> up/down
do_ = fr.dev_local ? tb : 0; // by dev_local; count it as download
stats.bytes_reported += tb;
} else if (tb <= 0) {
stats.no_total++;
}
if (tb > 0)
fr.total = tb;
account(fr, dl, do_, now);
}
function ev_flow_stats(ev, f, now) {
let fr = flows[f.digest];
if (!fr) {
// Stats for a digest we hold no flow event for. Two distinct causes
// with DIFFERENT outcomes; both characterised on hardware 2026-08-22
// (EA8500, netifyd 4.4.7, deconfounded LAN-local traffic):
//
// 1. A genuine stats-before-flow race on a NEW flow. The flow event
// lands shortly after and identity follows via flow_identify().
//
// 2. A flow already established when we connected or reconnected.
// netifyd 4.4.7 has no established-flow dump, so it emits
// flow_stats for such a flow but NEVER a flow event: identity
// never arrives and the bytes stay under Unknown until the flow
// ends. Measured by restarting this daemon mid-transfer, which
// stranded 10.7 MB of a correctly-classified HTTP flow (a fresh
// socket client saw 5 flow_stats and 0 flow events for it).
// Bounded to the connect/reconnect window, self-heals as flows
// cycle, and not fixable here without netifyd support.
//
// Either way the bytes are PARKED as Unknown, never dropped: byte
// conservation through this path is verified (DESIGN 2.5, 8).
fr = flow_new(f.digest, ev, f, now, true);
if (!fr)
return;
}
reshadow(fr, f, now); // close the stale-shadow double-count
flow_update(fr, f, now, false);
}
function ev_purge(ev, f, now) {
let fr = flows[f.digest];
stats.purges++;
if (!fr)
return;
// The purge carries the flow's final counter delta: account it, clamped like
// any other update, before the record goes away.
fr.reason = ev.reason ?? "purged";
flow_update(fr, f, now, true); // `final`: the purge-only rescue path
flow_drop(f.digest);
}
const HANDLERS = {
agent_hello: ev_hello,
definitions: ev_definitions,
agent_status: ev_agent_status
};
const FLOW_HANDLERS = {
flow: ev_flow,
flow_stats: ev_flow_stats,
flow_purge: ev_purge
};
function dispatch(ev, now) {
let t = ev.type;
if (!length(t ?? "")) {
stats.malformed++;
return;
}
stats.events++;
if (stats.by_type[t] != null)
stats.by_type[t]++;
else if (length(stats.by_type) < EVENT_TYPE_MAX)
stats.by_type[t] = 1;
stats.last_event_ms = now;
let h = HANDLERS[t];
if (h) {
h(ev);
return;
}
let f = ev.flow;
if (type(f) == "object" && length(f.digest ?? "")) {
h = FLOW_HANDLERS[t];
if (h) {
h(ev, f, now);
return;
}
// Unknown but flow-scoped: a purge-ish name finalises the flow, which
// keeps a future netifyd rename from leaking flow records.
if (match(t, PURGE_RE)) {
ev_purge(ev, f, now);
return;
}
}
stats.unknown++;
if (stats.unknown_types[t] == null && length(stats.unknown_types) < UNKNOWN_TYPE_MAX)
stats.unknown_types[t] = 0;
if (stats.unknown_types[t] != null)
stats.unknown_types[t]++;
}
/* ------------------------------------------------------------------ *
* Wire protocol (§2.1): newline-delimited JSON, each payload preceded by a
* {"length": N} header line.
* ------------------------------------------------------------------ */
function feed(line, now) {
if (!length(line))
return;
let ev;
try {
ev = json(line);
} catch (e) {
stats.malformed++;
logonce("badjson", "warn", "malformed event line (%d bytes): %s", length(line), e);
return;
}
if (type(ev) != "object") {
stats.malformed++;
return;
}
// Frame header: sole key "length".
if (ev.length != null && length(ev) == 1) {
stats.frames++;
return;
}
dispatch(ev, now);
}
function on_readable() {
let now = now_ms();
for (let i = 0; i < RX_DRAIN_MAX; i++) {
let data;
try {
data = sock.recv(RX_CHUNK, socket.MSG_DONTWAIT);
} catch (e) {
disconnect("recv error: " + e);
return;
}
if (data == null) {
let err = socket.error(true);
if (err == 11 || err == 4) // EAGAIN / EINTR
return;
disconnect(sprintf("recv failed: %s", socket.error()));
return;
}
if (length(data) == 0) {
disconnect("netifyd closed the connection");
return;
}
stats.rx_bytes += length(data);
rxbuf += data;
if (length(rxbuf) > RX_MAX) {
// Never let a peer that stops sending newlines grow us without
// bound (§3.3). Drop back to a line boundary and carry on.
stats.resyncs++;
logonce("rxmax", "warn", "receive buffer overflow, resyncing");
let cut = rindex(rxbuf, "\n");
rxbuf = (cut >= 0) ? substr(rxbuf, cut + 1) : "";
}
let parts = split(rxbuf, "\n");
rxbuf = parts[length(parts) - 1];
for (let n = 0; n < length(parts) - 1; n++)
feed(trim(parts[n]), now);
if (length(data) < RX_CHUNK)
return;
}
}
/* ------------------------------------------------------------------ *
* Connection management: start before netifyd, survive its restart.
* ------------------------------------------------------------------ */
disconnect = function(why) {
if (sock_fd) {
try {
sock_fd.delete();
} catch (e) {
try { sock_fd.cancel(); } catch (e2) {}
}
sock_fd = null;
}
if (sock) {
try { sock.close(); } catch (e) {}
sock = null;
}
rxbuf = "";
if (connected) {
connected = false;
logmsg("warn", "disconnected: %s", why);
}
schedule_reconnect();
};
schedule_reconnect = function() {
if (retry_timer)
return;
retry_timer = uloop.timer(backoff_ms, function() {
retry_timer = null;
connect();
});
backoff_ms = clampi(backoff_ms * 2, RECONNECT_MIN_MS, RECONNECT_MAX_MS);
};
connect = function() {
try {
sock = socket.connect(cfg.socket_path);
} catch (e) {
sock = null;
}
if (!sock) {
stats.connect_fail++;
logonce("noconn", "warn", "waiting for netifyd on %s", cfg.socket_path);
schedule_reconnect();
return;
}
sock_fd = uloop.handle(sock, on_readable, uloop.ULOOP_READ);
// Count reconnects, not the first connect, so `socket.reconnects` reads as
// churn that actually happened.
if (ever_connected)
stats.reconnects++;
ever_connected = true;
connected = true;
backoff_ms = RECONNECT_MIN_MS;
// A new socket is a new netifyd lifetime. saw_internal is a capability
// latch about the CURRENT stream ("is internal capture active"), so it must
// not carry over: a netifyd restarted external-only would otherwise keep the
// stale `true` and shadow every client flow to zero. Cleared here so the new
// stream re-establishes it from its own events.
saw_internal = false;
router_bootstrap();
logmsg("info", "connected to %s", cfg.socket_path);
};
/* ------------------------------------------------------------------ *
* Housekeeping (§3.3)
* ------------------------------------------------------------------ */
prune = function(now, force) {
let cutoff = now - cfg.idle_ms;
let dropped = 0;
for (let d in keys(flows)) {
let fr = flows[d];
if (fr && fr.seen < cutoff) {
flow_drop(d);
dropped++;
}
}
// Cap pressure: shed the least recently active tenth of the table.
//
// Counted separately into flows_shed as well as into the pruned total, so
// "the flow table is too small for this network" is visible instead of
// hiding under ordinary idle housekeeping. See the note beside flows_shed.
if (force && nflows >= cfg.flow_max) {
let list = [];
for (let d in flows)
push(list, [ d, flows[d].seen ]);
sort(list, (a, b) => a[1] - b[1]);
let want = int(cfg.flow_max / 10) + 1;
for (let i = 0; i < want && i < length(list); i++) {
flow_drop(list[i][0]);
dropped++;
stats.flows_shed++;
}
}
stats.flows_pruned += dropped;
// Aggregates with no live flows that have been silent for longer than the
// trailing-hour window carry no data anyone can still query.
let agg_cutoff = now - (SERIES_SLOTS * SERIES_MS) - 60000;
for (let tbl in [ apps, devs, cats ])
for (let k in keys(tbl)) {
let a = tbl[k];
let last = a.cur.last ? a.cur.last : a.cur.first;
if (a.cur.flows == 0 && last < agg_cutoff) {
delete tbl[k];
stats.aggs_pruned++;
}
}
};
function housekeeping() {
let now = now_ms();
stats.sweeps++;
let slot = int(now / SERIES_MS), dslot = int(now / DEVSER_MS);
prune(now, false);
// Advance the trailing-hour rings and roll the rate windows. Rolling here
// (not only in agg_bump) means an aggregate that has gone idle still sees
// its rate decay to zero, and it keeps agg_rate a pure read.
agg_roll(totals, now);
ring_sweep(totals.buckets, slot, SERIES_SLOTS);
for (let tbl in [ apps, devs, cats ])
for (let k in tbl) {
agg_roll(tbl[k], now);
ring_sweep(tbl[k].buckets, slot, SERIES_SLOTS);
for (let dk in tbl[k].devs ?? {})
ring_sweep(tbl[k].devs[dk].ring, dslot, DEVSER_SLOTS);
for (let hk in tbl[k].hosts ?? {})
ring_sweep(tbl[k].hosts[hk].ring, dslot, DEVSER_SLOTS);
}
// §7.3 seam: v1 has no persistent buckets, so this hands over nothing.
store.flush(totals.buckets, slot);
}
/* ------------------------------------------------------------------ *
* ubus surface (§5)
* ------------------------------------------------------------------ */
function rss_kb() {
let raw;
try {
raw = fs.readfile("/proc/self/status");
} catch (e) {
return 0;
}
let m = raw ? match(raw, /VmRSS:[ \t]*([0-9]+)/) : null;
return m ? int(m[1]) : 0;
}
function envelope(now) {
return { generated_at: now, agent_connected: connected };
}
function metrics(a, now) {
let r = agg_rate(a, now);
return {
bytes_up: a.cur.up,
bytes_down: a.cur.down,
bytes_total: a.cur.up + a.cur.down,
rate_up: r[0],
rate_down: r[1],
rate_total: r[0] + r[1],
flows: a.cur.flows,
first_seen: a.cur.first,
last_seen: a.cur.last ? a.cur.last : a.cur.first
};
}
function app_row(a, now) {
let e = metrics(a, now);
e.key = a.key;
e.name = a.name;
e.tag = a.tag ?? a.key;
e.category = a.cat ?? null;
return e;
}
function dev_row(a, now) {
let e = metrics(a, now);
let pseudo = (a.key == "router" || a.key == "multicast" || a.key == "unknown");
e.key = a.key;
e.name = device_name(a.key, a.mac, a.ip, now);
// The pseudo-devices collapse many endpoints; a single leftover MAC/IP
// under them would be misleading, so present them without one.
e.mac = pseudo ? null : a.mac;
e.ip = pseudo ? null : a.ip;
e.is_router = (a.router == true);
return e;
}
function cat_row(a, now) {
let e = metrics(a, now);
e.key = a.key;
e.name = a.name;
return e;
}
const SORTERS = {
rate: (a, b) => (b.rate_total - a.rate_total) || (b.bytes_total - a.bytes_total),
bytes: (a, b) => b.bytes_total - a.bytes_total,
up: (a, b) => b.bytes_up - a.bytes_up,
down: (a, b) => b.bytes_down - a.bytes_down,
flows: (a, b) => b.flows - a.flows,
last: (a, b) => b.last_seen - a.last_seen,
name: (a, b) => (a.name < b.name) ? -1 : ((a.name > b.name) ? 1 : 0)
};
function rows_of(tbl, rowfn, now, sortkey, limit) {
let out = [];
for (let k in tbl)
push(out, rowfn(tbl[k], now));
sort(out, SORTERS[sortkey ?? "rate"] ?? SORTERS.rate);
return (limit > 0 && length(out) > limit) ? slice(out, 0, limit) : out;
}
function find_app(want) {
if (!length(want ?? ""))
return null;
if (apps[want])
return apps[want];
let lw = lc(want);
for (let k in apps) {
let a = apps[k];
if (lc(a.tag ?? "") == lw || lc(a.name) == lw)
return a;
}
return null;
}
function m_summary(req) {
let now = now_ms();
let n = clampi(num(req.args?.limit, cfg.top_n), 1, LIST_REPLY_MAX);
let r = envelope(now);
r.window = cfg.rate_window;
r.totals = metrics(totals, now);
r.totals.flows = nflows;
r.top_apps = rows_of(apps, app_row, now, "rate", n);
r.top_devices = rows_of(devs, dev_row, now, "rate", n);
r.top_categories = rows_of(cats, cat_row, now, "rate", n);
r.counts = {
apps: length(apps),
devices: length(devs),
categories: length(cats),
flows: nflows
};
return r;
}
/* Application totals restricted to one device.
*
* The mirror of devices_for_apps(): same bounded flow table, same join already
* present on every flow record, grouped the other way. No new aggregate.
*
* As with that function these are the bytes CURRENTLY TRACKED flows account
* for, not the application's lifetime totals, and the reply says so.
*/
function apps_for_device(dev, now) {
let acc = {};
for (let dg in flows) {
let fr = flows[dg];
if (!fr || fr.shadow || fr.dev_key != dev || !fr.app_key)
continue;
let e = acc[fr.app_key];
if (!e) {
e = { key: fr.app_key, cat: fr.cat_key, up: 0, down: 0, flows: 0 };
acc[fr.app_key] = e;
}
e.up += (fr.up ?? 0);
e.down += (fr.down ?? 0);
e.flows++;
}
let out = [];
for (let k in acc) {
let e = acc[k];
/* Names live on the aggregate, not on the flow, so look the app up. A
* flow whose app has already been pruned from the aggregate still gets
* a row, keyed rather than named, instead of vanishing. */
let a = apps[k];
push(out, {
key: k,
name: a ? a.name : k,
tag: a ? (a.tag ?? k) : k,
category: e.cat,
upload: e.up,
download: e.down,
bytes_total: e.up + e.down,
flows: e.flows
});
}
sort(out, (x, y) => y.bytes_total - x.bytes_total);
return out;
}
function m_apps(req) {
let now = now_ms();
let r = envelope(now);
r.window = cfg.rate_window;
let lim = clampi(num(req.args?.limit, LIST_REPLY_MAX), 1, LIST_REPLY_MAX);
let dev = req.args?.device;
/* `device` scopes the answer to one device: what is that thing doing. */
if (type(dev) == "string" && length(dev)) {
let rows = apps_for_device(dev, now);
r.from_flows = true;
r.device = dev;
r.total = length(rows);
r.apps = (length(rows) > lim) ? slice(rows, 0, lim) : rows;
return r;
}
r.from_flows = false;
r.apps = rows_of(apps, app_row, now, req.args?.sort, lim);
r.total = length(apps);
return r;
}
/* Device totals restricted to a set of application keys.
*
* Computed from the live flow table rather than from a new aggregate. Every
* flow already carries dev_key and app_key, so the join is there; the table is
* bounded by flow_max, so this is bounded too. No new memory.
*
* The numbers are what the CURRENTLY TRACKED flows account for, not the
* device's lifetime totals: a flow that has ended is no longer here. The reply
* carries from_flows so a caller cannot present it as cumulative history.
*
* Shadow flows are excluded for the same reason they are excluded everywhere
* else: they are the NAT-side duplicate and counting them would double.
*/
function devices_for_apps(keys, now) {
let want = {};
for (let k in (keys ?? []))
if (type(k) == "string" && length(k))
want[k] = true;
let acc = {};
for (let dg in flows) {
let fr = flows[dg];
if (!fr || fr.shadow || !fr.dev_key || !want[fr.app_key])
continue;
let e = acc[fr.dev_key];
if (!e) {
e = { key: fr.dev_key, mac: fr.dev_mac, ip: fr.dev_ip,
up: 0, down: 0, flows: 0 };
acc[fr.dev_key] = e;
}
if (!e.ip && fr.dev_ip)
e.ip = fr.dev_ip;
e.up += (fr.up ?? 0);
e.down += (fr.down ?? 0);
e.flows++;
}
let out = [];
for (let k in acc) {
let e = acc[k];
let pseudo = (k == "router" || k == "multicast" || k == "unknown");
push(out, {
key: k,
name: device_name(k, e.mac, e.ip, now),
mac: pseudo ? null : e.mac,
ip: pseudo ? null : e.ip,
upload: e.up,
download: e.down,
bytes_total: e.up + e.down,
flows: e.flows
});
}
sort(out, (x, y) => y.bytes_total - x.bytes_total);
return out;
}
function m_devices(req) {
let now = now_ms();
let r = envelope(now);
r.window = cfg.rate_window;
let lim = clampi(num(req.args?.limit, LIST_REPLY_MAX), 1, LIST_REPLY_MAX);
/* `apps` restricts the answer to the devices carrying those applications,
* which is how the UI answers "who is generating this traffic" once the
* user has filtered the application list. Matching stays in the UI, where
* the user's text lives; summing stays here, where the flows are. */
let keys = req.args?.apps;
if (type(keys) == "array" && length(keys)) {
let rows = devices_for_apps(keys, now);
r.from_flows = true;
r.total = length(rows);
r.devices = (length(rows) > lim) ? slice(rows, 0, lim) : rows;
return r;
}
r.from_flows = false;
r.devices = rows_of(devs, dev_row, now, req.args?.sort, lim);
r.total = length(devs);
return r;
}
function m_app_detail(req) {
let now = now_ms();
let a = find_app(req.args?.app);
let r = envelope(now);
if (!a) {
r.found = false;
r.app = req.args?.app;
return r;
}
let slot = int(now / SERIES_MS), dslot = int(now / DEVSER_MS);
r.found = true;
r.app = a.key;
r.tag = a.tag ?? a.key;
r.label = a.name;
// The detail drawer is opened from the Statistics table, which is a
// trailing-hour view. Report the byte totals over that same window (from the
// hour ring) so the drawer header cannot disagree with the row that opened it;
// the earlier metrics(a) returned session-lifetime bytes, which read as a
// gross mismatch under an "last 60 minutes" caption. Rate and flow context
// stay as-is (a rate is inherently current).
let at = ring_total(a.buckets, slot, SERIES_SLOTS);
r.metrics = metrics(a, now);
r.metrics.bytes_down = at[0];
r.metrics.bytes_up = at[1];
r.metrics.bytes_total = at[0] + at[1];
r.metrics.window = "hour";
r.time_series = ring_series(a.buckets, slot, SERIES_SLOTS, SERIES_MS);
let ds = [];
for (let k in a.devs ?? {}) {
let e = a.devs[k];
let t = ring_total(e.ring, dslot, DEVSER_SLOTS);
push(ds, {
key: k,
mac: e.mac,
ip: e.ip,
name: device_name(k, e.mac, e.ip, now),
download: t[0],
upload: t[1],
total: t[0] + t[1],
last_seen: e.last
});
}
sort(ds, (x, y) => (y.total - x.total));
r.devices = ds;
let hs = [];
for (let h in a.hosts ?? {}) {
let ht = ring_total(a.hosts[h].ring, dslot, DEVSER_SLOTS);
push(hs, {
host: h,
download: ht[0],
upload: ht[1],
total: ht[0] + ht[1],
last_seen: a.hosts[h].last
});
}
sort(hs, (x, y) => (y.total - x.total));
r.hosts = hs;
return r;
}
function m_flows(req) {
let now = now_ms();
let want_app = req.args?.app, want_dev = req.args?.device;
let limit = clampi(num(req.args?.limit, 50), 1, FLOWS_REPLY_MAX);
let out = [];
for (let d in flows) {
let fr = flows[d];
if (length(want_app ?? "") && fr.app_key != want_app && fr.tag != want_app)
continue;
if (length(want_dev ?? "") && fr.dev_key != want_dev)
continue;
push(out, {
digest: substr(d, 0, 16),
app: fr.app_key,
app_name: fr.app_name,
category: fr.cat_key,
device: fr.dev_key,
device_name: device_name(fr.dev_key, fr.dev_mac, fr.dev_ip, now),
host: fr.host,
interface: fr.iface,
internal: fr.internal,
nat: fr.nat,
shadow: fr.shadow,
ip_protocol: fr.proto,
upload: fr.up,
download: fr.down,
// netifyd's cumulative lifetime counter for the flow, NOT the sum of
// what appflow attributed (up+down): a stub flow adopted mid-life has
// a large lifetime but small attributed bytes. Named distinctly so a
// consumer never confuses it with the aggregate `bytes_total`.
lifetime_bytes: fr.total,
first_seen: fr.start,
last_seen: fr.seen
});
}
sort(out, (x, y) => y.last_seen - x.last_seen);
let r = envelope(now);
r.total = length(out);
r.truncated = (length(out) > limit);
r.flows = r.truncated ? slice(out, 0, limit) : out;
return r;
}
// GL-parity trailing-hour view. Only range "hour" exists in v1; the argument
// is required so the contract can grow (day/week) without a breaking change.
function m_stats(req) {
let range = req.args?.range ?? "hour";
if (range != "hour")
return ubus.STATUS_INVALID_ARGUMENT;
let now = now_ms();
let slot = int(now / SERIES_MS);
let r = envelope(now);
r.range = "hour";
r.range_start = (slot - SERIES_SLOTS + 1) * SERIES_MS;
r.range_end = now;
let t = ring_total(totals.buckets, slot, SERIES_SLOTS);
r.all = { download: t[0], upload: t[1], total: t[0] + t[1] };
let list = [];
for (let k in apps) {
let a = apps[k];
let at = ring_total(a.buckets, slot, SERIES_SLOTS);
if ((at[0] + at[1]) == 0 && a.cur.flows == 0)
continue;
push(list, {
app: a.tag ?? a.key,
key: a.key,
label: a.name,
// An application's category is a property of its flows; the
// aggregate carries the most recent one seen.
category: a.cat ?? null,
download: at[0],
upload: at[1],
total: at[0] + at[1],
active_flows: a.cur.flows
});
}
sort(list, (x, y) => y.total - x.total);
// Only the leaders carry a time series -- twelve buckets per application
// would dominate the reply for no benefit.
let top = [];
for (let i = 0; i < length(list) && i < 10; i++) {
let e = list[i];
push(top, {
app: e.app, key: e.key, label: e.label, category: e.category,
download: e.download, upload: e.upload, total: e.total,
active_flows: e.active_flows,
time_series: ring_series(apps[e.key].buckets, slot, SERIES_SLOTS, SERIES_MS)
});
}
r.applications = list;
r.top_apps = top;
return r;
}
function m_status(req) {
let now = now_ms();
let r = envelope(now);
r.version = VERSION;
r.uptime = int((mono_ms() - start_mono) / 1000);
r.started_at = start_ms;
r.socket = { path: cfg.socket_path, connected: connected, reconnects: stats.reconnects,
connect_failures: stats.connect_fail };
r.config = { flow_max: cfg.flow_max, rate_window: cfg.rate_window,
top_n: cfg.top_n, idle_timeout: cfg.idle_timeout,
// 0/1 rather than a JSON boolean, so every value in this
// object is numeric and one reader parses all of them.
ai_breakout: cfg.ai_breakout ? 1 : 0 };
r.flows = { tracked: nflows, seen: stats.flows_seen, late: stats.flows_late,
pruned: stats.flows_pruned, shed: stats.flows_shed,
dropped: stats.flows_dropped,
shadowed: stats.shadowed, purged: stats.purges,
no_total: stats.no_total, ai_labeled: stats.ai_labeled };
// NAT twin resolution (3.2). `available` false means conntrack could not
// be read, in which case external captures are shadowed on the safe
// default and the router's own traffic is not attributed. A persistently
// high `unresolved` means entries are expiring before the flow is
// classified, which has the same effect for those flows.
r.conntrack = { available: conntrack.avail, entries: conntrack.entries,
twins: stats.ct_twins, router_local: stats.ct_local,
unresolved: stats.ct_unresolved };
// Byte conservation (§2.5). `leaked` is what netifyd reported growing
// that neither reached an aggregate nor was deliberately shadowed as a NAT
// twin. It should sit at 0. A number that climbs is a byte-accounting
// defect, which is exactly the class that was previously invisible here.
r.bytes = { reported: stats.bytes_reported,
attributed: stats.bytes_attributed,
shadowed: stats.bytes_shadowed,
leaked: stats.bytes_reported - stats.bytes_attributed - stats.bytes_shadowed,
purge_only_rescued: stats.purge_only };
r.aggregates = { apps: length(apps), devices: length(devs),
categories: length(cats), pruned: stats.aggs_pruned,
refused: stats.aggs_refused };
r.events = { total: stats.events, frames: stats.frames, malformed: stats.malformed,
unknown: stats.unknown, unknown_types: stats.unknown_types,
by_type: stats.by_type, rx_bytes: stats.rx_bytes, resyncs: stats.resyncs,
last_event_age_ms: stats.last_event_ms ? (now - stats.last_event_ms) : -1 };
r.agent = { version: agent.version, uptime: agent.uptime,
flow_count: agent.flow_count, maxrss_kb: agent.rss_kb ?? 0,
last_status_at: agent.ts };
r.housekeeping = { sweeps: stats.sweeps, interval_ms: PRUNE_MS };
r.memory = { rss_kb: rss_kb() };
r.last_reset = stats.last_reset_ms;
return r;
}
// "Clear" — zero every accumulator while keeping flow tracking intact, so the
// numbers restart from now without losing the live flow context.
function m_reset(req) {
let now = now_ms();
agg_reset(totals, now);
for (let tbl in [ apps, devs, cats ])
for (let k in tbl) {
agg_reset(tbl[k], now);
delete tbl[k].devs;
delete tbl[k].hosts;
}
// Zero each flow's attributed counters, but NOT flows[].total: that mirrors
// netifyd's cumulative lifetime counter and is the baseline the next
// flow_stats delta is measured against. Clearing it would make the next
// update compute a false multi-KB spike. flows[].lifetime_bytes therefore
// survives a reset by design.
for (let d in flows) {
flows[d].up = 0;
flows[d].down = 0;
}
stats.last_reset_ms = now;
logmsg("info", "statistics reset via ubus");
let r = envelope(now);
r.reset = true;
return r;
}
function ubus_publish(conn) {
// Every args policy accepts ubus_rpc_session: rpcd injects it into all
// LuCI-proxied calls, and an unknown typed field is rejected as EINVAL.
const SID = { ubus_rpc_session: "" };
return conn.publish("appflow", {
summary: { call: m_summary, args: { limit: 0, ...SID } },
/* `device` must be declared or ubus rejects the call outright, which
* presents as the method returning nothing at all. */
apps: { call: m_apps, args: { sort: "", limit: 0, device: "", ...SID } },
/* `apps` is an array of application keys. It MUST be declared here or
* ubus rejects the call outright rather than ignoring the argument,
* which presents as the method returning nothing at all. */
devices: { call: m_devices, args: { sort: "", limit: 0, apps: [], ...SID } },
app_detail: { call: m_app_detail, args: { app: "", ...SID } },
flows: { call: m_flows, args: { limit: 0, app: "", device: "", ...SID } },
stats: { call: m_stats, args: { range: "", ...SID } },
status: { call: m_status, args: { ...SID } },
reset: { call: m_reset, args: { ...SID } }
});
// Reserved for §7.4: history { since, until } — deliberately not published
// in v1 so the method name stays free for the persistent-store version.
}
/* ------------------------------------------------------------------ *
* main
* ------------------------------------------------------------------ */
function main() {
start_ms = now_ms();
start_mono = mono_ms();
cfg_load();
totals = agg_new("total", "total", "Total", start_ms);
load_categories();
router_bootstrap();
uloop.init();
let conn;
try {
conn = ubus.connect();
} catch (e) {
logmsg("error", "ubus connect failed: %s", e);
exit(1);
}
try {
ubus_obj = ubus_publish(conn);
} catch (e) {
logmsg("error", "ubus publish failed: %s", e);
exit(1);
}
for (let sig in [ "SIGTERM", "SIGINT" ])
try {
uloop.signal(sig, function() { uloop.end(); });
} catch (e) {
// signal handling is a nicety; procd will SIGKILL if we ignore it
}
uloop.interval(PRUNE_MS, housekeeping);
logmsg("info", "%s %s started (flow_max=%d rate_window=%ds)",
NAME, VERSION, cfg.flow_max, cfg.rate_window);
connect();
uloop.run();
uloop.done();
if (sock)
try { sock.close(); } catch (e) {}
logmsg("info", "exiting");
}
main();