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