{c.status === 'pending' ? (
diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts
index d7ac9a2..193e38b 100644
--- a/apps/web/src/types/api.ts
+++ b/apps/web/src/types/api.ts
@@ -356,6 +356,8 @@ export type FirewallClient = {
last_apply_at?: string | null
last_apply_status?: string
last_apply_prefix_count?: number
+ last_apply_packets_dropped?: number
+ last_apply_packets_accepted?: number
last_apply_source?: string
client_version?: string
created_at: string
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index e1662d9..a385e65 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -1605,6 +1605,14 @@ components:
type: string
last_apply_prefix_count:
type: integer
+ last_apply_packets_dropped:
+ type: integer
+ format: int64
+ description: Cumulative packets dropped by blocklist rule (from client kernel counter).
+ last_apply_packets_accepted:
+ type: integer
+ format: int64
+ description: Cumulative packets accepted past blocklist chain (nft counter accept rule).
client_version:
type: string
@@ -4547,6 +4555,31 @@ paths:
tags: [Firewall]
summary: Report last apply status
operationId: firewallApplyReport
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ status:
+ type: string
+ error:
+ type: string
+ prefix_count:
+ type: integer
+ ip_count:
+ type: integer
+ packets_dropped:
+ type: integer
+ format: int64
+ packets_accepted:
+ type: integer
+ format: int64
+ kernel_method:
+ type: string
+ source:
+ type: string
responses:
"200":
description: OK
diff --git a/internal/firewallscripts/evobgp-firewall.sh b/internal/firewallscripts/evobgp-firewall.sh
index ffc2dd8..7727471 100644
--- a/internal/firewallscripts/evobgp-firewall.sh
+++ b/internal/firewallscripts/evobgp-firewall.sh
@@ -147,8 +147,119 @@ if [[ -z "${TOTAL// }" ]]; then
TOTAL=${#PREFIXES[@]}
fi
+PACKETS_DROPPED=0
+PACKETS_ACCEPTED=0
+KERNEL_METHOD=""
+APPLIED_V4=0
+
+count_ipv4_prefixes() {
+ local n=0 p
+ for p in "${PREFIXES[@]}"; do
+ [[ "$p" == *:* ]] && continue
+ n=$((n + 1))
+ done
+ APPLIED_V4=$n
+}
+
+nft_rule_packets() {
+ local line=$1
+ if [[ "$line" =~ counter[[:space:]]+packets[[:space:]]+([0-9]+) ]]; then
+ echo "${BASH_REMATCH[1]}"
+ else
+ echo 0
+ fi
+}
+
+ensure_nft_counters() {
+ local table=inet name=evobgp_blocklist
+ nft list chain "$table" "$name" input >/dev/null 2>&1 || return 0
+ local drop_line
+ drop_line=$(nft -a list chain "$table" "$name" input 2>/dev/null | grep 'ip saddr @v4' | grep drop | head -1 || true)
+ if [[ -n "$drop_line" && "$drop_line" != *counter* ]]; then
+ local handle
+ handle=$(echo "$drop_line" | sed -n 's/.*# handle \([0-9]\+\).*/\1/p')
+ if [[ -n "$handle" ]]; then
+ nft delete rule "$table" "$name" input handle "$handle" 2>>"$LOG_FILE" || true
+ drop_line=""
+ fi
+ fi
+ if [[ -z "$drop_line" ]]; then
+ nft add rule "$table" "$name" input ip saddr @v4 counter drop
+ fi
+ if ! nft list chain "$table" "$name" input 2>/dev/null | grep -qE '[[:space:]]counter[[:space:]]+accept'; then
+ nft add rule "$table" "$name" input counter accept
+ fi
+}
+
+collect_nft_packet_stats() {
+ PACKETS_DROPPED=0
+ PACKETS_ACCEPTED=0
+ local line pkts
+ while IFS= read -r line; do
+ if [[ "$line" == *"ip saddr @v4"* && "$line" == *drop* ]]; then
+ pkts=$(nft_rule_packets "$line")
+ [[ -n "$pkts" ]] && PACKETS_DROPPED=$pkts
+ elif [[ "$line" == *counter* && "$line" == *accept* && "$line" != *@v4* ]]; then
+ pkts=$(nft_rule_packets "$line")
+ [[ -n "$pkts" ]] && PACKETS_ACCEPTED=$pkts
+ fi
+ done < <(nft list chain inet evobgp_blocklist input 2>/dev/null || true)
+}
+
+collect_ipset_packet_stats() {
+ PACKETS_DROPPED=0
+ PACKETS_ACCEPTED=0
+ local pkts
+ pkts=$(iptables -L INPUT -v -n -x 2>/dev/null | awk '/match-set evobgp_blocklist_v4/ {print $1; exit}')
+ [[ "$pkts" =~ ^[0-9]+$ ]] && PACKETS_DROPPED=$pkts
+}
+
+collect_packet_stats() {
+ case "${KERNEL_METHOD:-$BACKEND}" in
+ nft)
+ ensure_nft_counters
+ collect_nft_packet_stats
+ ;;
+ ipset)
+ collect_ipset_packet_stats
+ ;;
+ iptables)
+ PACKETS_DROPPED=$(iptables -L INPUT -v -n -x 2>/dev/null | awk '/DROP/ {s+=$1} END {print s+0}')
+ PACKETS_ACCEPTED=0
+ ;;
+ *)
+ if command -v nft >/dev/null 2>&1 && nft list chain inet evobgp_blocklist input >/dev/null 2>&1; then
+ KERNEL_METHOD=nft
+ ensure_nft_counters
+ collect_nft_packet_stats
+ elif iptables -L INPUT -v -n -x 2>/dev/null | grep -q 'evobgp_blocklist_v4'; then
+ KERNEL_METHOD=ipset
+ collect_ipset_packet_stats
+ fi
+ ;;
+ esac
+}
+
+send_client_reports() {
+ collect_packet_stats
+ local km="${KERNEL_METHOD:-$BACKEND}"
+ local report
+ report=$(printf '{"status":"ok","prefix_count":%s,"ip_count":%s,"packets_dropped":%s,"packets_accepted":%s,"source":"cp","kernel_method":"%s"}' \
+ "${TOTAL:-0}" "${APPLIED_V4:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "$km")
+ curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
+ -H "Authorization: Bearer ${CLIENT_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d "$report" >/dev/null 2>&1 || true
+ curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/heartbeat" \
+ -H "Authorization: Bearer ${CLIENT_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d '{"source":"cp"}' >/dev/null 2>&1 || true
+}
+
if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then
- log "unchanged hash $HASH — skip kernel apply"
+ count_ipv4_prefixes
+ log "unchanged hash $HASH — skip kernel apply (ipv4=${APPLIED_V4})"
+ send_client_reports
exit 0
fi
@@ -184,8 +295,11 @@ apply_nft() {
nft list chain "$table" "$name" input >/dev/null 2>&1 || {
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }'
- nft add rule "$table" "$name" input ip saddr @v4 drop
+ nft add rule "$table" "$name" input ip saddr @v4 counter drop
+ nft add rule "$table" "$name" input counter accept
}
+ ensure_nft_counters
+ KERNEL_METHOD=nft
APPLIED_V4=${#v4[@]}
}
@@ -202,6 +316,7 @@ apply_ipset() {
done
iptables -C INPUT -m set --match-set "$set" src -j DROP 2>/dev/null || \
iptables -I INPUT -m set --match-set "$set" src -j DROP
+ KERNEL_METHOD=ipset
APPLIED_V4=$n
}
@@ -214,6 +329,7 @@ apply_iptables_only() {
iptables -C INPUT -s "$p" -j DROP 2>/dev/null || iptables -A INPUT -s "$p" -j DROP
n=$((n + 1))
done
+ KERNEL_METHOD=iptables
APPLIED_V4=$n
}
@@ -227,9 +343,11 @@ clear_block() {
iptables) iptables -S INPUT | grep -i evobgp | sed 's/^-A /-D /' | while read -r line; do iptables $line 2>/dev/null || true; done ;;
esac
APPLIED_V4=0
+ KERNEL_METHOD="${BACKEND:-auto}"
}
APPLIED_V4=0
+KERNEL_METHOD=""
if [[ "${TOTAL:-0}" == "0" || ${#PREFIXES[@]} -eq 0 ]]; then
clear_block
log "cleared blocklist (api total=${TOTAL:-0}) backend=$BACKEND"
@@ -244,14 +362,4 @@ else
fi
echo "$HASH" >"$HASH_FILE"
-
-REPORT=$(printf '{"status":"ok","prefix_count":%s,"ip_count":%s,"source":"cp"}' "${TOTAL:-0}" "${APPLIED_V4:-0}")
-curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
- -H "Authorization: Bearer ${CLIENT_TOKEN}" \
- -H "Content-Type: application/json" \
- -d "$REPORT" >/dev/null 2>&1 || true
-
-curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/heartbeat" \
- -H "Authorization: Bearer ${CLIENT_TOKEN}" \
- -H "Content-Type: application/json" \
- -d '{"source":"cp"}' >/dev/null 2>&1 || true
+send_client_reports
diff --git a/internal/httpapi/routes_firewall.go b/internal/httpapi/routes_firewall.go
index f2dee27..2924ed3 100644
--- a/internal/httpapi/routes_firewall.go
+++ b/internal/httpapi/routes_firewall.go
@@ -450,13 +450,15 @@ func (s *Server) handleFirewallApplyReport(w http.ResponseWriter, r *http.Reques
return
}
var body struct {
- Status string `json:"status"`
- Error string `json:"error"`
- PrefixCount int `json:"prefix_count"`
- IPCount int `json:"ip_count"`
- Version string `json:"version"`
- KernelMethod string `json:"kernel_method"`
- Source string `json:"source"`
+ Status string `json:"status"`
+ Error string `json:"error"`
+ PrefixCount int `json:"prefix_count"`
+ IPCount int `json:"ip_count"`
+ PacketsDropped int64 `json:"packets_dropped"`
+ PacketsAccepted int64 `json:"packets_accepted"`
+ Version string `json:"version"`
+ KernelMethod string `json:"kernel_method"`
+ Source string `json:"source"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
@@ -466,7 +468,10 @@ func (s *Server) handleFirewallApplyReport(w http.ResponseWriter, r *http.Reques
if src == "" {
src = "cp"
}
- _ = s.store.TouchFirewallClientLastApply(a.APIKeyID, src, body.Status, body.Error, body.PrefixCount, body.IPCount)
+ _ = s.store.TouchFirewallClientLastApply(
+ a.APIKeyID, src, body.Status, body.Error,
+ body.PrefixCount, body.IPCount, body.PacketsDropped, body.PacketsAccepted,
+ )
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
diff --git a/internal/httpapi/routes_firewall_test.go b/internal/httpapi/routes_firewall_test.go
index f2ec0f3..b088d00 100644
--- a/internal/httpapi/routes_firewall_test.go
+++ b/internal/httpapi/routes_firewall_test.go
@@ -95,6 +95,27 @@ func TestFirewallEnrollAndBlocklist(t *testing.T) {
if total, _ := bl["total"].(float64); total != 0 {
t.Fatalf("accept-only want empty blocklist, total=%v", total)
}
+
+ reportBody := `{"status":"ok","prefix_count":0,"ip_count":0,"packets_dropped":42,"packets_accepted":1000,"source":"cp","kernel_method":"nft"}`
+ reqReport, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/apply-report", strings.NewReader(reportBody))
+ reqReport.Header.Set("Authorization", "Bearer "+tok)
+ reqReport.Header.Set("Content-Type", "application/json")
+ respReport, err := client.Do(reqReport)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = respReport.Body.Close() }()
+ if respReport.StatusCode != http.StatusOK {
+ b, _ := io.ReadAll(respReport.Body)
+ t.Fatalf("apply-report status=%d body=%s", respReport.StatusCode, b)
+ }
+ gotClient, err := srv.Store().GetFirewallClient(tenant, clientID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if gotClient.LastApplyPacketsDropped != 42 || gotClient.LastApplyPacketsAccepted != 1000 {
+ t.Fatalf("packet stats dropped=%d accepted=%d", gotClient.LastApplyPacketsDropped, gotClient.LastApplyPacketsAccepted)
+ }
}
func TestFirewallEnrollBadSeed(t *testing.T) {
diff --git a/internal/repository/postgres_firewall.go b/internal/repository/postgres_firewall.go
index 41613ce..86319e8 100644
--- a/internal/repository/postgres_firewall.go
+++ b/internal/repository/postgres_firewall.go
@@ -17,7 +17,9 @@ const firewallClientSelectCols = `
id, name, COALESCE(hostname, ''), token_prefix, status,
last_seen_at, COALESCE(last_seen_at_source, ''), COALESCE(last_seen_ip, ''),
last_apply_at, COALESCE(last_apply_status, ''), COALESCE(last_apply_error, ''),
- COALESCE(last_apply_prefix_count, 0), COALESCE(last_apply_ip_count, 0), COALESCE(last_apply_source, ''),
+ COALESCE(last_apply_prefix_count, 0), COALESCE(last_apply_ip_count, 0),
+ COALESCE(last_apply_packets_dropped, 0), COALESCE(last_apply_packets_accepted, 0),
+ COALESCE(last_apply_source, ''),
COALESCE(client_version, ''), created_at, approved_at, approved_by_api_key_id, revoked_at`
func (p *Postgres) ListFirewallClients(tenantID string) ([]*store.FirewallClient, error) {
@@ -167,12 +169,13 @@ func (p *Postgres) TouchFirewallClientLastSeen(id, source, clientIP, clientVersi
return err
}
-func (p *Postgres) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error {
+func (p *Postgres) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int, packetsDropped, packetsAccepted int64) error {
ctx := context.Background()
_, err := p.pool.Exec(ctx, `
UPDATE firewall_client SET last_apply_at=now(), last_apply_source=$2, last_apply_status=$3,
- last_apply_error=$4, last_apply_prefix_count=$5, last_apply_ip_count=$6
- WHERE id=$1`, id, strings.TrimSpace(source), strings.TrimSpace(status), strings.TrimSpace(errMsg), prefixCount, ipCount)
+ last_apply_error=$4, last_apply_prefix_count=$5, last_apply_ip_count=$6,
+ last_apply_packets_dropped=$7, last_apply_packets_accepted=$8
+ WHERE id=$1`, id, strings.TrimSpace(source), strings.TrimSpace(status), strings.TrimSpace(errMsg), prefixCount, ipCount, packetsDropped, packetsAccepted)
return err
}
@@ -435,16 +438,17 @@ func scanFirewallClientRow(scan scanFn, tenantID string) (*store.FirewallClient,
var approvedBy *string
var lastSeen, lastApply, approved, revoked *time.Time
var prefixCount, ipCount *int
+ var packetsDropped, packetsAccepted *int64
if err := scan(
&c.ID, &c.Name, &c.Hostname, &c.TokenPrefix, &c.Status,
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
- &prefixCount, &ipCount, &c.LastApplySource,
+ &prefixCount, &ipCount, &packetsDropped, &packetsAccepted, &c.LastApplySource,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
); err != nil {
return nil, err
}
- return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount), nil
+ return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
}
func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
@@ -452,19 +456,20 @@ func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
var approvedBy *string
var lastSeen, lastApply, approved, revoked *time.Time
var prefixCount, ipCount *int
+ var packetsDropped, packetsAccepted *int64
if err := scan(
&c.TenantID, &c.ID, &c.Name, &c.Hostname, &c.TokenPrefix, &c.Status,
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
- &prefixCount, &ipCount, &c.LastApplySource,
+ &prefixCount, &ipCount, &packetsDropped, &packetsAccepted, &c.LastApplySource,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
); err != nil {
return nil, err
}
- return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount), nil
+ return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
}
-func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy *string, prefixCount, ipCount *int) *store.FirewallClient {
+func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy *string, prefixCount, ipCount *int, packetsDropped, packetsAccepted *int64) *store.FirewallClient {
c.LastSeenAt = lastSeen
c.LastApplyAt = lastApply
c.ApprovedAt = approved
@@ -478,6 +483,12 @@ func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, appr
if ipCount != nil {
c.LastApplyIPCount = *ipCount
}
+ if packetsDropped != nil {
+ c.LastApplyPacketsDropped = *packetsDropped
+ }
+ if packetsAccepted != nil {
+ c.LastApplyPacketsAccepted = *packetsAccepted
+ }
return c
}
diff --git a/internal/store/backend.go b/internal/store/backend.go
index afff8b2..4f91205 100644
--- a/internal/store/backend.go
+++ b/internal/store/backend.go
@@ -142,7 +142,7 @@ type Backend interface {
DeleteFirewallClient(tenantID, id string) error
LookupFirewallClientByTokenHash(hash []byte) (*FirewallClient, error)
TouchFirewallClientLastSeen(id, source, clientIP, clientVersion string) error
- TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error
+ TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int, packetsDropped, packetsAccepted int64) error
ListActiveFirewallClientHashes() ([]FirewallClientAuthRow, error)
ListApprovedFirewallClientsForReplication(tenantID string) ([]FirewallClientReplicationRow, error)
diff --git a/internal/store/firewall_types.go b/internal/store/firewall_types.go
index f5370b5..88f4294 100644
--- a/internal/store/firewall_types.go
+++ b/internal/store/firewall_types.go
@@ -7,26 +7,28 @@ import (
// FirewallClient is a Linux blocklist sync client enrolled via seed.
type FirewallClient struct {
- ID string `json:"id"`
- TenantID string `json:"tenant_id,omitempty"`
- Name string `json:"name"`
- Hostname string `json:"hostname,omitempty"`
- TokenPrefix string `json:"token_prefix"`
- Status string `json:"status"`
- LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
- LastSeenAtSource string `json:"last_seen_at_source,omitempty"`
- LastSeenIP string `json:"last_seen_ip,omitempty"`
- LastApplyAt *time.Time `json:"last_apply_at,omitempty"`
- LastApplyStatus string `json:"last_apply_status,omitempty"`
- LastApplyError string `json:"last_apply_error,omitempty"`
- LastApplyPrefixCount int `json:"last_apply_prefix_count,omitempty"`
- LastApplyIPCount int `json:"last_apply_ip_count,omitempty"`
- LastApplySource string `json:"last_apply_source,omitempty"`
- ClientVersion string `json:"client_version,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- ApprovedAt *time.Time `json:"approved_at,omitempty"`
- ApprovedByAPIKeyID string `json:"approved_by_api_key_id,omitempty"`
- RevokedAt *time.Time `json:"revoked_at,omitempty"`
+ ID string `json:"id"`
+ TenantID string `json:"tenant_id,omitempty"`
+ Name string `json:"name"`
+ Hostname string `json:"hostname,omitempty"`
+ TokenPrefix string `json:"token_prefix"`
+ Status string `json:"status"`
+ LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
+ LastSeenAtSource string `json:"last_seen_at_source,omitempty"`
+ LastSeenIP string `json:"last_seen_ip,omitempty"`
+ LastApplyAt *time.Time `json:"last_apply_at,omitempty"`
+ LastApplyStatus string `json:"last_apply_status,omitempty"`
+ LastApplyError string `json:"last_apply_error,omitempty"`
+ LastApplyPrefixCount int `json:"last_apply_prefix_count,omitempty"`
+ LastApplyIPCount int `json:"last_apply_ip_count,omitempty"`
+ LastApplyPacketsDropped int64 `json:"last_apply_packets_dropped,omitempty"`
+ LastApplyPacketsAccepted int64 `json:"last_apply_packets_accepted,omitempty"`
+ LastApplySource string `json:"last_apply_source,omitempty"`
+ ClientVersion string `json:"client_version,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ ApprovedAt *time.Time `json:"approved_at,omitempty"`
+ ApprovedByAPIKeyID string `json:"approved_by_api_key_id,omitempty"`
+ RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
// FirewallClientCreate is input for enroll (token hash supplied by caller).
diff --git a/internal/store/memory_firewall.go b/internal/store/memory_firewall.go
index 81c0289..30897a8 100644
--- a/internal/store/memory_firewall.go
+++ b/internal/store/memory_firewall.go
@@ -171,7 +171,7 @@ func (m *Memory) TouchFirewallClientLastSeen(id, source, clientIP, clientVersion
return nil
}
-func (m *Memory) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error {
+func (m *Memory) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int, packetsDropped, packetsAccepted int64) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.firewallClients[id]
@@ -185,6 +185,8 @@ func (m *Memory) TouchFirewallClientLastApply(id, source, status, errMsg string,
rec.LastApplyError = strings.TrimSpace(errMsg)
rec.LastApplyPrefixCount = prefixCount
rec.LastApplyIPCount = ipCount
+ rec.LastApplyPacketsDropped = packetsDropped
+ rec.LastApplyPacketsAccepted = packetsAccepted
return nil
}
diff --git a/migrations/postgres/000028_firewall_packet_stats.down.sql b/migrations/postgres/000028_firewall_packet_stats.down.sql
new file mode 100644
index 0000000..d315158
--- /dev/null
+++ b/migrations/postgres/000028_firewall_packet_stats.down.sql
@@ -0,0 +1,3 @@
+ALTER TABLE firewall_client
+ DROP COLUMN IF EXISTS last_apply_packets_dropped,
+ DROP COLUMN IF EXISTS last_apply_packets_accepted;
diff --git a/migrations/postgres/000028_firewall_packet_stats.up.sql b/migrations/postgres/000028_firewall_packet_stats.up.sql
new file mode 100644
index 0000000..cdd4563
--- /dev/null
+++ b/migrations/postgres/000028_firewall_packet_stats.up.sql
@@ -0,0 +1,3 @@
+ALTER TABLE firewall_client
+ ADD COLUMN last_apply_packets_dropped BIGINT NOT NULL DEFAULT 0,
+ ADD COLUMN last_apply_packets_accepted BIGINT NOT NULL DEFAULT 0;
diff --git a/migrations/sqlite/000028_firewall_packet_stats.down.sql b/migrations/sqlite/000028_firewall_packet_stats.down.sql
new file mode 100644
index 0000000..8fd8103
--- /dev/null
+++ b/migrations/sqlite/000028_firewall_packet_stats.down.sql
@@ -0,0 +1,2 @@
+ALTER TABLE firewall_client DROP COLUMN last_apply_packets_dropped;
+ALTER TABLE firewall_client DROP COLUMN last_apply_packets_accepted;
diff --git a/migrations/sqlite/000028_firewall_packet_stats.up.sql b/migrations/sqlite/000028_firewall_packet_stats.up.sql
new file mode 100644
index 0000000..2a81302
--- /dev/null
+++ b/migrations/sqlite/000028_firewall_packet_stats.up.sql
@@ -0,0 +1,2 @@
+ALTER TABLE firewall_client ADD COLUMN last_apply_packets_dropped INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE firewall_client ADD COLUMN last_apply_packets_accepted INTEGER NOT NULL DEFAULT 0;
diff --git a/scripts/firewall/evobgp-firewall.sh b/scripts/firewall/evobgp-firewall.sh
index ffc2dd8..7727471 100644
--- a/scripts/firewall/evobgp-firewall.sh
+++ b/scripts/firewall/evobgp-firewall.sh
@@ -147,8 +147,119 @@ if [[ -z "${TOTAL// }" ]]; then
TOTAL=${#PREFIXES[@]}
fi
+PACKETS_DROPPED=0
+PACKETS_ACCEPTED=0
+KERNEL_METHOD=""
+APPLIED_V4=0
+
+count_ipv4_prefixes() {
+ local n=0 p
+ for p in "${PREFIXES[@]}"; do
+ [[ "$p" == *:* ]] && continue
+ n=$((n + 1))
+ done
+ APPLIED_V4=$n
+}
+
+nft_rule_packets() {
+ local line=$1
+ if [[ "$line" =~ counter[[:space:]]+packets[[:space:]]+([0-9]+) ]]; then
+ echo "${BASH_REMATCH[1]}"
+ else
+ echo 0
+ fi
+}
+
+ensure_nft_counters() {
+ local table=inet name=evobgp_blocklist
+ nft list chain "$table" "$name" input >/dev/null 2>&1 || return 0
+ local drop_line
+ drop_line=$(nft -a list chain "$table" "$name" input 2>/dev/null | grep 'ip saddr @v4' | grep drop | head -1 || true)
+ if [[ -n "$drop_line" && "$drop_line" != *counter* ]]; then
+ local handle
+ handle=$(echo "$drop_line" | sed -n 's/.*# handle \([0-9]\+\).*/\1/p')
+ if [[ -n "$handle" ]]; then
+ nft delete rule "$table" "$name" input handle "$handle" 2>>"$LOG_FILE" || true
+ drop_line=""
+ fi
+ fi
+ if [[ -z "$drop_line" ]]; then
+ nft add rule "$table" "$name" input ip saddr @v4 counter drop
+ fi
+ if ! nft list chain "$table" "$name" input 2>/dev/null | grep -qE '[[:space:]]counter[[:space:]]+accept'; then
+ nft add rule "$table" "$name" input counter accept
+ fi
+}
+
+collect_nft_packet_stats() {
+ PACKETS_DROPPED=0
+ PACKETS_ACCEPTED=0
+ local line pkts
+ while IFS= read -r line; do
+ if [[ "$line" == *"ip saddr @v4"* && "$line" == *drop* ]]; then
+ pkts=$(nft_rule_packets "$line")
+ [[ -n "$pkts" ]] && PACKETS_DROPPED=$pkts
+ elif [[ "$line" == *counter* && "$line" == *accept* && "$line" != *@v4* ]]; then
+ pkts=$(nft_rule_packets "$line")
+ [[ -n "$pkts" ]] && PACKETS_ACCEPTED=$pkts
+ fi
+ done < <(nft list chain inet evobgp_blocklist input 2>/dev/null || true)
+}
+
+collect_ipset_packet_stats() {
+ PACKETS_DROPPED=0
+ PACKETS_ACCEPTED=0
+ local pkts
+ pkts=$(iptables -L INPUT -v -n -x 2>/dev/null | awk '/match-set evobgp_blocklist_v4/ {print $1; exit}')
+ [[ "$pkts" =~ ^[0-9]+$ ]] && PACKETS_DROPPED=$pkts
+}
+
+collect_packet_stats() {
+ case "${KERNEL_METHOD:-$BACKEND}" in
+ nft)
+ ensure_nft_counters
+ collect_nft_packet_stats
+ ;;
+ ipset)
+ collect_ipset_packet_stats
+ ;;
+ iptables)
+ PACKETS_DROPPED=$(iptables -L INPUT -v -n -x 2>/dev/null | awk '/DROP/ {s+=$1} END {print s+0}')
+ PACKETS_ACCEPTED=0
+ ;;
+ *)
+ if command -v nft >/dev/null 2>&1 && nft list chain inet evobgp_blocklist input >/dev/null 2>&1; then
+ KERNEL_METHOD=nft
+ ensure_nft_counters
+ collect_nft_packet_stats
+ elif iptables -L INPUT -v -n -x 2>/dev/null | grep -q 'evobgp_blocklist_v4'; then
+ KERNEL_METHOD=ipset
+ collect_ipset_packet_stats
+ fi
+ ;;
+ esac
+}
+
+send_client_reports() {
+ collect_packet_stats
+ local km="${KERNEL_METHOD:-$BACKEND}"
+ local report
+ report=$(printf '{"status":"ok","prefix_count":%s,"ip_count":%s,"packets_dropped":%s,"packets_accepted":%s,"source":"cp","kernel_method":"%s"}' \
+ "${TOTAL:-0}" "${APPLIED_V4:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "$km")
+ curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
+ -H "Authorization: Bearer ${CLIENT_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d "$report" >/dev/null 2>&1 || true
+ curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/heartbeat" \
+ -H "Authorization: Bearer ${CLIENT_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d '{"source":"cp"}' >/dev/null 2>&1 || true
+}
+
if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then
- log "unchanged hash $HASH — skip kernel apply"
+ count_ipv4_prefixes
+ log "unchanged hash $HASH — skip kernel apply (ipv4=${APPLIED_V4})"
+ send_client_reports
exit 0
fi
@@ -184,8 +295,11 @@ apply_nft() {
nft list chain "$table" "$name" input >/dev/null 2>&1 || {
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }'
- nft add rule "$table" "$name" input ip saddr @v4 drop
+ nft add rule "$table" "$name" input ip saddr @v4 counter drop
+ nft add rule "$table" "$name" input counter accept
}
+ ensure_nft_counters
+ KERNEL_METHOD=nft
APPLIED_V4=${#v4[@]}
}
@@ -202,6 +316,7 @@ apply_ipset() {
done
iptables -C INPUT -m set --match-set "$set" src -j DROP 2>/dev/null || \
iptables -I INPUT -m set --match-set "$set" src -j DROP
+ KERNEL_METHOD=ipset
APPLIED_V4=$n
}
@@ -214,6 +329,7 @@ apply_iptables_only() {
iptables -C INPUT -s "$p" -j DROP 2>/dev/null || iptables -A INPUT -s "$p" -j DROP
n=$((n + 1))
done
+ KERNEL_METHOD=iptables
APPLIED_V4=$n
}
@@ -227,9 +343,11 @@ clear_block() {
iptables) iptables -S INPUT | grep -i evobgp | sed 's/^-A /-D /' | while read -r line; do iptables $line 2>/dev/null || true; done ;;
esac
APPLIED_V4=0
+ KERNEL_METHOD="${BACKEND:-auto}"
}
APPLIED_V4=0
+KERNEL_METHOD=""
if [[ "${TOTAL:-0}" == "0" || ${#PREFIXES[@]} -eq 0 ]]; then
clear_block
log "cleared blocklist (api total=${TOTAL:-0}) backend=$BACKEND"
@@ -244,14 +362,4 @@ else
fi
echo "$HASH" >"$HASH_FILE"
-
-REPORT=$(printf '{"status":"ok","prefix_count":%s,"ip_count":%s,"source":"cp"}' "${TOTAL:-0}" "${APPLIED_V4:-0}")
-curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
- -H "Authorization: Bearer ${CLIENT_TOKEN}" \
- -H "Content-Type: application/json" \
- -d "$REPORT" >/dev/null 2>&1 || true
-
-curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/heartbeat" \
- -H "Authorization: Bearer ${CLIENT_TOKEN}" \
- -H "Content-Type: application/json" \
- -d '{"source":"cp"}' >/dev/null 2>&1 || true
+send_client_reports