💋 Sync 2026-10-04 04:16:09

This commit is contained in:
github-actions[bot] committed 2026-10-04 04:16:09 +08:00
1 parent f6b2df73ec
commit f62992fd80
745 files changed
+212416 -1173

No files matched your search

+984
View File
@@ -0,0 +1,984 @@
# 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 lone
`length` key = frame header (skip); anything else dispatches on `type`.
- Event types observed: `agent_hello`, `definitions`, `agent_status` (15 s
cadence), `flow`, `flow_stats`.
- `/var/run/netifyd/status.json` contains **agent status only** (plus a useful
MAC→IP `devices` map), 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):
```json
{ "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):
```json
{ "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
- `definitions` event carries id→tag maps: 416 applications, 217 protocols.
Applications and protocols are SEPARATE id spaces that can collide numerically
(id 206 is both `WireGuard` the protocol and `netify.cloudflare` the app), and
legacy netify apps exist below 10000 (netflix=133), the `netify.` 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`, …) and
`protocol_tag_index` (18 tags), plus `application_index` /
`protocol_index` mapping category-id → member ids.
- Display-name policy: application name if detected (strip `netify.` prefix,
title-case), else protocol name, else `Unknown`; 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 the `netifyd` package. 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_purge` events carry `reason: closed|expired|terminated` and the
flow's final cumulative `total_bytes`/`total_packets`, but
`local_bytes`/`other_bytes` are 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_flows` does 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
tolerates `flow_stats` for unknown digests (stub "Unknown" entry). **Corrected
2026-08-22:** that stub is backfilled only when a `flow` event 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 a `flow` event 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: `libndpi` is 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 any `flow_stats`):
all 150 accounted, `no_total` = 0, nothing lost. A plausible reading of the
`fr.total > 0` gate in `flow_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_rescued` was 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 a `flow_stats` DO
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_purge` per digest,
UDP DNS and short TCP both driven deliberately), **64 of 64 flows whose full
lifecycle was observed carried a `flow_stats` before their `flow_purge`.**
Zero went `flow` -> `flow_purge` directly. 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 a `final` flag from `ev_purge()`
and attributes `total_bytes` for a purge-only flow, counted in
`stats.purge_only` and reported as `status.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_stats` but never a
`flow` event (§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 5 `flow_stats` and 0 `flow` events 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 `internal` capture the device is the non-router
endpoint; on an `external` capture 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 from `agent_status`.
- **Multicast/broadcast** endpoints collapse to a single `multicast`
pseudo-device (via netifyd's `other_type` when present, else the group-bit MAC
test), so they cannot mint device aggregates.
- **Single-count (shadow) rule.** An `internal` capture is always counted (it is
the device's own copy). An `external` capture 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 on `saw_internal`, a
process-wide latch meaning "internal capture is active at all": on an
external-only netifyd (`-E` with 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
```json
{ "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:
1. **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.
2. **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-appflow` renders 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)
1. **Single aggregation choke point**: every counter delta flows through one
`account(flow, delta_bytes, ts)` function, the only place history taps in.
2. **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 the `stats`/`app_detail` time 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.
3. **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.
4. **ubus contract reserves** `history` method name + `since/until` args.
## 8. Risks / open items
- ~~UI reference spec~~, landed 2026-08-21. It lived here as section 4 and
now lives in [frontend.md](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_stats` cadence config. Purge event confirmed as `flow_purge` (extracted from the netifyd binary, 2026-08-21).
- ~~ucode `publish()` reliability → decides §3 option A/B~~: resolved, §3
states Option A shipped, proven reliable on 25.12.
- ~~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_router` is true and `want_shadow` is false: the twin
is counted again under `router`. 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 the `router` row that are
wrong. No fix yet: `!dev_is_router` protects genuinely router-originated
traffic and cannot simply be removed, and `ip_nat` is 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()` called `fs.readfile()` and then `split(raw, "
")`, which
materialised the whole conntrack table and the whole line array *before*
`CT_MAX` was 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 via `fs.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 no `CT_FORCE_MIN_MS` floor,
so every external flow identification re-read the whole file. Now gated on
`conntrack.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.bytes` reports `reported` (what netifyd
said moved, before any clamp or shadow decision), `attributed` (what reached
an aggregate), `shadowed` (what was deliberately dropped as a NAT twin) and
`leaked` (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 transfer `reported` lands 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_interval` case.
- **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 `flow` event 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 on
`dev_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
into `flow_identify()` and was never wired into `reshadow()`, so the one class
of flow it must correct was the one it skipped. It now calls `ct_nat_twin()`
and defaults the same way `flow_identify()` does.
Measured on hardware, NAT-ed client transfer with `appflowd` restarted
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 in
`classify()`). 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-digest `flow`/`flow_stats`/detection-update ordering, excluding
connect-time orphans by `first_seen_at`; alternatively instrument the re-point
in `classify()` to accumulate stranded mass into a `status` counter. Deferred
rather than guessed at.
## 9. Roadmap (queued, post-v1)
1. **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.
2. **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.com` has no suffix specific to Bedrock,
and matching `amazonaws.com` would 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.unresolved` isolates it.
- **A positive finding is remembered.** `fr.ct_local` records 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
about `statusStrip()`.
**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.
- **`null` for a string ubus argument.** ubus rejects it with code 2 rather
than treating it as absent, and `L.resolveDefault` swallowed 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.
- **`normDevice` dropped the daemon's `key`.** Clicking a device passed
`undefined`, 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.**
Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

+11
View File
@@ -0,0 +1,11 @@
# AppFlow frontend
- `common.js`: shared class (`require view.appflow.common as appflow`); ubus bindings, payload normalizers, byte/rate formatting, category colors, the icon probe, SVG chart primitives, injected CSS.
- `overview.js`: Overview (live). Polls `summary`+`devices`+`status` every 5 s; the 60-sample chart window lives in the view, not the daemon.
- `statistics.js`: Statistics (past hour). Polls `stats {range:"hour"}` every 15 s; search, sort and the detail drawer are client-side; `reset` sits behind a confirm dialog.
- Menu: `root/usr/share/luci/menu.d/luci-app-appflow.json`, top-level `admin/appflow` firstchild node gated on the `luci-app-appflow` ACL group.
Conventions: charts are hand-drawn SVG (`lineChart`/`columnChart`/`donut`) so they inherit the theme's CSS variables and read on both bootstrap themes; `luci-lib-chartjs` is unused (its shipped build ships Doughnut/Pie only, no Line/Bar). All CSS is prefixed `af-` and injected once via `appflow.injectCSS()`.
Every payload field is read through `normApp`/`normDevice`/`normTotals`/`normSeries`, which absorb the daemon's two dialects (`bytes_down`/`rate_down` on live aggregates, `download`/`upload` on range views); tighten those alias lists once the DESIGN §5 contract freezes instead of reaching into payloads from the views.
Icons are an optional soft dependency (DESIGN §9): `loadIcons()` probes `/luci-static/resources/appflow-icons/manifest.json` once per page load from each view's `load()`, keyed by the row's netify `tag`; if the sibling package is absent the probe resolves silently and every tile stays a letter tile. `simple-icons` entries are fill-less glyphs and get whitened over the category-colored tile; other sources render as-is. Category colors come from the fixed `CATEGORY_SLOT` table (hash fallback) so the doughnut legend never shows two identical swatches.
Both views build their skeleton once and repaint only inner nodes, so the search box keeps focus and caret across polls; each keeps a `fails` counter so one dropped poll does not flash the "appflowd is not running" screen.
@@ -0,0 +1,624 @@
# The netifyd 4.4.7 JSON export interface, as shipped by OpenWrt
## Scope
This document describes the JSON socket interface exposed by **netifyd 4.4.7**,
the exact version OpenWrt packages and installs (`netifyd-4.4.7-r2.apk` at the
time of writing). It does **not** describe the current upstream Netify Agent
(v5.x), which has moved to a plugin (`plugins.d`) architecture with a
different sink model, additional fields (`digest_prev`, `risks.*` extensions,
`app_ip_override`), and options that do not exist in 4.4.7. Netify's own
public documentation site currently documents v5, which is a meaningful
source of confusion if you go looking for confirmation of anything here: most
of what you'll find online describes a daemon that is not the one running on
your router.
Everything stated as fact below is grounded in one of two sources:
1. **Direct inspection of the 4.4.7 source and OpenWrt packaging**
(`gitlab.com/netify.ai/public/netify-agent` at tag `v4.4.7`, and the
`openwrt/packages` build recipe).
2. **A real captured stream from a running 4.4.7 instance** on a Linksys
EA8500 (OpenWrt 25.12.5, `arm_cortex-a15_neon-vfpv4`), taken over a UNIX
socket connection immediately following daemon start. Excerpts from that
capture are reproduced verbatim throughout this document (only field
*values* like MAC/IP addresses and timestamps are real lab data, not
synthetic).
Where the two disagree, or where a claim could only be confirmed from source
and not observed live, this is called out explicitly. An "Unverified /
unknown" section at the end collects every open question directly rather
than papering over it.
**A note on `dump_established_flows`.** You may see this option referenced in
blog posts, forum threads, or even in comments in older netifyd
configuration examples. It does not exist in 4.4.7: the string is entirely
absent from the compiled binary and from the 4.4.7 man page source. It is a
v5-era `plugins.d` option. Practically, this means a client that connects to
the socket mid-stream (which, for most consumers, is every connection) gets
**no backfill of already-tracked flows**. It starts blind. See "Connection
lifecycle" and "Practical consumer guidance" below for what this means and
how to handle it; the real capture used throughout this document directly
demonstrates the resulting behavior.
## 1. Socket and wire framing
netifyd exposes a UNIX domain socket, configured in `/etc/netifyd.conf`:
```ini
[socket]
listen_path[0] = /var/run/netifyd/netifyd.sock
```
On stock OpenWrt this is active by default as soon as the `netifyd` init
script starts the daemon; no additional configuration is required to get a
live stream. (A TCP listener is also supported via `listen_address[n]` /
`listen_port[n]`, but is not used by default on OpenWrt and was not
exercised for this document.)
**Wire format**: every message is a JSON object terminated by `\n`, and every
payload line is preceded by a one-line header of the form `{"length": N}`.
This is newline-delimited JSON with an explicit length prefix, not bare NDJSON.
Real captured pair (the very first two lines netifyd writes to a freshly
connected socket):
```
{"length": 165}
{"agent_version":"4.4.7","build_version":"Netify Agent/4.4.7 (openwrt; arm; conntrack; netlink; dns-cache; plugins; regex)","json_version":1.9,"type":"agent_hello"}
```
**Framing detail confirmed by direct measurement**: `N` is the byte length of
the payload line **including its trailing `\n`**, not the length of the raw
JSON content alone. Checked across all 17 header/payload pairs in the capture
used for this document, the declared `length` matched `byteLength(payload) + 1`
in every case, and never matched `byteLength(payload)` alone. A parser must
account for the newline when deciding how many bytes to read for a given
frame (or, more robustly, simply read line-delimited text and treat the
length header as a frame-boundary hint rather than the sole read-size
authority).
Parser rule, in short: read a line; if its only key is `length`, it is a
frame header, discard it; otherwise the line is a complete JSON event and its
`type` field determines how to interpret it.
## 2. Connection lifecycle
The real capture shows this exact sequence on connect (daemon `update_interval`
was 15 seconds for this run):
1. `agent_hello` -- sent once, immediately, before anything else.
2. `agent_status` -- sent immediately after `agent_hello`. This first status
frame is a **reduced** one; see the field-presence note in section 3.3.
3. `definitions` -- sent once per connection, a large one-time dump of the
application and protocol id-to-name tables (416 applications / 217
protocols in this capture).
4. A burst of `flow_stats` events (11 of them in this capture) for flows that
were **already being tracked** before this client connected. **None of
these digests were ever announced via a `flow` event on this connection.**
This is the live, directly observable consequence of
`dump_established_flows` not existing in 4.4.7: the client is receiving
counter updates for flows whose identity (application, protocol, hostname,
IPs) it was never told.
5. A second `agent_status`, 15 seconds after the first (matching
`update_interval`), this time carrying the fuller field set (interfaces,
devices, per-interface capture stats).
6. Two `flow` events, for newly detected DNS queries to `openwrt.org`,
showing the daemon has now genuinely observed new traffic from
first packet.
No `flow_purge` event occurred during this capture window (the two new flows
were still active when the capture ended). Its shape is documented in
section 3.6, confirmed live via 10+ purge events captured separately during
this project's hardware testing (`DESIGN.md`), even though none happened to
land in the specific capture excerpted in this document.
Practical implication for a long-running consumer (this is what
`appflowd` in this repo does): on every connect or reconnect, assume you know
nothing about flows already in progress. You will start receiving
`flow_stats` (and eventually `flow_purge`) for digests you have never seen a
`flow` event for. Treat that as the normal, permanent case, not a transient
startup race.
## 3. Event type reference
Every event on the socket, except `agent_hello` and `definitions`, shares the
same top-level envelope:
```json
{"type": "<event-type>", "interface": "<ifname>", "internal": <bool>, "established": <bool>, "flow": { ... }}
```
`interface` is the capturing interface name (`br-lan`, `wan`, etc.). `internal`
reflects which capture point saw the packet (true for internal/LAN-side
capture, false for external/WAN-side), which matters because netifyd watches
both `br-lan` and `wan` simultaneously and a NAT'd client's traffic can appear
as two distinct flows with two distinct digests (see `DESIGN.md` section 3.2
for this project's dedup policy; that is an application-level concern, not
part of the wire protocol itself).
`established` is present on every `flow`/`flow_stats` event in the capture
and was `false` in all observed instances. Its precise semantics were not
confirmed from source in the research this document is based on, and the
capture is too short and too uniform to infer it empirically; see
"Unverified / unknown."
### 3.1 `agent_hello`
Sent once, immediately on connect, before any other event.
```json
{"agent_version":"4.4.7","build_version":"Netify Agent/4.4.7 (openwrt; arm; conntrack; netlink; dns-cache; plugins; regex)","json_version":1.9,"type":"agent_hello"}
```
| Field | Type | Notes |
|---|---|---|
| `agent_version` | string | e.g. `"4.4.7"` |
| `build_version` | string | Human-readable build string; the parenthesized list is compile-time capability flags. This build has `conntrack` compiled in (relevant to `ct_id`/`ct_mark`, see 3.4). |
| `json_version` | number | Schema/protocol version for the JSON payloads on this socket, `1.9` in this capture. Matches the `version` field later seen in `agent_status`. |
| `type` | string | Always `"agent_hello"` |
### 3.2 `definitions`
Sent once per connection, immediately after the first `agent_status`. Carries
the full id-to-name tables for both id spaces used elsewhere in the stream
(`detected_protocol` and `detected_application`). This is a large message
(18,704 bytes in this capture, framed with the `{"length": N}` header like
everything else).
Truncated real excerpt (full arrays omitted for length; see section 5 for a
detailed discussion of what's in them):
```json
{"applications":[{"id":248,"tag":"ZOOM"},{"id":177,"tag":"ZMQ"}, ... ,{"id":10126,"tag":"netify.zendesk"}],
"protocols":[{"id":248,"tag":"ZOOM"},{"id":177,"tag":"ZMQ"}, ... ,{"id":191,"tag":"OOKLA"}],
"type":"definitions"}
```
| Field | Type | Notes |
|---|---|---|
| `applications` | array of `{id: uint, tag: string}` | 416 entries in this capture. Resolves `flow.detected_application`. **Do not** also use this array to resolve `detected_protocol` -- see the id-collision warning in section 5. |
| `protocols` | array of `{id: uint, tag: string}` | 217 entries in this capture. Resolves `flow.detected_protocol` only. |
| `type` | string | Always `"definitions"` |
The captured counts (416 / 217) match `DESIGN.md`'s figures exactly, which is
good independent corroboration that this capture is representative and not a
truncated or unusual snapshot.
### 3.3 `agent_status`
Agent-level telemetry: uptime, CPU, memory, flow-table size, sink (upload)
state, and (on richer frames) per-interface identity and packet-capture
counters. **Never contains per-flow data.** This is the same payload also
written periodically to `/var/run/netifyd/status.json`; on the socket it is
additionally interleaved with flow events, once per `update_interval`.
**Two different shapes were observed for this event type in the same
capture, and this is worth flagging explicitly because it is easy to design
a parser around only the richer one and then break on the first frame.**
First `agent_status` (sent immediately after `agent_hello`, before
`definitions`):
```json
{"cpu_cores":2,"cpu_system":0.075803,"cpu_system_prev":0.028308,"cpu_user":1.212848,"cpu_user_prev":0.113235,"dhc_size":1,"dhc_status":true,"flow_count":16,"flow_count_prev":0,"maxrss_kb":15372,"maxrss_kb_prev":9332,"sink_status":false,"sink_uploads":false,"timestamp":1787325073,"type":"agent_status","update_imf":1,"update_interval":15,"uptime":15,"version":1.9}
```
Second `agent_status` (15 seconds later, on the regular cadence):
```json
{"agent_version":"4.4.7","cpu_cores":2,"cpu_system":0.077808,"cpu_system_prev":0.075803,"cpu_user":1.256057,"cpu_user_prev":1.212848,"devices":{"b4:2e:99:3a:d6:e4":["192.168.1.10"],"c0:56:27:4e:3e:92":["192.168.72.10"],"c0:56:27:4e:3e:93":["192.168.1.1"],"dc:b5:4f:ad:0d:6e":["192.168.72.21","fe80::18f3:bf8d:dc6e:980f"]},"dhc_size":1,"dhc_status":true,"flow_count":21,"flow_count_prev":16,"interfaces":{"br-lan":{"addr":["fe80::c256:27ff:fe4e:3e93","fda8:3a0e:1478::1","192.168.1.1"],"mac":"c0:56:27:4e:3e:93","role":"LAN","state":1},"wan":{"addr":["fe80::c256:27ff:fe4e:3e92","192.168.72.10"],"mac":"c0:56:27:4e:3e:92","role":"WAN","state":1}},"maxrss_kb":15372,"maxrss_kb_prev":15372,"sink_status":false,"sink_uploads":false,"stats":{"br-lan":{"capture_dropped":0,"capture_filtered":0,"discarded":0,"discarded_bytes":0,"ethernet":35,"fragmented":0,"icmp":0,"igmp":0,"ip":35,"ip_bytes":10806,"largest_bytes":1718,"mpls":0,"pcap_drop":0,"pcap_ifdrop":0,"pcap_recv":35,"pppoe":0,"queue_dropped":0,"raw":35,"tcp":35,"tcp_resets":0,"tcp_seq_error":0,"udp":0,"vlan":0,"wire_bytes":11646},"wan":{"capture_dropped":0,"capture_filtered":0,"discarded":2,"discarded_bytes":102,"ethernet":158,"fragmented":0,"icmp":2,"igmp":0,"ip":156,"ip_bytes":149662,"largest_bytes":4338,"mpls":0,"pcap_drop":0,"pcap_ifdrop":0,"pcap_recv":158,"pppoe":0,"queue_dropped":0,"raw":158,"tcp":134,"tcp_resets":2,"tcp_seq_error":0,"udp":20,"vlan":0,"wire_bytes":153406}},"timestamp":1787325074,"type":"agent_status","update_imf":1,"update_interval":15,"uptime":30,"version":1.9}
```
| Field | Type | Present on | Notes |
|---|---|---|---|
| `type` | string | both | Always `"agent_status"` |
| `version` | number | both | JSON schema version, `1.9` here (same value as `agent_hello.json_version`) |
| `timestamp` | uint | both | Epoch **seconds**. See the timing anomaly noted below; don't lean on this for interval math. |
| `uptime` | uint | both | Seconds since agent start. Reliable for computing elapsed time between status frames. |
| `update_interval` | uint | both | Configured status/stats cadence in seconds, `15` here |
| `update_imf` | uint | both | Present, value `1` in both frames. Meaning not confirmed by any source consulted; see "Unverified / unknown." |
| `cpu_cores` | uint | both | |
| `cpu_user`, `cpu_user_prev`, `cpu_system`, `cpu_system_prev` | number (seconds) | both | Cumulative CPU time, current and previous sample |
| `flow_count`, `flow_count_prev` | uint | both | Size of the daemon's live flow table, current and previous sample. This is a **count**, never the flows themselves. |
| `maxrss_kb`, `maxrss_kb_prev` | uint | both | Resident memory high-water mark, current and previous sample |
| `dhc_status` | bool | both | DNS hint cache active |
| `dhc_size` | uint | both | DNS hint cache entry count |
| `sink_status` | bool | both | Cloud upload sink connected. `false` throughout this capture (sink disabled). |
| `sink_uploads` | bool | both | Whether upload is compiled/enabled at all |
| `agent_version` | string | second frame only | e.g. `"4.4.7"` |
| `devices` | object, MAC string to array of IP strings | second frame only | MAC to IP(s) map; useful for device-name resolution independent of DHCP leases |
| `interfaces` | object keyed by ifname | second frame only | Per-interface `addr[]`, `mac`, `role` (`"LAN"`/`"WAN"`), `state` (observed `1`, presumed up/down) |
| `stats` | object keyed by ifname | second frame only | Per-interface packet-capture counters (drops, protocol breakdown, byte totals). Not per-flow. |
**Consumer guidance from this asymmetry**: do not assume `agent_version`,
`devices`, `interfaces`, or `stats` are present on every `agent_status`
frame. Merge additively into whatever state you're building rather than
replacing it wholesale on frames that omit these keys, or you will discard
good state on the first frame after every reconnect.
**Timing anomaly, flagged rather than explained**: between the two captured
`agent_status` frames, `uptime` advanced by exactly 15 seconds (matching
`update_interval`, as expected), but the wall-clock `timestamp` field only
advanced by 1 second (`1787325073` to `1787325074`). This is inconsistent
with both fields tracking real elapsed time. It may be a genuine daemon
quirk, or an artifact of how this particular lab capture was recorded (the
JSON was captured live but the header rows were relatively hand-timed for
this exercise). No independent source was available to confirm which.
**Do not use `timestamp` deltas to infer the update cadence; use `uptime`
deltas or the configured `update_interval` instead.**
### 3.4 `flow`
Fires when a flow's Layer-7 identity is (re)determined: first classification,
or a later refinement flagged via `detection_updated`. Carries full identity
metadata. **Carries no byte or packet counters** -- confirmed directly by
this capture, where neither of the two captured `flow` events has
`local_bytes`, `other_bytes`, `total_bytes`, or any packet counter field.
Counters arrive separately via `flow_stats`.
Real captured example (a router-originated DNS query, unedited beyond
formatting):
```json
{"established":false,"flow":{"category":{"application":31,"domain":0,"protocol":14},"detected_application":10465,"detected_application_name":"netify.openwrt","detected_protocol":5,"detected_protocol_name":"DNS","detection_guessed":false,"detection_updated":false,"dhc_hit":false,"digest":"9c2d7b56c4571e146f65b0d15c12cec661d56b3f","first_seen_at":1787325075857,"first_update_at":1787325075857,"host_server_name":"openwrt.org","ip_nat":false,"ip_protocol":17,"ip_version":4,"last_seen_at":1787325075869,"local_ip":"192.168.72.1","local_mac":"94:83:c4:a8:8f:f1","local_origin":false,"local_port":53,"other_ip":"192.168.72.10","other_mac":"c0:56:27:4e:3e:92","other_port":57052,"other_type":"local","risks":{"ndpi_risk_score":10,"ndpi_risk_score_client":5,"ndpi_risk_score_server":5,"risks":[46]},"soft_dissector":false,"vlan_id":0},"interface":"wan","internal":false,"type":"flow"}
```
Reading this example concretely: `local_ip` (192.168.72.1, port 53) is the
upstream DNS resolver; `other_ip` (192.168.72.10, port 57052) is this
router's own WAN address. `local_origin: false` means the "local" side (the
DNS resolver, from netifyd's point of view) did not initiate; the "other"
side (the router) did -- i.e. this is the router itself making a DNS query,
captured on the `wan` interface (`internal: false`). `other_type: "local"`
classifies the *other* endpoint's address as being on a locally-adjacent
network, independent of which capture point (`internal`/`interface`) saw the
packet. This distinction (address locality vs. capture-point locality) is
exactly what `DESIGN.md` section 3.2's router-origin-traffic attribution
policy depends on.
| Field | Type | Notes |
|---|---|---|
| `digest` | string | 40-char lowercase hex (SHA1). Correlation key; unconditionally present on every event type that carries a `flow` object. |
| `first_seen_at`, `last_seen_at` | uint64 | Epoch **milliseconds** |
| `first_update_at` | uint64 | Present in this capture, not documented in prior source-only notes on this interface. In both captured examples it is numerically identical to `first_seen_at`. Whether it diverges once `detection_updated` fires was not observable in this capture (neither example had `detection_updated: true`). See "Unverified / unknown." |
| `detection_guessed` | bool | |
| `detection_updated` | bool | `false` in both captured examples (both are first-detection events, not re-detections) |
| `dhc_hit` | bool | DNS hint cache was used for this detection |
| `soft_dissector` | bool | |
| `ip_version` | uint | 4 or 6 |
| `ip_protocol` | uint | IANA protocol number (`17` = UDP in the example) |
| `vlan_id` | uint | |
| `ip_nat` | bool | `false` in the example |
| `other_type` | string | Observed value: `"local"`. Source also documents `"remote"`, `"broadcast"`, `"multicast"`, `"unsupported"`, `"error"`, `"unknown"` -- not independently confirmed live in this capture. |
| `local_origin` | bool | Which side initiated; see worked example above |
| `local_mac` / `other_mac` | string | MAC addresses |
| `local_ip` / `other_ip` | string | |
| `local_port` / `other_port` | uint | |
| `detected_protocol` | uint | Id into the `definitions.protocols[]` table |
| `detected_protocol_name` | string | Plain string, not namespaced (`"DNS"` here) |
| `detected_application` | uint | Id into the `definitions.applications[]` table |
| `detected_application_name` | string | `"netify.<slug>"` format when an app-level identification was made. Source indicates this can be absent/empty when only a protocol, not a specific app, was identified; not observed empty in this capture (both examples had it set). |
| `category.application`, `category.protocol`, `category.domain` | uint | Category ids. Resolved via `/etc/netify.d/netify-categories.json`'s index tables, **not** via the `definitions` applications/protocols name arrays (different id space entirely; see section 5). |
| `dns_host_name` | string | Not present in either captured example (both flows already had `host_server_name` populated from the query itself) |
| `host_server_name` | string | TLS SNI or DNS/HTTP hostname, when available. `"openwrt.org"` in the example. |
| `http.user_agent`, `http.url` | -- | Not observed (neither captured flow is HTTP); documented from source only |
| `dhcp.fingerprint` | -- | Not observed; documented from source only |
| `gtp.*` | -- | Not observed; documented from source only, GTP tunnel inner addressing |
| `risks` | object | Present in both captured examples: `ndpi_risk_score`, `ndpi_risk_score_client`, `ndpi_risk_score_server` (uint scores) and `risks` (array of numeric risk-flag ids, e.g. `[46]`). Not present in the source-derived field list this document's predecessor research was built from -- this is a real, confirmed field that prior source-reading missed. No id-to-name mapping for the values in `risks[]` was found in any source consulted here; see "Unverified / unknown." |
| `ct_id`, `ct_mark` | -- | **Not present** in either captured `flow` example, despite this build having `conntrack` compiled in (per the `agent_hello.build_version` string). Whether this is because these are UDP/no-NAT flows, or because emitting these fields needs an encode flag not set by default, is unconfirmed. |
Notably absent from `flow` in this capture: `detection_packets` and every
byte/packet counter. Those appear only in `flow_stats` (below), confirming
the two-event split cleanly.
### 3.5 `flow_stats`
Periodic per-flow counter update, once per `update_interval`, for flows past
initial detection. No identity metadata (app/protocol names, hostnames,
etc.) -- only the digest, timestamp, and counters.
Real captured example:
```json
{"established":false,"flow":{"detection_packets":16,"digest":"f663f9df9734cbaf815438f6e4b7361794729816","last_seen_at":1787325073851,"local_bytes":5336,"local_packets":19,"other_bytes":5470,"other_packets":16,"total_bytes":10806,"total_packets":35},"interface":"br-lan","internal":true,"type":"flow_stats"}
```
| Field | Type | Notes |
|---|---|---|
| `digest` | string | Correlation key |
| `last_seen_at` | uint64 | Epoch milliseconds |
| `detection_packets` | uint | Packets DPI examined before finalizing classification (a detection-time count, not a running traffic count) |
| `local_bytes`, `other_bytes` | uint | **Per-update deltas, not cumulative.** See the correction below. |
| `local_packets`, `other_packets` | uint | Same: per-update deltas |
| `total_bytes`, `total_packets` | uint | **Cumulative** since flow start. This is the only running total in the record. |
**Correction to a prior assumption, stated here as confirmed fact**:
`local_bytes`/`other_bytes` (and the matching packet counters) are
per-update deltas, not running totals; only `total_bytes`/`total_packets`
are cumulative. This was verified on hardware by observing directional
counters return to zero in the final `flow_purge` for a flow while
`total_bytes` did not (documented in this project's `DESIGN.md`; that
specific purge event was not part of the shorter capture excerpted in this
document). It also holds up as independent arithmetic in this capture's own
`flow_stats` records, without needing a purge event: for several digests,
`local_bytes + other_bytes` is an exact multiple of `total_bytes`
(consistent with equal-sized prior deltas already having accumulated into
the total), and never simply equal to it by coincidence across the whole
set. Two examples from the real data:
| digest (first 8 hex) | local+other bytes (this update) | total_bytes | local+other pkts | total_packets | Reading |
|---|---|---|---|---|---|
| `7f3838be` | 240 | 480 | 2 | 4 | This update's delta is exactly half the cumulative total: one earlier, equal-sized update already happened. |
| `37be5b0b` | 189 | 378 | 3 | 6 | Same pattern: exactly 2x, one prior equal delta already folded into `total_bytes`. |
| `f663f9df` | 10806 | 10806 | 35 | 35 | Delta equals total exactly: consistent with this being the flow's first `flow_stats` update (nothing prior to sum in). |
If `local_bytes`/`other_bytes` were themselves cumulative, the delta sum
would always equal the total, never a clean multiple of it. Records like
`7f3838be` and `37be5b0b` rule that out directly from this capture's data.
### 3.6 `flow_purge`
**Confirmed live**, across 10+ purge events captured separately during this
project's hardware testing (`DESIGN.md`), though none happened to fall
inside the specific capture session excerpted in this document (the two
newly detected flows were still active when that capture ended, and none of
the already-tracked flows from the initial `flow_stats` burst closed during
the window). The confirmed live behavior: `local_bytes`/`other_bytes`
(and the matching packet counters) are **always zero** on the closing purge
event; `total_bytes`/`total_packets` carry the flow's final cumulative
totals, unchanged from the flow's last known value going into the purge.
The example below is reconstructed for illustration, since this document's
own capture did not happen to include one, reusing a real digest from this
capture with counters set to that confirmed pattern:
```json
{"established":false,"flow":{"digest":"9c2d7b56c4571e146f65b0d15c12cec661d56b3f","last_seen_at":1787325199999,"local_bytes":0,"other_bytes":0,"total_bytes":642,"local_packets":0,"other_packets":0,"total_packets":6,"reason":"closed"},"interface":"wan","internal":false,"type":"flow_purge"}
```
**(Reconstructed for illustration: this document's own capture did not include a purge event, but the local_bytes/other_bytes=0, total_bytes=final pattern shown is confirmed live behavior, not speculation.)**
| Field | Type | Notes |
|---|---|---|
| `digest` | string | Correlation key; the flow being removed |
| `last_seen_at` | uint64 | Epoch milliseconds |
| `local_bytes`, `other_bytes`, `local_packets`, `other_packets` | uint | **Always zero** on `flow_purge`, confirmed live across 10+ purge events (`DESIGN.md`). The flow's final directional split is not available on this event; see the accounting note below. |
| `total_bytes`, `total_packets` | uint | Final cumulative totals for the flow's whole lifetime |
| `reason` | string | One of `"closed"` (e.g. TCP FIN), `"expired"` (idle timeout), `"terminated"` (e.g. agent shutdown) |
Emitted when a flow leaves the tracking table: normal close, idle expiry, or
agent shutdown. This is the last event you will see for a given digest.
Because the directional counters are zeroed on purge, a consumer cannot read
a flow's final up/down split off this event directly; this project's daemon
instead recovers it by distributing the `total_bytes` delta since the last
known total across up/down using the flow's established ratio from its
prior `flow_stats` updates.
## 4. Consumer-relevant caveat on `agent_status`
Repeated from section 3.3 for visibility: never rely on `agent_status`
carrying `agent_version`/`devices`/`interfaces`/`stats`. Only the base
process-telemetry fields are guaranteed on every frame.
## 5. Naming and id spaces
Two id spaces coexist in `detected_protocol`/`detected_application`, and
they are **not** globally unique against each other -- this is a genuine
footgun confirmed directly from the real `definitions` payload.
- **nDPI-native protocol detections** (`detected_protocol` / `protocols[]`
in `definitions`): plain, unnamespaced strings -- `"DNS"`, `"HTTP"`,
`"HTTP/S"`, `"BitTorrent"`, `"WireGuard"`, `"Tailscale"`, `"ZOOM"`, etc.
217 entries in this capture, ids observed from `0` (`"Unknown"`) up to
`316` (`"Meraki/Cloud"`), plus a sentinel id `4294967295` (`0xFFFFFFFF`,
uint32 max) tagged `"TODO"` whose meaning is not confirmed by any source
consulted (placeholder for an unassigned/pending signature, presumably,
but unverified).
- **Netify application-layer detections** (`detected_application` /
`applications[]` in `definitions`): use the `netify.<slug>` naming
convention when a specific app/service was identified on top of the
transport protocol -- `netify.netflix`, `netify.tailscale`,
`netify.openwrt`, etc. 416 entries in this capture, 199 of which are
`netify.`-prefixed; the remaining 217 non-`netify.`-prefixed entries in
the *applications* array are the same low-numbered nDPI protocol tags
mirrored into that array as well (e.g. id `156` appears in `applications[]`
tagged `"Spotify"` *and* separately in `applications[]` tagged... no --
more precisely: id `156` is `"Spotify"` in `protocols[]` and
`"netify.spotify"` in `applications[]`. These are two different arrays
with independently assigned ids that happen to collide numerically.
**The id-collision gotcha, confirmed directly from the real captured
`definitions` payload:**
| Numeric id | Meaning in `protocols[]` | Meaning in `applications[]` |
|---|---|---|
| 121 | `Dropbox` | `netify.dropbox` |
| 156 | `Spotify` | `netify.spotify` |
| 206 | `WireGuard` | `netify.cloudflare` |
| 207 | `SMPP` | `netify.microsoft-onedrive` |
`id 206` is the sharpest example: it is `WireGuard` in the protocol table
and `netify.cloudflare` in the application table -- two completely unrelated
things sharing a number. **Always resolve `detected_protocol` only against
`definitions.protocols[]`, and `detected_application` only against
`definitions.applications[]`. Never cross-reference an id between the two
arrays.**
A second, related nuance: a widely-repeated simplification (also present in
this project's own `DESIGN.md`, stated there as a general rule of thumb) is
that netify application ids are `>= 10000` and nDPI protocol ids are
`< 10000`. That is the dominant pattern for *recently added* app signatures,
but it is not a hard rule: this capture's `applications[]` array contains at
least 26 legacy `netify.<slug>` entries with ids well under 10000, including
some very common ones:
```
68 netify.msn
70 netify.yahoo
119 netify.facebook
120 netify.twitter
121 netify.dropbox
122 netify.gmail
124 netify.youtube
133 netify.netflix
156 netify.spotify
195 netify.twitch
199 netify.snapchat
201 netify.instagram
202 netify.microsoft
206 netify.cloudflare
```
The reliable discriminator for "is this an app-level detection, not just a
protocol-level one" is the literal `netify.` string prefix on
`detected_application_name`, not the numeric id range. Use the prefix test,
not an id threshold.
Cross-checked against the actual `netify-apps.conf` examples cited in prior
source-reading of this project (`app:133:netify.netflix`,
`app:10119:netify.adobe`, `app:10552:netify.tesla`, `app:199:netify.snapchat`):
all four id-to-slug pairs match exactly what's in this capture's
`definitions.applications[]` array, which is good cross-validation that the
signature-file mapping and the live socket's id table are the same data.
## 6. Category files on disk
`/etc/netify.d/netify-categories.json` (installed by the `netifyd` package,
not vendored by this repo) provides the category taxonomy:
- `application_tag_index` / `protocol_tag_index` -- numeric category id to
name, e.g. `streaming-media`, `social-media`, `games`, `voip`, `cdn`,
`advertiser`, `malware`, `os-software-updates`.
- `application_index` / `protocol_index` -- numeric category id to the
member application/protocol ids in that category.
These map to `flow.category.application` / `flow.category.protocol` /
`flow.category.domain` (see section 3.4). No category object was captured in
enough detail in this session to re-derive the full tag list independently;
figures below are as previously documented and are called out where they
disagree.
**Count discrepancy, unresolved**: prior source-reading of this project
enumerated exactly 31 application-category tags and 17 protocol-category
tags by name. This project's `DESIGN.md` states 32 and 18 respectively
(without enumerating the 32nd/18th). Neither the category JSON file itself
nor a `flow.category.*` value dense enough to reverse-engineer the missing
entry was available for this document. Treat `DESIGN.md`'s counts as
authoritative per this project's convention, but be aware the exact
32nd application tag and 18th protocol tag were not independently confirmed
here.
**Licensing**: both the signature database that drives application/protocol
detection and the generated category file are proprietary data owned by
eGloo/Netify, shipped as part of the `netifyd` package itself, not part of
netifyd's GPLv3 source distribution in the way the daemon code is. This
project reads them at runtime from the paths above and vendors none of this
data in its own repository. (Prior source-level reading found the explicit
proprietary-license header specifically inside a signature pattern-matching
file distinct from the plain id-to-slug `netify-apps.conf`; that specific
filename-level detail was not independently re-confirmed for this document
and is listed under "Unverified / unknown." The operative conclusion, that
none of this data should be vendored or redistributed, is confirmed by both
sources independently and is not in question.)
## 7. Practical consumer guidance
**Join on digest.** `flow.digest` and `flow.last_seen_at` are the only two
fields serialized unconditionally across every event type that carries a
`flow` object. Key your flow table by `digest`. Do not assume any other
field is always present -- `flow` carries identity and no counters,
`flow_stats` carries counters and no identity, `flow_purge` carries a final
cumulative `total_bytes`/`total_packets` plus a reason, but zeroed
directional counters (§3.6): it does not carry a final directional split.
**Stub-flow pattern.** Because `dump_established_flows` does not exist in
4.4.7 (section "Scope" above), and because this is directly demonstrated by
the real capture (11 `flow_stats` records for digests never announced via
`flow` on this connection), a consumer must tolerate `flow_stats` or
`flow_purge` arriving for an unknown digest. Do not drop these events.
Instead, create a placeholder entry (interface/internal known from the
envelope, identity fields set to an "Unknown" sentinel) and backfill
identity if and when a `flow` event for that same digest eventually arrives.
It may never arrive, if the flow was already fully classified before this
connection started and its classification does not get re-announced.
**Delta clamping rationale.** Because `local_bytes`/`other_bytes` are
per-update deltas while `total_bytes` is the only cumulative field, a
consumer accounting a running rate should sum the directional deltas per
interval directly. But right after a reconnect (where a stub flow has no
prior history), or after any missed update, naively summing deltas risks
overshooting the flow's real observed growth. Clamp the accounted delta so
the consumer's own running total for that digest never exceeds
`total_bytes`'s growth: `accounted_delta = min(local_bytes + other_bytes,
total_bytes - last_known_total)`. This is the approach this project's daemon
takes, and it is the correct general-purpose defense against the blind-spot
created by the missing backfill option.
**Resolve ids against the matching array only.** `detected_protocol` against
`definitions.protocols[]`; `detected_application` against
`definitions.applications[]`; `flow.category.*` against
`netify-categories.json`'s index tables. All three are separate id spaces
that can and do collide numerically (section 5).
## 8. Unverified / unknown
Collected here in one place rather than scattered as caveats only:
- **`established` (top-level boolean)** -- precise semantics not confirmed
by source reading or by this capture (always `false` in the sample, which
is too uniform to infer meaning from).
- **`update_imf`** (in `agent_status`) -- present, value `1` in both captured
frames, meaning not confirmed by any source consulted.
- **`first_update_at`** (in `flow`) -- a real field, not previously
documented for this interface. Identical to `first_seen_at` in both
captured examples (both first-detection events); whether/how it diverges
when `detection_updated` fires is unconfirmed.
- **The `agent_status` timing anomaly** -- `uptime` advanced 15s between two
captured frames while `timestamp` advanced only 1s. Not explained; may be
a genuine daemon quirk or a capture-environment artifact. Flagged, not
asserted as behavior to depend on.
- **`risks.risks[]` numeric values** (e.g. `46` observed) -- no id-to-name
mapping found in any netifyd-side source or doc consulted. These are
presumably nDPI's own risk-flag enum (documented upstream in nDPI, not in
netifyd), which was out of scope to chase down for this document.
- **`other_type` full enum** -- only `"local"` was observed live. The other
values (`remote`, `broadcast`, `multicast`, `unsupported`, `error`,
`unknown`) come from source reading only, not from this capture.
- **`ct_id`/`ct_mark`/conntrack correlation** -- absent from both captured
`flow` examples despite `conntrack` being a compiled-in capability per
`build_version`. Unconfirmed whether that's because these are UDP/no-NAT
flows or because emission requires a config flag not on by default.
- **Category tag counts** -- 31/17 (prior source enumeration, with names) vs
32/18 (`DESIGN.md`, without full enumeration). Not reconciled; see
section 6.
- **Whether `detected_application_name` can be absent/empty** for
protocol-only detections -- asserted by source reading, not observed
empty in this capture (both examples had an app name set).
- **The specific filename carrying the proprietary-license header** on the
signature data (`netify-apps.conf` vs. a separate pattern-matching file) --
see the licensing note in section 6.
- **TCP socket default port** -- irrelevant to OpenWrt's default
UNIX-socket-only configuration, so omitted from the main body, but never
resolved in prior research: conflicting values (`7150` in man-page prose,
`2100` in a shipped sample config) were found and neither was checked
against a running instance with TCP enabled.
- **`http.*`, `dhcp.fingerprint`, `gtp.*` sub-objects** -- none observed in
this capture (no flows of the relevant type were present); documented from
source only.
## 9. Sources
- Direct hardware capture: Linksys EA8500, OpenWrt 25.12.5, netifyd 4.4.7,
UNIX socket capture immediately following daemon start (this document's
primary evidence for every "real captured example").
- This project's `docs/DESIGN.md`, sections 2 and 2.4 (separately
hardware-verified facts, treated as authoritative wherever they conflict
with source-only reading).
- netifyd 4.4.7 source at the exact shipped tag:
`gitlab.com/netify.ai/public/netify-agent` (tag `v4.4.7`), in particular
`src/netifyd.cpp`, `src/nd-detection.cpp`, `src/nd-json.cpp`,
`src/nd-socket.cpp`, `include/nd-flow.h`, `include/netifyd.h`,
`deploy/netifyd.conf.in`, `deploy/netify-categories.json`,
`deploy/netify-apps.conf`, `doc/netifyd.conf.5.in`,
`doc/README-JSON-socket-example.md`, `doc/json-socket-filter.jq`.
- OpenWrt packaging: `github.com/openwrt/packages`, `net/netifyd/`
(Makefile, `files/netifyd.init`, `files/netifyd.config`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB