mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-27 11:52:51 +08:00
1874 lines
87 KiB
Bash
Executable File
1874 lines
87 KiB
Bash
Executable File
#!/bin/sh
|
|
# appflow protocol and bounds suite.
|
|
#
|
|
# The sibling suite, hardware-suite.sh, drives real traffic through a real
|
|
# netifyd and checks that the totals stay near wire truth. It is an integration
|
|
# test and its own header says the weakness is coverage: it can only assert on
|
|
# whatever netifyd happened to send during the window, so the arithmetic is
|
|
# checked loosely, the error paths are never reached at all, and a test either
|
|
# waits a minute or skips.
|
|
#
|
|
# This suite replaces netifyd with a socket WE control, so the input is exact
|
|
# and chosen. That buys three things the hardware suite cannot have:
|
|
#
|
|
# 1. Byte arithmetic checked against a hand-computed total rather than
|
|
# against a ratio band. If the daemon adds one byte that netifyd did not
|
|
# report, a check here goes red; the 0.80-1.25 ratio band cannot see it.
|
|
# 2. The error and bounds paths, which real netifyd never exercises:
|
|
# malformed frames, oversized tags, a full flow table, a full aggregate
|
|
# table, unknown event types, a peer that stops sending newlines.
|
|
# 3. Determinism. No traffic, no waiting on a transfer, no SKIP.
|
|
#
|
|
# It does NOT replace the hardware suite. Everything here is the daemon's view
|
|
# of a wire this file wrote, so nothing here can catch appflowd and netifyd
|
|
# disagreeing about what a field means. That is exactly what the hardware
|
|
# suite's wire-truth comparison is for, and neither suite subsumes the other.
|
|
#
|
|
# usage: sh protocol-suite.sh run everything
|
|
# sh protocol-suite.sh conservation run one group
|
|
# (conservation|protocol|bounds|attribution|hostile|acl|lifecycle)
|
|
#
|
|
# RUN IT ON A SANDBOX ROUTER. It stops appflowd, repoints appflow.socket_path
|
|
# at a temporary socket, and restarts the daemon many times. It restores the
|
|
# original socket_path on exit, including on interrupt. It never touches
|
|
# netifyd's own configuration.
|
|
#
|
|
# Exit status is the number of failed checks.
|
|
#
|
|
# ---------------------------------------------------------------------------
|
|
# HOW TO READ THE `SABOTAGE:` COMMENTS, AND WHY THEY ARE THERE
|
|
#
|
|
# Every check below carries one. It names the smallest edit to appflowd -- the
|
|
# product, not this file -- that makes that check report failure, and it was
|
|
# written BEFORE the assertion it sits above.
|
|
#
|
|
# That ordering is the whole discipline. Asking "what does this test check"
|
|
# answers itself in the author's own words, which is how a test that cannot
|
|
# fail gets written and then read a dozen times without anyone noticing. Asking
|
|
# "what edit turns this red" is a construction task: either a concrete edit
|
|
# exists or you hit a wall, and the wall is the finding.
|
|
#
|
|
# They are also here so a reviewer can check this suite in sixty seconds rather
|
|
# than trusting it. Apply the named edit, run the group, and exactly the checks
|
|
# that name it should go red. If one of them stays green, that check is
|
|
# decorative and this comment is how you found out.
|
|
#
|
|
# An edit that makes the file unparseable does not count and none is used
|
|
# below: an error is not a red assertion, and it is the escape hatch every
|
|
# hollow test reaches for.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
set -u
|
|
|
|
# ---------------------------------------------------------------- exclusivity
|
|
#
|
|
# Every suite in these two packages mutates GLOBAL router state: this one
|
|
# rewrites /etc/config/mwan3 and drives the mwan3 service, its sibling repoints
|
|
# appflow.socket_path and restarts appflowd. Two of them running at once
|
|
# corrupt each other's fixtures, and the failures look like product defects.
|
|
#
|
|
# That is not hypothetical. On 2026-08-26 two suites were launched a minute
|
|
# apart against the same router; the second reported two failures that could
|
|
# not be reproduced in isolation, and the ownership arithmetic was rewritten
|
|
# twice chasing a bug that was never there.
|
|
#
|
|
# One lock file for ALL suites across both packages, because the resource being
|
|
# protected is the router, not the config file.
|
|
# mkdir is atomic and is NOT a file descriptor, which is the whole point.
|
|
# The first version of this guard used `exec 9>lock; flock -n 9`. Every child
|
|
# inherits an fd, and these suites launch children that outlive them: socat
|
|
# serving the fake agent, and mwan3track respawned per interface by mwan3's
|
|
# init script. The lock therefore stayed held after the suite exited and the
|
|
# next suite in a serial run was refused. Observed: suite 1 passed, suites 2
|
|
# and 3 produced no output at all because both exited 2.
|
|
SUITE_LOCK=/tmp/openwrt-suite.lock.d
|
|
if ! mkdir "$SUITE_LOCK" 2>/dev/null; then
|
|
# Someone holds it, or a killed run left it behind. Only the second is
|
|
# ours to clear, and only when the recorded pid is provably gone.
|
|
stale=1
|
|
if [ -r "$SUITE_LOCK/pid" ]; then
|
|
kill -0 "$(cat "$SUITE_LOCK/pid" 2>/dev/null)" 2>/dev/null && stale=0
|
|
fi
|
|
if [ "$stale" = 1 ]; then
|
|
printf 'clearing a stale suite lock (%s)\n' "$SUITE_LOCK"
|
|
rm -rf "$SUITE_LOCK"
|
|
mkdir "$SUITE_LOCK" 2>/dev/null || { printf "cannot take the suite lock\n"; exit 2; }
|
|
else
|
|
printf 'another test suite is running on this router (pid %s).\n' \
|
|
"$(cat "$SUITE_LOCK/pid" 2>/dev/null)"
|
|
printf 'they mutate shared router state; run them one at a time.\n'
|
|
exit 2
|
|
fi
|
|
fi
|
|
echo $$ > "$SUITE_LOCK/pid"
|
|
|
|
GROUP="${1:-all}"
|
|
PASS=0; FAIL=0
|
|
|
|
SOCK=/tmp/appflow-proto.sock
|
|
EV=/tmp/appflow-proto-events.jsonl
|
|
FAKE=""
|
|
ORIG_SOCK=""
|
|
ORIG_FLOWMAX=""
|
|
|
|
head2() { printf '\n=== %s ===\n' "$*"; }
|
|
ok() { PASS=$((PASS+1)); printf ' PASS %s\n' "$*"; }
|
|
bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$*"; }
|
|
note() { printf ' %s\n' "$*"; }
|
|
|
|
# chk <description> <expected> <actual>
|
|
chk() {
|
|
if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (expected '$2', got '$3')"; fi
|
|
}
|
|
|
|
# chk_ge <description> <floor> <actual>
|
|
chk_ge() {
|
|
if [ "${3:-0}" -ge "$2" ] 2>/dev/null; then
|
|
ok "$1 ($3 >= $2)"
|
|
else
|
|
bad "$1 (expected >= $2, got '${3:-}')"
|
|
fi
|
|
}
|
|
|
|
# ------------------------------------------------------------------ fixtures
|
|
|
|
st() { ubus call appflow status 2>/dev/null; }
|
|
|
|
# field <group> <key> -- read one integer out of one object in status.
|
|
#
|
|
# Anchored to the group so `flows.pruned` and `aggregates.pruned`, which share a
|
|
# key name, cannot be confused for each other.
|
|
#
|
|
# TWO DEFECTS IN EARLIER DRAFTS OF THIS FUNCTION, both found by running it, and
|
|
# both worth keeping written down because they are the shape that makes a whole
|
|
# suite worthless rather than one check:
|
|
#
|
|
# 1. The range ended at `/}/`, which is the first closing brace ANYWHERE in
|
|
# the group -- and `events` contains two nested objects, `unknown_types`
|
|
# and `by_type`. So the range closed early and `rx_bytes`, `resyncs` and
|
|
# `last_event_age_ms` were unreadable. Anchoring both ends to a single tab
|
|
# of indentation is what fixes it.
|
|
#
|
|
# 2. Far worse: a key that could not be found returned the empty string, and
|
|
# every caller wrote `${x:-0}`. Six checks in this suite assert that a
|
|
# counter is exactly "0". Every one of them would have passed
|
|
# unconditionally, for as long as the helper stayed broken, with the
|
|
# daemon uninstalled entirely. That is the "loop over a property that does
|
|
# not exist" trap wearing a different hat: not zero iterations, but a
|
|
# default that happens to equal the expected value.
|
|
#
|
|
# So a miss now returns the literal MISSING, which equals no expected value and
|
|
# turns the check red. A test helper that cannot find its subject must fail
|
|
# loudly; returning a plausible number is how a suite starts lying.
|
|
field() {
|
|
local v
|
|
v=$(st | sed -n "/^ \"$1\": {/,/^ }/p" \
|
|
| sed -n "s/.*\"$2\": *\([0-9-]*\).*/\1/p" | head -1)
|
|
printf '%s' "${v:-MISSING}"
|
|
}
|
|
|
|
cleanup() {
|
|
fake_down
|
|
rm -f "$SOCK" "$EV"
|
|
if [ -n "$ORIG_SOCK" ]; then
|
|
uci set appflow.main.socket_path="$ORIG_SOCK"
|
|
[ -n "$ORIG_FLOWMAX" ] && uci set appflow.main.flow_max="$ORIG_FLOWMAX"
|
|
uci commit appflow
|
|
/etc/init.d/appflowd restart >/dev/null 2>&1
|
|
printf '\nrestored appflow.socket_path=%s flow_max=%s\n' "$ORIG_SOCK" "$ORIG_FLOWMAX"
|
|
fi
|
|
rm -rf "$SUITE_LOCK"
|
|
}
|
|
# HUP IS IN THAT LIST DELIBERATELY. These suites are normally run over SSH, and
|
|
# a dropped session sends SIGHUP, which the original INT/TERM/EXIT list did not
|
|
# catch: the process died without running the trap. Observed 2026-08-26, where
|
|
# it left a stale lock behind. The lock self-heals; what would NOT self-heal is
|
|
# the rest of what this trap undoes -- the mwan3 init-script shim, a rewritten
|
|
# /etc/config/mwan3, or appflow left pointed at a socket that no longer exists.
|
|
# A router in that state looks broken and gives no clue why.
|
|
trap 'cleanup; exit $FAIL' INT TERM HUP EXIT
|
|
|
|
# Start a fake netifyd that serves $EV and then holds the connection open.
|
|
#
|
|
# It must HOLD rather than close. appflowd treats a closed socket as netifyd
|
|
# going away and reconnects after a second, and socat is in `fork` mode, so the
|
|
# reconnect would be served a FRESH `cat` of the same file: every event
|
|
# delivered twice, every byte counted twice, and every arithmetic check in this
|
|
# suite quietly wrong in a way that looks like a daemon defect. The hold is
|
|
# what makes the input exactly-once.
|
|
#
|
|
# HOLD_S is 60, not 600. At 600 the shell that socat spawns outlives the suite
|
|
# holding the inherited stdout pipe, so anything reading this script's output
|
|
# blocks for ten minutes after the last check has printed. That is not
|
|
# hypothetical, it happened on the first run of this file. cleanup() now kills
|
|
# the child by pattern as well as killing socat, and the shorter hold bounds the
|
|
# damage if both miss.
|
|
HOLD_S=60
|
|
|
|
fake_up() {
|
|
fake_down
|
|
rm -f "$SOCK"
|
|
socat UNIX-LISTEN:"$SOCK",fork,unlink-early \
|
|
SYSTEM:"cat $EV; sleep $HOLD_S" 2>/dev/null &
|
|
FAKE=$!
|
|
local i=0
|
|
while [ "$i" -lt 30 ]; do
|
|
[ -S "$SOCK" ] && return 0
|
|
i=$((i + 1)); sleep 1
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# kill every process whose command line matches, or nothing if none do.
|
|
#
|
|
# `pkill` DOES NOT EXIST on stock OpenWrt. busybox ships pgrep and killall but
|
|
# not pkill, so `pkill -f ...` is `ash: pkill: not found` -- which, inside a
|
|
# cleanup function whose failures are all redirected to /dev/null, is a silent
|
|
# no-op. The first version of this suite used it twice and left socat and its
|
|
# child running after every case.
|
|
#
|
|
# The `[ -n ... ]` guard matters too: bare `kill $(pgrep ...)` with no matches
|
|
# calls kill with no arguments, which is a usage error, and under `set -u`-style
|
|
# discipline that noise hides the real failures.
|
|
killmatch() {
|
|
local pids
|
|
pids=$(pgrep -f "$1" 2>/dev/null)
|
|
[ -n "$pids" ] && kill $pids 2>/dev/null
|
|
return 0
|
|
}
|
|
|
|
fake_down() {
|
|
[ -n "$FAKE" ] && kill "$FAKE" 2>/dev/null
|
|
killmatch "UNIX-LISTEN:$SOCK"
|
|
# The SYSTEM: child is a separate process and does not die with socat.
|
|
killmatch "cat $EV"
|
|
FAKE=""
|
|
}
|
|
|
|
# Restart the daemon against the current $EV and let it drain.
|
|
#
|
|
# The wait is on OBSERVED PROGRESS rather than a fixed sleep: poll until the
|
|
# daemon reports having read the bytes we staged, then stop. A fixed sleep is
|
|
# either slower than it needs to be or, on a loaded box, shorter than the drain,
|
|
# and a check that reads counters mid-drain fails intermittently and gets
|
|
# "fixed" by lengthening the sleep until it no longer tests anything.
|
|
feed_and_settle() {
|
|
local want; want=$(wc -c < "$EV")
|
|
fake_up || { bad "fake netifyd did not come up"; return 1; }
|
|
/etc/init.d/appflowd restart >/dev/null 2>&1
|
|
local i=0 rx=0
|
|
while [ "$i" -lt 40 ]; do
|
|
rx=$(field events rx_bytes); rx=${rx:-0}
|
|
[ "$rx" -ge "$want" ] 2>/dev/null && { sleep 1; return 0; }
|
|
i=$((i + 1)); sleep 1
|
|
done
|
|
note "warning: drained ${rx} of ${want} staged bytes before the deadline"
|
|
return 0
|
|
}
|
|
|
|
# A flow event. Deliberately verbose rather than templated: a generator that
|
|
# builds these from a table would put the expected byte totals and the events
|
|
# that produce them in the same expression, and then the oracle comes from the
|
|
# artifact under test rather than from a human. The numbers below are stated by
|
|
# hand, and the totals asserted against are stated by hand too.
|
|
#
|
|
# flow_ev <digest> <app_id> <app_name> <local_bytes> <other_bytes> <total_bytes> <internal> <mac>
|
|
flow_ev() {
|
|
printf '{"type":"flow","internal":%s,"interface":"%s","flow":{"digest":"%s","local_ip":"192.168.9.10","local_mac":"%s","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","local_port":40001,"other_port":443,"ip_protocol":6,"detected_application":%s,"detected_application_name":"%s","detected_protocol":91,"detected_protocol_name":"TLS","category":{"application":10,"protocol":20},"local_bytes":0,"other_bytes":0,"total_bytes":0}}\n' \
|
|
"$7" "$([ "$7" = true ] && echo br-lan || echo eth0)" "$1" "$8" "$2" "$3"
|
|
printf '{"type":"flow_stats","internal":%s,"interface":"%s","flow":{"digest":"%s","local_ip":"192.168.9.10","local_mac":"%s","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","local_bytes":%s,"other_bytes":%s,"total_bytes":%s}}\n' \
|
|
"$7" "$([ "$7" = true ] && echo br-lan || echo eth0)" "$1" "$8" "$4" "$5" "$6"
|
|
}
|
|
|
|
hello() { printf '{"type":"agent_hello","agent_version":"test-4.4.7","build_version":"protocol-suite"}\n'; }
|
|
|
|
# ------------------------------------------------------------- conservation
|
|
|
|
test_conservation() {
|
|
head2 "BYTE CONSERVATION (against a hand-computed total, not a ratio band)"
|
|
|
|
# Three flows, chosen so no two sums coincide and a swapped or dropped term
|
|
# cannot land on the right answer by accident:
|
|
#
|
|
# flow A local 1000 other 2000 total 3000
|
|
# flow B local 500 other 700 total 1200
|
|
# flow C local 17 other 83 total 100
|
|
#
|
|
# sum(local) = 1517 sum(other) = 2783 sum(total) = 4300
|
|
#
|
|
# Every expected value below is one of those three, computed here and not
|
|
# read back from the daemon.
|
|
{
|
|
hello
|
|
flow_ev A0000001 1001 protoapp-one 1000 2000 3000 true 02:00:00:00:00:aa
|
|
flow_ev B0000002 1002 protoapp-two 500 700 1200 true 02:00:00:00:00:aa
|
|
flow_ev C0000003 1003 protoapp-three 17 83 100 true 02:00:00:00:00:aa
|
|
} > "$EV"
|
|
|
|
feed_and_settle || return
|
|
|
|
local rep att shd lk up dn tot
|
|
rep=$(field bytes reported); att=$(field bytes attributed)
|
|
shd=$(field bytes shadowed); lk=$(field bytes leaked)
|
|
note "reported=$rep attributed=$att shadowed=$shd leaked=$lk"
|
|
|
|
# SABOTAGE: in appflowd flow_update(), change the `stats.bytes.reported +=`
|
|
# line to add `tb` instead of the clamped delta. reported becomes 4300 on
|
|
# the first update and keeps climbing; this check goes red immediately.
|
|
chk "reported == the 4300 bytes we sent, exactly" "4300" "$rep"
|
|
|
|
# SABOTAGE: in account(), drop the `stats.bytes.attributed +=` line.
|
|
# attributed falls to 0 while reported stays 4300, and leaked becomes 4300.
|
|
chk "attributed == reported when nothing is shadowed" "4300" "$att"
|
|
chk "nothing was shadowed on an all-internal capture" "0" "$shd"
|
|
|
|
# SABOTAGE: same edit as above. leaked is reported minus what reached an
|
|
# aggregate, so any accounting path that stops adding shows here. This is
|
|
# the check the README tells a user to run, so it must bind.
|
|
chk "leaked == 0 (every reported byte reached an aggregate)" "0" "$lk"
|
|
|
|
head2 "DIRECTIONAL SPLIT (a swap survives every total-only check)"
|
|
|
|
up=$(ubus call appflow summary 2>/dev/null | sed -n 's/.*"bytes_up": *\([0-9]*\).*/\1/p' | head -1)
|
|
dn=$(ubus call appflow summary 2>/dev/null | sed -n 's/.*"bytes_down": *\([0-9]*\).*/\1/p' | head -1)
|
|
tot=$(ubus call appflow summary 2>/dev/null | sed -n 's/.*"bytes_total": *\([0-9]*\).*/\1/p' | head -1)
|
|
note "up=$up down=$dn total=$tot"
|
|
|
|
# SABOTAGE: in flow_update(), swap the two initialisers so `dl` takes
|
|
# f.other_bytes and `do_` takes f.local_bytes.
|
|
#
|
|
# This is why the two directions are asserted separately rather than as a
|
|
# sum. That swap leaves reported, attributed, leaked and bytes_total all
|
|
# exactly correct -- every check in the group above stays green, and so does
|
|
# the hardware suite's wire-truth ratio, because the sum is unchanged. Only
|
|
# these two go red. A suite that checks totals alone cannot see a swap.
|
|
chk "bytes_up == sum(local_bytes) == 1517" "1517" "$up"
|
|
chk "bytes_down == sum(other_bytes) == 2783" "2783" "$dn"
|
|
chk "bytes_total == 4300" "4300" "$tot"
|
|
|
|
head2 "SELF-INCONSISTENT COUNTERS ARE CLAMPED ONCE A BASELINE EXISTS"
|
|
|
|
# netifyd is trusted to report its own counters honestly, and DESIGN 2.2
|
|
# says the directional deltas are bounded by the growth of total_bytes so a
|
|
# self-inconsistent counter cannot inflate the totals.
|
|
#
|
|
# READ THE GUARD BEFORE BELIEVING THAT SENTENCE. In flow_update() the clamp
|
|
# sits behind `if (tb > 0 && fr.total > 0)`, so it applies from the SECOND
|
|
# update of a flow onward. On the first there is no baseline to measure
|
|
# growth against and the deltas are trusted as given -- deliberately, and
|
|
# the comment beside it argues the case: a missing or reset counter must not
|
|
# scale real deltas to zero, which would silently stop accounting a flow
|
|
# forever. That is a defensible trade and it is not what this checks.
|
|
#
|
|
# The first draft of this check sent ONE update with local 1e9 + other 1e9
|
|
# against total 100 and asserted the result was bounded by 100. It came back
|
|
# 2000000000 and read as a live defect. It was not: the test asserted a
|
|
# promise the code never made, on the one path where the promise is
|
|
# explicitly suspended. Written sabotage-first it would have died at the
|
|
# wall, because there is no clamp on that path to remove.
|
|
#
|
|
# So drive the path the clamp actually governs. Two updates:
|
|
#
|
|
# stats #1 local 100 other 100 total 200 no baseline, trusted
|
|
# stats #2 local 1e9 other 1e9 total 250 baseline 200, grew 50
|
|
#
|
|
# Growth is 50, so the second update may contribute 50 and no more:
|
|
# reported = 200 + 50 = 250
|
|
# attributed = 100 + 100 + 25 + 25 = 250
|
|
#
|
|
# Both hand-computed. An unclamped daemon reports 2000000200.
|
|
{
|
|
hello
|
|
printf '{"type":"flow","internal":true,"interface":"br-lan","flow":{"digest":"D0000004","local_ip":"192.168.9.10","local_mac":"02:00:00:00:00:aa","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","detected_application":1004,"detected_application_name":"protoapp-liar","local_bytes":0,"other_bytes":0,"total_bytes":0}}\n'
|
|
printf '{"type":"flow_stats","internal":true,"interface":"br-lan","flow":{"digest":"D0000004","local_ip":"192.168.9.10","local_mac":"02:00:00:00:00:aa","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","local_bytes":100,"other_bytes":100,"total_bytes":200}}\n'
|
|
printf '{"type":"flow_stats","internal":true,"interface":"br-lan","flow":{"digest":"D0000004","local_ip":"192.168.9.10","local_mac":"02:00:00:00:00:aa","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","local_bytes":1000000000,"other_bytes":1000000000,"total_bytes":250}}\n'
|
|
} > "$EV"
|
|
feed_and_settle || return
|
|
|
|
rep=$(field bytes reported); att=$(field bytes attributed)
|
|
note "reported=$rep attributed=$att (two billion offered, 50 bytes of growth)"
|
|
|
|
# SABOTAGE: in flow_update(), delete the `if (sum > dt)` branch so `dl` and
|
|
# `do_` pass through unclamped. reported becomes 2000000200 and both checks
|
|
# go red.
|
|
chk "growth, not the declared deltas, bounds what is reported" "250" "$rep"
|
|
chk "and what is attributed" "250" "$att"
|
|
|
|
# The first-update path is a real and deliberate hole in the clamp: one
|
|
# oversized update on a brand-new digest is trusted in full. Recorded here
|
|
# rather than asserted, because the daemon documents the trade and changing
|
|
# it is a design decision, not a bug fix.
|
|
note "note: the clamp does not cover a flow's FIRST update, by design"
|
|
}
|
|
|
|
# ------------------------------------------------------------------ protocol
|
|
|
|
test_protocol() {
|
|
head2 "WIRE PROTOCOL (the paths real netifyd never exercises)"
|
|
|
|
# One good flow so there is something to compare against, then five lines
|
|
# that are not JSON objects at all, then another good flow. The good flows
|
|
# bracket the garbage: if a malformed line desynchronised the reader, the
|
|
# second flow would not arrive and the byte total would be short.
|
|
{
|
|
hello
|
|
flow_ev E0000005 1005 protoapp-before 100 200 300 true 02:00:00:00:00:aa
|
|
printf '{"type":"flow","flow":{"digest":"trunc\n'
|
|
printf 'not json at all\n'
|
|
printf '[1,2,3]\n'
|
|
printf '"a bare string"\n'
|
|
printf '{"unterminated": \n'
|
|
flow_ev F0000006 1006 protoapp-after 400 500 900 true 02:00:00:00:00:aa
|
|
} > "$EV"
|
|
feed_and_settle || return
|
|
|
|
local mal rep
|
|
mal=$(field events malformed)
|
|
rep=$(field bytes reported)
|
|
note "malformed=$mal reported=$rep"
|
|
|
|
# SABOTAGE: in feed(), delete `stats.malformed++` from the json() catch
|
|
# block. malformed stays 0 and this goes red.
|
|
#
|
|
# The floor is 4 rather than 5 because two of the six garbage lines are a
|
|
# truncated object and an unterminated one; how the split lands depends on
|
|
# buffering, and pinning the exact count would make this check assert on
|
|
# socat's write sizes rather than on the daemon. Four is below any plausible
|
|
# split and above zero, which is the property under test.
|
|
chk_ge "malformed lines are counted, not silently dropped" 4 "$mal"
|
|
|
|
# SABOTAGE: in feed(), change the `return` in the catch block to a bare
|
|
# `dispatch(ev, now)` on an undefined `ev`. The daemon stops parsing after
|
|
# the first garbage line and this total falls to 300.
|
|
chk "a malformed line does not desynchronise the reader (300+900)" "1200" "$rep"
|
|
|
|
head2 "FRAME HEADERS ARE NOT EVENTS, AND EVENTS ARE NOT FRAMES"
|
|
|
|
{
|
|
hello
|
|
printf '{"length":512}\n'
|
|
printf '{"length":1024}\n'
|
|
flow_ev G0000007 1007 protoapp-framed 300 300 600 true 02:00:00:00:00:aa
|
|
} > "$EV"
|
|
feed_and_settle || return
|
|
|
|
local fr ev_total
|
|
fr=$(field events frames)
|
|
ev_total=$(field events total)
|
|
note "frames=$fr events.total=$ev_total"
|
|
|
|
# SABOTAGE: in feed(), delete `stats.frames++` from the frame-header branch.
|
|
chk "a sole-key {\"length\":N} object counts as a frame" "2" "$fr"
|
|
|
|
# SABOTAGE: in feed(), weaken the frame test from
|
|
# `ev.length != null && length(ev) == 1`
|
|
# to just `ev.length != null`.
|
|
#
|
|
# This is the interesting direction and the reason the check below exists.
|
|
# With the key-count dropped, ANY event that happens to carry a `length`
|
|
# field is swallowed as a frame header and never dispatched. netifyd does
|
|
# not send one today, so nothing in the hardware suite or in production
|
|
# would notice until it did -- at which point flows would silently vanish.
|
|
{
|
|
hello
|
|
printf '{"type":"flow","length":99,"internal":true,"interface":"br-lan","flow":{"digest":"H0000008","local_ip":"192.168.9.10","local_mac":"02:00:00:00:00:aa","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","detected_application":1008,"detected_application_name":"protoapp-lengthy","local_bytes":0,"other_bytes":0,"total_bytes":0}}\n'
|
|
printf '{"type":"flow_stats","length":99,"internal":true,"interface":"br-lan","flow":{"digest":"H0000008","local_ip":"192.168.9.10","local_mac":"02:00:00:00:00:aa","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","local_bytes":250,"other_bytes":250,"total_bytes":500}}\n'
|
|
} > "$EV"
|
|
feed_and_settle || return
|
|
|
|
rep=$(field bytes reported)
|
|
chk "an event carrying a 'length' field is still dispatched" "500" "$rep"
|
|
|
|
head2 "UNKNOWN EVENT TYPES ARE COUNTED AND NAMED"
|
|
|
|
{
|
|
hello
|
|
printf '{"type":"some_future_event","payload":{"a":1}}\n'
|
|
printf '{"type":"another_new_one","payload":{"b":2}}\n'
|
|
} > "$EV"
|
|
feed_and_settle || return
|
|
|
|
local unk
|
|
unk=$(field events unknown)
|
|
note "unknown=$unk"
|
|
|
|
# SABOTAGE: in dispatch(), delete the `stats.unknown++` line at the tail.
|
|
chk_ge "an unhandled event type is counted" 2 "$unk"
|
|
|
|
# SABOTAGE: in dispatch(), delete the `stats.unknown_types[t] = 0`
|
|
# initialiser so the name is never recorded.
|
|
if st | grep -q '"some_future_event"'; then
|
|
ok "the unknown type is named in status, not just counted"
|
|
else
|
|
bad "unknown_types does not name 'some_future_event'"
|
|
fi
|
|
|
|
head2 "A PURGE-ISH UNKNOWN TYPE STILL FINALISES THE FLOW"
|
|
|
|
# PURGE_RE exists so a netifyd rename cannot leak flow records. Nothing in
|
|
# the hardware suite reaches it, because real netifyd only ever sends the
|
|
# three names the handler table already knows.
|
|
{
|
|
hello
|
|
flow_ev I0000009 1009 protoapp-terminated 100 100 200 true 02:00:00:00:00:aa
|
|
printf '{"type":"flow_terminated","internal":true,"interface":"br-lan","flow":{"digest":"I0000009","local_ip":"192.168.9.10","local_mac":"02:00:00:00:00:aa","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","local_bytes":0,"other_bytes":0,"total_bytes":200}}\n'
|
|
} > "$EV"
|
|
feed_and_settle || return
|
|
|
|
local tracked purged
|
|
tracked=$(field flows tracked); purged=$(field flows purged)
|
|
note "tracked=$tracked purged=$purged"
|
|
|
|
# SABOTAGE: in dispatch(), delete the `if (match(t, PURGE_RE))` branch.
|
|
# The flow is never finalised, so tracked stays 1 and purged stays 0.
|
|
chk "a 'flow_terminated' event purges the flow" "0" "$tracked"
|
|
chk_ge "the purge was counted" 1 "$purged"
|
|
|
|
head2 "BLANK LINES ARE IGNORED, NOT COUNTED AS MALFORMED"
|
|
|
|
{
|
|
hello
|
|
printf '\n\n \n\t\n'
|
|
flow_ev J0000010 1010 protoapp-blanks 50 50 100 true 02:00:00:00:00:aa
|
|
} > "$EV"
|
|
feed_and_settle || return
|
|
|
|
mal=$(field events malformed)
|
|
|
|
# SABOTAGE: in feed(), delete the `if (!length(line)) return;` guard.
|
|
# Each blank line then reaches json(), throws, and is counted, so malformed
|
|
# becomes 4 and this goes red.
|
|
chk "whitespace-only lines are not counted as malformed" "0" "$mal"
|
|
}
|
|
|
|
# -------------------------------------------------------------------- bounds
|
|
|
|
test_bounds() {
|
|
head2 "BOUNDS: a single flow record cannot hold an unbounded string"
|
|
|
|
# TAG_MAX is 64. Send an 8 KB application name.
|
|
local longname
|
|
longname=$(awk 'BEGIN{ s=""; while (length(s) < 8192) s = s "A"; print s }')
|
|
{
|
|
hello
|
|
printf '{"type":"flow","internal":true,"interface":"br-lan","flow":{"digest":"K0000011","local_ip":"192.168.9.10","local_mac":"02:00:00:00:00:aa","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","detected_application":1011,"detected_application_name":"%s","local_bytes":0,"other_bytes":0,"total_bytes":0}}\n' "$longname"
|
|
printf '{"type":"flow_stats","internal":true,"interface":"br-lan","flow":{"digest":"K0000011","local_ip":"192.168.9.10","local_mac":"02:00:00:00:00:aa","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","local_bytes":10,"other_bytes":10,"total_bytes":20}}\n'
|
|
} > "$EV"
|
|
feed_and_settle || return
|
|
|
|
# Longest run of A's anywhere in the summary reply.
|
|
local worst
|
|
worst=$(ubus call appflow summary 2>/dev/null | grep -oE 'A+' | awk '{ if (length($0) > m) m = length($0) } END { print m+0 }')
|
|
note "longest A-run in the summary reply: $worst (TAG_MAX is 64)"
|
|
|
|
# SABOTAGE: in flow_identify(), replace the `cap(...)` wrapper around
|
|
# f.detected_application_name with the raw field. The stored tag becomes
|
|
# 8192 characters and this goes red.
|
|
if [ "${worst:-0}" -le 64 ] 2>/dev/null && [ "${worst:-0}" -gt 0 ]; then
|
|
ok "an oversized detection tag is capped at TAG_MAX ($worst <= 64)"
|
|
else
|
|
bad "detection tag not capped: longest run is $worst"
|
|
fi
|
|
|
|
head2 "BOUNDS: the flow table refuses to grow past flow_max"
|
|
|
|
# flow_max is clamped to a floor of 64 by cfg_load, so 64 is the smallest
|
|
# table this can ask for. 200 distinct digests against it.
|
|
uci set appflow.main.flow_max=64
|
|
uci commit appflow
|
|
{
|
|
hello
|
|
awk 'BEGIN {
|
|
for (i = 1; i <= 200; i++) {
|
|
d = sprintf("FLOOD%05d", i)
|
|
printf "{\"type\":\"flow\",\"internal\":true,\"interface\":\"br-lan\",\"flow\":{\"digest\":\"%s\",\"local_ip\":\"192.168.9.10\",\"local_mac\":\"02:00:00:00:00:aa\",\"other_ip\":\"93.184.216.34\",\"other_mac\":\"02:00:00:00:00:ff\",\"detected_application\":2000,\"detected_application_name\":\"flood\",\"local_bytes\":0,\"other_bytes\":0,\"total_bytes\":0}}\n", d
|
|
printf "{\"type\":\"flow_stats\",\"internal\":true,\"interface\":\"br-lan\",\"flow\":{\"digest\":\"%s\",\"local_ip\":\"192.168.9.10\",\"local_mac\":\"02:00:00:00:00:aa\",\"other_ip\":\"93.184.216.34\",\"other_mac\":\"02:00:00:00:00:ff\",\"local_bytes\":1,\"other_bytes\":1,\"total_bytes\":2}}\n", d
|
|
}
|
|
}'
|
|
} > "$EV"
|
|
feed_and_settle || return
|
|
|
|
local tracked dropped pruned
|
|
tracked=$(field flows tracked); dropped=$(field flows dropped)
|
|
pruned=$(field flows pruned)
|
|
note "tracked=$tracked pruned=$pruned dropped=$dropped against flow_max=64"
|
|
|
|
# SABOTAGE: in flow_new(), delete the `if (nflows >= cfg.flow_max)` block.
|
|
# tracked climbs to 200 and this goes red.
|
|
if [ "${tracked:-0}" -le 64 ] 2>/dev/null; then
|
|
ok "the flow table stays within flow_max ($tracked <= 64)"
|
|
else
|
|
bad "flow table holds $tracked with flow_max=64"
|
|
fi
|
|
|
|
# The bound above passes trivially if the table never actually filled, so
|
|
# prove the cap-pressure path RAN. Without this, offering three flows
|
|
# instead of two hundred would look identical.
|
|
#
|
|
# SABOTAGE: in prune(), delete the `if (force && nflows >= cfg.flow_max)`
|
|
# block. Nothing sheds, tracked climbs past 64 and pruned stays 0, so this
|
|
# and the check above go red together.
|
|
chk_ge "the cap-pressure prune actually ran" 1 "$pruned"
|
|
|
|
# `flows.shed` is the counter that says "the table is too small for this
|
|
# network", and it exists because of this check.
|
|
#
|
|
# The first version asserted `dropped >= 1` and failed. That was the test
|
|
# being wrong, not the daemon: flow_new() increments flows_dropped 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 never happens. `flows_dropped` cannot be moved
|
|
# by any input this daemon can receive.
|
|
#
|
|
# Status therefore carried a counter that permanently read 0 while flows
|
|
# were being shed continuously, with the shedding hidden inside `pruned`
|
|
# alongside ordinary idle housekeeping. Same shape as aggregates.refused.
|
|
# `flows.shed` was added 2026-08-26 to make it visible.
|
|
#
|
|
# SABOTAGE: in prune(), delete the `stats.flows_shed++` line. The table
|
|
# still sheds correctly and the operator still cannot see it, which is
|
|
# exactly the defect this counter was added for.
|
|
local shed; shed=$(field flows shed)
|
|
note "flows.shed=$shed flows.dropped=$dropped (dropped is a last-resort backstop)"
|
|
chk_ge "cap-pressure shedding is counted separately from idle pruning" 1 "$shed"
|
|
|
|
uci set appflow.main.flow_max="$ORIG_FLOWMAX"
|
|
uci commit appflow
|
|
|
|
head2 "BOUNDS: a full aggregate table refuses rather than evicting live data"
|
|
|
|
# AGG_MAX is 384 per class. 500 distinct application ids, each with a live
|
|
# flow, so nothing is evictable: every entry has cur.flows > 0.
|
|
#
|
|
# This is the LAN-attacker case the 2026-08-25 security panel converged on,
|
|
# reproduced deterministically. The right behaviour is the one already
|
|
# shipped: refuse to grow rather than drop a device that is actively
|
|
# transferring. What this checks is that the refusal is VISIBLE, because a
|
|
# status reading `apps: 384` with no other signal looks healthy at exactly
|
|
# the moment the table has stopped accepting anything.
|
|
{
|
|
hello
|
|
awk 'BEGIN {
|
|
for (i = 1; i <= 500; i++) {
|
|
d = sprintf("AGGF%06d", i)
|
|
printf "{\"type\":\"flow\",\"internal\":true,\"interface\":\"br-lan\",\"flow\":{\"digest\":\"%s\",\"local_ip\":\"192.168.9.10\",\"local_mac\":\"02:00:00:00:00:aa\",\"other_ip\":\"93.184.216.34\",\"other_mac\":\"02:00:00:00:00:ff\",\"detected_application\":%d,\"detected_application_name\":\"agg-%d\",\"local_bytes\":0,\"other_bytes\":0,\"total_bytes\":0}}\n", d, 5000+i, i
|
|
printf "{\"type\":\"flow_stats\",\"internal\":true,\"interface\":\"br-lan\",\"flow\":{\"digest\":\"%s\",\"local_ip\":\"192.168.9.10\",\"local_mac\":\"02:00:00:00:00:aa\",\"other_ip\":\"93.184.216.34\",\"other_mac\":\"02:00:00:00:00:ff\",\"local_bytes\":10,\"other_bytes\":10,\"total_bytes\":20}}\n", d
|
|
}
|
|
}'
|
|
} > "$EV"
|
|
feed_and_settle || return
|
|
|
|
local apps refused rep2
|
|
apps=$(field aggregates apps); refused=$(field aggregates refused)
|
|
rep2=$(field bytes reported)
|
|
note "apps=$apps refused=$refused reported=$rep2 (500 apps offered, AGG_MAX is 384)"
|
|
|
|
# SABOTAGE: in agg_make(), delete `stats.aggs_refused++` from the
|
|
# victim == null branch. The table still refuses correctly and the operator
|
|
# still cannot see it, which is the defect this counter was added for.
|
|
chk_ge "a refused aggregate is counted, not only logged" 1 "$refused"
|
|
|
|
if [ "${apps:-0}" -le 384 ] 2>/dev/null; then
|
|
ok "the aggregate table stays within AGG_MAX ($apps <= 384)"
|
|
else
|
|
bad "aggregate table holds $apps, above AGG_MAX"
|
|
fi
|
|
|
|
# SABOTAGE: in agg_make(), change the victim == null branch to evict the
|
|
# oldest entry regardless of cur.flows. Bytes belonging to an evicted live
|
|
# aggregate stop reaching the totals, so reported and the summary total
|
|
# diverge and this goes red.
|
|
#
|
|
# 500 flows x 20 bytes = 10000, and every one is reported whether or not it
|
|
# got its own aggregate row. That is the documented promise: the table
|
|
# refuses to GROW, it does not drop bytes from the totals.
|
|
chk "every offered byte is still reported when the table is full" "10000" "$rep2"
|
|
|
|
head2 "BOUNDS: the unknown-type map cannot be grown without limit"
|
|
|
|
# UNKNOWN_TYPE_MAX is 16. Offer 60 distinct unhandled type names.
|
|
{
|
|
hello
|
|
awk 'BEGIN { for (i = 1; i <= 60; i++) printf "{\"type\":\"unkflood_%d\",\"payload\":{}}\n", i }'
|
|
} > "$EV"
|
|
feed_and_settle || return
|
|
|
|
local ntypes
|
|
ntypes=$(st | sed -n '/"unknown_types": {/,/}/p' | grep -c '": *[0-9]')
|
|
note "distinct unknown types remembered: $ntypes (UNKNOWN_TYPE_MAX is 16)"
|
|
|
|
# SABOTAGE: in dispatch(), delete the
|
|
# `length(stats.unknown_types) < UNKNOWN_TYPE_MAX` guard. The map grows to
|
|
# 60 and this goes red. A peer that can pick type names would otherwise grow
|
|
# it without bound, and it is serialised into every status reply.
|
|
if [ "${ntypes:-0}" -le 16 ] 2>/dev/null; then
|
|
ok "the unknown-type map is bounded ($ntypes <= 16)"
|
|
else
|
|
bad "unknown_types holds $ntypes entries"
|
|
fi
|
|
|
|
head2 "BOUNDS: a peer that stops sending newlines is resynced, not unbounded"
|
|
|
|
# RX_MAX is 262144. Send 512 KB with no newline in it at all, then a real
|
|
# flow, so we can prove the reader recovered rather than merely survived.
|
|
{
|
|
hello
|
|
awk 'BEGIN { s = ""; while (length(s) < 4096) s = s "x"; for (i = 0; i < 128; i++) printf "%s", s }'
|
|
printf '\n'
|
|
flow_ev L0000012 1012 protoapp-afterflood 60 40 100 true 02:00:00:00:00:aa
|
|
} > "$EV"
|
|
feed_and_settle || return
|
|
|
|
local rs rep3
|
|
rs=$(field events resyncs); rep3=$(field bytes reported)
|
|
note "resyncs=$rs reported=$rep3"
|
|
|
|
# SABOTAGE: in on_readable(), delete the `if (length(rxbuf) > RX_MAX)`
|
|
# block. resyncs stays 0 and the buffer grows to whatever the peer sends.
|
|
chk_ge "a 512 KB line without a newline forces a resync" 1 "$rs"
|
|
|
|
# SABOTAGE: in the same block, drop back to `rxbuf = ""` unconditionally
|
|
# instead of cutting at the last newline. The flow that follows the flood is
|
|
# then eaten along with it and this goes red.
|
|
chk "the reader recovers and still parses the next flow" "100" "$rep3"
|
|
}
|
|
|
|
# --------------------------------------------------------------- attribution
|
|
|
|
test_attribution() {
|
|
head2 "ATTRIBUTION: a WAN-side copy of a LAN flow is shadowed, not counted twice"
|
|
|
|
# The double-count class. With netifyd watching br-lan and the WAN at once,
|
|
# a client's traffic is captured twice; DESIGN 3.2 says the internal capture
|
|
# is the device's own copy and the external one is a NAT twin.
|
|
#
|
|
# Same digest, two captures, 1000 bytes each. The honest answer is that 2000
|
|
# were reported and 1000 attributed, with 1000 shadowed. A daemon that
|
|
# counts both says the client used twice what it did.
|
|
{
|
|
hello
|
|
flow_ev M0000013 1013 protoapp-twin 500 500 1000 true 02:00:00:00:00:aa
|
|
flow_ev N0000014 1013 protoapp-twin 500 500 1000 false 02:00:00:00:00:bb
|
|
} > "$EV"
|
|
feed_and_settle || return
|
|
|
|
local rep att shd
|
|
rep=$(field bytes reported); att=$(field bytes attributed); shd=$(field bytes shadowed)
|
|
note "reported=$rep attributed=$att shadowed=$shd"
|
|
|
|
# SABOTAGE: in flow_identify(), replace the `dev_local` expression with a
|
|
# constant `true`. The external capture is then treated as its own device's
|
|
# traffic, shadowed falls to 0 and attributed rises to 2000.
|
|
chk "both captures are reported" "2000" "$rep"
|
|
chk "only one copy is attributed" "1000" "$att"
|
|
chk "the WAN-side twin is shadowed" "1000" "$shd"
|
|
|
|
# SABOTAGE: any of the above. Conservation must hold across the shadow
|
|
# split as well, and this is the check that ties the three together: a
|
|
# daemon that shadowed a copy but forgot to report it would pass the three
|
|
# above individually while losing bytes.
|
|
chk "attributed + shadowed == reported" "2000" "$(( att + shd ))"
|
|
chk "leaked is still 0 across a shadow decision" "0" "$(field bytes leaked)"
|
|
}
|
|
|
|
# -------------------------------------------------------------------- hostile
|
|
|
|
test_hostile() {
|
|
head2 "HOSTILE INPUT: nothing off the wire is interpreted"
|
|
|
|
# Everything in a flow record arrives from netifyd, which derives most of it
|
|
# from packets. host_server_name in particular is an SNI or an HTTP Host
|
|
# header: it is attacker-chosen, it reaches this daemon, and it is
|
|
# serialised into a ubus reply that a browser then renders.
|
|
#
|
|
# The daemon shells out to nothing, so the shell metacharacters below should
|
|
# be inert. This check is what makes that a tested property rather than an
|
|
# assumption, and it is cheap to keep.
|
|
local canary=/tmp/appflow-hostile-canary
|
|
rm -f "$canary"
|
|
{
|
|
hello
|
|
printf '{"type":"flow","internal":true,"interface":"br-lan","flow":{"digest":"O0000015","local_ip":"192.168.9.10","local_mac":"02:00:00:00:00:aa","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","host_server_name":"$(touch %s)`touch %s`;touch %s","detected_application":1015,"detected_application_name":"protoapp-hostile","local_bytes":0,"other_bytes":0,"total_bytes":0}}\n' "$canary" "$canary" "$canary"
|
|
printf '{"type":"flow_stats","internal":true,"interface":"br-lan","flow":{"digest":"O0000015","local_ip":"192.168.9.10","local_mac":"02:00:00:00:00:aa","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","local_bytes":11,"other_bytes":22,"total_bytes":33}}\n'
|
|
} > "$EV"
|
|
feed_and_settle || return
|
|
|
|
# THIS RUNTIME CHECK BINDS NOTHING TODAY, AND THE STATIC ONE BELOW IS THE
|
|
# REAL GUARD. A review panel converged on this and it was right.
|
|
#
|
|
# The canary cannot be turned red by any edit that leaves appflowd doing
|
|
# what it does, because appflowd never invokes a shell. The only edits that
|
|
# make it fire ADD a capability the daemon has never had. That is defect
|
|
# injection, not sabotage, so under the rule this file is written to it
|
|
# binds nothing and it must not be counted as behavioural coverage.
|
|
#
|
|
# It is kept, clearly labelled, because it costs one file existence test and
|
|
# it is the only thing that would catch a future maintainer adding a shell
|
|
# call on this path. What it is NOT is evidence that shell safety is tested.
|
|
if [ -e "$canary" ]; then
|
|
bad "a host_server_name was interpreted by a shell: $canary exists"
|
|
else
|
|
ok "(future-change guard, binds nothing today) no shell ran on the hostile name"
|
|
fi
|
|
|
|
# THIS is the assertion that binds, and it binds because the property being
|
|
# asserted is itself a property of the text: appflowd's shell safety comes
|
|
# from the daemon containing no shell invocation at all, so "the source
|
|
# contains no such construct" is the guarantee rather than a stand-in for
|
|
# it. The defect and the text are the same object.
|
|
#
|
|
# SABOTAGE: add `system("true");` anywhere in root/usr/sbin/appflowd. This
|
|
# goes red and the canary above does not, which is the whole reason both
|
|
# are here.
|
|
local D=/usr/sbin/appflowd
|
|
if [ -r "$D" ]; then
|
|
local shellcalls
|
|
shellcalls=$(grep -cE '\b(system|popen|exec|spawn)[[:space:]]*\(' "$D")
|
|
chk "the daemon contains no shell or process-spawning construct" "0" "$shellcalls"
|
|
|
|
# The same property one level up: a module it never imports is a
|
|
# capability it cannot reach by accident.
|
|
#
|
|
# SABOTAGE: add `import * as fs2 from "fs";`-style access to a process
|
|
# module. Pinned as an exact set because every import is a capability.
|
|
local imports
|
|
imports=$(grep -oE '^import .* from "[a-z]+"' "$D" | grep -oE '"[a-z]+"$' | tr -d '"' | sort | tr '\n' ' ')
|
|
chk "the daemon imports exactly the five modules it needs" \
|
|
"fs socket ubus uci uloop " "$imports"
|
|
else
|
|
bad "cannot read $D to check it for shell constructs"
|
|
fi
|
|
|
|
# SABOTAGE: in flow_identify(), remove the `substr(f.host_server_name, 0, 63)`
|
|
# bound. The stored host grows to whatever the peer sent.
|
|
chk "the flow was still accounted normally (11+22)" "33" "$(field bytes reported)"
|
|
|
|
head2 "HOSTILE INPUT: reserved-looking key names are ordinary data"
|
|
|
|
# In a JS engine these names reach Object.prototype and a lookup on a plain
|
|
# object literal returns a function rather than undefined. That defect was
|
|
# real in this package's BROWSER code and was fixed there with
|
|
# hasOwnProperty.
|
|
#
|
|
# ucode is not JavaScript and its objects carry no prototype chain, so the
|
|
# daemon has never been exposed to it. Verified directly:
|
|
# ucode -e 'let o = {}; print(o.constructor);' prints nothing
|
|
#
|
|
# The check stays because the property under test is "a device or
|
|
# application named like a language builtin is stored and returned as
|
|
# ordinary data", which is worth pinning on either engine, and because the
|
|
# sibling defect proves the input is reachable. It is honestly a weak check
|
|
# on this engine and section 7 of any review should say so.
|
|
{
|
|
hello
|
|
flow_ev P0000016 1016 constructor 10 10 20 true 02:00:00:00:00:aa
|
|
flow_ev Q0000017 1017 __proto__ 10 10 20 true 02:00:00:00:00:aa
|
|
flow_ev R0000018 1018 hasOwnProperty 10 10 20 true 02:00:00:00:00:aa
|
|
flow_ev S0000019 1019 toString 10 10 20 true 02:00:00:00:00:aa
|
|
} > "$EV"
|
|
feed_and_settle || return
|
|
|
|
local apps rep
|
|
apps=$(field aggregates apps); rep=$(field bytes reported)
|
|
note "apps=$apps reported=$rep"
|
|
|
|
# SABOTAGE: in agg_make(), change the `let a = tbl[key]` lookup to consult a
|
|
# fresh object literal seeded from tbl. On an engine with a prototype chain
|
|
# the four names below would then resolve to inherited functions; on ucode
|
|
# they would not, which is the honest limit of this check.
|
|
chk "four builtin-named applications produce four aggregates" "4" "$apps"
|
|
chk "and their bytes are accounted normally (4 x 20)" "80" "$rep"
|
|
|
|
head2 "HOSTILE INPUT: the ubus reply survives control characters"
|
|
|
|
# A name containing an embedded quote, a backslash and a newline escape. If
|
|
# any of them reached the reply unescaped, ubus's own JSON output would be
|
|
# malformed and the browser would fail to parse the whole document rather
|
|
# than showing one odd row.
|
|
{
|
|
hello
|
|
printf '{"type":"flow","internal":true,"interface":"br-lan","flow":{"digest":"T0000020","local_ip":"192.168.9.10","local_mac":"02:00:00:00:00:aa","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","detected_application":1020,"detected_application_name":"ev\\"il\\\\name\\nsecond","local_bytes":0,"other_bytes":0,"total_bytes":0}}\n'
|
|
printf '{"type":"flow_stats","internal":true,"interface":"br-lan","flow":{"digest":"T0000020","local_ip":"192.168.9.10","local_mac":"02:00:00:00:00:aa","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","local_bytes":5,"other_bytes":5,"total_bytes":10}}\n'
|
|
} > "$EV"
|
|
feed_and_settle || return
|
|
|
|
# SABOTAGE: in the ubus reply path, build the JSON by string concatenation
|
|
# instead of handing the object to the ubus binding. The reply stops being
|
|
# parseable and this goes red.
|
|
#
|
|
# Parsed rather than pattern-matched, deliberately: a grep for a quote would
|
|
# assert on the spelling of the escape, while this asserts that the document
|
|
# is well formed, which is the property that matters to the browser.
|
|
if ubus call appflow summary 2>/dev/null | jsonfilter -e '@' >/dev/null 2>&1; then
|
|
ok "the summary reply is still well-formed JSON"
|
|
else
|
|
bad "a hostile application name made the ubus reply unparseable"
|
|
fi
|
|
chk "and the flow was accounted (5+5)" "10" "$(field bytes reported)"
|
|
}
|
|
|
|
# ------------------------------------------------------------------ lifecycle
|
|
|
|
test_lifecycle() {
|
|
head2 "LIFECYCLE: reset clears the counters it claims to"
|
|
|
|
{
|
|
hello
|
|
flow_ev U0000021 1021 protoapp-reset 1234 4321 5555 true 02:00:00:00:00:aa
|
|
} > "$EV"
|
|
feed_and_settle || return
|
|
|
|
local before tot_before tot_after rep att shd lk
|
|
before=$(field bytes reported)
|
|
tot_before=$(ubus call appflow summary 2>/dev/null | sed -n 's/.*"bytes_total": *\([0-9]*\).*/\1/p' | head -1)
|
|
chk "fixture: bytes were counted before the reset" "5555" "$before"
|
|
chk "fixture: and the summary shows them" "5555" "$tot_before"
|
|
|
|
ubus call appflow reset >/dev/null 2>&1
|
|
sleep 2
|
|
|
|
tot_after=$(ubus call appflow summary 2>/dev/null | sed -n 's/.*"bytes_total": *\([0-9]*\).*/\1/p' | head -1)
|
|
rep=$(field bytes reported); att=$(field bytes attributed)
|
|
shd=$(field bytes shadowed); lk=$(field bytes leaked)
|
|
note "after reset: summary=$tot_after reported=$rep attributed=$att shadowed=$shd leaked=$lk"
|
|
|
|
# WHAT RESET ACTUALLY PROMISES, because the first version of this check got
|
|
# it wrong and read as a defect.
|
|
#
|
|
# It asserted `bytes.reported == 0` after a reset and failed with 5555.
|
|
# m_reset() zeroes the AGGREGATES -- totals, apps, devices, categories, and
|
|
# each flow's attributed up/down -- and deliberately does not touch
|
|
# `stats.bytes_*`, which are lifetime process counters. Its own comment says
|
|
# so. The summary is what the button clears and what the user sees; the
|
|
# conservation counters measure the daemon against netifyd for the life of
|
|
# the process and resetting them would destroy the only record of a leak.
|
|
#
|
|
# SABOTAGE: in m_reset(), delete the `agg_reset(totals, now)` call. The
|
|
# summary keeps its 5555 and this goes red.
|
|
chk "the summary total is zero after a reset" "0" "$tot_after"
|
|
|
|
# The invariant is the part worth guarding, not any single counter's value.
|
|
#
|
|
# SABOTAGE: in m_reset(), add `stats.bytes_reported = 0;` and nothing else.
|
|
# reported drops to 0 while attributed stays 5555, so leaked computes
|
|
# negative and this goes red. That is the realistic way a future edit
|
|
# breaks this: somebody makes reset "more thorough" one counter at a time.
|
|
chk "the conservation invariant survives a reset (leaked still 0)" "0" "$lk"
|
|
chk "and reported still equals attributed + shadowed" "$rep" "$(( att + shd ))"
|
|
|
|
note "note: bytes.* are lifetime counters and survive a reset by design"
|
|
|
|
head2 "LIFECYCLE: the daemon reconnects when the peer goes away"
|
|
|
|
local rc0 rc1
|
|
rc0=$(field socket reconnects); rc0=${rc0:-0}
|
|
fake_down
|
|
sleep 3
|
|
fake_up || { bad "could not bring the fake agent back up"; return; }
|
|
|
|
local i=0
|
|
while [ "$i" -lt 30 ]; do
|
|
[ "$(st | sed -n 's/.*"connected": *\(true\|false\).*/\1/p' | head -1)" = "true" ] && break
|
|
i=$((i + 1)); sleep 1
|
|
done
|
|
rc1=$(field socket reconnects); rc1=${rc1:-0}
|
|
note "reconnects before=$rc0 after=$rc1"
|
|
|
|
# SABOTAGE: in disconnect(), delete the schedule_reconnect() call. The
|
|
# daemon never comes back and both checks go red. Losing netifyd silently
|
|
# and permanently is the failure this guards: status would keep answering,
|
|
# with numbers frozen at the moment the agent went away.
|
|
chk "the daemon is connected again after the peer returned" "true" \
|
|
"$(st | sed -n 's/.*"connected": *\(true\|false\).*/\1/p' | head -1)"
|
|
chk_ge "the reconnect was counted" "$((rc0 + 1))" "$rc1"
|
|
}
|
|
|
|
# ------------------------------------------------------------------------ acl
|
|
|
|
test_acl() {
|
|
head2 "ACL: the grant list matches what the package uses"
|
|
|
|
local A=/usr/share/rpcd/acl.d/luci-app-appflow.json
|
|
[ -f "$A" ] || { bad "ACL file not installed"; return; }
|
|
|
|
# These four are the same checks the hardware suite makes and they are
|
|
# repeated here on purpose: this suite is the one that can run without
|
|
# traffic, and an ACL check that only runs on the slow path is an ACL check
|
|
# that stops being run.
|
|
#
|
|
# SABOTAGE for each: add the named grant back to the ACL file. That file is
|
|
# part of the product, it is what rpcd enforces, and these are properties of
|
|
# its text rather than of a behaviour standing behind the text -- which is
|
|
# the case where reading source is a binding assertion rather than a hollow
|
|
# one.
|
|
if grep -q '"network"' "$A"; then
|
|
bad "ACL grants uci read on 'network' (PPPoE/wireguard secrets)"
|
|
else
|
|
ok "ACL does not grant uci read on 'network'"
|
|
fi
|
|
|
|
if grep -q '"uci"' "$A"; then
|
|
bad "ACL contains a uci grant; this package reads only its own ubus object"
|
|
else
|
|
ok "ACL contains no uci grant at all"
|
|
fi
|
|
|
|
if grep -q '"file"' "$A"; then
|
|
bad "ACL contains a file grant; this package shells out to nothing"
|
|
else
|
|
ok "ACL contains no file/exec grant"
|
|
fi
|
|
|
|
local W
|
|
W=$(sed -n '/"write"/,$p' "$A" | grep -oE '"[a-z_]+"' | grep -vE '"write"|"ubus"|"appflow"' | tr -d '"' | tr '\n' ' ')
|
|
chk "the only write grant is appflow.reset" "reset" "$(echo "$W" | tr -d ' ')"
|
|
|
|
# Every granted read method must be one the frontend actually declares, and
|
|
# every method the frontend declares must answer. The second half is the
|
|
# addition: the hardware suite checks that the ACL grants nothing extra, and
|
|
# nothing checked that a granted method exists at all.
|
|
#
|
|
# SABOTAGE: add `"nonexistent_method"` to the read grant list in the ACL.
|
|
# The loop below calls it, ubus refuses, and this goes red. A grant naming a
|
|
# method that does not exist is dead text that outlives the thing it named.
|
|
local m missing=""
|
|
for m in $(sed -n '/"read"/,/"write"/p' "$A" | grep -oE '"[a-z_]+"' \
|
|
| grep -vE '"read"|"write"|"ubus"|"appflow"' | tr -d '"' | sort -u); do
|
|
ubus -v list appflow 2>/dev/null | grep -q "\"$m\"" || missing="$missing $m"
|
|
done
|
|
if [ -z "$(echo "$missing" | tr -d ' ')" ]; then
|
|
ok "every granted read method exists on the ubus object"
|
|
else
|
|
bad "granted but not offered by the daemon:$missing"
|
|
fi
|
|
}
|
|
|
|
# ------------------------------------------------------------- AI breakout
|
|
|
|
# A flow carrying an SNI. Separate from flow_ev() rather than an extra
|
|
# parameter on it, because every existing caller of flow_ev states its byte
|
|
# totals by hand and adding an optional field in the middle is how those calls
|
|
# silently shift by one.
|
|
#
|
|
# ai_flow <digest> <app_id> <app_name> <host> <local> <other> <total>
|
|
ai_flow() {
|
|
printf '{"type":"flow","internal":true,"interface":"br-lan","flow":{"digest":"%s","local_ip":"192.168.9.10","local_mac":"02:00:00:00:00:aa","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","local_port":40001,"other_port":443,"ip_protocol":6,"host_server_name":"%s","detected_application":%s,"detected_application_name":"%s","detected_protocol":91,"detected_protocol_name":"TLS","category":{"application":10,"protocol":20},"local_bytes":0,"other_bytes":0,"total_bytes":0}}\n' \
|
|
"$1" "$4" "$2" "$3"
|
|
printf '{"type":"flow_stats","internal":true,"interface":"br-lan","flow":{"digest":"%s","local_ip":"192.168.9.10","local_mac":"02:00:00:00:00:aa","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","local_bytes":%s,"other_bytes":%s,"total_bytes":%s}}\n' \
|
|
"$1" "$5" "$6" "$7"
|
|
}
|
|
|
|
# The application label the daemon settled on for a given app name, read from
|
|
# the summary. Returns MISSING when absent, for the same reason field() does:
|
|
# a helper that cannot find its subject must turn its check red rather than
|
|
# return something plausible.
|
|
# jsonfilter, not sed. The first version of app_bytes() ranged from the "name"
|
|
# line to the next closing brace and looked for bytes_total inside it, but
|
|
# bytes_total appears BEFORE name in the object, so the range never contained
|
|
# it and every call returned the empty string. A structural query cannot make
|
|
# that mistake; a line-range over JSON can, and did.
|
|
app_present() {
|
|
local k
|
|
k=$(ubus call appflow summary '{"limit":50}' 2>/dev/null |
|
|
jsonfilter -e "@.top_apps[@.name=\"$1\"].key" | head -1)
|
|
[ -n "$k" ] && echo YES || echo NO
|
|
}
|
|
|
|
# Bytes attributed to one application name. MISSING on a miss, never 0: a
|
|
# helper that cannot find its subject must turn its check red rather than
|
|
# return a number that happens to look plausible.
|
|
app_bytes() {
|
|
local v
|
|
v=$(ubus call appflow summary '{"limit":50}' 2>/dev/null |
|
|
jsonfilter -e "@.top_apps[@.name=\"$1\"].bytes_total" | head -1)
|
|
printf '%s' "${v:-MISSING}"
|
|
}
|
|
|
|
# The category the daemon PUTS ON THE WIRE for one application. MISSING on a
|
|
# miss, never empty, so a helper that cannot find its subject turns its check
|
|
# red rather than comparing two blanks and passing.
|
|
app_cat() {
|
|
local v
|
|
v=$(ubus call appflow apps '{"limit":50}' 2>/dev/null |
|
|
jsonfilter -e "@.apps[@.name=\"$1\"].category" | head -1)
|
|
printf '%s' "${v:-MISSING}"
|
|
}
|
|
|
|
# Bytes on one device aggregate. MISSING on a miss, never 0, so a helper that
|
|
# cannot find its subject turns its check red instead of comparing two blanks.
|
|
dev_bytes() {
|
|
local v
|
|
v=$(ubus call appflow devices '{"limit":20}' 2>/dev/null |
|
|
jsonfilter -e "@.devices[@.key=\"$1\"].bytes_total" | head -1)
|
|
printf '%s' "${v:-MISSING}"
|
|
}
|
|
|
|
# A WAN-side flow the router itself originated: local_* is the router's own
|
|
# address and MAC, which is what makes is_router() true for the local side.
|
|
router_flow() {
|
|
printf '{"type":"flow","internal":false,"interface":"wan","flow":{"digest":"%s","local_ip":"%s","local_mac":"%s","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","local_port":40001,"other_port":443,"ip_protocol":6,"detected_application":0,"detected_application_name":"","detected_protocol":91,"detected_protocol_name":"TLS","category":{"application":10,"protocol":20},"local_bytes":0,"other_bytes":0,"total_bytes":0}}\n' \
|
|
"$1" "$RIP" "$RMAC"
|
|
printf '{"type":"flow_stats","internal":false,"interface":"wan","flow":{"digest":"%s","local_ip":"%s","local_mac":"%s","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","local_bytes":%s,"other_bytes":%s,"total_bytes":%s}}\n' \
|
|
"$1" "$RIP" "$RMAC" "$2" "$3" "$4"
|
|
}
|
|
|
|
# A second stats event on an existing router flow, carrying a larger cumulative
|
|
# counter. This is the update that used to discard the flow.
|
|
router_stats() {
|
|
printf '{"type":"flow_stats","internal":false,"interface":"wan","flow":{"digest":"%s","local_ip":"%s","local_mac":"%s","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","local_bytes":%s,"other_bytes":%s,"total_bytes":%s}}\n' \
|
|
"$1" "$RIP" "$RMAC" "$2" "$3" "$4"
|
|
}
|
|
|
|
test_router_origin() {
|
|
head2 "ROUTER-ORIGINATED TRAFFIC SURVIVES CONNTRACK FORGETTING THE FLOW"
|
|
|
|
# THE DEFECT, found 2026-08-31 on BOTH routers. The "Router" pseudo-device
|
|
# had never counted a single byte in production: 107,476 flows where
|
|
# conntrack positively confirmed the router originated the traffic, and a
|
|
# device row reading zero. The dashboard then correctly hides a zero-byte
|
|
# device, so the feature was invisibly absent rather than visibly broken.
|
|
#
|
|
# The mechanism, measured with temporary counters in the daemon rather than
|
|
# reasoned about: flow_identify() asks conntrack, gets a positive "the
|
|
# router originated this", keeps the flow and attaches it. Then the FIRST
|
|
# flow_stats event runs reshadow(), which asks the same question again. By
|
|
# then the connection has often closed and its conntrack entry is gone, so
|
|
# the answer is "unknown", and the code treated unknown as grounds to
|
|
# discard. Absence of evidence overrode a positive determination already
|
|
# made. That is the same mistake statusStrip() carries a comment about.
|
|
#
|
|
# This test pins the ordering that matters: resolve once, then let conntrack
|
|
# forget, and the bytes must still land.
|
|
local RIP RMAC CT
|
|
CT=/tmp/appflow-ct-fixture
|
|
RIP=$(jsonfilter -i /var/run/netifyd/status.json -e '@.interfaces.wan.addr[0]' 2>/dev/null)
|
|
case "$RIP" in
|
|
*.*) : ;;
|
|
*) RIP=$(jsonfilter -i /var/run/netifyd/status.json -e '@.interfaces.wan.addr[1]' 2>/dev/null) ;;
|
|
esac
|
|
RMAC=$(jsonfilter -i /var/run/netifyd/status.json -e '@.interfaces.wan.mac' 2>/dev/null)
|
|
|
|
if [ -z "$RIP" ] || [ -z "$RMAC" ]; then
|
|
skip "no WAN address/mac in netifyd status; cannot forge a router flow"
|
|
return
|
|
fi
|
|
note "router WAN identity: $RIP / $RMAC"
|
|
|
|
# A conntrack line in the kernel's own format. The daemon keys on the REPLY
|
|
# tuple and decides "was the source translated" by comparing the ORIGINAL
|
|
# src against the REPLY dst. Equal means no NAT, i.e. the router itself
|
|
# opened this connection.
|
|
printf 'ipv4 2 tcp 6 431999 ESTABLISHED src=%s dst=93.184.216.34 sport=40001 dport=443 packets=4 bytes=400 src=93.184.216.34 dst=%s sport=443 dport=40001 packets=4 bytes=400 [ASSURED] mark=0 zone=0 use=2\n' \
|
|
"$RIP" "$RIP" > "$CT"
|
|
|
|
uci set appflow.main.conntrack_path="$CT"
|
|
uci commit appflow
|
|
|
|
# An internal flow first, so saw_internal is true: without it the whole
|
|
# shadow path is skipped and this test would pass against a daemon that had
|
|
# never worked. Then the router's own flow, and a SECOND stats update on it,
|
|
# because the second update is what used to destroy the flow.
|
|
#
|
|
# local_bytes/other_bytes are per-update deltas; total_bytes is cumulative.
|
|
{
|
|
hello
|
|
flow_ev RO000001 1001 lan-client 400 600 1000 true 02:00:00:00:00:aa
|
|
router_flow RO000002 700 1300 2000
|
|
router_stats RO000002 700 1300 4000
|
|
} > "$EV"
|
|
feed_and_settle || { uci delete appflow.main.conntrack_path; uci commit appflow; return; }
|
|
|
|
# The conntrack block is the instrument this test depends on. Print it: a
|
|
# run where entries is 0 or available is null is not a failing product, it
|
|
# is a test that never armed, and the two must not look alike in the log.
|
|
note "conntrack: $(ubus call appflow status 2>/dev/null | jsonfilter -e '@.conntrack' 2>/dev/null)"
|
|
|
|
# THE SABOTAGE FOR THIS ONE IS BOTH HALVES OF THE FIX, NOT EITHER, and that
|
|
# was established by running it rather than by reasoning. Reverting only the
|
|
# ct_nat_twin() port lookup leaves this GREEN, because the fr.ct_local guard
|
|
# then rescues the flow; reverting only the guard leaves it green too,
|
|
# because the lookup succeeds. Revert both -- key off `f.local_port` again
|
|
# AND drop the `if (fr.ct_local) return;` -- and this reads 0, which is what
|
|
# every release up to and including 1.1.1 did.
|
|
#
|
|
# The check below is the one that isolates the root cause on its own.
|
|
chk "a router-originated flow reaches the router device" "4000" "$(dev_bytes router)"
|
|
|
|
# Same run, different question: the counter must show the lookup SUCCEEDING
|
|
# on the update, not merely that some bytes arrived. unresolved > 0 here
|
|
# means the key is not matching even though the entry is present.
|
|
#
|
|
# SABOTAGE: revert ct_nat_twin() to keying off `f.local_port`/`f.other_port`
|
|
# instead of the ports stored on the flow record. A flow_stats event carries
|
|
# no ports, so the key gains two empty fields and matches nothing. This is
|
|
# the precise detector for the shipped defect, and it fires on that revert
|
|
# alone even while the check above stays green.
|
|
chk "and conntrack resolved every re-check" "0" \
|
|
"$(ubus call appflow status 2>/dev/null | jsonfilter -e '@.conntrack.unresolved' 2>/dev/null)"
|
|
|
|
# THE MIRROR, and it is the one that matters most. A fix that simply stopped
|
|
# shadowing would pass both checks above and reintroduce the double-count
|
|
# this whole mechanism exists to prevent: a WAN-side copy of a NAT-ed client
|
|
# measured at 163-199% of wire truth. With conntrack unable to confirm the
|
|
# router originated anything, the flow must still be discarded.
|
|
#
|
|
# SABOTAGE: make reshadow() return unconditionally for dev_key "router".
|
|
# The two checks above stay green and this goes red.
|
|
: > "$CT"
|
|
{
|
|
hello
|
|
flow_ev RO000011 1001 lan-client 400 600 1000 true 02:00:00:00:00:aa
|
|
router_flow RO000012 700 1300 2000
|
|
router_stats RO000012 700 1300 4000
|
|
} > "$EV"
|
|
feed_and_settle
|
|
|
|
note "with an empty conntrack: router = $(dev_bytes router)"
|
|
chk "an unconfirmable router flow is still shadowed, not counted" \
|
|
"MISSING" "$(dev_bytes router)"
|
|
|
|
uci delete appflow.main.conntrack_path 2>/dev/null
|
|
uci commit appflow
|
|
rm -f "$CT"
|
|
}
|
|
|
|
test_ai_breakout() {
|
|
head2 "AI BREAKOUT (hostname matching, and the things it must NOT match)"
|
|
|
|
# WHY THIS FEATURE EXISTS, measured: /etc/netify.d/netify-apps.conf is dated
|
|
# 10 August 2023, holds 199 signatures, and contains no AI vendor at all, so
|
|
# this traffic arrives as generic HTTP/S with detected_application 0. The SNI
|
|
# is already present in the flow record. Everything below drives the REAL
|
|
# daemon through the fake netifyd socket, so the oracle is the daemon's own
|
|
# output rather than a reimplementation of its table.
|
|
|
|
uci set appflow.main.ai_breakout=1 2>/dev/null
|
|
uci commit appflow
|
|
|
|
# Byte totals stated by hand, chosen so no two sums coincide:
|
|
#
|
|
# anthropic A local 1100 other 2200 total 3300
|
|
# claude.ai B local 100 other 200 total 300
|
|
# openai C local 11 other 22 total 33
|
|
# evil D local 7 other 13 total 20
|
|
#
|
|
# Anthropic (Claude) must aggregate A+B = 3600 into ONE row.
|
|
# sum(total) over all four = 3653
|
|
{
|
|
hello
|
|
ai_flow AI000001 0 "" "api.anthropic.com" 1100 2200 3300
|
|
ai_flow AI000002 0 "" "claude.ai" 100 200 300
|
|
ai_flow AI000003 0 "" "api.openai.com" 11 22 33
|
|
# SUFFIX ANCHORING. This host is registrable by anyone and a substring
|
|
# test on "anthropic.com" matches it. It must not be labelled Anthropic.
|
|
#
|
|
# SABOTAGE: in ai_match(), replace the label-boundary suffix rebuild with
|
|
# an index()/substring test. This check goes red on its own.
|
|
ai_flow AI000004 0 "" "anthropic.com.attacker.example" 7 13 20
|
|
} > "$EV"
|
|
feed_and_settle
|
|
|
|
chk "an SNI netifyd could not classify is labelled" \
|
|
"YES" "$(app_present 'Anthropic (Claude)')"
|
|
chk "and so is a second AI vendor" "YES" "$(app_present 'OpenAI')"
|
|
|
|
# THE MERGE. anthropic.com and claude.ai are one vendor. Keying on the
|
|
# matched suffix instead of the label would produce two identically-named
|
|
# rows, which looks like a rendering bug and hides the real total.
|
|
#
|
|
# SABOTAGE: in flow_identify(), set `key = "ai:" + ai.via`. The bytes split
|
|
# across two rows and this drops to 3300.
|
|
chk "two domains of one vendor aggregate into a single row" \
|
|
"3600" "$(app_bytes 'Anthropic (Claude)')"
|
|
|
|
# THE LOOKALIKE MUST LAND SOMEWHERE ELSE, and that is asserted by finding it
|
|
# where it belongs rather than by failing to find it where it does not.
|
|
#
|
|
# Two earlier versions of this check were TAUTOLOGIES, caught by running the
|
|
# sabotage rather than by reading them. One asked whether an app named
|
|
# "Anthropic (Claude)x" was present, which nothing can ever make true. The
|
|
# other grepped the summary for the hostname, which the summary does not
|
|
# carry at all. Both stayed green with the suffix matcher replaced by a
|
|
# substring test, which is the exact defect they were written for.
|
|
#
|
|
# anthropic.com.attacker.example arrives as app_id 0 over detected_protocol
|
|
# 91, so unlabelled it aggregates as "TLS" carrying its own 20 bytes. Under
|
|
# a substring matcher those bytes move into Anthropic and this reads MISSING.
|
|
#
|
|
# SABOTAGE: in ai_match(), match with index() instead of rebuilding the last
|
|
# N labels.
|
|
chk "a lookalike domain keeps its own identity, not the vendor it imitates" \
|
|
"20" "$(app_bytes TLS)"
|
|
|
|
# The counter must move, and by exactly the number of flows relabelled.
|
|
# Without this, "the label appeared" could come from anywhere.
|
|
#
|
|
# SABOTAGE: delete `stats.ai_labeled++`.
|
|
chk "exactly three flows were relabelled" "3" "$(field flows ai_labeled)"
|
|
|
|
# THE CATEGORY IS A SLUG, AND NOTHING ELSE IN EITHER SUITE CHECKED IT.
|
|
#
|
|
# 1.1.0 shipped this column as four English display strings, "AI assistants"
|
|
# and three siblings, where every netifyd category is a lowercase slug. They
|
|
# 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 check in this group passed throughout, because all of them
|
|
# assert on labels, bytes and counters and none of them had ever looked at
|
|
# the category at all.
|
|
#
|
|
# This asserts the value ON THE WIRE, from the real daemon, which is the
|
|
# only place the contract is real. The frontend suite reads the same table
|
|
# out of the daemon source; that catches a bad edit, this catches a bad
|
|
# emission, and neither subsumes the other.
|
|
#
|
|
# SABOTAGE: in appflowd's AI_HOSTS table, change any category back to a
|
|
# display string, e.g. "ai-assistants" -> "AI assistants". This goes red and
|
|
# every other check in this group stays green.
|
|
chk "the AI category reaches the wire as a slug" \
|
|
"ai-assistants" "$(app_cat 'Anthropic (Claude)')"
|
|
chk "and so does a second vendor's" "ai-assistants" "$(app_cat 'OpenAI')"
|
|
|
|
# The mirror: a daemon that hardcoded one slug for everything would pass
|
|
# both rows above. A flow the AI table did NOT match must still carry the
|
|
# category netifyd gave it.
|
|
#
|
|
# ASSERTED AS A PROPERTY, NOT A LITERAL, AND THAT IS DELIBERATE. The first
|
|
# version expected "unclassified" and was simply wrong: the fixture sends
|
|
# category.application 10, and appflowd resolves that through
|
|
# catnames.app[] against the box's own /etc/netify.d/netify-categories.json,
|
|
# which on this bench is "file-sharing". Pasting that observed value back in
|
|
# would have made the check depend on one router's netifyd index, so a
|
|
# netifyd update would turn it red having broken nothing. What the check is
|
|
# actually for is that the AI block does not reach flows it never matched.
|
|
#
|
|
# SABOTAGE: set the category unconditionally in the AI block rather than
|
|
# from the table. This goes red while the two rows above stay green.
|
|
local tlscat verdict
|
|
tlscat=$(app_cat TLS)
|
|
case "$tlscat" in
|
|
MISSING|"") verdict=MISSING ;;
|
|
ai-*) verdict=AI-LEAKED ;;
|
|
*) verdict=OK ;;
|
|
esac
|
|
note "the unmatched TLS flow carries category '$tlscat'"
|
|
chk "a flow the AI table did not match keeps its netifyd category" \
|
|
"OK" "$verdict"
|
|
|
|
# ACCOUNTING IS UNTOUCHED. This is the check that matters most: the feature
|
|
# changes an identity and must not be able to change a byte.
|
|
#
|
|
# SABOTAGE: move the AI block after flow_attach(), or have it touch fr.a.
|
|
local tot
|
|
tot=$(ubus call appflow summary 2>/dev/null | sed -n 's/.*"bytes_total": *\([0-9]*\).*/\1/p' | head -1)
|
|
chk "byte conservation holds with AI flows present (3300+300+33+20)" \
|
|
"3653" "${tot:-MISSING}"
|
|
|
|
head2 "AI BREAKOUT: netifyd's own answer wins unless we say otherwise"
|
|
|
|
# THE SELF-RETIRING PROPERTY. If netifyd identified an application, its
|
|
# answer stands. That is what makes this table fall silent by itself the day
|
|
# Netify ship AI signatures, instead of permanently shadowing better data.
|
|
#
|
|
# SABOTAGE: drop the `app_id == 0` half of the gate in flow_identify(). The
|
|
# first check goes red.
|
|
{
|
|
hello
|
|
ai_flow AI000010 4242 "netify.some-cdn" "api.anthropic.com" 500 500 1000
|
|
} > "$EV"
|
|
feed_and_settle
|
|
|
|
# "netify.some-cdn" renders as "Some CDN" via display_name()/title_case(),
|
|
# read back from a live run rather than predicted.
|
|
chk "netifyd's application identification is NOT overridden" \
|
|
"YES" "$(app_present 'Some CDN')"
|
|
chk "and the AI label was not applied over it" \
|
|
"NO" "$(app_present 'Anthropic (Claude)')"
|
|
chk "so no flow was relabelled" "0" "$(field flows ai_labeled)"
|
|
|
|
# THE EXCEPTION. A `strong` entry overrides even a netifyd identification,
|
|
# because netifyd recognises the PARENT brand and would swallow the service
|
|
# inside it: Gemini reported as Google, Copilot as GitHub.
|
|
#
|
|
# SABOTAGE: change gemini.google.com's third field to false. This goes red
|
|
# and the check above stays green, which is the distinction it exists for.
|
|
{
|
|
hello
|
|
ai_flow AI000011 4243 "netify.google" "gemini.google.com" 400 600 1000
|
|
} > "$EV"
|
|
feed_and_settle
|
|
|
|
chk "a strong entry DOES override netifyd's parent-brand answer" \
|
|
"YES" "$(app_present 'Google Gemini')"
|
|
|
|
head2 "AI BREAKOUT: hostile and awkward hostnames"
|
|
|
|
# "constructor" and "toString.prototype" as hostnames.
|
|
#
|
|
# These are here to PROVE ucode behaves as the daemon's comment claims, not
|
|
# because they catch a live bug. In a browser `{}["constructor"]` returns a
|
|
# function and such a hostname would read as a match; in ucode there is no
|
|
# prototype chain and the lookup is null. Confirmed by sabotage: replacing
|
|
# the array type-check with a truthiness test changes no result here, so
|
|
# this group is NOT the reason that check exists and must not claim to be.
|
|
#
|
|
# What these hostnames do still prove is that a label-shaped input which is
|
|
# not a domain neither matches nor crashes the daemon.
|
|
#
|
|
# Also covers case folding and a trailing dot, both legal in a FQDN and both
|
|
# capable of shifting every suffix by one label.
|
|
{
|
|
hello
|
|
ai_flow AI000020 0 "" "constructor" 10 10 20
|
|
ai_flow AI000021 0 "" "toString.prototype" 10 10 20
|
|
ai_flow AI000022 0 "" "API.ANTHROPIC.COM" 30 30 60
|
|
ai_flow AI000023 0 "" "api.openai.com." 40 40 80
|
|
ai_flow AI000024 0 "" "notanthropic.com" 50 50 100
|
|
ai_flow AI000025 0 "" "" 60 60 120
|
|
} > "$EV"
|
|
feed_and_settle
|
|
|
|
chk "an uppercase SNI still matches" "YES" "$(app_present 'Anthropic (Claude)')"
|
|
chk "a trailing-dot FQDN still matches" "YES" "$(app_present 'OpenAI')"
|
|
|
|
# notanthropic.com shares a substring with anthropic.com but not a label
|
|
# boundary, and must not match. Counted rather than name-checked, because
|
|
# "is Anthropic present" is already true from AI000022 in this same batch.
|
|
#
|
|
# SABOTAGE: as for the lookalike above.
|
|
chk "exactly two of six awkward hostnames were relabelled" \
|
|
"2" "$(field flows ai_labeled)"
|
|
|
|
if [ "$(field bytes reported)" = "MISSING" ]; then
|
|
bad "status did not report byte totals after the hostile batch"
|
|
else
|
|
ok "the daemon survived the hostile hostname batch and still reports"
|
|
fi
|
|
|
|
head2 "AI BREAKOUT: the upgrade path, where the option is absent"
|
|
|
|
# THE UPGRADE PATH. apk treats /etc/config/appflow as a configuration file
|
|
# and leaves an existing one alone, so a config written by a version before
|
|
# this feature existed has no ai_breakout line at all. That is what every
|
|
# upgrading router has, which makes it the COMMON case, and it was the one
|
|
# case this group did not cover: the checks above set the option explicitly
|
|
# to 1, and the off-switch check sets it explicitly to 0.
|
|
#
|
|
# CFG_DEFAULTS says 1 and the daemon comment says "Absent means ON". This is
|
|
# the check that makes that true rather than merely claimed.
|
|
#
|
|
# SABOTAGE: change CFG_DEFAULTS.ai_breakout to 0, or make load_config() treat
|
|
# a missing value as off. Both go red here and nowhere else, because every
|
|
# other check in this file states the option explicitly.
|
|
uci delete appflow.main.ai_breakout 2>/dev/null
|
|
uci commit appflow
|
|
chk "the option really is absent (fixture guard)" "" "$(uci -q get appflow.main.ai_breakout)"
|
|
|
|
{
|
|
hello
|
|
ai_flow AI000040 0 "" "api.anthropic.com" 300 300 600
|
|
} > "$EV"
|
|
feed_and_settle
|
|
|
|
chk "with no ai_breakout option at all, the feature is ON" \
|
|
"1" "$(field flows ai_labeled)"
|
|
chk "and the vendor is labelled" "YES" "$(app_present 'Anthropic (Claude)')"
|
|
chk "status reports the default as enabled" "1" "$(field config ai_breakout)"
|
|
|
|
head2 "AI BREAKOUT: the off switch"
|
|
|
|
# A user who does not want an asserted classification must be able to turn
|
|
# it off completely, and the setting must be visible in status so support
|
|
# questions can be answered without asking for the config file.
|
|
#
|
|
# SABOTAGE: ignore cfg.ai_breakout in flow_identify().
|
|
uci set appflow.main.ai_breakout=0
|
|
uci commit appflow
|
|
{
|
|
hello
|
|
ai_flow AI000030 0 "" "api.anthropic.com" 100 100 200
|
|
} > "$EV"
|
|
feed_and_settle
|
|
|
|
chk "with ai_breakout off, nothing is relabelled" "0" "$(field flows ai_labeled)"
|
|
chk "and the AI label does not appear" "NO" "$(app_present 'Anthropic (Claude)')"
|
|
chk "status reports the setting" "0" "$(field config ai_breakout)"
|
|
|
|
uci set appflow.main.ai_breakout=1
|
|
uci commit appflow
|
|
}
|
|
|
|
test_hostmap() {
|
|
head2 "HOSTMAP: teaching appflow a service it does not know"
|
|
|
|
# WHY THIS EXISTS. Someone asked the obvious question of any traffic
|
|
# classifier: "I have a service I want measured, what do I do?" The answer
|
|
# used to be "edit the daemon and rebuild the package", which for a tool
|
|
# whose pitch is that netifyd's signatures are three years stale is the
|
|
# wrong shape entirely.
|
|
#
|
|
# `config hostmap` sections add to the built-in table, or override it.
|
|
|
|
cp /etc/config/appflow /tmp/appflow.hostmap.keep
|
|
|
|
# The worked example from the documentation, verbatim. If the docs and the
|
|
# code ever diverge, this is where it shows.
|
|
cat >> /etc/config/appflow <<'HMEOF'
|
|
|
|
config hostmap
|
|
option suffix 'torproject.org'
|
|
option name 'Tor'
|
|
option category 'Privacy'
|
|
HMEOF
|
|
uci commit appflow 2>/dev/null
|
|
|
|
{
|
|
hello
|
|
ai_flow HM000001 0 "" "check.torproject.org" 100 200 300
|
|
ai_flow HM000002 0 "" "www.torproject.org" 10 20 30
|
|
# NOT Tor: shares a substring but not a label boundary. Anyone can
|
|
# register this, and a substring matcher would label it Tor.
|
|
#
|
|
# SABOTAGE: make the matcher use index() instead of rebuilding the
|
|
# last N labels. This host is relabelled and the byte check goes red.
|
|
ai_flow HM000003 0 "" "torproject.org.evil.test" 7 13 20
|
|
} > "$EV"
|
|
feed_and_settle
|
|
|
|
chk "a user-supplied suffix is recognised" "YES" "$(app_present 'Tor')"
|
|
|
|
# Both real hosts aggregate into the one application: 300 + 30 = 330.
|
|
#
|
|
# SABOTAGE: key the app on the matched suffix rather than the label. The
|
|
# rows split and this drops to 300.
|
|
chk "both hosts under it aggregate into one application" "330" "$(app_bytes Tor)"
|
|
|
|
# The lookalike must keep its own identity. It is app_id 0 over
|
|
# detected_protocol 91, so unlabelled it aggregates as TLS with its own 20
|
|
# bytes.
|
|
chk "a lookalike domain is not captured by a user entry" "20" "$(app_bytes TLS)"
|
|
|
|
# The counter must move by exactly the number relabelled, so "the label
|
|
# appeared" cannot come from somewhere else.
|
|
chk "exactly two flows were relabelled" "2" "$(field flows ai_labeled)"
|
|
|
|
head2 "HOSTMAP: overriding a built-in entry"
|
|
|
|
# The same mechanism corrects a built-in that is wrong for someone's
|
|
# network, which is the second half of what extensibility means.
|
|
#
|
|
# SABOTAGE: consult AI_HOSTS before cfg.hostmap in the matcher. The
|
|
# built-in name comes back and this goes red.
|
|
cp /tmp/appflow.hostmap.keep /etc/config/appflow
|
|
cat >> /etc/config/appflow <<'HMEOF'
|
|
|
|
config hostmap
|
|
option suffix 'anthropic.com'
|
|
option name 'Work AI'
|
|
option category 'Business'
|
|
HMEOF
|
|
uci commit appflow 2>/dev/null
|
|
|
|
{
|
|
hello
|
|
ai_flow HM000010 0 "" "api.anthropic.com" 500 500 1000
|
|
} > "$EV"
|
|
feed_and_settle
|
|
|
|
chk "a user entry overrides the built-in name" "YES" "$(app_present 'Work AI')"
|
|
chk "and the built-in name is gone" "NO" "$(app_present 'Anthropic (Claude)')"
|
|
|
|
head2 "HOSTMAP: malformed entries are refused, not half-applied"
|
|
|
|
# A suffix with no dot can never match, because the matcher compares whole
|
|
# labels, so accepting one produces a rule that silently does nothing.
|
|
#
|
|
# THE GUARD'S ONLY OBSERVABLE EFFECT IS THE LOG LINE, and that is worth
|
|
# knowing. Removing the guard changes no classification at all: a dotless
|
|
# suffix matches nothing either way. What it changes is whether a user who
|
|
# fatfingered an entry is TOLD, or is left staring at a feature that appears
|
|
# not to work. An earlier version of this comment claimed the sabotage would
|
|
# redden a classification check; running it proved otherwise, so the check
|
|
# below asserts the thing that actually differs.
|
|
#
|
|
# SABOTAGE: drop the `index(suf, ".") < 0` guard. The warning disappears and
|
|
# the log check goes red.
|
|
cp /tmp/appflow.hostmap.keep /etc/config/appflow
|
|
# MARK THE LOG, do not count it.
|
|
#
|
|
# Two ways this went wrong before it worked. Grepping the whole history
|
|
# passes forever once a message has appeared even once, so the sabotage that
|
|
# removes the guard left the check green on a PREVIOUS run's warning. Then
|
|
# counting lines and reading past the count failed too, because logread is a
|
|
# RING: the buffer was already full at its maximum, the count stopped
|
|
# growing, and the window was always empty. A unique marker is immune to
|
|
# both.
|
|
local LOGTAG
|
|
LOGTAG="hostmap-test-$$-$(date +%s)"
|
|
logger -t appflow-suite "$LOGTAG"
|
|
cat >> /etc/config/appflow <<'HMEOF'
|
|
|
|
config hostmap
|
|
option suffix 'notadomain'
|
|
option name 'Bad'
|
|
|
|
config hostmap
|
|
option suffix 'example.net'
|
|
option name ''
|
|
|
|
config hostmap
|
|
option suffix 'good.example'
|
|
option name 'Good'
|
|
HMEOF
|
|
uci commit appflow 2>/dev/null
|
|
|
|
{
|
|
hello
|
|
ai_flow HM000020 0 "" "host.good.example" 40 60 100
|
|
ai_flow HM000021 0 "" "notadomain" 10 10 20
|
|
} > "$EV"
|
|
feed_and_settle
|
|
|
|
chk "the valid entry still works alongside malformed ones" "YES" "$(app_present 'Good')"
|
|
chk "an entry with no name is refused" "NO" "$(app_present 'Bad')"
|
|
chk "only the valid flow was relabelled" "1" "$(field flows ai_labeled)"
|
|
|
|
# The user must be TOLD which entry was skipped, by name. Silence here is
|
|
# indistinguishable from the feature being broken.
|
|
local NEWLOG
|
|
NEWLOG=$(logread 2>/dev/null | sed -n "/$LOGTAG/,\$p")
|
|
if printf '%s
|
|
' "$NEWLOG" | grep -q "hostmap: skipping entry with suffix notadomain"; then
|
|
ok "a dotless suffix is named in the log, not silently dropped"
|
|
else
|
|
bad "a dotless suffix was skipped without telling anyone"
|
|
fi
|
|
if printf '%s
|
|
' "$NEWLOG" | grep -q "hostmap: skipping entry with suffix example.net"; then
|
|
ok "an entry with no name is named in the log too"
|
|
else
|
|
bad "the nameless entry was skipped without telling anyone"
|
|
fi
|
|
|
|
# Accounting is untouched by any of this: 100 + 20 = 120.
|
|
#
|
|
# SABOTAGE: have the hostmap path touch fr.a or run after flow_attach().
|
|
local tot
|
|
tot=$(ubus call appflow summary 2>/dev/null | sed -n 's/.*"bytes_total": *\([0-9]*\).*/\1/p' | head -1)
|
|
chk "byte conservation holds with user entries present" "120" "${tot:-MISSING}"
|
|
|
|
cp /tmp/appflow.hostmap.keep /etc/config/appflow
|
|
uci commit appflow 2>/dev/null
|
|
rm -f /tmp/appflow.hostmap.keep
|
|
}
|
|
|
|
test_drilldown() {
|
|
head2 "DRILL-DOWN: device <-> application, from the live flow table"
|
|
|
|
# WHY THIS EXISTS. "Who is using the bandwidth" was answerable and "what is
|
|
# that thing doing" was not. Both answers are one pass over the flow table,
|
|
# which already carries dev_key, app_key and cat_key on every record, so
|
|
# neither needed a new aggregate. That was worth checking rather than
|
|
# assuming: the first answer given was that it required a per-device
|
|
# per-application table and was therefore a memory design question. It did
|
|
# not, and the check took two minutes.
|
|
#
|
|
# Both directions report what CURRENTLY TRACKED flows account for, not
|
|
# lifetime totals, because a flow that has ended is no longer in the table.
|
|
# Every reply carries from_flows so a view cannot present one as the other.
|
|
|
|
# Two devices, three applications, byte totals chosen so no two sums
|
|
# coincide and every assertion below is a distinct number.
|
|
#
|
|
# dev A (02:00:00:00:00:aa) app 4001 local 1000 other 2000 = 3000
|
|
# dev A app 4002 local 100 other 200 = 300
|
|
# dev B (02:00:00:00:00:bb) app 4001 local 10 other 20 = 30
|
|
#
|
|
# app 4001 spans BOTH devices -> 3030
|
|
# dev A spans BOTH applications -> 3300
|
|
dev_flow() {
|
|
printf '{"type":"flow","internal":true,"interface":"br-lan","flow":{"digest":"%s","local_ip":"192.168.9.%s","local_mac":"%s","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","local_port":40001,"other_port":443,"ip_protocol":6,"detected_application":%s,"detected_application_name":"%s","detected_protocol":91,"detected_protocol_name":"TLS","category":{"application":10,"protocol":20},"local_bytes":0,"other_bytes":0,"total_bytes":0}}\n' \
|
|
"$1" "$2" "$3" "$4" "$5"
|
|
printf '{"type":"flow_stats","internal":true,"interface":"br-lan","flow":{"digest":"%s","local_ip":"192.168.9.%s","local_mac":"%s","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","local_bytes":%s,"other_bytes":%s,"total_bytes":%s}}\n' \
|
|
"$1" "$2" "$3" "$6" "$7" "$8"
|
|
}
|
|
|
|
{
|
|
hello
|
|
dev_flow DD000001 10 02:00:00:00:00:aa 4001 "netify.alpha" 1000 2000 3000
|
|
dev_flow DD000002 10 02:00:00:00:00:aa 4002 "netify.beta" 100 200 300
|
|
dev_flow DD000003 11 02:00:00:00:00:bb 4001 "netify.alpha" 10 20 30
|
|
# A flow whose LIFETIME counter exceeds what appflow attributed, which
|
|
# is what a stub adopted mid-transfer looks like: netifyd reports the
|
|
# connection's whole life, appflow only ever attributes what it saw.
|
|
# Without a row like this the two numbers are equal for every flow and
|
|
# summing the wrong one is invisible, which is exactly what happened:
|
|
# the sabotage below passed until this line existed.
|
|
dev_flow DD000004 12 02:00:00:00:00:cc 4003 "netify.gamma" 400 600 9000
|
|
} > "$EV"
|
|
feed_and_settle
|
|
|
|
# --- devices restricted to an application -----------------------------
|
|
|
|
# SABOTAGE: in m_devices, ignore req.args.apps and always take the
|
|
# aggregate path. from_flows goes false and every count below is wrong.
|
|
local A B
|
|
A=$(ubus call appflow devices '{"apps":["a4001"],"limit":10}' 2>/dev/null)
|
|
|
|
chk "a device query restricted to one app says it came from flows" \
|
|
"1" "$(printf '%s' "$A" | grep -c '"from_flows": true')"
|
|
chk "and finds BOTH devices carrying that application" \
|
|
"2" "$(printf '%s' "$A" | jsonfilter -e '@.total' 2>/dev/null)"
|
|
|
|
# The heavier device sorts first, and its bytes are its own, not the
|
|
# application's total across devices.
|
|
#
|
|
# SABOTAGE: sum fr.total instead of fr.up + fr.down. Device cc's flow has a
|
|
# lifetime counter of 9000 and 1000 attributed, so the sabotage reports
|
|
# 9000 and this goes red. Every other flow in the fixture has the two equal,
|
|
# which is why that row had to be added: without it the sabotage passed.
|
|
chk "the busier device is first" "3000" \
|
|
"$(printf '%s' "$A" | jsonfilter -e '@.devices[0].bytes_total' 2>/dev/null)"
|
|
chk "and the quieter one carries only its own bytes" "30" \
|
|
"$(printf '%s' "$A" | jsonfilter -e '@.devices[1].bytes_total' 2>/dev/null)"
|
|
|
|
# An application nothing used yields an empty answer, not the whole list.
|
|
#
|
|
# SABOTAGE: treat an empty match set as "no filter". This returns every
|
|
# device, which is the failure that looks like the filter silently not
|
|
# working.
|
|
chk "an application with no flows yields no devices" "0" \
|
|
"$(ubus call appflow devices '{"apps":["a9999"]}' 2>/dev/null | jsonfilter -e '@.total')"
|
|
|
|
# Absent or empty `apps` must take the ordinary aggregate path, or every
|
|
# unfiltered page load starts answering from the flow table instead.
|
|
chk "no apps argument means the ordinary aggregate" "1" \
|
|
"$(ubus call appflow devices '{"limit":5}' 2>/dev/null | grep -c '"from_flows": false')"
|
|
|
|
# --- applications restricted to a device ------------------------------
|
|
|
|
# The mirror image, and the one a person actually reaches for.
|
|
#
|
|
# SABOTAGE: in apps_for_device, compare fr.app_key against dev instead of
|
|
# fr.dev_key. Everything below goes red at once.
|
|
B=$(ubus call appflow apps '{"device":"02:00:00:00:00:aa","limit":10}' 2>/dev/null)
|
|
|
|
chk "an application query scoped to a device says it came from flows" \
|
|
"1" "$(printf '%s' "$B" | grep -c '"from_flows": true')"
|
|
chk "and finds both applications that device used" \
|
|
"2" "$(printf '%s' "$B" | jsonfilter -e '@.total' 2>/dev/null)"
|
|
chk "the heavier application is first" "3000" \
|
|
"$(printf '%s' "$B" | jsonfilter -e '@.apps[0].bytes_total' 2>/dev/null)"
|
|
chk "and the lighter one is its own total" "300" \
|
|
"$(printf '%s' "$B" | jsonfilter -e '@.apps[1].bytes_total' 2>/dev/null)"
|
|
|
|
# The other device used only one application, which is the check that the
|
|
# scope is really per-device rather than global.
|
|
chk "a different device sees only its own applications" "1" \
|
|
"$(ubus call appflow apps '{"device":"02:00:00:00:00:bb"}' 2>/dev/null | jsonfilter -e '@.total')"
|
|
|
|
chk "a device with no flows yields nothing" "0" \
|
|
"$(ubus call appflow apps '{"device":"02:00:00:00:00:zz"}' 2>/dev/null | jsonfilter -e '@.total')"
|
|
|
|
chk "no device argument means the ordinary aggregate" "1" \
|
|
"$(ubus call appflow apps '{"limit":5}' 2>/dev/null | grep -c '"from_flows": false')"
|
|
|
|
# --- the argument policy ---------------------------------------------
|
|
|
|
# ubus REJECTS an undeclared argument rather than ignoring it, and the
|
|
# rejection presents as the method returning nothing at all. Both new
|
|
# arguments had to be added to the published policy, and forgetting either
|
|
# is silent.
|
|
#
|
|
# SABOTAGE: remove `apps: []` or `device: ""` from the publish() policy.
|
|
chk "the devices policy declares apps" "1" \
|
|
"$(ubus -v list appflow 2>/dev/null | grep -c '\"devices\":.*\"apps\":\"Array\"')"
|
|
chk "the apps policy declares device" "1" \
|
|
"$(ubus -v list appflow 2>/dev/null | grep -c '\"apps\":.*\"device\":\"String\"')"
|
|
|
|
# --- accounting is untouched -----------------------------------------
|
|
|
|
# Both directions read the flow table and write nothing. Conservation must
|
|
# hold exactly as it did: 3000 + 300 + 30.
|
|
#
|
|
# SABOTAGE: have either function mutate fr.up/fr.down while summing.
|
|
# ATTRIBUTED, NOT LIFETIME. Device cc contributes its attributed 1000, not
|
|
# its 9000 lifetime, so the total is 3000 + 300 + 30 + 1000.
|
|
#
|
|
# SABOTAGE: sum fr.total in either drill-down function.
|
|
chk "a drill-down reports ATTRIBUTED bytes, not the lifetime counter" "1000" \
|
|
"$(ubus call appflow apps '{"device":"02:00:00:00:00:cc"}' 2>/dev/null | jsonfilter -e '@.apps[0].bytes_total')"
|
|
|
|
chk "byte conservation is unaffected by either drill-down" "4330" \
|
|
"$(ubus call appflow summary 2>/dev/null | sed -n 's/.*"bytes_total": *\([0-9]*\).*/\1/p' | head -1)"
|
|
}
|
|
|
|
# ------------------------------------------------------------------------ run
|
|
|
|
printf 'appflow protocol and bounds suite\n'
|
|
printf 'router: %s group: %s\n' \
|
|
"$(uci -q get system.@system[0].hostname 2>/dev/null || echo '?')" "$GROUP"
|
|
|
|
command -v socat >/dev/null 2>&1 || { printf 'socat is required and is not installed\n'; exit 2; }
|
|
|
|
# Wait for the ubus object rather than demanding it instantly.
|
|
#
|
|
# appflowd registers a few seconds after procd starts it, so a bare check here
|
|
# fails whenever this suite is run right after something that restarted the
|
|
# daemon -- including the previous group of this same suite, whose cleanup
|
|
# restarts it. That happened on the first full pass: the hostile group exited 2
|
|
# and reported the daemon absent while it was starting up two feet away.
|
|
#
|
|
# The guard itself is right and stays. An absent daemon must stop the run, not
|
|
# be tested around, because every counter would then read MISSING and the
|
|
# failures would blame the product.
|
|
i=0
|
|
while [ "$i" -lt 20 ]; do
|
|
ubus list appflow >/dev/null 2>&1 && break
|
|
i=$((i + 1)); sleep 1
|
|
done
|
|
ubus list appflow >/dev/null 2>&1 || {
|
|
printf 'the appflow ubus object never appeared; is appflowd installed and running?\n'
|
|
exit 2
|
|
}
|
|
|
|
ORIG_SOCK=$(uci -q get appflow.main.socket_path)
|
|
ORIG_FLOWMAX=$(uci -q get appflow.main.flow_max)
|
|
[ -n "$ORIG_SOCK" ] || { printf 'appflow.main.socket_path is unset; refusing to guess\n'; exit 2; }
|
|
printf 'saved socket_path=%s flow_max=%s\n' "$ORIG_SOCK" "$ORIG_FLOWMAX"
|
|
|
|
# The test MACs must not be the router's own, or is_router() reclassifies every
|
|
# synthetic flow and the attribution checks assert on the wrong branch while
|
|
# still passing. Proving the fixture is valid costs one check and is the
|
|
# difference between a suite that tests attribution and one that tests nothing.
|
|
if ip link show 2>/dev/null | grep -qiE 'link/ether (02:00:00:00:00:aa|02:00:00:00:00:bb|02:00:00:00:00:ff)'; then
|
|
bad "fixture: a test MAC collides with a real interface on this router"
|
|
else
|
|
ok "fixture: no test MAC collides with a real interface"
|
|
fi
|
|
|
|
uci set appflow.main.socket_path="$SOCK"
|
|
uci commit appflow
|
|
|
|
case "$GROUP" in
|
|
conservation) test_conservation ;;
|
|
protocol) test_protocol ;;
|
|
bounds) test_bounds ;;
|
|
attribution) test_attribution ;;
|
|
hostile) test_hostile ;;
|
|
lifecycle) test_lifecycle ;;
|
|
acl) test_acl ;;
|
|
ai) test_ai_breakout ;;
|
|
router) test_router_origin ;;
|
|
hostmap) test_hostmap ;;
|
|
drilldown) test_drilldown ;;
|
|
all) test_conservation; test_protocol; test_bounds
|
|
test_attribution; test_hostile; test_lifecycle; test_acl
|
|
test_ai_breakout; test_hostmap; test_drilldown
|
|
test_router_origin ;;
|
|
*) printf "unknown group '%s'\n" "$GROUP"; exit 2 ;;
|
|
esac
|
|
|
|
printf '\n----------------------------------------\n'
|
|
printf 'passed %d, failed %d\n' "$PASS" "$FAIL"
|