Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions cli/lib/nftban/cli/cmd_firewall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -298,15 +298,21 @@ firewall_status() {
if [[ "$json_mode" == "true" ]]; then
# Collect stats for JSON
local v4_sets=0 v6_sets=0 v4_chains=0 v6_chains=0 v4_elements=0 v6_elements=0
# v1.87.2: Use `nft list table` — plural `list sets/chains` with table filter
# is broken on nftables v1.0.x-v1.1.x.
if [[ "$v4_ok" == "true" ]]; then
v4_sets=$(nft list sets $ipv4_table 2>/dev/null | grep -c "set " || echo 0)
v4_chains=$(nft list chains $ipv4_table 2>/dev/null | grep -c "chain " || echo 0)
v4_elements=$(nft list sets $ipv4_table 2>/dev/null | grep -oP 'elements\s*=\s*\K\d+' | paste -sd+ | bc 2>/dev/null || echo 0)
local _v4_dump
_v4_dump=$(nft list table $ipv4_table 2>/dev/null || true)
v4_sets=$(echo "$_v4_dump" | grep -c "set " || echo 0)
v4_chains=$(echo "$_v4_dump" | grep -c "chain " || echo 0)
v4_elements=$(echo "$_v4_dump" | grep -oP 'elements\s*=\s*\K\d+' | paste -sd+ | bc 2>/dev/null || echo 0)
fi
if [[ "$v6_ok" == "true" ]]; then
v6_sets=$(nft list sets $ipv6_table 2>/dev/null | grep -c "set " || echo 0)
v6_chains=$(nft list chains $ipv6_table 2>/dev/null | grep -c "chain " || echo 0)
v6_elements=$(nft list sets $ipv6_table 2>/dev/null | grep -oP 'elements\s*=\s*\K\d+' | paste -sd+ | bc 2>/dev/null || echo 0)
local _v6_dump
_v6_dump=$(nft list table $ipv6_table 2>/dev/null || true)
v6_sets=$(echo "$_v6_dump" | grep -c "set " || echo 0)
v6_chains=$(echo "$_v6_dump" | grep -c "chain " || echo 0)
v6_elements=$(echo "$_v6_dump" | grep -oP 'elements\s*=\s*\K\d+' | paste -sd+ | bc 2>/dev/null || echo 0)
fi

local daemon_active=false
Expand Down Expand Up @@ -347,12 +353,12 @@ ENDJSON
echo "Sets:"
if [[ "$v4_ok" == "true" ]]; then
local set_count
set_count=$(nft list sets $ipv4_table 2>/dev/null | grep -c "set " || echo 0)
set_count=$(nft list table $ipv4_table 2>/dev/null | grep -c "set " || echo 0)
echo " IPv4: ${set_count} sets"
fi
if [[ "$v6_ok" == "true" ]]; then
local set_count6
set_count6=$(nft list sets $ipv6_table 2>/dev/null | grep -c "set " || echo 0)
set_count6=$(nft list table $ipv6_table 2>/dev/null | grep -c "set " || echo 0)
echo " IPv6: ${set_count6} sets"
fi

Expand Down
5 changes: 4 additions & 1 deletion cli/lib/nftban/cli/cmd_stats.sh
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,10 @@ nftban_stats_cmd_brief() {
fi

# Dropped packets from counters
dropped=$(nft list counters table ip nftban 2>/dev/null | grep -oP 'packets\s+\K[0-9]+' | paste -sd+ | bc 2>/dev/null || echo 0)
# v1.87.2: Use global JSON `nft -j list counters` + jq filter.
# The filtered form `nft list counters table <family> <table>` is broken on v1.0.x-v1.1.x.
# Only sum drop/block counters — not accepts, loopbacks, or anchors.
dropped=$(nft -j list counters 2>/dev/null | jq '[.nftables[] | select(.counter) | select(.counter.table == "nftban") | select(.counter.name | test("_drop$|_exceeded$")) | .counter.packets] | add // 0' 2>/dev/null || echo 0)

# Today's bans from log
local today
Expand Down
3 changes: 2 additions & 1 deletion cli/lib/nftban/tests/nft_crosscheck.sh
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ $JSON_MODE || echo "[3/6] Set element counts..."
for family in ip ip6; do
nft list table "$family" nftban >/dev/null 2>&1 || continue

for set_name in $(nft list sets "$family" nftban 2>/dev/null | grep -oP 'set \K\S+' | tr -d '{'); do
# v1.87.2: Use `list table` — plural `list sets <family> <table>` is broken on v1.0.x
for set_name in $(nft list table "$family" nftban 2>/dev/null | grep -oP '^\tset \K\S+' | tr -d '{'); do
nft_count=$(nft list set "$family" nftban "$set_name" 2>/dev/null | grep -cE '^\s+[0-9a-f]' || echo "0")
log_pass "Set $family/$set_name: $nft_count elements in kernel"
done
Expand Down
13 changes: 8 additions & 5 deletions internal/metrics/evidence_chains.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func listChains(ctx context.Context, family string) (map[string]bool, bool) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()

output, err := nftListChains(ctx, family)
output, err := nftListTableText(ctx, family)
if err != nil {
return nil, true // collection failed
}
Expand All @@ -91,13 +91,16 @@ func listChains(ctx context.Context, family string) (map[string]bool, bool) {
return result, false
}

// nftListChains runs nft list chains for a specific family.
func nftListChains(ctx context.Context, family string) ([]byte, error) {
// nftListTableText runs nft list table <family> nftban (text mode).
// v1.87.2: The filtered plural form `nft list chains <family> <table>` is NOT
// supported on fleet nftables v1.0.x-v1.1.x. The singular `list table` form
// returns the full table including chain definitions, which we parse for names.
func nftListTableText(ctx context.Context, family string) ([]byte, error) {
switch family {
case "ip":
return exec.CommandContext(ctx, "nft", "list", "chains", "ip", "nftban").Output() // #nosec G204
return exec.CommandContext(ctx, "nft", "list", "table", "ip", "nftban").Output() // #nosec G204
case "ip6":
return exec.CommandContext(ctx, "nft", "list", "chains", "ip6", "nftban").Output() // #nosec G204
return exec.CommandContext(ctx, "nft", "list", "table", "ip6", "nftban").Output() // #nosec G204
default:
return nil, nil
}
Expand Down
55 changes: 55 additions & 0 deletions internal/metrics/evidence_counters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,61 @@ func TestParse_EmptyNameExcluded(t *testing.T) {
}
}

// =============================================================================
// A2. v1.87.2 — Global counter JSON with family filtering
// =============================================================================

func TestParseFiltered_MixedFamilies(t *testing.T) {
// Global nft -j list counters returns BOTH families.
// parseNamedCountersJSONFiltered must filter by requested family.
fixture := `{"nftables": [
{"metainfo": {"version": "1.0.9"}},
{"counter": {"family": "ip", "table": "nftban", "name": "input_ct_ssh_drop", "packets": 42, "bytes": 1234}},
{"counter": {"family": "ip6", "table": "nftban", "name": "input_ct_ssh_drop", "packets": 7, "bytes": 350}},
{"counter": {"family": "ip", "table": "nftban", "name": "input_syn_rate_exceeded", "packets": 100, "bytes": 5000}},
{"counter": {"family": "ip6", "table": "nftban", "name": "input_syn_prefix_drop", "packets": 3, "bytes": 150}}
]}`

// Filter for ip only
ipCounters, err := parseNamedCountersJSONFiltered([]byte(fixture), "ip")
if err != nil {
t.Fatalf("ip filter error: %v", err)
}
if len(ipCounters) != 2 {
t.Errorf("expected 2 ip counters, got %d", len(ipCounters))
}
if ipCounters["input_ct_ssh_drop"].Packets != 42 {
t.Errorf("ip ssh_drop packets = %d, want 42", ipCounters["input_ct_ssh_drop"].Packets)
}

// Filter for ip6 only
ip6Counters, err := parseNamedCountersJSONFiltered([]byte(fixture), "ip6")
if err != nil {
t.Fatalf("ip6 filter error: %v", err)
}
if len(ip6Counters) != 2 {
t.Errorf("expected 2 ip6 counters, got %d", len(ip6Counters))
}
if ip6Counters["input_syn_prefix_drop"].Packets != 3 {
t.Errorf("ip6 syn_prefix_drop packets = %d, want 3", ip6Counters["input_syn_prefix_drop"].Packets)
}
}

func TestParseFiltered_ForeignTableExcluded(t *testing.T) {
fixture := `{"nftables": [
{"counter": {"family": "ip", "table": "nftban", "name": "input_drop", "packets": 10, "bytes": 100}},
{"counter": {"family": "ip", "table": "filter", "name": "foreign", "packets": 999, "bytes": 9999}}
]}`

counters, err := parseNamedCountersJSONFiltered([]byte(fixture), "ip")
if err != nil {
t.Fatalf("error: %v", err)
}
if len(counters) != 1 {
t.Errorf("expected 1 counter (foreign excluded), got %d", len(counters))
}
}

// =============================================================================
// B. Public key contract tests — verify family-prefixed stable keys
// =============================================================================
Expand Down
95 changes: 55 additions & 40 deletions internal/metrics/rule_counters.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,17 +111,21 @@
ruleCounterMu.Lock()
defer ruleCounterMu.Unlock()

// Note: context.Background() is intentional here — this is the legacy
// sampler-driven Prometheus path, not the new evidence CLI path.
// Timeout is bounded per-family (2s) inside collectNamedCountersStructured.
// v1.87.2: Single global nft call for named counters.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
output, err := nftListAllCountersJSON(ctx)

for _, family := range []string{"ip", "ip6"} {
counters, err := collectNamedCountersStructured(context.Background(), family)
if err == nil && len(counters) > 0 {
updatePrometheusFromNamedCounters(family, counters)
} else {
// Fallback to anonymous counter extraction (pre-v1.41.0 schemas)
collectFamilyCounters(family)
if err == nil {
counters, parseErr := parseNamedCountersJSONFiltered(output, family)
if parseErr == nil && len(counters) > 0 {
updatePrometheusFromNamedCounters(family, counters)
continue
}
}
// Fallback to anonymous counter extraction (pre-v1.41.0 schemas)
collectFamilyCounters(family)
}
}

Expand All @@ -142,55 +146,66 @@
Counters: make(map[string]CounterValue),
}

var lastErr error
familiesSucceeded := 0
// v1.87.2: Single global nft call, filter both families in code.
// The per-family form `nft list counters <family> <table>` is broken
// on fleet nftables versions (v1.0.2-v1.1.1).
ctx2, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()

output, err := nftListAllCountersJSON(ctx2)
if err != nil {
return nil, err
}

for _, family := range []string{"ip", "ip6"} {
counters, err := collectNamedCountersStructured(ctx, family)
counters, err := parseNamedCountersJSONFiltered(output, family)
if err != nil {
lastErr = err
continue
return nil, err
}
familiesSucceeded++
for name, val := range counters {
key := family + ":" + name
result.Counters[key] = val
}
}

// Both families failed → return error (contract: non-nil error = collection failed)
// At least one succeeded → return result, nil (may be partial)
if familiesSucceeded == 0 && lastErr != nil {
return nil, lastErr
}

return result, nil
}

// collectNamedCountersStructured executes nft and parses the result.
// Does NOT update Prometheus — caller decides.
func collectNamedCountersStructured(ctx context.Context, family string) (map[string]CounterValue, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()

output, err := nftListCounters(ctx, family)
if err != nil {
// parseNamedCountersJSONFiltered parses global counter JSON and filters
// by family and table=="nftban". This replaces the broken per-family query.
func parseNamedCountersJSONFiltered(data []byte, family string) (map[string]CounterValue, error) {
var nft nftJSON
if err := json.Unmarshal(data, &nft); err != nil {
return nil, err
}

return parseNamedCountersJSON(output)
}
counters := make(map[string]CounterValue)
for _, raw := range nft.NFTables {
var wrapper nftNamedCounterWrapper
if err := json.Unmarshal(raw, &wrapper); err != nil || wrapper.Counter == nil {
continue
}

// nftListCounters runs nft -j list counters for a specific family.
func nftListCounters(ctx context.Context, family string) ([]byte, error) {
switch family {
case "ip":
return exec.CommandContext(ctx, "nft", "-j", "list", "counters", "ip", "nftban").Output() // #nosec G204
case "ip6":
return exec.CommandContext(ctx, "nft", "-j", "list", "counters", "ip6", "nftban").Output() // #nosec G204
default:
return nil, nil
c := wrapper.Counter
if c.Table != "nftban" || c.Family != family || c.Name == "" {
continue
}

counters[c.Name] = CounterValue{
Packets: int64(c.Packets),

Check failure

Code scanning / gosec

integer overflow conversion uint64 -> uint32 Error

integer overflow conversion uint64 -> int64
Comment thread
itcmsgr marked this conversation as resolved.
Bytes: int64(c.Bytes),

Check failure

Code scanning / gosec

integer overflow conversion uint64 -> uint32 Error

integer overflow conversion uint64 -> int64
Comment thread
itcmsgr marked this conversation as resolved.
}
}

return counters, nil
}

// nftListAllCountersJSON runs nft -j list counters (global, no family/table filter).
// v1.87.2: The filtered form `nft list counters <family> <table>` is NOT supported
// on fleet nftables versions (v1.0.2-v1.1.1). Global listing works everywhere.
// Filtering by family and table happens in parseNamedCountersJSON().
func nftListAllCountersJSON(ctx context.Context) ([]byte, error) {
return exec.CommandContext(ctx, "nft", "-j", "list", "counters").Output() // #nosec G204
}

// nftListTable runs nft -j list table for a specific family.
Expand Down
Loading