mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-02 20:33:56 +08:00
💋 Sync 2026-10-04 04:16:09
This commit is contained in:
1 parent
f6b2df73ec
commit
f62992fd80
745 files changed
+212416
-1173
No files matched your search
@@ -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.**
|
||||
Reference in new issue
Block a user