Last active
February 28, 2026 21:36
-
-
Save Strykar/72c20bb021347eafe4294511f21791be to your computer and use it in GitHub Desktop.
Traffick - NFTables Traffic Monitor: A POSIX-compliant grokker for tagged nftables counters.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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