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