Files

139 lines
5.9 KiB
Bash
Executable File

#!/bin/sh
# shellcheck shell=dash
# Per-device byte counters from conntrack (for speed calculation).
# Output: JSON array
# [{"ip":"…","bytes_in":N,"bytes_out":N,"bytes_tcp":N,"bytes_udp":N,
# "src":"ct","degraded":false}]
#
# "src" names the counter source. trafficctl-totals.sh accumulates these into
# lifetime totals and the two sources have unrelated magnitudes (conntrack
# reports only live flows, the nft maps count since the table was built), so it
# has to be able to tell a source switch from a burst of traffic.
#
# "degraded" says whether the numbers can be believed AT ALL — see below. It is
# separate from "src" on purpose: src is about magnitude (and drives the
# rebaseline), degraded is about trust, and conflating them would make a change
# in trust look like a change of counter and throw away a delta for nothing.
#
# bytes_tcp / bytes_udp carry both directions summed, and exist only on the
# conntrack path — nft counter maps are keyed by address alone and cannot split
# by protocol, so trafficctl-bytes-nft.sh reports -1 there. -1 rather than 0:
# "unknown" and "no TCP traffic" must not render as the same number.
. /usr/local/bin/trafficctl-fw.sh
# Any offload mode (software, hardware, hardware-counter) bypasses conntrack counters
# for fast-path packets. Use nftables counters at forward priority -200 (before the
# flowtable at -150) which capture every packet regardless of offload state.
# Only pure "none" mode has accurate conntrack counters.
# Modes whose counters ARE synced back to conntrack ("*-counter") keep the
# conntrack path accurate; only the uncountered ones need the nft fallback.
# TCTL_FORCE_CONNTRACK is set by that fallback when the kernel lacks dynamic
# counter maps, so we don't bounce between the two.
_offload=$(tctl_get_offload_mode)
_uncountered=0
case "$_offload" in
none|*-counter) ;;
*)
_uncountered=1
[ "$TCTL_FW" = "nft" ] && [ -z "$TCTL_FORCE_CONNTRACK" ] && \
exec /usr/local/bin/trafficctl-bytes-nft.sh
;;
esac
# Reaching here with uncountered offload means the nft fallback was unavailable
# and we are about to read counters the forwarding path does not update.
#
# Two ways in, and the second is easy to miss:
# * TCTL_FORCE_CONNTRACK — bytes-nft.sh bounced back because the kernel has
# no dynamic counter map support. Observed on real hardware.
# * fw3/iptables — there is no nft fallback to reach in the first place.
#
# The numbers that follow are not merely imprecise, they are FROZEN for every
# offloaded flow: they stop moving while traffic continues. Left unflagged, a
# lifetime total accumulated from them looks authoritative and simply stalls,
# which reads as stale statistics rather than as a broken counter — the same
# shape as the 2 GiB %d freeze (#56), and harder to notice than the tiny live
# values that prompted #26, because the number is plausible.
_degraded=false
[ "$_uncountered" = "1" ] && _degraded=true
# All monitored subnets (connected LANs + routed downstream subnets +
# trafficctl.main.extra_subnets), as awk membership spec.
MATCH_SPEC=$(tctl_monitored_subnets | awk '{printf "%s%s:%s:%s",(NR>1?" ":""),$2,$3,$4}')
[ -z "$MATCH_SPEC" ] && { echo '[]'; exit 0; }
# Router-owned IPv4 addresses, excluded from the NAT fallback below.
LOCAL_IPS=$(ip -4 addr show 2>/dev/null | awk '/inet /{split($2,a,"/");print a[1]}' | tr '\n' ' ')
cat /proc/net/nf_conntrack 2>/dev/null | awk -v spec="$MATCH_SPEC" -v localips="$LOCAL_IPS" -v degraded="$_degraded" '
function ip2int(ip, a) {
split(ip, a, ".")
return a[1]*16777216 + a[2]*65536 + a[3]*256 + a[4]
}
function is_lan(ip, si, k) {
si = ip2int(ip)
for (k = 1; k <= ns; k++)
if (si - (si % blk[k]) == base[k]) return 1
return 0
}
BEGIN {
printf "["
ns = split(spec, parts, " ")
for (k = 1; k <= ns; k++) {
split(parts[k], kv, ":")
base[k] = kv[1] + 0; blk[k] = kv[2] + 0
}
nl = split(localips, lp, " ")
for (k = 1; k <= nl; k++) if (lp[k] != "") islocal[lp[k]] = 1
}
{
src=""; osrc=""; rdst=""; nsrc=0; bytes_orig=0; bytes_reply=0; bc=0; proto=""
for (i=1; i<=NF; i++) {
# The L4 protocol is a bare word in the header fields ("ipv4 2 tcp 6 …"),
# before any key=value pair, so it is matched by value like summary.sh.
if ($i == "tcp") proto="tcp"
else if ($i == "udp") proto="udp"
if ($i ~ /^src=/) {
v = substr($i, 5)
nsrc++
if (nsrc == 1) osrc = v
if (src == "" && is_lan(v)) src = v
}
if (nsrc == 2 && rdst == "" && $i ~ /^dst=/) rdst = substr($i, 5)
if ($i ~ /^bytes=/) {
v = substr($i, 7) + 0
bc++
if (bc == 1) bytes_orig = v
else if (bc == 2) bytes_reply = v
}
}
# NAT fallback: flow was SNAT/masqueraded here (reply dst != original
# src), so the original src is a forwarded client even though it is not
# in any monitored subnet (e.g. behind a downstream router).
if (src == "" && osrc != "" && rdst != "" && rdst != osrc && \
osrc ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ && !(osrc in islocal))
src = osrc
if (src != "") {
key = src
in_total[key] += bytes_reply
out_total[key] += bytes_orig
# Both directions, unlike the per-direction sums above: the protocol
# split answers how much of the traffic for this device was TCP, which
# is not a question about direction.
if (proto == "tcp") tcp_total[key] += bytes_orig + bytes_reply
else if (proto == "udp") udp_total[key] += bytes_orig + bytes_reply
}
}
END {
n = 0
for (ip in in_total) {
if (n > 0) printf ","
printf "{\"ip\":\"%s\",\"bytes_in\":%.0f,\"bytes_out\":%.0f,\"bytes_tcp\":%.0f,\"bytes_udp\":%.0f,\"src\":\"ct\",\"degraded\":%s}", \
ip, in_total[ip], out_total[ip], tcp_total[ip]+0, udp_total[ip]+0, degraded
n++
}
printf "]\n"
}
'