From 5edbd656baf4bc67b8e1cac2862d33f47d9f0b6e Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 9 Jul 2026 00:50:38 +0700 Subject: [PATCH] feat(firewall): add packet statistics tracking for firewall clients Enhanced the firewall client functionality by introducing packet statistics tracking, including the cumulative count of packets dropped and accepted. Updated the API to support these new fields and modified the database schema accordingly. Improved the firewall scripts to collect and report packet statistics, ensuring better visibility into client performance. Adjusted the UI components to display packet counts in the clients table, enhancing user experience and monitoring capabilities. --- apps/web/src/routes/_auth/firewall.tsx | 25 ++++ apps/web/src/types/api.ts | 2 + docs/openapi.yaml | 33 +++++ internal/firewallscripts/evobgp-firewall.sh | 134 ++++++++++++++++-- internal/httpapi/routes_firewall.go | 21 +-- internal/httpapi/routes_firewall_test.go | 21 +++ internal/repository/postgres_firewall.go | 29 ++-- internal/store/backend.go | 2 +- internal/store/firewall_types.go | 42 +++--- internal/store/memory_firewall.go | 4 +- .../000028_firewall_packet_stats.down.sql | 3 + .../000028_firewall_packet_stats.up.sql | 3 + .../000028_firewall_packet_stats.down.sql | 2 + .../000028_firewall_packet_stats.up.sql | 2 + scripts/firewall/evobgp-firewall.sh | 134 ++++++++++++++++-- 15 files changed, 392 insertions(+), 65 deletions(-) create mode 100644 migrations/postgres/000028_firewall_packet_stats.down.sql create mode 100644 migrations/postgres/000028_firewall_packet_stats.up.sql create mode 100644 migrations/sqlite/000028_firewall_packet_stats.down.sql create mode 100644 migrations/sqlite/000028_firewall_packet_stats.up.sql diff --git a/apps/web/src/routes/_auth/firewall.tsx b/apps/web/src/routes/_auth/firewall.tsx index 4c255ba..c4bad53 100644 --- a/apps/web/src/routes/_auth/firewall.tsx +++ b/apps/web/src/routes/_auth/firewall.tsx @@ -279,6 +279,13 @@ function FirewallPage() { ) } +function formatPacketCount(value?: number | null): string | null { + if (value == null || value <= 0) return null + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M` + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k` + return String(value) +} + function ClientsTable({ clients, onApprove, @@ -305,6 +312,7 @@ function ClientsTable({ Статус Last seen Apply + Пакеты @@ -323,6 +331,23 @@ function ClientsTable({ {c.last_apply_status ?? '—'} {c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''} + + {formatPacketCount(c.last_apply_packets_dropped) || formatPacketCount(c.last_apply_packets_accepted) ? ( + <> + {formatPacketCount(c.last_apply_packets_dropped) ? ( + ↓{formatPacketCount(c.last_apply_packets_dropped)} + ) : null} + {formatPacketCount(c.last_apply_packets_dropped) && formatPacketCount(c.last_apply_packets_accepted) + ? ' · ' + : null} + {formatPacketCount(c.last_apply_packets_accepted) ? ( + ↑{formatPacketCount(c.last_apply_packets_accepted)} + ) : null} + + ) : ( + '—' + )} +
{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