Skip to content

Instantly share code, notes, and snippets.

@Strykar
Last active February 28, 2026 21:36
Show Gist options
  • Select an option

  • Save Strykar/72c20bb021347eafe4294511f21791be to your computer and use it in GitHub Desktop.

Select an option

Save Strykar/72c20bb021347eafe4294511f21791be to your computer and use it in GitHub Desktop.
Traffick - NFTables Traffic Monitor: A POSIX-compliant grokker for tagged nftables counters.
#!/bin/sh
set -fuC
# --- USER CONFIG STARTS ---
IN_KEYS="ingress|input|incoming|ollama"
OUT_KEYS="egress|output|outgoing"
SEC_KEYS="drop|deny|reject|block"
# --- USER CONFIG ENDS ---
show_help() {
cat << EOF
NFTables Traffic Monitor: A POSIX-compliant grokker for tagged nftables counters.
Works in any shell (sh, bash, dash, ksh, zsh) and supports BusyBox awk.
USAGE:
traffick Static ruleset snapshot.
traffick --live 1s refresh with rate/peak tracking.
traffick --reset Clear local peak/rate cache (does not affect nft counters).
traffick -h Show this help menu.
DESCRIPTION:
This script parses 'nft list ruleset' for rules containing both a 'counter'
and a 'comment'. It categorizes traffic by matching comment text against
user-supplied regex keys (Security, Inbound, Outbound).
SETUP:
To track a flow, add a counter and a comment to your nftables rule:
... counter comment "ingress: web traffic"
... counter comment "drop: ssh conn"
MATCHING PRIORITY:
1. SECURITY (Key: $SEC_KEYS)
2. INBOUND (Key: $IN_KEYS)
3. OUTBOUND (Key: $OUT_KEYS)
4. UNCATEGORIZED (Fallback for no match)
EOF
}
# --- HANDLERS ---
USER_ID=$(id -u)
[ -d /dev/shm ] && STATE_FILE="/dev/shm/nft_state_$USER_ID" || STATE_FILE="/tmp/nft_state_$USER_ID"
SORT_BIN=$(command -v sort) || SORT_BIN="sort"
[ "$(id -u)" -ne 0 ] && SUDO="sudo" || SUDO=""
CMD="${1:-}"
date +%N | grep -qv "%N" && DATE_FMT="+%s.%N" || DATE_FMT="+%s"
for cmd in nft awk grep date sort; do
command -v "$cmd" >/dev/null 2>&1 || { echo "Error: $cmd not found. Please install it."; exit 1; }
done
if [ "$CMD" = "--help" ] || [ "$CMD" = "-h" ]; then show_help; exit 0; fi
if [ "$CMD" = "--reset" ]; then
rm -f "$STATE_FILE"* && echo "Local traffic state cache cleared. Kernel counters remain untouched."
exit 0
fi
if [ "$CMD" = "--live" ]; then
command -v watch >/dev/null 2>&1 || { echo "Error: 'watch' is required for live mode."; exit 1; }
trap 'rm -f "$STATE_FILE"*' EXIT INT TERM
COLS=$(tput cols 2>/dev/null || echo 80)
watch --color -t -n 1 "$0 --internal-live $COLS"
exit 0
fi
LIVE_MODE=0
[ "$CMD" = "--internal-live" ] && LIVE_MODE=1 && COLS="${2:-80}"
: "${COLS:=$(tput cols 2>/dev/null || echo 80)}"
# --- CORE ENGINE ---
$SUDO nft list ruleset | awk -v now="$(date "$DATE_FMT")" -v tw="$COLS" \
-v live="$LIVE_MODE" -v sfile="$STATE_FILE" -v p_cmd="$SORT_BIN" \
-v in_k="$IN_KEYS" -v out_k="$OUT_KEYS" -v sec_k="$SEC_KEYS" '
BEGIN {
in_k = tolower(in_k); out_k = tolower(out_k); sec_k = tolower(sec_k)
w_dir=6; w_tbl=22; w_trf=13
w_id = tw - w_dir - w_tbl - w_trf - 12
if (w_id < 28) w_id = 28
if (w_id > 60) w_id = 60
w_tot=(w_dir + w_tbl + w_id + 2)
live_x = (live == 1) ? 32 : 0
sep_len = w_tot + w_trf + 2 + live_x
for(i=1; i<=sep_len; i++) sep = sep "-"
if (live == 1) {
while ((getline < sfile) > 0) {
if (NF == 4 && $1 != "") { p_bytes[$1] = $2; p_time[$1] = $3; p_peak[$1] = $4 }
}
close(sfile)
}
}
/^table / {
t_fam = $2; t_nam = $3
tbl_fmt = "[" t_fam " " t_nam "]"
tbl_id = t_fam ":" t_nam
}
/^chain / {
t_chn = $2
}
/counter/ && /comment/ {
b=0; comm=""; clean_c=""; id=""; d=""; lbl=""
for (i=1; i<=NF; i++) if ($i=="bytes") b=$(i+1)
if (match($0, /comment "[^"]+"/)) {
comm = substr($0, RSTART+9, RLENGTH-10)
gsub("IPv4/inet", "IPv4", comm)
gsub(/\[|\]/, "", comm)
clean_c = comm; gsub(/[ \/]/, "", clean_c)
id = tbl_id ":" t_chn ":" clean_c
} else { next }
# Use the full comment to identify the rule, then shorten it to fit the table.
if (length(comm) > w_id) {
half = int(w_id/2); comm = substr(comm, 1, half-2) ".." substr(comm, length(comm)-half+3)
}
curr_rate = 0; peak_rate = 0
if (live == 1) {
if (id in p_bytes) {
t_diff = now - p_time[id]; b_diff = b - p_bytes[id]
if (t_diff > 0.1 && b_diff >= 0) {
curr_rate = (b_diff / t_diff) / 1024
peak_rate = (curr_rate > p_peak[id]) ? curr_rate : p_peak[id]
} else if (b_diff < 0) {
peak_rate = 0
} else { peak_rate = p_peak[id] }
}
new_states = new_states id " " b " " now " " peak_rate "\n"
}
# CATEGORIZATION LOGIC
# Works with awk that does not support IGNORECASE=1
l_comm = tolower(comm)
if (l_comm ~ "("sec_k")") { d="DROP"; lbl="[DROP]" }
else if (l_comm ~ "("in_k")") { d="IN"; lbl="IN" }
else if (l_comm ~ "("out_k")") { d="OUT"; lbl="OUT" }
else { d="UNCT"; lbl="UNCT" }
mb = b/1024/1024
v_str = (mb >= 1024) ? sprintf("%9.2f GB", mb/1024) : sprintf("%9.2f MB", mb)
if (live == 1) {
r_s = (curr_rate >= 1024) ? sprintf("%8.2f Mb/s", curr_rate/1024) : sprintf("%8.2f Kb/s", curr_rate)
p_s = (peak_rate >= 1024) ? sprintf("%8.2f Mb/s", peak_rate/1024) : sprintf("%8.2f Kb/s", peak_rate)
c_on = (curr_rate > 0.1) ? "\033[1;37m" : "\033[0;37m"
row = sprintf("%-"w_dir"s %-"w_tbl"s %-"w_id"s %"w_trf"s | %s%11s\033[0m | %11s", lbl, tbl_fmt, comm, v_str, c_on, r_s, p_s)
} else {
row = sprintf("%-"w_dir"s %-"w_tbl"s %-"w_id"s %"w_trf"s", lbl, tbl_fmt, comm, v_str)
}
if (d=="IN") { r_in[cnt_in] = row; cnt_in++; s_in += mb }
else if (d=="OUT") { r_out[cnt_out] = row; cnt_out++; s_out += mb }
else if (d=="DROP") { r_drp[cnt_drp] = row; cnt_drp++; s_drp += mb }
else { r_unc[cnt_unc] = row; cnt_unc++; s_unc += mb }
}
END {
if (live == 1) {
tmp_file = sfile ".tmp"
printf "%s", new_states > tmp_file
close(tmp_file)
system("mv " tmp_file " " sfile)
}
h = (live == 1) ? sprintf("%-"w_dir"s %-"w_tbl"s %-"w_id"s %"w_trf"s | %11s | %11s", "DIR", "TABLE", "IDENTIFIER", "TRAFFIC", "CURRENT", "PEAK") : \
sprintf("%-"w_dir"s %-"w_tbl"s %-"w_id"s %"w_trf"s", "DIR", "TABLE", "IDENTIFIER", "TRAFFIC")
t_line = "\033[1;%dm%-"w_tot"s %"w_trf"s\033[0m\n"
if (cnt_in > 0) {
print "\n\033[1;32m--- INBOUND ---\033[0m\n" h "\n" sep
for (i=0; i<cnt_in; i++) print r_in[i] | p_cmd
close(p_cmd)
v = (s_in >= 1024) ? sprintf("%.2f GB", s_in/1024) : sprintf("%.2f MB", s_in)
printf t_line, 32, "TOTAL INBOUND", v
}
if (cnt_out > 0) {
print "\n\033[1;34m--- OUTBOUND ---\033[0m\n" h "\n" sep
for (i=0; i<cnt_out; i++) print r_out[i] | p_cmd
close(p_cmd)
v = (s_out >= 1024) ? sprintf("%.2f GB", s_out/1024) : sprintf("%.2f MB", s_out)
printf t_line, 34, "TOTAL OUTBOUND", v
}
if (cnt_drp > 0) {
print "\n\033[1;31m--- SECURITY ---\033[0m\n" h "\n" sep
for (i=0; i<cnt_drp; i++) print r_drp[i] | p_cmd
close(p_cmd)
v = (s_drp >= 1024) ? sprintf("%.2f GB", s_drp/1024) : sprintf("%.2f MB", s_drp)
printf t_line, 31, "TOTAL SECURITY", v
}
if (cnt_unc > 0) {
print "\n\033[1;35m--- UNCATEGORIZED ---\033[0m\n" h "\n" sep
for (i=0; i<cnt_unc; i++) print r_unc[i] | p_cmd
close(p_cmd)
v = (s_unc >= 1024) ? sprintf("%.2f GB", s_unc/1024) : sprintf("%.2f MB", s_unc)
printf t_line, 35, "TOTAL UNCATEGORIZED", v
}
print ""
}
'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment