mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-27 01:52:51 +08:00
985 lines
52 KiB
Markdown
985 lines
52 KiB
Markdown
# 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.**
|