Real-time per-application traffic visibility for OpenWrt.
appflow answers the question stock OpenWrt cannot: what is my network doing
right now, by application? Netflix vs. YouTube vs. a Windows update, broken
down per device, live in LuCI, built entirely on open components.
Status: working, published as a demonstration project. Developed and tested on real hardware, with byte-attribution behavior measured rather than assumed. Read Known limitations below before installing: several are inherent to the DPI engine, and one affects anyone who restarts the service. Not in the OpenWrt feeds yet, so the package below is unsigned.
One file, every device. The package is noarch — only JavaScript and ucode
inside — and the same .apk is verified installing on both aarch64 and
arm_cortex-a15 hardware.
wget https://github.com/VolanticSystems/luci-app-appflow/releases/download/v1.1.2/luci-app-appflow-1.1.2-r1.apk
apk add --allow-untrusted ./luci-app-appflow-1.1.2-r1.apk
/etc/init.d/appflowd enable && /etc/init.d/appflowd start
Needs OpenWrt 25.12 or newer and netifyd. --allow-untrusted is required
because this is not signed by the OpenWrt build key. Optional icon pack and
Simplified Chinese translation are on the same
releases page.
Prefer to build it yourself? See Building from source below.
Live overview:
Statistics (past hour):
Filtering. Typing ai narrows both lists to AI traffic, and the device panel
switches to what currently-tracked flows account for:
netifyd (DPI engine, nDPI-based)
│ JSON flow events (unix socket)
▼
appflowd (ucode daemon: aggregates flows → apps × devices)
│ ubus
▼
LuCI view (hand-drawn inline SVG charts, live polling)
There is no database and no polling of the DPI engine: appflowd holds a live
aggregate in memory and publishes it over ubus. Design notes, including the
netifyd 4.4.7 event contract as observed on real hardware, are in
docs/DESIGN.md.
appflow itself contacts no network service: it reads a local socket and
publishes over ubus, with no account, key or registration of any kind.
netifyd is a separate package with its own configuration and its own
vendor-supplied signature data, so appflow cannot speak for it. It was measured
rather than assumed. On the development router, after 4 days 23 hours of
uptime, netifyd held 16 sockets: 12 unix and 3 packet capture, and zero TCP
or UDP sockets, with no outbound connection at any point. The agent does have
a telemetry sink, and OpenWrt's packaged /etc/netifyd.conf ships it turned
off:
[netifyd]
dump_established_flows = yes
enable_sink = no
enable_sink = no is the line that matters. Check it on your own install
rather than trusting this one: it is netifyd's setting, not appflow's, and
anyone can change it.
The line above it is quoted only because it is really there. It does
nothing on 4.4.7: the string dump_established_flows does not appear in the
shipped binary at all, while enable_sink does. It is a stale key in the
packaged config, and if you were hoping it means the agent will replay
established flows to a client that connects late, it does not. That is the
cause of the "Unknown" limitation below, not something a setting can turn off.
nlbwmon is the usual answer to "what is using my bandwidth" on OpenWrt, and
it is the first thing worth checking before installing this.
nlbwmon |
appflow |
|
|---|---|---|
| Axis | Per host, per protocol and port | Per application, per host |
| How it identifies traffic | Conntrack accounting | DPI, via netifyd |
| Storage | Persistent database, survives reboot | In memory only, live view, keeps nothing |
| Range | Days and months | Live, plus past hour |
| Cost | Under 1 MB VSZ for the daemon | netifyd, around 17 MB VSZ, plus about 2 MB for appflowd |
They answer different questions, and both were run on the same router at the same time on identical traffic to check they can be: nlbwmon reported HTTP 9.99 MB and HTTPS 2.09 MB; appflow reported the same traffic as cloudflare, github and wikipedia. Neither interfered with the other. If you want durable per-host accounting, nlbwmon does it better and appflow does not attempt it; if you want to know which application is doing it right now, that is the question this package exists to answer.
netifyd cannot classify AI traffic and will not soon. Its signature set is dated August 2023, carries 199 applications, and contains no AI vendor at all, so anything from Claude to Midjourney lands in generic HTTP/S.
The identifying data is already present: netifyd reports the TLS SNI, and appflow already stores it. So appflow matches that name against a built-in table of eighty-five AI service domains and gives them their own application rows and four categories: assistants, media, developer tools and infrastructure (aggregators, inference APIs, GPU rental, vector stores).
This is hostname matching, not DPI, and it is labelled as such. It asserts a classification netifyd did not make. netifyd's own answer always wins except for a handful of services whose parent brand it recognizes and would otherwise swallow (Gemini under Google, Copilot under GitHub), so the table falls silent by itself if Netify ever ship AI signatures.
Matching is anchored on label boundaries, never a substring, so
anthropic.com.attacker.example is not labelled Anthropic. It changes an
identity and never a byte: byte conservation is asserted across a batch
containing AI flows.
Turn it off with option ai_breakout '0' in /etc/config/appflow. Two things
it cannot do: match a service behind a shared CDN hostname, and survive ECH,
which encrypts the SNI. netifyd has the same ECH exposure. Detail in
docs/DESIGN.md section 11.
One streaming session can bury everything else, so both lists filter.
Type to narrow. The box above the application list matches the application
name, its internal key and its category, so ai finds every AI service and
streaming finds every streaming one.
Prefix a word with a minus to exclude it. -netflix shows everything
except Netflix, which is the case a search box alone cannot express: to use it
you would have to already know what you wanted to keep. Terms combine, so
ai -claude is AI traffic that is not Claude.
Click anything that names something. A category in the Categories panel, an application name, or the category under it: each fills the filter box, so the click is visible, editable and clearable rather than a hidden mode.
Click a device to see what it is doing. The application list then shows only that device's traffic and a chip appears naming it. The device list has its own filter box matching name, MAC and IP, with the same minus-to-exclude behavior.
Both filters compose: pick a device, then type -netflix, and you get that
device's traffic minus streaming.
A device drill-down is computed from the live flow table, not from a stored per-device-per-application history, so the columns change from Download and Upload rates to Downloaded and Uploaded bytes and the caption says "from flows in progress". Those are the bytes currently-tracked flows account for: a connection that has already finished is no longer in the table.
That is a real limitation and it is why the headings change rather than leaving a byte figure sitting under a column promising a rate. Producing a genuine per-device-per-application rate would need a delta ring for every pair, which is the unbounded state this design avoids on purpose.
netifyd's signature set is vendor data and it goes stale: the copy shipped with OpenWrt is dated August 2023. AppFlow recognizes services by the server name their TLS connections announce, and you can add to that list yourself without touching any code.
Add a section to /etc/config/appflow:
config hostmap
option suffix 'torproject.org'
option name 'Tor'
option category 'Privacy'
then /etc/init.d/appflowd restart. Traffic to www.torproject.org and
check.torproject.org now appears as Tor under Privacy.
| option | meaning |
|---|---|
suffix |
matched against the last two, three or four labels of the server name, anchored on a label boundary. Must contain a dot. |
name |
what appears in the application list. |
category |
what appears in the category breakdown. Free text; reuse an existing one to group with it, or invent your own. |
strong |
optional, default 0. 0 fills a gap only, leaving netifyd's own identification alone where it has one. 1 overrides netifyd too, for when it recognizes a parent brand and swallows the service inside it. |
The same section overrides a built-in entry, so this is also how you correct one that is wrong for your network:
config hostmap
option suffix 'anthropic.com'
option name 'Work AI'
option category 'Business'
Matching is anchored on label boundaries, so torproject.org matches
check.torproject.org and not torproject.org.example.com, which anyone
can register. A malformed entry is skipped and named in the log rather than
silently ignored, because a typo otherwise looks exactly like the feature not
working:
logread -e appflowd | grep hostmap
This is name recognition, not deep packet inspection, and it inherits the same
limits: a service behind a shared CDN hostname is invisible, and ECH would end
it. Thirteen checks in tests/protocol-suite.sh hostmap cover it.
Read these before installing. They are measured and documented rather than left to be discovered. Details and supporting measurements are in docs/DESIGN.md sections 2.5 and 8.
-
All statistics live in memory and do not survive a restart. There is no database, by design. Restarting
appflowd, restarting netifyd, or rebooting starts the past-hour view from empty. If you need history across restarts, this package does not currently provide it. -
Flows in progress across a restart are attributed to "Unknown". netifyd 4.4.7 sends counter updates for a flow that was already established when
appflowdconnected, but never the event carrying its identity, so those bytes accumulate under "Unknown" until the flow ends. Measured: restarting the daemon mid-transfer moved 10.7 MB of a correctly classified HTTP flow. It is bounded to the window after a restart, resolves as connections cycle, and loses no byte totals. A future version could mitigate theappflowd-restart case by checkpointing its own identity map; the case where netifyd restarts cannot be fixed without support from netifyd.Accuracy across a restart was measured in 1.0.2 against the client's own interface counter: 0.89 and 1.01 of wire truth on two runs, against 0.70 and 0.79 before that release, with a no-restart control at 1.01. If you want an exact figure, take it after the restart window rather than across it.
-
Accurate accounting for clients behind NAT needs conntrack. netifyd reports the WAN-side capture of a NAT'd flow using the router's own address, which is indistinguishable from traffic the router originated. appflow resolves it by looking the flow up in
/proc/net/nf_conntrack, which is present on any normal OpenWrt firewall install. Measured against interface counters: client traffic 99.8% and router traffic 97.9% of actual, each counted once. If conntrack cannot be read, appflow keeps client accounting correct and stops attributing the router's own traffic, rather than double-counting; it logs one warning saying so. Checkubus call appflow statusunderconntrackto see which mode you are in. -
Late re-classification may leave early bytes under "Unknown". If netifyd identifies a flow only after several packets, bytes counted before that point are not retroactively moved, because the accounting path is deliberately append-only. Not observed in practice and currently unquantified.
-
Flow offloading is untested. appflow has not been run on a router with software or hardware flow offloading enabled. netifyd captures packets on the interface rather than through netfilter, so the two paths are not obviously in conflict, but that is reasoning and not a measurement, and no measurement has been made. Reports from anyone running offloading are welcome.
-
Detection quality is netifyd's, not ours. Which applications are recognized, and how accurately, is a property of the DPI engine and its signature data. appflow reports what netifyd detects.
-
This inspects traffic. Consider whether deep packet inspection is appropriate on your network, and for the people using it, before installing.
On accounting accuracy: measured against client-side ground truth, appflow accounted for at least every byte transferred and never lost any (25 flows of 2 MB gave 50,000,000 bytes transferred against 51,127,054 accounted; 150 flows of 1 KB were all accounted). The roughly 2% excess is consistent with protocol overhead being counted, but it has not been reconciled packet by packet, so treat these totals as a close upper bound rather than an exact byte count.
If something looks wrong with the numbers, run ubus call appflow status.
Three fields there answer most questions:
| field | what a non-zero value means |
|---|---|
bytes.leaked |
Should read 0. Traffic was reported by the agent and never reached an aggregate. If this moves, please tell me. |
aggregates.refused |
A per-class table filled with live entries, so new applications or devices stopped being tracked. The dashboard is incomplete. Nothing is being mis-counted; the totals stay correct. |
flows.shed |
The flow table hit flow_max and the least recently active flows were evicted to make room. Raise flow_max if this climbs steadily. |
flows.shed is counted separately from flows.pruned, which is ordinary
housekeeping of idle flows and is expected to be large. Reading them as one
number hides cap pressure underneath normal behavior.
Traffic monitoring is a privacy question by definition, so here is the whole
surface. Everything below is checkable in root/usr/sbin/appflowd and
root/usr/share/rpcd/acl.d/luci-app-appflow.json.
It runs as root, because it has to. The daemon reads netifyd's agent socket
and /proc/net/nf_conntrack, neither of which is readable otherwise. There is
no privilege drop. If that is not acceptable on your box, do not install it.
It reads four files and writes none. /etc/netify.d/netify-categories.json,
/tmp/dhcp.leases, /var/run/netifyd/status.json and /proc/net/nf_conntrack,
all read-only, plus its own UCI config at start-up. It contains no writefile,
no shell-out and no popen. It opens no listening socket: it connects out to
netifyd's Unix socket and publishes a ubus object, which is the local bus and
not the network.
Nothing is stored. There is no database, no log of who visited what, no
spool file. Every counter lives in memory and dies with the process, so
/etc/init.d/appflowd restart is a complete erase. That is also the honest
limitation: there is no history, and restarting the router loses the day.
The ubus surface is six read methods and one write, and the write is
reset. The ACL is deliberately narrower than the daemon:
| method | granted to the web UI | what it returns |
|---|---|---|
summary, stats, status |
yes | totals, time series, health |
devices, apps |
yes | per-device and per-application aggregates |
app_detail |
yes | one application: its devices, and the hostnames it contacted, each with byte totals |
flows |
no | the live per-connection table, including remote addresses and ports |
reset |
yes (write) | clears the counters |
flows is ungranted on purpose and the reason is written into the ACL file
itself. That is the difference between a dashboard and a surveillance log. A
LuCI session can see that a device used a streaming service and that the
service contacted a set of hostnames; it cannot pull the connection table and
join a specific device to a specific remote endpoint, because app_detail
returns devices and hostnames as two separate lists for one application rather
than as a cross product.
What the dashboard does reveal, so nobody is surprised: DHCP hostnames, MAC and IP addresses of LAN devices, which applications each device used, and the TLS SNI hostnames each application contacted. Anyone who can log into LuCI can see all of it. On a family router that is a real consideration and it is your call, not the package's.
Bounded tables that refuse rather than lie. Aggregate tables are capped. At
the cap the daemon evicts only entries with no live flow, and when every slot is
busy it refuses to grow rather than dropping data that is still arriving, so the
totals stay correct while the breakdown goes incomplete. That refusal used to be
invisible; it is now counted and reported as aggregates.refused, because a
table that has quietly stopped accepting new devices looks identical to a quiet
network.
Reporting something. If you find a problem, open an issue on the repository, or send it privately if you would rather not post it. Either is fine, and either gets an answer.
Three suites, 179 checks, no failures. Two need a sandbox router; one needs nothing but Node and runs on every push.
| suite | checks | what it does |
|---|---|---|
tests/frontend-suite.js |
59 | loads the real view code under Node. Mostly regression tests for defects that shipped. |
tests/protocol-suite.sh |
104 | replaces the agent with a socket the test controls, so byte arithmetic is checked against hand-computed totals rather than a tolerance band, and the error paths a real agent never produces get exercised. |
tests/hardware-suite.sh |
16 | drives real traffic and compares against the client interface's own counter in /sys/class/net, which nothing in this daemon can influence. |
Two of the hardware checks SKIP, loudly and counted, when the router cannot support them: when netifyd is not capturing every default-route interface a wire-ratio comparison is meaningless rather than merely weak, and the purge-rate check needs more flows than a quiet bench produces. A skipped check is reported as skipped, never as a pass.
Every check names, in a comment written before the assertion, the smallest edit to the product that turns it red, and those sabotages are actually run. That is what caught three checks in the 2026-08-27 round that could not fail at all, two of them in the group written to prove the AI matcher refuses lookalike domains.
Every check carries a comment naming the edit to the product that turns it red, written before the assertion. Details in CONTRIBUTING.md.
- OpenWrt with
netifydavailable (developed and tested against OpenWrt 25.12.5 with netifyd 4.4.7). - Enough headroom to run DPI. Measured on the test device under light load: netifyd around 17 MB VSZ, appflowd around 2 MB VSZ, negligible CPU for both. netifyd is the expensive component; appflow adds little on top.
Tested on a Linksys EA8500 (ipq806x). It is architecture-independent
(LUCI_PKGARCH:=all), and CI builds it for arm_cortex-a15_neon-vfpv4 and
aarch64_cortex-a53.
Prefer to compile it yourself, or running an OpenWrt release older than
25.12 where the .apk above will not install? Build it with the OpenWrt
SDK for your release, then install the resulting package.
Build:
# from an OpenWrt SDK tree matching your device's release
./scripts/feeds update -a
./scripts/feeds install -a
git clone https://github.com/VolanticSystems/luci-app-appflow.git \
package/luci-app-appflow
make package/luci-app-appflow/compile V=s
The optional icon pack is a separate package in the same tree, so build it separately if you want it:
make package/luci-app-appflow-icons/compile V=s
Install on the device (copy the built package over first):
apk add --allow-untrusted /tmp/luci-app-appflow-[0-9]*.apk
Note the [0-9] in that glob: a plain luci-app-appflow-*.apk would also
match luci-app-appflow-icons-*.apk and pull in the optional icon pack.
--allow-untrusted is required because a locally built package is not signed
by an OpenWrt repository key. On releases still using opkg, use
opkg install ./luci-app-appflow_*.ipk instead.
luci-app-appflow-icons adds brand icons for the applications netifyd can
detect. Without it the dashboard renders letter-tile avatars, which is a
supported configuration rather than a degraded one. It is packaged separately
so the base package stays small, and so it can be removed on its own if the
third-party brand marks it ships ever need to be. Build it as shown above,
then:
apk add --allow-untrusted /tmp/luci-app-appflow-icons-[0-9]*.apk
luci-app-wansentry
generates a two-uplink mwan3 failover configuration from one page, for people
who want a second WAN to take over automatically without assembling six mwan3
sections by hand. Same author, same OpenWrt release, and it solves a problem
next to this one rather than the same one: appflow tells you what your traffic
is, wansentry keeps it moving when a line drops. Neither depends on the other.
Apache-2.0, see LICENSE. The icon pack is a separate package with
its own licensing; see icons/licenses/ in that package.


