feat(api, web): implement per-IP blocked stats for agents
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m51s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Added functionality to report per-IP drop counters in the `evofw-firewall.sh` script, capturing the top 200 IPs with packet counts.
- Introduced new API endpoints to retrieve blocked IP statistics and reset these stats for agents, enhancing monitoring capabilities.
- Updated the agent detail view to display blocked IPs, improving user visibility into agent performance.
- Enhanced database schema and repositories to support the storage and management of IP block statistics.

These changes provide a comprehensive view of blocked IPs, improving the overall management and monitoring of agents.
This commit is contained in:
Denozordec
2026-08-07 14:15:10 +07:00
parent 9fd7ddb26c
commit 4ee78032c4
14 changed files with 743 additions and 15 deletions
+120 -11
View File
@@ -7,6 +7,7 @@ LOG_FILE=/var/log/evofw-firewall.log
STATE_DIR=/var/lib/evofw
HASH_FILE="${STATE_DIR}/last_hash"
POLICY_FILE="${STATE_DIR}/last_policy.json"
IP_HITS_TOP=200
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" | tee -a "$LOG_FILE"; }
@@ -97,6 +98,7 @@ PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
KERNEL_METHOD=""
APPLIED=0
IP_HITS_JSON="[]"
nft_join() {
local out="" p
@@ -116,6 +118,20 @@ nft_add_chunk() {
}
}
# Ensure inet set exists with interval + counter (recreate if missing counter).
ensure_nft_set() {
local table=$1 name=$2 setname=$3
local def
def=$(nft -a list set "$table" "$name" "$setname" 2>/dev/null || true)
if [[ -n "$def" ]] && [[ "$def" == *"counter"* ]]; then
return 0
fi
if [[ -n "$def" ]]; then
nft delete set "$table" "$name" "$setname" 2>>"$LOG_FILE" || true
fi
nft add set "$table" "$name" "$setname" '{ type ipv4_addr; flags interval; counter; }' 2>>"$LOG_FILE"
}
collect_nft_stats() {
PACKETS_DROPPED=0; PACKETS_ACCEPTED=0
local line n
@@ -135,6 +151,62 @@ collect_nft_stats() {
done < <(nft list chain inet evofw input 2>/dev/null || true)
}
# Parse nft set / ipset listing → top-N JSON [{"ip":"...","packets":N},...]
build_ip_hits_json() {
local text="$1"
if command -v python3 >/dev/null 2>&1; then
IP_HITS_JSON=$(IP_HITS_TOP="$IP_HITS_TOP" python3 -c '
import json, os, re, sys
text = sys.stdin.read()
top = int(os.environ.get("IP_HITS_TOP", "200"))
hits = {}
# nft: "1.2.3.4 counter packets 10 bytes 100" or "1.2.3.0/24 packets 5 bytes 20"
for m in re.finditer(r"([0-9]{1,3}(?:\.[0-9]{1,3}){3}(?:/[0-9]{1,2})?)\s+(?:counter\s+)?packets\s+(\d+)", text):
ip, pkts = m.group(1), int(m.group(2))
if pkts > 0:
hits[ip] = max(hits.get(ip, 0), pkts)
# ipset list Members: "1.2.3.4 packets 10 bytes 100"
for m in re.finditer(r"^([0-9]{1,3}(?:\.[0-9]{1,3}){3}(?:/[0-9]{1,2})?)\s+packets\s+(\d+)", text, re.M):
ip, pkts = m.group(1), int(m.group(2))
if pkts > 0:
hits[ip] = max(hits.get(ip, 0), pkts)
items = [{"ip": k, "packets": v} for k, v in hits.items()]
items.sort(key=lambda x: x["packets"], reverse=True)
print(json.dumps(items[:top], separators=(",", ":")))
' <<<"$text" 2>/dev/null) || IP_HITS_JSON="[]"
return
fi
if command -v jq >/dev/null 2>&1; then
# Fallback without python: empty (jq alone cannot easily top-N from free text)
IP_HITS_JSON="[]"
return
fi
IP_HITS_JSON="[]"
}
collect_nft_ip_hits() {
local text
text=$(nft list set inet evofw deny_v4 2>/dev/null || true)
build_ip_hits_json "$text"
}
collect_ipset_ip_hits() {
local text
text=$(ipset list evofw_deny_v4 2>/dev/null || true)
build_ip_hits_json "$text"
}
collect_ip_hits() {
IP_HITS_JSON="[]"
if [[ "${KERNEL_METHOD:-}" == "nft" ]] || { [[ -z "${KERNEL_METHOD:-}" || "${KERNEL_METHOD:-}" == "auto" ]] && command -v nft >/dev/null 2>&1 && nft list set inet evofw deny_v4 >/dev/null 2>&1; }; then
collect_nft_ip_hits
return
fi
if command -v ipset >/dev/null 2>&1 && ipset list evofw_deny_v4 >/dev/null 2>&1; then
collect_ipset_ip_hits
fi
}
apply_nft() {
local table=inet name=evofw
local deny_v4=() allow_v4=() p
@@ -142,10 +214,8 @@ apply_nft() {
for p in "${ALLOW[@]+"${ALLOW[@]}"}"; do [[ "$p" == *:* ]] && continue; allow_v4+=("$p"); done
nft list table "$table" "$name" >/dev/null 2>&1 || nft add table "$table" "$name"
nft list set "$table" "$name" deny_v4 >/dev/null 2>&1 || \
nft add set "$table" "$name" deny_v4 '{ type ipv4_addr; flags interval; }'
nft list set "$table" "$name" allow_v4 >/dev/null 2>&1 || \
nft add set "$table" "$name" allow_v4 '{ type ipv4_addr; flags interval; }'
ensure_nft_set "$table" "$name" deny_v4
ensure_nft_set "$table" "$name" allow_v4
nft flush set "$table" "$name" deny_v4
nft flush set "$table" "$name" allow_v4
@@ -182,10 +252,25 @@ apply_nft() {
APPLIED=$((${#deny_v4[@]} + ${#allow_v4[@]}))
}
ensure_ipset_counters() {
local name=$1
if ! ipset list "$name" >/dev/null 2>&1; then
ipset create "$name" hash:net family inet counters
return
fi
# Recreate once if set has no packet counters (Header lacks "counters").
local header
header=$(ipset list "$name" 2>/dev/null | head -n 5 || true)
if [[ "$header" != *"counters"* && "$header" != *"packet"* ]]; then
ipset destroy "$name" 2>>"$LOG_FILE" || true
ipset create "$name" hash:net family inet counters
fi
}
apply_ipset() {
local dset=evofw_deny_v4 aset=evofw_allow_v4
ipset list "$dset" >/dev/null 2>&1 || ipset create "$dset" hash:net family inet
ipset list "$aset" >/dev/null 2>&1 || ipset create "$aset" hash:net family inet
ensure_ipset_counters "$dset"
ensure_ipset_counters "$aset"
ipset flush "$dset"; ipset flush "$aset"
local p n=0
for p in "${DENY[@]+"${DENY[@]}"}"; do [[ "$p" == *:* ]] && continue; ipset add "$dset" "$p" -exist; n=$((n+1)); done
@@ -210,9 +295,12 @@ send_report() {
collect_nft_stats
fi
fi
if [[ -z "${IP_HITS_CAPTURED:-}" ]]; then
collect_ip_hits
fi
local report
report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent"}' \
"${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}")
report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent","ip_hits":%s}' \
"${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}" "${IP_HITS_JSON:-[]}")
curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/apply-report" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
@@ -225,15 +313,36 @@ send_report() {
if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then
log "unchanged hash $HASH — skip apply"
KERNEL_METHOD="${BACKEND}"
if command -v nft >/dev/null 2>&1 && nft list table inet evofw >/dev/null 2>&1; then
KERNEL_METHOD=nft
elif command -v ipset >/dev/null 2>&1 && ipset list evofw_deny_v4 >/dev/null 2>&1; then
KERNEL_METHOD=ipset
else
KERNEL_METHOD="${BACKEND}"
fi
# Count applied prefixes from live sets when skipping apply.
if [[ "$KERNEL_METHOD" == "nft" ]]; then
APPLIED=$(nft list set inet evofw deny_v4 2>/dev/null | grep -cE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' || true)
local_allow=$(nft list set inet evofw allow_v4 2>/dev/null | grep -cE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' || true)
APPLIED=$((APPLIED + local_allow))
elif [[ "$KERNEL_METHOD" == "ipset" ]]; then
APPLIED=$(ipset list evofw_deny_v4 2>/dev/null | awk '/^[0-9]/{c++} END{print c+0}')
local_allow=$(ipset list evofw_allow_v4 2>/dev/null | awk '/^[0-9]/{c++} END{print c+0}')
APPLIED=$((APPLIED + local_allow))
fi
send_report
exit 0
fi
# Capture counters BEFORE recreate (nft delete chain zeroes them).
if command -v nft >/dev/null 2>&1; then
# Capture counters BEFORE recreate (nft delete chain / flush set zeroes them).
if command -v nft >/dev/null 2>&1 && nft list table inet evofw >/dev/null 2>&1; then
collect_nft_stats
collect_nft_ip_hits
STATS_CAPTURED=1
IP_HITS_CAPTURED=1
elif command -v ipset >/dev/null 2>&1 && ipset list evofw_deny_v4 >/dev/null 2>&1; then
collect_ipset_ip_hits
IP_HITS_CAPTURED=1
fi
case "$BACKEND" in
+3
View File
@@ -221,6 +221,9 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
kernelMethod: body.kernel_method ?? null,
recordedAt: now,
})
if (body.ip_hits?.length) {
repos.upsertIpBlockStats(app.db, agentId, body.ip_hits, now)
}
return { ok: true }
})
+17
View File
@@ -23,6 +23,22 @@ export const statsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
})),
}))
app.get<{ Params: { id: string } }>(
'/agents/:id/blocked-ips',
async (req) => {
const agent = repos.getAgent(app.db, req.params.id)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
return {
items: repos.listIpBlockStats(app.db, agent.id).map((s) => ({
ip: s.ip,
packets: s.packets,
first_seen_at: s.firstSeenAt,
last_seen_at: s.lastSeenAt,
})),
}
},
)
app.post<{ Params: { id: string } }>(
'/agents/:id/stats/reset',
async (req) => {
@@ -35,6 +51,7 @@ export const statsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
totalPacketsAccepted: 0,
})
repos.deleteStatsSamplesForAgent(app.db, agent.id)
repos.deleteIpBlockStatsForAgent(app.db, agent.id)
auditMutation(app, config, req, {
action: 'agent.stats_reset',
severity: 'info',
@@ -0,0 +1,188 @@
import { describe, it, expect, afterAll } from 'vitest'
import { buildApp } from '../app.js'
import type { AppConfig } from '../config.js'
const testConfig: AppConfig = {
databaseUrl: 'sqlite::memory:',
jwtSecret: 'test',
jwtTtlHours: 24,
serverPort: 8080,
staticDir: null,
logLevel: 'error',
authRequired: false,
authIssuer: 'https://auth.test',
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
}
async function enrollApprovedLinux(
app: Awaited<ReturnType<typeof buildApp>>,
name: string,
token: string,
) {
const created = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name, platform: 'linux' },
})
expect(created.statusCode).toBe(201)
const link = created.json() as { id: string; agent_id: string }
const enroll = await app.inject({
method: 'POST',
url: '/v1/agent/enroll',
headers: {
'content-type': 'application/json',
'x-evofw-seed': 'test-seed',
},
payload: {
name,
platform: 'linux',
token,
install_link_id: link.id,
},
})
expect(enroll.statusCode).toBe(201)
await app.inject({
method: 'POST',
url: `/api/v1/agents/${link.agent_id}/approve`,
})
return { agentId: link.agent_id, token }
}
describe('apply-report ip_hits / blocked-ips', () => {
const appPromise = buildApp({ memory: true, config: testConfig })
afterAll(async () => {
const app = await appPromise
await app.close()
})
it('upserts ip_hits with delta accumulation and clears on reset', async () => {
const app = await appPromise
await app.ready()
const { agentId, token } = await enrollApprovedLinux(
app,
'ip-hits-01',
'evofw_ip_hits_token_abcdefghij',
)
const report1 = await app.inject({
method: 'POST',
url: '/v1/agent/apply-report',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
payload: {
status: 'ok',
prefix_count: 2,
packets_dropped: 15,
packets_accepted: 1,
kernel_method: 'nft',
source: 'agent',
ip_hits: [
{ ip: '203.0.113.10', packets: 10 },
{ ip: '198.51.100.0/24', packets: 5 },
],
},
})
expect(report1.statusCode).toBe(200)
const list1 = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}/blocked-ips`,
})
expect(list1.statusCode).toBe(200)
const body1 = list1.json() as {
items: { ip: string; packets: number; last_seen_at: string }[]
}
expect(body1.items).toHaveLength(2)
expect(body1.items[0]?.ip).toBe('203.0.113.10')
expect(body1.items[0]?.packets).toBe(10)
expect(body1.items[1]?.ip).toBe('198.51.100.0/24')
expect(body1.items[1]?.packets).toBe(5)
const report2 = await app.inject({
method: 'POST',
url: '/v1/agent/apply-report',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
payload: {
status: 'ok',
prefix_count: 2,
packets_dropped: 25,
packets_accepted: 1,
kernel_method: 'nft',
source: 'agent',
ip_hits: [
{ ip: '203.0.113.10', packets: 18 },
{ ip: '198.51.100.0/24', packets: 5 },
],
},
})
expect(report2.statusCode).toBe(200)
const list2 = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}/blocked-ips`,
})
const body2 = list2.json() as {
items: { ip: string; packets: number }[]
}
// 10 + (18-10) = 18; /24 unchanged (delta 0) stays 5
expect(body2.items.find((i) => i.ip === '203.0.113.10')?.packets).toBe(18)
expect(body2.items.find((i) => i.ip === '198.51.100.0/24')?.packets).toBe(5)
const reset = await app.inject({
method: 'POST',
url: `/api/v1/agents/${agentId}/stats/reset`,
})
expect(reset.statusCode).toBe(200)
const list3 = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}/blocked-ips`,
})
expect(
(list3.json() as { items: unknown[] }).items,
).toEqual([])
})
it('rejects ip_hits longer than 200', async () => {
const app = await appPromise
await app.ready()
const { token } = await enrollApprovedLinux(
app,
'ip-hits-max',
'evofw_ip_hits_max_token_abcdef',
)
const hits = Array.from({ length: 201 }, (_, i) => ({
ip: `203.0.113.${(i % 254) + 1}`,
packets: 1,
}))
const report = await app.inject({
method: 'POST',
url: '/v1/agent/apply-report',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
payload: {
status: 'ok',
packets_dropped: 201,
ip_hits: hits,
},
})
expect(report.statusCode).toBeGreaterThanOrEqual(400)
})
})
@@ -0,0 +1,167 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
getCoreRowModel,
useReactTable,
type ColumnDef,
} from '@tanstack/react-table'
import { BanIcon, RouterIcon } from 'lucide-react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { EmptyState } from '@/components/empty-state'
import { agentBlockedIpsQueryOptions } from '@/queries'
import { Skeleton } from '@evofw/ui/components/skeleton'
/**
* Per-IP/CIDR drop counters from Linux nft/ipset.
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* · https://reui.io/preview/base/empty-state-12
* MikroTik: address-list has no per-entry counters — empty hint only.
*/
export type BlockedIpRow = {
ip: string
packets: number
first_seen_at: string
last_seen_at: string
}
type AgentBlockedIpsProps = {
agentId: string
platform: string
}
const packetFmt = new Intl.NumberFormat('ru-RU')
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
function formatSeen(iso: string): string {
const t = Date.parse(iso)
if (Number.isNaN(t)) return '—'
return seenFmt.format(t)
}
export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
const isMikrotik = platform === 'mikrotik'
const q = useQuery({
...agentBlockedIpsQueryOptions(agentId),
enabled: !isMikrotik,
})
const columns = useMemo<ColumnDef<BlockedIpRow>[]>(
() => [
{
accessorKey: 'ip',
id: 'ip',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="IP / CIDR" />
),
cell: ({ row }) => (
<span className="font-mono text-xs tabular-nums">{row.original.ip}</span>
),
meta: { headerTitle: 'IP / CIDR' },
},
{
accessorKey: 'packets',
id: 'packets',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Packets" />
),
cell: ({ row }) => (
<span className="tabular-nums">
{packetFmt.format(row.original.packets)}
</span>
),
meta: { headerTitle: 'Packets' },
},
{
accessorKey: 'last_seen_at',
id: 'last_seen_at',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Last seen" />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{formatSeen(row.original.last_seen_at)}
</span>
),
meta: { headerTitle: 'Last seen' },
},
],
[],
)
const data = q.data?.items ?? []
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (r) => r.ip,
})
return (
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Blocked IPs</FrameTitle>
<FrameDescription>
Drop-пакеты по записям deny (nft/ipset). Top по накопленным packets.
</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
{isMikrotik ? (
<EmptyState
icon={RouterIcon}
title="Per-IP недоступен на MikroTik"
description="У address-list в RouterOS нет counters по записи. Доступны только суммарные Traffic ↓/↑ с filter-правил."
centered={false}
className="py-8"
/>
) : q.isLoading ? (
<div className="flex flex-col gap-2 p-4">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-2/3" />
</div>
) : q.isError ? (
<EmptyState
icon={BanIcon}
title="Не удалось загрузить"
description={q.error?.message ?? 'Ошибка API'}
centered={false}
className="py-8"
/>
) : data.length === 0 ? (
<EmptyState
icon={BanIcon}
title="Пока нет hit’ов"
description="Когда deny-префиксы начнут дропать пакеты, здесь появятся IP/CIDR с counters."
centered={false}
className="py-8"
/>
) : (
<DataGrid
table={table}
recordCount={data.length}
tableLayout={{ dense: true }}
>
<DataGridTable />
</DataGrid>
)}
</FramePanel>
</Frame>
)
}
@@ -33,6 +33,7 @@ import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-s
import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
import { AgentBlockedIps } from '@/components/agents/agent-blocked-ips'
import {
AgentCloneSetsSheet,
AgentOverrideSheet,
@@ -102,6 +103,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
void qc.invalidateQueries({ queryKey: ['agents'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ips'] })
void qc.invalidateQueries({ queryKey: ['stats'] })
void qc.invalidateQueries({ queryKey: ['dashboard'] })
},
@@ -317,6 +319,8 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
preview={previewQ.data}
isLoading={previewQ.isLoading}
/>
<AgentBlockedIps agentId={agentId} platform={a.platform} />
</div>
</DetailPanel.Section>
</DetailPanel>
+15 -1
View File
@@ -154,7 +154,7 @@ export const settingsQueryOptions = () =>
export const agentStatsQueryOptions = (id: string) =>
queryOptions({
queryKey: ['agent-stats', id],
queryKey: ['agents', id, 'stats'],
queryFn: () =>
apiFetch<{
items: {
@@ -165,6 +165,20 @@ export const agentStatsQueryOptions = (id: string) =>
}>(`/api/v1/agents/${id}/stats`),
})
export const agentBlockedIpsQueryOptions = (id: string) =>
queryOptions({
queryKey: ['agents', id, 'blocked-ips'],
queryFn: () =>
apiFetch<{
items: {
ip: string
packets: number
first_seen_at: string
last_seen_at: string
}[]
}>(`/api/v1/agents/${id}/blocked-ips`),
})
export const recentStatsQueryOptions = () =>
queryOptions({
queryKey: ['stats-recent'],