52 KiB
appflow: Design
Status: v1 spec LOCKED (2026-08-21). Remaining open items tracked in §8.
1. Goal
A LuCI-native, fully open per-application traffic dashboard for OpenWrt, built on the Netify Agent. Stock OpenWrt can tell you how much traffic crossed an interface; it cannot tell you which application produced it. Several vendor firmwares ship a DPI traffic-statistics screen that can, and this is an open equivalent for stock OpenWrt.
Primary scope (v1): live view, per-application and per-device rates and session totals, category grouping, live charts. Explicit non-goal for v1, but designed-for: historical accounting (see §7).
2. Ground truth (verified on hardware, 2026-08-21)
Test platform: Linksys EA8500, OpenWrt 25.12.5, netifyd 4.4.7 (nDPI 5.0-based),
arch arm_cortex-a15_neon-vfpv4. All facts below observed live, not assumed.
2.1 Data source: netifyd JSON export socket
- netifyd listens on
unix:/var/run/netifyd/netifyd.sock([socket] listen_path[0], enabled by default in the OpenWrt package). - ucode connects with plain
socket.connect(path), verified. - Wire framing: newline-delimited JSON where each payload line is preceded
by a header line
{"length": N}. Parser rule: parse each line; a lonelengthkey = frame header (skip); anything else dispatches ontype. - Event types observed:
agent_hello,definitions,agent_status(15 s cadence),flow,flow_stats. /var/run/netifyd/status.jsoncontains agent status only (plus a useful MAC→IPdevicesmap), no flow data. The socket is the only flow source.
2.2 Event schemas (as observed)
flow (fires on detection; detection_updated re-fires on refinement):
{ "type": "flow", "interface": "wan", "internal": false,
"flow": {
"digest": "9c2d…", // unique flow key
"detected_application": 10465,
"detected_application_name": "netify.openwrt",
"detected_protocol": 5,
"detected_protocol_name": "DNS",
"category": { "application": 31, "protocol": 14, "domain": 0 },
"host_server_name": "openwrt.org", // SNI/hostname when known
"local_ip": "…", "local_mac": "…", "local_port": 53,
"other_ip": "…", "other_mac": "…", "other_port": 57052,
"other_type": "local" | "remote",
"local_origin": bool, "ip_nat": bool, "ip_version": 4, "ip_protocol": 17,
"first_seen_at": 1787325075857, "last_seen_at": …, // ms epoch
"risks": { "ndpi_risk_score": 10, "risks": [46] } // bonus signal
} }
flow_stats (periodic per-flow counter updates):
{ "type": "flow_stats", "interface": "wan", "internal": false,
"flow": { "digest": "…",
"local_bytes": 0, "other_bytes": 240, "total_bytes": 480,
"local_packets": 0, "other_packets": 2, "total_packets": 4,
"last_seen_at": 1787325070685 } }
Correction (implementation ground truth, 2026-08-21): local_bytes /
other_bytes are per-update deltas during a flow's life, but on the
final flow_purge event for that flow they are always zero (confirmed
on 10+ live purge events), while total_bytes is the flow's final
cumulative value. Since the purge event cannot supply a final directional
split, appflowd recovers it by distributing the total_bytes delta since the
last known total across up/down using the flow's established up/down ratio
(accumulated from that flow's prior flow_stats deltas), rather than
accounting local_bytes/other_bytes from the purge event itself.
2.3 Naming and taxonomy
definitionsevent carries id→tag maps: 416 applications, 217 protocols. Applications and protocols are SEPARATE id spaces that can collide numerically (id 206 is bothWireGuardthe protocol andnetify.cloudflarethe app), and legacy netify apps exist below 10000 (netflix=133), thenetify.tag prefix, not the id range, is the discriminator. Details: docs/netifyd-4.4.7-interface.md.- Category names live in
/etc/netify.d/netify-categories.json:application_tag_index(32 tags:streaming-media,social-media,games,voip,cdn,advertiser,os-software-updates,malware, …) andprotocol_tag_index(18 tags), plusapplication_index/protocol_indexmapping category-id → member ids. - Display-name policy: application name if detected (strip
netify.prefix, title-case), else protocol name, elseUnknown; hostname (host_server_name) shown as secondary detail. - Licensing: the signature DB (
netify-apps.conf) and generated category file are eGloo-proprietary data shipped by thenetifydpackage. appflow reads them at runtime and vendors none of this data in the repo.
2.4 Additional confirmed facts (research pass, 2026-08-21)
flow_purgeevents carryreason: closed|expired|terminatedand the flow's final cumulativetotal_bytes/total_packets, butlocal_bytes/other_bytesare always zero on purge (confirmed live, see §2.2); the final up/down split must be recovered from the accumulated ratio, not read off the purge event, before flow removal.- Corrected:
dump_established_flowsdoes NOT exist in netifyd 4.4.7 (string absent from the binary; a research citation of v5-era docs was wrong). On (re)connect a client starts blind. The daemon toleratesflow_statsfor unknown digests (stub "Unknown" entry). Corrected 2026-08-22: that stub is backfilled only when aflowevent actually follows, which happens for a genuine stats-before-flow race but NOT for a flow that was already established at connect time, netifyd never emits aflowevent for those, so they stay Unknown for their remaining lifetime. Measured in §2.5. - App detections are
netify.<slug>strings; protocol names are plain. - OpenWrt ships netifyd 4.4.7 while upstream is v5.x with a different plugin architecture, all behavior here is pinned to 4.4.7-as-packaged.
- netifyd bundles its own nDPI statically:
libndpiis NOT a dependency.
2.5 Byte-attribution behavior (measured 2026-08-22)
Three questions about where bytes land were settled on the bench (EA8500,
netifyd 4.4.7). Method matters here: all earlier attempts were confounded by
upstream hosts throttling the test router's WAN IP under repeated fetches, and a
refused connection carries no SNI, so netifyd cannot classify it and it lands as
Unknown for reasons unrelated to this daemon. The measurements below therefore
use LAN-local traffic only (a file served from the router over br-lan via
socat, pulled by a LAN client with curl --limit-rate), so no upstream can
participate and throttling is impossible by construction.
-
Byte conservation through the Unknown path: HOLDS. 25 short flows of 2 MB = 50,000,000 bytes of client-side ground truth against 51,127,054 bytes accounted (the excess is TCP/HTTP overhead, both directions). Repeated with 150 tiny 1 KB flows (the case most likely to complete inside one
update_interval, and so most likely to be purged before anyflow_stats): all 150 accounted,no_total= 0, nothing lost. A plausible reading of thefr.total > 0gate inflow_update()predicts silent loss for such flows; that prediction is refuted on this hardware, because netifyd supplies accountable byte counts for sub-interval flows.CORRECTED 2026-08-25. The two paragraphs below understated this, and the review panel was right. Once
purge_only_rescuedwas instrumented and left running against ordinary traffic, it reported 13 purge-only flows out of 1,341 seen, 0.97%. Flows purged without ever receiving aflow_statsDO occur on netifyd 4.4.7. Before the defensive path was added, every one of them lost its entire byte count silently, which is exactly what three carriers predicted and what this section briefly called refuted.The refutation was under-powered, not wrong-headed: a 38-flow capture at a 0.97% rate has an expected count of 0.4, so observing none said almost nothing. Reading "0 of 38" as "netifyd never does this" was the error, and the lesson is that a negative result needs its sample size checked against the rate it claims to exclude. The event-level evidence below is still accurate about what it saw; it simply did not see enough.
Re-tested at the EVENT level 2026-08-24, after an adversarial review panel independently reached the same prediction three times. The 2026-08-22 result above is outcome-based: it shows the bytes landed, not why. Tapping netifyd's export socket directly for 170 s (
flow/flow_stats/flow_purgeper digest, UDP DNS and short TCP both driven deliberately), 64 of 64 flows whose full lifecycle was observed carried aflow_statsbefore theirflow_purge. Zero wentflow->flow_purgedirectly. The premise the prediction rests on does not occur on netifyd 4.4.7.Two flows in that capture did show a purge with no preceding stats, and both were flows that predated the tap: their birth was simply not observed. That is §2.4 connect-blindness, not this. Mistaking one for the other is easy and is why the analysis is stated at the level of fully observed lifecycles.
The gate is defended:
flow_update()takes afinalflag fromev_purge()and attributestotal_bytesfor a purge-only flow, counted instats.purge_onlyand reported asstatus.bytes.purge_only_rescued. That counter is not expected to stay 0 -- it runs at about 1% of flows -- and it is the reason those bytes are now counted at all. Watch its rate rather than its presence: a sharp rise means netifyd's event contract has shifted, and a zero across a busy run means the rescue has regressed. -
Connect/reconnect blindness: REAL, bounded, not fixable here. A flow that is already established when the daemon connects gets
flow_statsbut never aflowevent (§2.4), so its identity never arrives and its ongoing bytes accumulate under Unknown until it ends. Forced deterministically by restarting the daemon mid-transfer: 10.7 MB of a correctly-classified HTTP flow moved to Unknown while the app's own total stayed flat, and a fresh socket client observed 5flow_statsand 0flowevents for that flow. Confined to the window after a daemon restart or a netifyd restart, self-heals as flows cycle, and the bytes are parked rather than lost. No fix exists on our side without an established-flow dump from netifyd; see §8. -
Late re-point stranding: OPEN, see §8. Distinct from the above and not yet observed in the wild.
Counters relevant to all three (status.flows) behaved consistently: dropped
stayed 0 and late stayed flat under sustained load, so the daemon is not
losing events under load; the Unknown mass seen during earlier testing was the
upstream confound plus the restart blindness above, not an event-handling defect.
3. Architecture
netifyd (DPI, procd service; nDPI engine)
│ unix stream, length-framed JSON events
▼
appflowd — ucode daemon (procd service)
• connects to netifyd.sock, reconnects with backoff
• parses frames, maintains flow table keyed by digest
• aggregates: totals + windowed rates per (application × device),
per category, per device; prunes idle flows
• serves snapshots over ubus ←— single writer, many readers
▼
rpcd ACL (usr/share/rpcd/acl.d) gates LuCI session access
▼
LuCI JS view (htdocs/…/view/appflow/*.js)
• L.poll every 3–5 s → ubus call → render
• hand-drawn inline SVG charts (theme-aware via CSS custom properties);
standard LuCI tables/widgets
Two implementation options for the ubus surface:
- A (preferred): appflowd registers a ubus object directly via
ucode-mod-ubus(conn.publish()); rpcd ACL still governs LuCI access. - B (fallback): snapshot file, appflowd atomically writes
/var/run/appflow/state.json; a stateless rpcd-ucode plugin serves reads.
DECIDED: Option A shipped, publish() proven reliable on 25.12 (typed args,
status returns, int64 all verified on-device). Option B text retained above only as design rationale.
3.1 Why a persistent daemon (not an on-demand reader)
The socket is a stream: flows announce once, then only counter deltas follow. Any on-demand reader would miss all context before it connected. State must live in a long-running consumer; ucode + uloop keeps that consumer tiny (no C, no external deps beyond packaged ucode modules).
3.2 Flow attribution and the dual-capture problem
netifyd watches br-lan (internal) and wan (external) simultaneously, so a
NAT'd client flow can be captured twice (pre- and post-NAT, different
digests, one per interface). The device a flow belongs to and whether it is a
duplicate must be decided without double-counting.
The shipping rule (implemented in flow_identify). An earlier design paired
the two captures on the remote endpoint and keyed the dedup on ip_nat; it was
replaced before v1 because that pairing was fragile (see §10). The rule that
ships is deterministic and needs no cross-flow pairing state:
- Device side. On an
internalcapture the device is the non-router endpoint; on anexternalcapture the device is the router-owned endpoint (upstream infrastructure is the peer).is_router()decides which side is the router, from the interface MAC/IP maps learned fromagent_status. - Multicast/broadcast endpoints collapse to a single
multicastpseudo-device (via netifyd'sother_typewhen present, else the group-bit MAC test), so they cannot mint device aggregates. - Single-count (shadow) rule. An
internalcapture is always counted (it is the device's own copy). Anexternalcapture is counted only when the device is the router itself; an external capture of a client flow is a NAT twin and is shadowed (tracked, never counted). This is gated onsaw_internal, a process-wide latch meaning "internal capture is active at all": on an external-only netifyd (-Ewith no-I) nothing is shadowed, so every flow is counted rather than dropped to zero.
What this rule is and is not. It is a mode test ("is internal capture
running"), not a per-flow twin test ("does this specific flow have an internal
twin"). The two differ only for flows that appear on the WAN side alone, and
fr.nat (ip_nat) is retained as a diagnostic field in the flows method but
drives no decision.
FIXED 2026-08-24 by consulting conntrack. The history below is kept because the mistakes are the instructive part.
netifyd cannot answer this question, so appflowd now asks the kernel. For each
external capture whose device resolves to the router, ct_nat_twin() looks the
flow up in /proc/net/nf_conntrack on its reply tuple and compares the ORIGINAL
source against the reply destination: they differ for a masqueraded client and
match for router-originated traffic. Snapshots are reused for CT_TTL_MS, and a
lookup miss forces one refresh (floored at CT_FORCE_MIN_MS) because a flow is
normally younger than the current snapshot. If conntrack cannot be read at all
the code shadows on the safe default and says so once in the log, which keeps
client accounting correct at the cost of not attributing the router's own
traffic.
Measured after the fix, against wire ground truth from interface counters:
| wire truth | attributed | ||
|---|---|---|---|
| NAT-ed client | 21,323,454 | 21,287,298 | 99.8% |
| router's own | 6,290,088 | 6,157,849 | 97.9% |
with conntrack: available true, twins 20, router_local 2, unresolved 2. Both
are counted once. The fallback path was tested by pointing CONNTRACK_FILE at a
non-existent path: totals stayed at 97% rather than doubling, unresolved rose
to 16, and the warning fired.
A defect found in the first version of this very fix, worth recording because
it would have been invisible: caching the snapshot for 2 s meant new flows,
which are exactly the ones needing classification, usually missed it. It looked
like it worked (totals were right, because everything got shadowed) while
silently degrading to the no-conntrack behavior with conntrack fully available.
unresolved: 20 against router_local: 0 was the tell.
The original defect, for the record. This used to double-count. An earlier revision of this section, committed the same day, claimed the opposite and said the twin was correctly shadowed. That claim rested on a single measurement and was wrong; the correction and how it happened are recorded at the end of this section, because the mistake is more instructive than the result.
The deciding fact was what netifyd 4.4.7 puts in local_mac/other_mac when
ip_nat is true. Measured: it carries the router's own WAN identity, not the
client's. A NAT'd flow's external
capture reports
{ "ip_nat": true, "local_ip": "192.168.72.10",
"local_mac": "c0:56:27:4e:3e:92", "local_origin": true,
"other_ip": "172.66.0.218" }
where 192.168.72.10 / c0:56:27:4e:3e:92 are this router's WAN address and
MAC, while the internal capture of the same flow carries the real client
(192.168.1.50).
That makes dev_is_router true on the twin, and the rule reads
want_shadow = (saw_internal && !fr.internal && !dev_is_router && !group)
so !dev_is_router is false and the twin is NOT shadowed. It is counted a
second time, under the router pseudo-device, on top of the internal capture
already counted against the real client. The global total roughly doubles for
NAT'd client traffic.
Measured with a genuine NAT'd client: a veth pair into a network namespace
attached to br-lan, addressed 192.168.1.50/24 with a default route through
the router, so its traffic is really forwarded and really NATed. Ground truth
is the veth interface byte counter, which is wire bytes, the same quantity
appflow counts.
Per-device attribution of the client itself is essentially exact. 25 flows of 2 MB:
| bytes | |
|---|---|
| wire ground truth to/from the client | 53,145,843 |
| appflow, client device | 53,145,347 |
496 bytes of difference in 53 MB. The client's own row is trustworthy.
The global total is not. The same run credited the router pseudo-device a
further 51,785,387 bytes, the external twin of the very same traffic. Three
clean single-flow trials, each restarting appflowd first and letting the flow
purge:
| run | wire truth | appflow grand total | ratio |
|---|---|---|---|
| 1 | 10,479,105 | 17,089,074 | 163% |
| 2 | 10,478,155 | 20,639,013 | 196% |
| 3 | 10,478,879 | 20,877,799 | 199% |
The rest of this subsection is HISTORICAL and describes the state before the fix above. It is kept because the reasoning is what led to the fix, but nothing below is an open problem. A review panel reading this document cold reported it as a live double-count, having entered mid-section, so if you are skimming, stop here and go to §3.3.
Fix direction, at the time this was written, since implemented. The !dev_is_router term exists for a
real reason: traffic the router genuinely originates belongs under router and
must not be shadowed. So it cannot simply be dropped. What is needed is a way
to tell a router-originated flow from a NAT'd client's twin, both of which
present the router's own MAC on the external capture. ip_nat looks like the
obvious discriminator and fr.nat already carries it, but it is not
reliable: a capture taken while generating both kinds of traffic reported
ip_nat: false for every flow including the NAT'd client's, even though
ip_nat: true was definitely observed on a router-WAN flow earlier the same
day. Any fix must first establish when netifyd actually sets that field.
How the wrong conclusion got committed, because this is the useful part.
The first measurement was a single 20 MB flow that reported the client at
20,950,037 bytes and router at 452. That looked decisive and was written up
as resolved. It was not reproducible: repeating the identical single-flow test
gave router 20,572,398 and a grand total of 167%. One measurement, no
repetition, and a result that happened to match the hoped-for branch. The same
failure this project has caught in reviewers repeatedly, committed by the
author, on the day it was caught elsewhere.
Methodology warning for anyone repeating this. An earlier run of the same
test reported 69% and then a spread of 46-101%, which would have been a serious
false finding. Both were the measurement, not the daemon: the first sampled
before the flow had purged and immediately after an appflowd restart (so it
also measured connect-blindness, §2.5), and the spread came from fragile shell
parsing picking the wrong record out of the JSON. Let appflowd settle before
the flow starts, let the flow purge before sampling, and read the numbers
directly rather than through grep.
Related sub-case, now mitigated. A pre-existing external flow captured only
as a stub (flow_stats before any flow event) is identified once, before
saw_internal latches, and would otherwise stay counted alongside its internal
twin for its whole life, inflating the central total. reshadow() re-applies
the shadow rule on later stats updates, closing that window to at most one stats
interval. The residual sign question for an unidentifiable stub (a genuine NAT
twin vs. pre-existing router-origin traffic, both landing in the "unknown"
bucket) folds into the same bench test above.
3.3 Memory bounds (runs on 128 MB devices)
Flow table capped (default 4096 flows, uci-tunable); LRU-pruned on purge
events, idle timeout, and cap pressure. No unbounded growth by construction,
but the real worst case for the aggregate tables is not small: each of
AGG_MAX (384) aggregates per class can carry up to APP_DEV_MAX (64)
per-device ring objects, a ceiling of 384 x 64 = 24,576 per-(app x device)
ring objects, plus up to APP_HOST_MAX (16) host ring entries per aggregate,
384 x 16 = 6,144 more. Observed RSS was approximately 2 MB at a light load of
33 apps and 11 devices (§8); the structural ceiling above is one to two
orders of magnitude higher than that observed point, not a number the box is
expected to reach under normal use, but worth stating plainly rather than
waved off as "naturally small".
Every bound must be VISIBLE when it binds, and two of the three were not. A table that silently stops accepting data reports the same status as a table with nothing to accept, which is the worst reading an operator can be given: the dashboard looks healthy at exactly the moment it has become incomplete.
| bound | counter | added |
|---|---|---|
| aggregate table full of live entries | aggregates.refused |
2026-08-25 |
flow table at flow_max |
flows.shed |
2026-08-26 |
| unknown event-type map | (bounded at 16, not counted) |
flows.shed counts evictions caused by cap pressure, separately from
flows.pruned, which is ordinary idle housekeeping and is expected to be
large. They shared one counter until 2026-08-26, so cap pressure was invisible
underneath normal behavior.
flows.dropped is a genuine last-resort backstop and reads 0 in practice.
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 does not occur.
Measured by tests/protocol-suite.sh: 200 distinct digests against
flow_max=64 gave tracked=60 pruned=143 shed=143 dropped=0. It is kept
because the guard is correct and cheap; it is not the number to watch.
The conventions this screen follows, which are the ones a DPI traffic screen conventionally uses and which users of such screens already expect: a range-based view (past hour, with day and week as later phases), a 15 s poll, table columns of Application / Download / Upload / Total sorted by total descending, searchable, with a synthetic "All traffic" first row, a Top-10 bar chart, and a per-app detail drawer carrying a time series plus per-client rows (name, MAC, last seen, share %).
Classification here is entirely local: netifyd runs on the router, reads a local socket, and nothing about the traffic leaves the device. Measured on the development router, netifyd held 16 sockets over 4 days 23 hours of uptime, 12 unix and 3 packet-capture, and zero TCP or UDP sockets, with no outbound connection at any point.
appflow v1 ships two tabs:
- Overview (live), the live view, which range-based DPI screens do not generally provide. Live total throughput chart, top apps by current rate, category breakdown, top devices. Poll 5 s.
- Statistics (Past hour), the range view, from in-RAM buckets
(12 × 5-min per app): Top-10 chart, the Application/Download/Upload/Total
table (sorted, searchable, "All traffic" row), per-app detail drawer with
12-point time series + per-device rows (name, MAC, last seen, DL/UL,
share %), and a Clear button (
reset). Poll 15 s.
Past day / Past week tabs are phase 2 (require persistence; the UI shows the tabs disabled with a tooltip, so the seam is visible rather than hidden).
Deliberate departures from the conventional layout, documented as choices:
- No app icons in core, by design. Vendor screens' per-app icons ship
from their CDN and are proprietary (netify/eGloo data). Core
luci-app-appflowrenders letter-tile avatars colored by category on its own, zero licensing exposure, still scannable. A separate, optional package,luci-app-appflow-icons(§9), adds brand icons for the ~100 most recognizable detectable apps; core probes for that package's icon directory at render time and falls back cleanly when it is not installed. - No per-app Block toggle (vendor screens wire it to a content-filter product; out of scope for a statistics package).
- No enable/disable toggle, that gates a heavy NFQUEUE pipeline elsewhere; appflowd is a lightweight socket consumer, service control via standard init.
- Device names resolved from
/tmp/dhcp.leases+ netifyd status.json MAC map (vendor implementations use a proprietary client registry socket).
5. ubus contract (v1 draft)
Object appflow:
| method | args | returns |
|---|---|---|
summary |
- | totals, rates, top_apps[], top_categories[], top_devices[], window meta |
apps |
{sort, limit} |
full per-app aggregate list |
devices |
- | per-device aggregates (+resolved names) |
app_detail |
{app} |
per-device + per-hostname breakdown + hour time series for one app |
stats |
{range:"hour"} |
range totals: all/applications[]/top_apps[] with 12×5-min time series (only hour in v1; other ranges rejected until phase 2) |
flows |
{limit} |
live flow list (debug/power-user view) |
status |
- | daemon health: uptime, flows tracked, events/s, socket state |
reset |
- | zero all aggregates/buckets (the "Clear" function; write-scope ACL, separate from read grants) |
All byte values in bytes, rates in bytes/sec (UI formats). Timestamps
ms epoch. Every response carries generated_at + agent_connected.
6. Package layout
luci-app-appflow/
├── Makefile # LuCI feed style, PKGARCH:=all
├── htdocs/luci-static/resources/view/appflow/{overview,statistics,common}.js
├── root/etc/init.d/appflowd # procd wrapper
├── root/etc/config/appflow # uci: caps, window sizes, top-N
├── root/usr/sbin/appflowd # ucode daemon (shebang /usr/bin/ucode)
├── root/usr/share/rpcd/acl.d/luci-app-appflow.json
├── root/usr/share/luci/menu.d/luci-app-appflow.json
├── po/templates/appflow.pot # string catalog, GENERATED from source
├── tests/protocol-suite.sh # 54 checks, fake agent, no traffic needed
├── tests/hardware-suite.sh # 15 checks, real traffic vs wire truth
├── tests/frontend-suite.js # 31 checks, Node, runs in CI
├── tests/luci-module.js # loads a real LuCI view module under Node
└── docs/DESIGN.md # this file
po/ is not hand-maintained. luci.mk discovers languages by globbing po/*
and generates a luci-i18n-appflow-<lang> subpackage per directory, so adding
a translation needs no Makefile change. See CONTRIBUTING.md.
Depends (from Makefile LUCI_DEPENDS): netifyd, luci-base,
ucode-mod-socket, ucode-mod-uloop, ucode-mod-ubus, ucode-mod-uci,
ucode-mod-fs.
7. History extension seams (deliberate, v1 ships them dormant)
- Single aggregation choke point: every counter delta flows through one
account(flow, delta_bytes, ts)function, the only place history taps in. - Time-bucketed accumulators: aggregates are structured as
{cur, buckets[]}from day one, and the buckets are load-bearing in v1, not a stub: every aggregate (totals, each app, each device, each category) carries a 12-slot, 5-minute ring covering the trailing hour (backs thestats/app_detailtime series), and every per-(app × device) and per-host breakdown carries a coarser 4-slot, 15-minute ring covering the same trailing window at a quarter of the memory. History (§9) extends retention and adds persistence of that same shape; it does not introduce the buckets themselves. - Persistence interface:
store.flush(buckets)no-op stub in v1; later implementations (RAM-rotating file / collectd exec / sqlite on USB) plug in without touching accounting. Flash-wear rule: never write per-sample to overlay; flush coarse buckets only. - ubus contract reserves
historymethod name +since/untilargs.
8. Risks / open items
-
UI reference spec, landed 2026-08-21. It lived here as section 4 and now lives in frontend.md; the numbering below still skips 4 rather than renumber every cross-reference in this file. -
netifyd conf details, landed: multi-client listen socket,dump_established_flows,flow_statscadence config. Purge event confirmed asflow_purge(extracted from the netifyd binary, 2026-08-21). -
ucode: resolved, §3 states Option A shipped, proven reliable on 25.12.publish()reliability → decides §3 option A/B -
Dual-capture dedup double-counts NAT'd client traffic, FIXED 2026-08-24 (§3.2) by resolving the ambiguity through conntrack rather than guessing. Client 99.8% and router 97.9% of wire truth, each counted once. Degrades safely when conntrack is unreadable. Original defect description follows. -
(historical) The defect this replaced (§3.2). netifyd 4.4.7 reports the router's own WAN MAC on the external capture of a NAT'd flow, so
dev_is_routeris true andwant_shadowis false: the twin is counted again underrouter. Measured 163%, 196% and 199% of wire ground truth across three trials. The per-device row for the client is accurate to 496 bytes in 53 MB; it is the grand total and therouterrow that are wrong. No fix yet:!dev_is_routerprotects genuinely router-originated traffic and cannot simply be removed, andip_natis not a reliable discriminator. See §3.2 for the fix direction. -
CT_MAX described as a memory bound, FIXED 2026-08-24, also from the panel.ct_load()calledfs.readfile()and thensplit(raw, " "), which materialised the whole conntrack table and the whole line array beforeCT_MAXwas consulted, so the constant bounded the retained map but not peak allocation, and its own comment calling it a memory bound was wrong. It now reads a line at a time viafs.open()/read("line"), so peak is one line and the cap stops the work as well as the retention.A second defect in the same function: the non-forced throttle read
conntrack.entries && (now - conntrack.at) < CT_TTL_MS. A snapshot that parsed to zero rows while the file was readable made that falsy and disabled the throttle entirely, and the non-forced path has noCT_FORCE_MIN_MSfloor, so every external flow identification re-read the whole file. Now gated onconntrack.at, i.e. on having read recently rather than on having read recently and found something. -
Byte conservation is now instrumented, not just asserted (2026-08-24, suggested by a panel juror).
status.bytesreportsreported(what netifyd said moved, before any clamp or shadow decision),attributed(what reached an aggregate),shadowed(what was deliberately dropped as a NAT twin) andleaked(the residual, which should be 0). Both silent byte-loss defects this daemon has had would have been visible here from the first minute. Measured 0 across every run since. In a NAT-ed transferreportedlands at roughly twice wire truth with about half shadowed, which is the dual capture being deduplicated, visible for the first time. -
EA8500 resource baseline MEASURED (2026-08-21, light load, 12 flows, synthetic traffic): netifyd ~16.5 MB VSZ, ~0% CPU, box 95% idle, 397 MB free. Heavy-load measurement (real client routed through) still pending.
-
Byte conservation through the Unknown/late-stub path, VERIFIED sound 2026-08-22 (§2.5), including the sub-update_intervalcase. -
Connect/reconnect blindness (KNOWN LIMITATION, will ship). Flows already established when the daemon or netifyd (re)starts have their remaining bytes attributed to Unknown, because netifyd 4.4.7 emits no
flowevent for them (§2.4, §2.5). Bounded to that window, self-healing, non-destructive. Cannot be fixed without an established-flow dump upstream. Documented in the README rather than hidden. -
reshadow() exempted the flows it existed to correct, FOUND by an adversarial panel and FIXED 2026-08-24.reshadow()returned early ondev_key == "router", which is exactly how a WAN-side capture of a NAT-ed client flow is classified, because netifyd reports the post-translation identity (§3.2). The conntrack twin resolution added earlier that day went intoflow_identify()and was never wired intoreshadow(), so the one class of flow it must correct was the one it skipped. It now callsct_nat_twin()and defaults the same wayflow_identify()does.Measured on hardware, NAT-ed client transfer with
appflowdrestarted mid-flight, appflow total against the client veth's own tx counter:run before fix after fix 1 0.701 0.892 2 0.791 1.013 No-restart control after the fix: 1.012. A caveat worth keeping: the panel predicted a persistent double count of the central total. That is not what was measured. The old code under-counted after a restart (0.70-0.79) and the fix restores accuracy; it does not remove an inflation that was never observed. The code defect was real, the predicted magnitude and sign were not. The residual gap in run 1 is connect-blindness, below.
-
Late re-point stranding (OPEN, unquantified). If netifyd first reports a flow unclassified (
app_id <= 0) and only later re-points it via a detection update, bytes counted before the re-point stay under Unknown, because the accounting seam is append-only by design (see the re-point comment inclassify()). No counter currently exposes this, so its real-world magnitude is unknown. It could not be induced deliberately on the bench (it needs a protocol netifyd classifies only after several packets on a stable connection). Cheapest settling test: a real-traffic socket capture correlating per-digestflow/flow_stats/detection-update ordering, excluding connect-time orphans byfirst_seen_at; alternatively instrument the re-point inclassify()to accumulate stranded mass into astatuscounter. Deferred rather than guessed at.
9. Roadmap (queued, post-v1)
- v1.1,
luci-app-appflow-icons, a SEPARATE package. Hand-curated mapping (netify app id → icon) for the ~100 most-recognizable detectable apps, using monochrome SVGs from CC0/Apache-licensed collections (simple-icons / dashboard-icons), bundled locally, tinted by category color. Deliberately split from core, i18n-style, for legal blast-radius isolation: brand icons are trademarks regardless of collection license, so if anyone ever objects the icon package is delisted on its own and core is untouched. Coupling is runtime-soft in BOTH directions: core probes the icon directory at render time and falls back to letter-tile avatars; the icon package depends on nothing and breaks nothing when absent or removed. Same repo, second package directory. - v2, Past day / Past week ranges (persistence per §7 seams; flash-wear policy: coarse-bucket flushes only, tmpfs-first with periodic backup, tiered downsampling).
10. As-implemented contract notes (v1 final)
§5 sketched the method set; the implemented field naming is authoritative:
live aggregate rows use bytes_up/bytes_down/bytes_total +
rate_up/rate_down/rate_total (+ key/name/tag/category), while the
The stats/app_detail range views use download/upload/total, which is the
conventional vocabulary for that screen. status is nested
(socket.*, flows.*, aggregates.*, agent.*, memory.*).
app_detail takes the aggregate key or the raw tag or the display
label (resolver tries all three). The frontend ships normalizers for
both dialects in view/appflow/common.js. luci-lib-chartjs was dropped:
the packaged build is a Chart.js 1.x fragment (Doughnut/Pie only), and
inline SVG follows LuCI theme variables, which canvas cannot.
11. AI service breakout
11.1 Why netifyd cannot do this
Measured on two routers, 2026-08-27. /etc/netify.d/netify-apps.conf is the
Netify Agent's signature set. On both it is dated 10 August 2023, carries
199 application signatures, and contains zero entries matching
anthropic, claude, openai, chatgpt, huggingface, perplexity, mistral, copilot,
bard or gemini.
So AI traffic arrives with detected_application 0 and aggregates into generic
HTTP/S alongside everything else. This is vendor data three years stale, not a
configuration problem, and nothing on the router can change it.
11.2 Why appflow can
netifyd already reports the TLS SNI as host_server_name, and appflow already
stores it. Traffic generated to the real endpoints on the bench carried
api.anthropic.com, claude.ai and api.openai.com with no code change at
all. The identifying data was there the whole time; nothing was making use of
it.
The daemon is the right layer because aggregation happens there. The browser only ever receives summarized rows, so a frontend fix would arrive after the decision that matters had already been made.
11.3 What it does
In flow_identify(), after netifyd's identity is resolved and after the
category is derived, the SNI is matched by domain suffix against a built-in
table. On a hit the application key, label, tag and category are replaced.
key ai:anthropic-claude
name Anthropic (Claude)
tag appflow.anthropic-claude
cat ai-assistants
Four categories: ai-assistants, ai-media, ai-developer,
ai-infrastructure. The last covers aggregators, inference APIs, GPU rental
and vector stores, which are different businesses but one thing from a
network's point of view.
The category is a SLUG on the wire, and 1.1.0 got this wrong. It shipped
AI assistants and its three siblings as display strings, 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 travels
as a lowercase slug; these four were the only ones that did not, and they were
ours. Corrected in 1.1.1. frontend-suite.js now reads this table out of the
daemon source and fails if a category is emitted that the frontend cannot
translate, so the two halves cannot drift apart again.
Keyed on the vendor label, not the matched domain. anthropic.com and
claude.ai are one vendor and must aggregate into one row; keying on the
suffix would produce two identically-named rows and split the total.
The appflow. tag prefix is deliberate. netifyd's own tags are netify.*.
Nothing here pretends to have come from netifyd.
11.4 The gate, and why it makes the feature self-retiring
netifyd's own answer wins. The override runs only when
detected_application is 0, with one exception: entries flagged strong,
which override even a positive identification.
strong is set only where netifyd is known to recognize the PARENT brand and
would otherwise swallow the AI service inside it: Gemini reported as Google,
Copilot as GitHub, Qwen as Alibaba. Everywhere else the rule is conservative,
which means the day Netify ship AI signatures this table falls silent by
itself rather than permanently shadowing better data.
11.5 Matching is anchored on label boundaries
The SNI is lowercased, a trailing dot is stripped, and the last two, three and four labels are rebuilt and looked up in turn. Never a substring test.
anthropic.com.attacker.example is a hostname anyone can register and point
anywhere. A substring match on anthropic.com matches it. Rebuilding the last
N labels makes that impossible by construction rather than by care, and the
suite asserts it with a flow carrying exactly that hostname.
11.6 Accounting is untouched
This changes an identity, never a byte. account() never sees it, byte
conservation is exactly as it was, and the existing conservation checks still
hold. tests/protocol-suite.sh ai asserts a hand-computed total across a batch
containing AI flows for precisely this reason.
11.7 Limitations, stated rather than discovered
- This is hostname matching, not DPI. It asserts a classification netifyd did not make. The technique is legitimate, matching the SNI is what netifyd itself does for HTTP/S, but the accurate description is "we recognize the name", never "we inspected the protocol".
- The list rots. New services appear constantly and nothing here updates
itself.
option ai_breakout '0'turns it off entirely. - ECH would end it. Encrypted Client Hello encrypts the SNI. netifyd has the identical exposure so this is no worse than the status quo, but it is not durable and should not be sold as such.
- Region-encoded hostnames under a generic cloud domain cannot be matched.
bedrock-runtime.us-east-1.amazonaws.comhas no suffix specific to Bedrock, and matchingamazonaws.comwould label every S3 bucket on the network as AI. Deliberately absent rather than approximated. - A service behind a shared CDN hostname is invisible, because the SNI names the CDN.
- No icons ship for these entries. They render as letter tiles until the
icon pack gains matching
appflow.*keys.
12. Extensibility: the hostname table is not a constant
The AI breakout (section 11) shipped its table as a constant in the daemon, which meant adding a service required editing the source and rebuilding the package. For a tool whose central claim is that netifyd's signature set is three years stale, being un-extendable is the wrong shape, and the question that exposed it was the obvious one: "I have a service I want measured, what do I do?"
config hostmap sections in /etc/config/appflow are read at config load and
consulted BEFORE the built-in table, so one mechanism does both jobs: adding a
service that is missing, and correcting one that is wrong.
config hostmap
option suffix 'torproject.org'
option name 'Tor'
option category 'Privacy'
option strong '0'
A malformed entry is refused and named in the log. A suffix with no dot can never match, because the matcher rebuilds whole labels rather than comparing substrings, so accepting one produces a rule that silently does nothing and looks identical to the feature being broken.
That guard has no effect on classification, only on whether the user is told,
and the test for it had to be written accordingly. Two earlier versions were
tautologies: one grepped the whole log and passed forever once the message had
ever appeared, the next counted log lines and read past the count, which failed
because logread is a RING whose line count stops growing once it is full. A
unique marker written with logger is immune to both. See
tests/protocol-suite.sh hostmap.
13. Shipped in 1.1.2: the Router row had never counted a byte
The most serious defect this package has had, and it was invisible by
construction. On both routers checked, the router pseudo-device reported
exactly zero bytes. Production had logged 107,476 flows where conntrack
positively confirmed the router originated the traffic, against a device row
reading zero for 2.6 days and 52 GB. The dashboard correctly hides a zero-byte
device, so the feature was absent rather than visibly broken, and nobody
noticed for the life of the package.
The cause is one line and it is worth stating precisely. ct_nat_twin()
built its lookup key from the event it was handed:
other_ip | local_ip | other_port | local_port
netifyd puts the ports on the initial flow event and on nothing afterwards. A
flow_stats payload carries addresses and byte counters only. So the key built
from an update had two empty fields, matched nothing, and returned null.
reshadow() treats null as grounds to discard, so every router-originated
flow was shadowed on its first stats update, before a single byte reached the
aggregate. flow_identify() resolved it correctly, once, and the next event
threw the answer away.
The fix has two halves, and they are independently sufficient, which is why the tests distinguish them:
- The ports are stored on the flow record and the lookup prefers them. This
is the root cause and the check on
conntrack.unresolvedisolates it. - A positive finding is remembered.
fr.ct_localrecords that conntrack once said the router originated this flow, and a later lookup that cannot answer no longer overturns it. Only a positive "this is a NAT twin" may. Absent evidence is not positive evidence, the same rule §5 already states aboutstatusStrip().
The dedup is deliberately unchanged. A fix that simply stopped shadowing would restore the 163-199% over-count this mechanism exists to prevent, so the suite asserts that a router flow conntrack cannot confirm is still discarded.
conntrack_path became a config option purely so this is testable, exactly as
socket_path already was. The router group in protocol-suite.sh supplies a
conntrack fixture and drives the real daemon.
Also in 1.1.2: tile letters outside ASCII. tile() stripped with
/^[^0-9A-Za-z]+/, which consumes a name written in any non-Latin script
entirely and falls through to ?. Every Chinese, Japanese, Korean, Russian,
Greek, Arabic and Hebrew name rendered an identical question mark. Not a
translation bug: any LAN client with a non-Latin DHCP hostname hit it on every
release. Now stripped on \p{L}\p{N} with the /u flag.
14. Shipped in 1.1.1: labels that could not be translated
Every item that stood in this section as "queued for 1.1.1" shipped in 1.1.0
instead (commit b8ca31d): the search placeholder, exclude terms, and the
clickable donut. The section was left describing them as pending for three
weeks. What actually became 1.1.1 is below.
A label is only translatable if a literal reaches _(). That is what the
.pot extractor scans for, and three separate paths produced labels that never
did. All three were invisible to a green test run, and all three were reported,
directly or indirectly, by a first-time contributor's translation pull request.
catLabel() held 12 entries and title-cased the rest at runtime. That is
string manipulation, not translation: web became Web by algorithm, in
English, in every language. The live netifyd category file carries 43 tags
across its application and protocol indexes. The table now holds all of them.
The daemon names its three synthetic pseudo-devices in English, because a daemon has no idea what language a browser wants. The frontend now maps the stable key it sends, not the name, so a daemon-side rewording cannot silently revert the translation.
The AI categories were ours, not inherited. See section 12.
The interesting part is why no test caught it. frontend-suite.js asserted
that catLabel('networking') returns 'Networking', which is satisfied
identically by a _() lookup and by a title-caser, and it asserted that every
string reaching _() is in the catalogue, which is blind by construction to a
string that never reaches _(). The new group asserts membership in the set
of strings the module actually passed to _(), recorded by the harness as it
loads. Six product edits were executed against it to confirm each check can
fail; two of those runs found defects in the test rather than the product.
15. Filtering and the drill-down
14.1 Where the logic lives, and why
parseFilter() and matchFilter() are in common.js, not inside either
view's render function. Both views share them so the two cannot drift, and Node
can load common.js, so the filter is covered by tests/frontend-suite.js
rather than by looking at a browser. The sibling package learned the same
lesson the same week: logic that decides something belongs where a test can
reach it.
Terms are space separated and every one must pass, so terms narrow. A leading minus excludes. Exclusion is not decoration: the question that produced this feature was "one streaming session is burying everything else", and an include-only filter cannot express it, because you would have to already know what you wanted to keep.
A bare - is dropped rather than treated as exclude-all, because that is what
a half-typed term looks like and blanking the table between two keystrokes is
not acceptable.
14.2 Fetching past the top-N cut
The overview asks the daemon for FETCH_LIMIT (50) application rows and
displays TOP_APPS (8). The extra rows are the filter's raw material. Without
them, filtering a top-10 list to "AI" returns nothing the moment a streaming
session pushes AI out of the top 10, which looks broken and is worse than
having no filter at all.
14.3 The drill-down needs no new aggregate
Both directions are one pass over the live flow table:
devices_for_apps(keys) which devices carry these applications
apps_for_device(dev) which applications is this device using
Every flow record already carries dev_key, app_key and cat_key, so the
join exists on every flow, and the table is already bounded by flow_max. No
new storage.
This was first answered wrongly. The initial response was that it required a per-device-per-application aggregate and was therefore a bounded-memory design question. Checking the flow record disproved that in two minutes. The episode is recorded because the shape of the mistake matters more than the mistake: an architectural-sounding objection offered instead of a two-minute check.
14.4 Attributed bytes, not lifetime counters
Both functions sum fr.up + fr.down, the bytes appflow attributed, and never
fr.total, which is netifyd's lifetime counter for the connection. A stub flow
adopted mid-transfer has a large lifetime and small attributed bytes, so the
two differ exactly where it matters.
The test fixture carries one flow with a lifetime of 9000 against 1000 attributed, for one reason: without it every flow had the two equal, summing the wrong field was invisible, and the sabotage for that check passed against broken code.
14.5 Three bugs of one shape
Getting this working produced three defects that presented identically: wired up, no error, no effect.
nullfor a string ubus argument. ubus rejects it with code 2 rather than treating it as absent, andL.resolveDefaultswallowed the rejection.- The poll repainted over the filtered result. A side call filtered the device card and the next five-second poll overwrote it. Filtering is view state, so every fetch carries it rather than one special path.
normDevicedropped the daemon'skey. Clicking a device passedundefined, which cleared the selection instead of setting it.
None of these are catchable by a test of logic, because in each case the logic was right and the wire was not connected. They were found by driving the page. That is the limit of this package's test strategy and it is written here rather than left implicit.
14.6 One ACL grant was widened, deliberately
apps was unbound in the frontend and ungranted in the ACL after an August
security review. The device drill-down needs it, so it was bound and granted,
in that order, exactly as that review's own note required.
apps {device} returns an aggregate per device and application from the live
flow table. It is more revealing than either list alone, since it says which
device used which application, and it is still far short of flows: no remote
address, no port, no per-connection record. flows remains unbound and
ungranted.