diff --git a/apps/api/src/agent-scripts/evofw-firewall.sh b/apps/api/src/agent-scripts/evofw-firewall.sh index 62bd644..30ba774 100644 --- a/apps/api/src/agent-scripts/evofw-firewall.sh +++ b/apps/api/src/agent-scripts/evofw-firewall.sh @@ -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 diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts index 5564962..bf81663 100644 --- a/apps/api/src/routes/agent.ts +++ b/apps/api/src/routes/agent.ts @@ -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 } }) diff --git a/apps/api/src/routes/stats.ts b/apps/api/src/routes/stats.ts index cea8751..7c60d2f 100644 --- a/apps/api/src/routes/stats.ts +++ b/apps/api/src/routes/stats.ts @@ -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', diff --git a/apps/api/src/services/ip-block-stats.test.ts b/apps/api/src/services/ip-block-stats.test.ts new file mode 100644 index 0000000..235149c --- /dev/null +++ b/apps/api/src/services/ip-block-stats.test.ts @@ -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>, + 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) + }) +}) diff --git a/apps/web/src/components/agents/agent-blocked-ips.tsx b/apps/web/src/components/agents/agent-blocked-ips.tsx new file mode 100644 index 0000000..041126a --- /dev/null +++ b/apps/web/src/components/agents/agent-blocked-ips.tsx @@ -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[]>( + () => [ + { + accessorKey: 'ip', + id: 'ip', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.ip} + ), + meta: { headerTitle: 'IP / CIDR' }, + }, + { + accessorKey: 'packets', + id: 'packets', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {packetFmt.format(row.original.packets)} + + ), + meta: { headerTitle: 'Packets' }, + }, + { + accessorKey: 'last_seen_at', + id: 'last_seen_at', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatSeen(row.original.last_seen_at)} + + ), + meta: { headerTitle: 'Last seen' }, + }, + ], + [], + ) + + const data = q.data?.items ?? [] + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getRowId: (r) => r.ip, + }) + + return ( + + + Blocked IPs + + Drop-пакеты по записям deny (nft/ipset). Top по накопленным packets. + + + + {isMikrotik ? ( + + ) : q.isLoading ? ( +
+ + + +
+ ) : q.isError ? ( + + ) : data.length === 0 ? ( + + ) : ( + + + + )} +
+ + ) +} diff --git a/apps/web/src/components/agents/agent-detail-view.tsx b/apps/web/src/components/agents/agent-detail-view.tsx index 1fd06fd..28fe1c2 100644 --- a/apps/web/src/components/agents/agent-detail-view.tsx +++ b/apps/web/src/components/agents/agent-detail-view.tsx @@ -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} /> + + diff --git a/apps/web/src/queries/index.ts b/apps/web/src/queries/index.ts index 8214ca1..687dd56 100644 --- a/apps/web/src/queries/index.ts +++ b/apps/web/src/queries/index.ts @@ -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'], diff --git a/docs/agents.md b/docs/agents.md index 37cdf6d..000c448 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -53,6 +53,18 @@ Backend auto-detect: nft → ipset → iptables. Whitelist: nft chain policy drop + allow set. Blacklist: policy accept + deny set. +## Per-IP blocked stats (Linux) + +Linux agent reports optional `ip_hits` in `POST /v1/agent/apply-report`: + +- **nft:** set `deny_v4` with `flags interval; counter;` — per-element packets; collected **before** flush/recreate and on unchanged-hash sync. +- **ipset:** `hash:net … counters` — same idea from `ipset list`. +- Payload: only entries with `packets > 0`, **top 200** by packets. +- Control plane stores cumulative totals in `agent_ip_block_stats` (delta vs last absolute report). `GET /api/v1/agents/:id/blocked-ips`. Reset via `POST …/stats/reset`. +- UI: agent detail → **Blocked IPs** (Frame + DataGrid). + +IPv6 skipped (as in apply). MikroTik: see below — no per-IP in v1. + ## MikroTik (RouterOS 7.21+) В UI `/agents` → **Добавить агента** → platform **MikroTik**. Скопируйте one-liner: @@ -72,6 +84,8 @@ Install RSC: Лог: `/log print where message~"evofw"`. Ручной sync: `/system script run evofw-sync`. Traffic ↓/↑ в UI — сумма `packets` с filter-правил `evofw-deny-*` / `evofw-allow-*` / `evofw-default-drop-*` (накопительно, пока правила не пересозданы re-install). +**Per-IP / blocked IPs:** на MikroTik **нет**. У `/ip firewall address-list` в ROS 7 нет `packets`/`bytes` на записи — только суммарные counters filter-правил. В карточке агента секция Blocked IPs показывает пояснение. + **Default action** задаётся на **агенте** (`default_action: accept | drop`): - **accept** — пакет вне deny/allow пропускается diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 65626a9..588d203 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -373,6 +373,53 @@ paths: '200': description: Samples + /api/v1/agents/{id}/stats: + get: + summary: Apply samples for one agent + tags: [ops] + security: [{ bearerAuth: [] }] + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: Samples + + /api/v1/agents/{id}/stats/reset: + post: + summary: Reset packet totals, samples, and per-IP block stats + tags: [ops] + security: [{ bearerAuth: [] }] + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: Agent after reset + + /api/v1/agents/{id}/blocked-ips: + get: + summary: Per-IP/CIDR drop counters (Linux nft/ipset) + tags: [ops] + security: [{ bearerAuth: [] }] + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: Top blocked IPs by accumulated packets + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + required: [ip, packets, first_seen_at, last_seen_at] + properties: + ip: { type: string } + packets: { type: integer } + first_seen_at: { type: string, format: date-time } + last_seen_at: { type: string, format: date-time } /api/v1/integrations/evobgp/communities: get: summary: Proxy EvoBGP communities @@ -473,9 +520,34 @@ paths: /v1/agent/apply-report: post: - summary: Apply report + packet stats + summary: Apply report + packet stats (+ optional ip_hits) tags: [agent] security: [{ agentToken: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [status] + properties: + status: { type: string } + prefix_count: { type: integer } + packets_dropped: { type: integer } + packets_accepted: { type: integer } + kernel_method: { type: string } + error: { type: string } + source: { type: string } + ip_hits: + type: array + maxItems: 200 + description: Linux per-element drop counters (packets > 0) + items: + type: object + required: [ip, packets] + properties: + ip: { type: string, maxLength: 64 } + packets: { type: integer, minimum: 0 } responses: '200': description: OK diff --git a/packages/db/migrations/009_agent_ip_block_stats.sql b/packages/db/migrations/009_agent_ip_block_stats.sql new file mode 100644 index 0000000..7ed7d38 --- /dev/null +++ b/packages/db/migrations/009_agent_ip_block_stats.sql @@ -0,0 +1,16 @@ +-- Per-IP/CIDR drop counters from Linux agent nft/ipset element counters. +CREATE TABLE IF NOT EXISTS agent_ip_block_stats ( + id TEXT PRIMARY KEY NOT NULL, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + ip TEXT NOT NULL, + packets INTEGER NOT NULL DEFAULT 0, + last_reported_packets INTEGER NOT NULL DEFAULT 0, + first_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_ip_block_stats_agent_ip + ON agent_ip_block_stats(agent_id, ip); + +CREATE INDEX IF NOT EXISTS idx_agent_ip_block_stats_agent_packets + ON agent_ip_block_stats(agent_id, packets); diff --git a/packages/db/src/repositories/index.ts b/packages/db/src/repositories/index.ts index 5406b65..9e25dda 100644 --- a/packages/db/src/repositories/index.ts +++ b/packages/db/src/repositories/index.ts @@ -57,6 +57,9 @@ export { listStatsSamples, listRecentStats, deleteStatsSamplesForAgent, + upsertIpBlockStats, + listIpBlockStats, + deleteIpBlockStatsForAgent, } from './stats.js' export { @@ -132,6 +135,9 @@ import { listStatsSamples, listRecentStats, deleteStatsSamplesForAgent, + upsertIpBlockStats, + listIpBlockStats, + deleteIpBlockStatsForAgent, } from './stats.js' import { getSetting, @@ -198,6 +204,9 @@ export const repos = { listStatsSamples, listRecentStats, deleteStatsSamplesForAgent, + upsertIpBlockStats, + listIpBlockStats, + deleteIpBlockStatsForAgent, getSetting, setSetting, listSettings, diff --git a/packages/db/src/repositories/stats.ts b/packages/db/src/repositories/stats.ts index 68498a3..dda0aca 100644 --- a/packages/db/src/repositories/stats.ts +++ b/packages/db/src/repositories/stats.ts @@ -1,6 +1,6 @@ -import { eq, desc } from 'drizzle-orm' +import { and, eq, desc } from 'drizzle-orm' import type { Db } from '../client.js' -import { agentStatsSamples } from '../schema.js' +import { agentIpBlockStats, agentStatsSamples } from '../schema.js' export function insertStatsSample( db: Db, @@ -33,3 +33,76 @@ export function deleteStatsSamplesForAgent(db: Db, agentId: string) { .where(eq(agentStatsSamples.agentId, agentId)) .run() } + +export type IpHitInput = { ip: string; packets: number } + +/** + * Upsert per-IP drop counters. Agent reports absolute kernel counters; + * CP accumulates deltas (mirrors totalPacketsDropped logic). + */ +export function upsertIpBlockStats( + db: Db, + agentId: string, + hits: IpHitInput[], + now = new Date().toISOString(), +) { + for (const hit of hits) { + const ip = hit.ip.trim() + if (!ip) continue + const reported = Math.max(0, Math.floor(hit.packets)) + const existing = db + .select() + .from(agentIpBlockStats) + .where( + and( + eq(agentIpBlockStats.agentId, agentId), + eq(agentIpBlockStats.ip, ip), + ), + ) + .get() + + if (!existing) { + db.insert(agentIpBlockStats) + .values({ + id: crypto.randomUUID(), + agentId, + ip, + packets: reported, + lastReportedPackets: reported, + firstSeenAt: now, + lastSeenAt: now, + }) + .run() + continue + } + + const prevReported = existing.lastReportedPackets ?? 0 + const delta = + reported >= prevReported ? reported - prevReported : reported + const packets = (existing.packets ?? 0) + delta + db.update(agentIpBlockStats) + .set({ + packets, + lastReportedPackets: reported, + ...(delta > 0 ? { lastSeenAt: now } : {}), + }) + .where(eq(agentIpBlockStats.id, existing.id)) + .run() + } +} + +export function listIpBlockStats(db: Db, agentId: string, limit = 200) { + return db + .select() + .from(agentIpBlockStats) + .where(eq(agentIpBlockStats.agentId, agentId)) + .orderBy(desc(agentIpBlockStats.packets)) + .limit(limit) + .all() +} + +export function deleteIpBlockStatsForAgent(db: Db, agentId: string) { + db.delete(agentIpBlockStats) + .where(eq(agentIpBlockStats.agentId, agentId)) + .run() +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 97d0e4b..d7b0a47 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -201,6 +201,33 @@ export const agentStatsSamples = sqliteTable( }), ) +/** Per-IP/CIDR drop counters reported by Linux agents (nft/ipset element counters). */ +export const agentIpBlockStats = sqliteTable( + 'agent_ip_block_stats', + { + id: text('id').primaryKey(), + agentId: text('agent_id') + .notNull() + .references(() => agents.id, { onDelete: 'cascade' }), + ip: text('ip').notNull(), + packets: integer('packets').notNull().default(0), + lastReportedPackets: integer('last_reported_packets').notNull().default(0), + firstSeenAt: text('first_seen_at') + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`), + lastSeenAt: text('last_seen_at') + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`), + }, + (t) => ({ + agentIp: uniqueIndex('idx_agent_ip_block_stats_agent_ip').on(t.agentId, t.ip), + agentPackets: index('idx_agent_ip_block_stats_agent_packets').on( + t.agentId, + t.packets, + ), + }), +) + /** Short install invite links (`/agent-install/:id` and `/:slug`). */ export const agentInstallLinks = sqliteTable( 'agent_install_links', @@ -263,6 +290,7 @@ export const schema = { policyRuleResolved, ipOverrides, agentStatsSamples, + agentIpBlockStats, agentInstallLinks, auditLog, } diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 95882f9..580fc8d 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -221,6 +221,11 @@ export const enrollBodySchema = z.object({ install_link_id: z.string().optional(), }) +export const applyReportIpHitSchema = z.object({ + ip: z.string().min(1).max(64), + packets: z.number().int().nonnegative(), +}) + export const applyReportBodySchema = z.object({ status: z.string(), prefix_count: z.number().int().optional(), @@ -229,6 +234,15 @@ export const applyReportBodySchema = z.object({ kernel_method: z.string().optional(), error: z.string().optional(), source: z.string().optional(), + /** Linux nft/ipset per-element drop counters (top-N, packets > 0). */ + ip_hits: z.array(applyReportIpHitSchema).max(200).optional(), +}) + +export const agentIpBlockStatSchema = z.object({ + ip: z.string(), + packets: z.number().int(), + first_seen_at: z.string(), + last_seen_at: z.string(), }) export const agentPolicySchema = z.object({