diff --git a/apps/api/src/agent-scripts/evofw-firewall.sh b/apps/api/src/agent-scripts/evofw-firewall.sh index 2195771..c207dec 100644 --- a/apps/api/src/agent-scripts/evofw-firewall.sh +++ b/apps/api/src/agent-scripts/evofw-firewall.sh @@ -8,6 +8,7 @@ STATE_DIR=/var/lib/evofw HASH_FILE="${STATE_DIR}/last_hash" POLICY_FILE="${STATE_DIR}/last_policy.json" IP_HITS_TOP=200 +PORT_HITS_TOP=500 log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" | tee -a "$LOG_FILE"; } @@ -99,6 +100,9 @@ PACKETS_ACCEPTED=0 KERNEL_METHOD="" APPLIED=0 IP_HITS_JSON="[]" +PORT_HITS_JSON="[]" +# 1 when deny_port_hits dynamic set is available for this apply. +PORT_HITS_ENABLED=0 nft_join() { local out="" p @@ -223,18 +227,110 @@ collect_ip_hits() { fi } +# Parse nft dynamic concat set → [{"ip","port","protocol","packets"},...] +build_port_hits_json() { + local text="$1" + PORT_HITS_JSON="[]" + if command -v python3 >/dev/null 2>&1; then + PORT_HITS_JSON=$(PORT_HITS_TOP="$PORT_HITS_TOP" python3 -c ' +import json, os, re, sys +text = sys.stdin.read() +top = int(os.environ.get("PORT_HITS_TOP", "500")) +hits = {} +# Elements look like: +# 1.2.3.4 . tcp . 22 counter packets 10 bytes 100 +# 1.2.3.4 . 6 . 443 timeout 1h counter packets 5 bytes 20 +proto_map = {"6": "tcp", "17": "udp", "tcp": "tcp", "udp": "udp"} +pat = re.compile( + r"([0-9]{1,3}(?:\.[0-9]{1,3}){3})\s*\.\s*([A-Za-z0-9]+)\s*\.\s*(\d+)\s+" + r"(?:timeout\s+\S+\s+)?(?:counter\s+)?packets\s+(\d+)", + re.I, +) +for m in pat.finditer(text): + ip, raw_proto, port_s, pkts_s = m.group(1), m.group(2).lower(), m.group(3), m.group(4) + proto = proto_map.get(raw_proto) + if not proto: + continue + pkts = int(pkts_s) + if pkts <= 0: + continue + port = int(port_s) + if port < 1 or port > 65535: + continue + key = (ip, port, proto) + hits[key] = max(hits.get(key, 0), pkts) +items = [ + {"ip": ip, "port": port, "protocol": proto, "packets": pkts} + for (ip, port, proto), pkts in hits.items() +] +items.sort(key=lambda x: x["packets"], reverse=True) +print(json.dumps(items[:top], separators=(",", ":"))) +' <<<"$text" 2>/dev/null) || PORT_HITS_JSON="[]" + return + fi + PORT_HITS_JSON="[]" +} + +collect_nft_port_hits() { + local text + PORT_HITS_JSON="[]" + text=$(nft list set inet evofw deny_port_hits 2>/dev/null || true) + [[ -z "$text" ]] && return + build_port_hits_json "$text" +} + +collect_port_hits() { + PORT_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_port_hits >/dev/null 2>&1; }; then + collect_nft_port_hits + fi +} + +# Dynamic concat set for per-(ip, proto, dport) deny hits. Returns 0 if usable. +ensure_nft_port_hits_set() { + local table=$1 name=$2 + local setname=deny_port_hits + local def + def=$(nft list set "$table" "$name" "$setname" 2>/dev/null || true) + if [[ -n "$def" ]] && { [[ "$def" == *"dynamic"* ]] || [[ "$def" == *"timeout"* ]]; }; then + # Keep existing; elements age out via timeout — do not flush on every apply + # (counters survive policy CIDR refresh when chain is recreated). + return 0 + fi + if [[ -n "$def" ]]; then + nft delete set "$table" "$name" "$setname" 2>>"$LOG_FILE" || true + fi + if nft add set "$table" "$name" "$setname" \ + '{ type ipv4_addr . inet_proto . inet_service; flags dynamic,timeout; timeout 1h; counter; }' \ + 2>>"$LOG_FILE"; then + return 0 + fi + # Older kernels may need slightly different flag spelling. + if nft add set "$table" "$name" "$setname" \ + '{ type ipv4_addr . inet_proto . inet_service; flags dynamic; timeout 1h; counter; }' \ + 2>>"$LOG_FILE"; then + return 0 + fi + log "nft: deny_port_hits unsupported — port hits disabled" + return 1 +} + apply_nft() { local table=inet name=evofw local deny_v4=() allow_v4=() p + PORT_HITS_ENABLED=0 for p in "${DENY[@]+"${DENY[@]}"}"; do [[ "$p" == *:* ]] && continue; deny_v4+=("$p"); done 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" - # Drop chain first so sets can be deleted/recreated (upgrade to counters). + # Drop chain first so sets can be deleted/recreated (upgrade to counters / port hits). # Stats were already captured by the caller before apply_nft. nft delete chain "$table" "$name" input 2>/dev/null || true ensure_nft_set "$table" "$name" deny_v4 ensure_nft_set "$table" "$name" allow_v4 + if ensure_nft_port_hits_set "$table" "$name"; then + PORT_HITS_ENABLED=1 + fi nft flush set "$table" "$name" deny_v4 2>>"$LOG_FILE" || true nft flush set "$table" "$name" allow_v4 2>>"$LOG_FILE" || true @@ -259,7 +355,21 @@ apply_nft() { fi nft add rule "$table" "$name" input ct state established,related counter accept nft add rule "$table" "$name" input iif lo counter accept - nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop + if [[ "$PORT_HITS_ENABLED" -eq 1 ]]; then + # TCP/UDP: learn (ip, proto, dport) then drop; other L4: plain drop. + if ! nft add rule "$table" "$name" input \ + ip saddr @deny_v4 meta l4proto '{ tcp, udp }' \ + update @deny_port_hits '{ ip saddr . meta l4proto . th dport }' \ + counter drop 2>>"$LOG_FILE"; then + log "nft: port-hit deny rule failed — fallback to plain deny drop" + PORT_HITS_ENABLED=0 + nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop + else + nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop + fi + else + nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop + fi nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept if [[ "$DEFAULT_ACTION" == "drop" ]]; then nft add rule "$table" "$name" input counter drop @@ -323,9 +433,12 @@ send_report() { if [[ -z "${IP_HITS_CAPTURED:-}" ]]; then collect_ip_hits fi + if [[ -z "${PORT_HITS_CAPTURED:-}" ]]; then + collect_port_hits + fi local report - 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:-[]}") + report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent","ip_hits":%s,"port_hits":%s}' \ + "${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}" "${IP_HITS_JSON:-[]}" "${PORT_HITS_JSON:-[]}") curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/apply-report" \ -H "Authorization: Bearer ${CLIENT_TOKEN}" \ -H "Content-Type: application/json" \ @@ -363,11 +476,15 @@ fi 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 + collect_nft_port_hits STATS_CAPTURED=1 IP_HITS_CAPTURED=1 + PORT_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 + PORT_HITS_JSON="[]" + PORT_HITS_CAPTURED=1 fi case "$BACKEND" in diff --git a/apps/api/src/agent-scripts/install.sh b/apps/api/src/agent-scripts/install.sh index 6e8660e..6d5de4a 100644 --- a/apps/api/src/agent-scripts/install.sh +++ b/apps/api/src/agent-scripts/install.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # EvoFirewall Linux install one-liner -# Re-run on an already-installed host updates scripts/timer and keeps credentials +# Re-run on an already-installed host updates scripts/timer **and forces nft +# rule re-apply** (clears last_hash), keeping credentials # (unless EVOFW_INSTALL_FORCE=1 → full re-enroll). set -euo pipefail @@ -250,10 +251,10 @@ if [[ -f "$CONF_FILE" && "${EVOFW_INSTALL_FORCE:-}" != "1" ]]; then download_sync_script "$SYNC_TMP" || exit 1 write_conf "$CLIENT_ID" "$CLIENT_TOKEN" "$CLIENT_NAME" "$BACKEND" install_sync_and_uninstall "$SYNC_TMP" - # Force one apply after script refresh (nft set upgrade, counters, etc.). + # Force one apply after script refresh (nft set upgrade, counters, port hits, etc.). rm -f /var/lib/evofw/last_hash enable_scheduler_and_run - echo "Updated. Client id=${CLIENT_ID}. Sync script + timer refreshed." + echo "Updated. Client id=${CLIENT_ID}. Sync script + timer refreshed; nft rules re-applied." echo "Force sync: $SYNC_SCRIPT" echo "Uninstall: $UNINSTALL_SCRIPT (or: curl -fsSL ${CP_URL}/v1/agent/uninstall.sh | bash)" exit 0 diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts index 0d8624e..630c886 100644 --- a/apps/api/src/routes/agent.ts +++ b/apps/api/src/routes/agent.ts @@ -201,6 +201,7 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( // Chain/set counters were flushed (policy apply). Zero per-IP baselines so // the next epoch of element counters accumulates (zeros are omitted from ip_hits). + // Port hits use a separate dynamic set that is not flushed on apply — leave baselines. if (reportedDropped < prevDropped) { repos.resetIpBlockStatsBaselines(app.db, agentId) } @@ -233,6 +234,9 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( mode: presence ? 'presence' : 'absolute', }) } + if (body.port_hits?.length && body.source !== 'mikrotik') { + repos.upsertPortBlockStats(app.db, agentId, body.port_hits, now) + } return { ok: true } }) diff --git a/apps/api/src/routes/stats.ts b/apps/api/src/routes/stats.ts index 7c60d2f..1dab821 100644 --- a/apps/api/src/routes/stats.ts +++ b/apps/api/src/routes/stats.ts @@ -28,12 +28,40 @@ export const statsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( async (req) => { const agent = repos.getAgent(app.db, req.params.id) if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404) + const items = repos.listIpBlockStats(app.db, agent.id) + const portsByIp = repos.mapTopPortsByIp( + app.db, + agent.id, + items.map((s) => s.ip), + 5, + ) return { - items: repos.listIpBlockStats(app.db, agent.id).map((s) => ({ + items: items.map((s) => ({ ip: s.ip, packets: s.packets, first_seen_at: s.firstSeenAt, last_seen_at: s.lastSeenAt, + ports: (portsByIp.get(s.ip) ?? []).map((p) => ({ + port: p.port, + protocol: p.protocol, + packets: p.packets, + })), + })), + } + }, + ) + + app.get<{ Params: { id: string } }>( + '/agents/:id/blocked-ports', + 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.listPortBlockStatsAggregate(app.db, agent.id, 50).map((s) => ({ + port: s.port, + protocol: s.protocol, + packets: s.packets, + last_seen_at: s.lastSeenAt, })), } }, @@ -52,6 +80,7 @@ export const statsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( }) repos.deleteStatsSamplesForAgent(app.db, agent.id) repos.deleteIpBlockStatsForAgent(app.db, agent.id) + repos.deletePortBlockStatsForAgent(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 index df1caf9..824f12d 100644 --- a/apps/api/src/services/ip-block-stats.test.ts +++ b/apps/api/src/services/ip-block-stats.test.ts @@ -362,3 +362,157 @@ describe('apply-report ip_hits / blocked-ips', () => { ).toBe(2) }) }) + +describe('apply-report port_hits / blocked-ports', () => { + const appPromise = buildApp({ memory: true, config: testConfig }) + + afterAll(async () => { + const app = await appPromise + await app.close() + }) + + it('upserts port_hits with delta accumulation, aggregate, per-IP ports, reset', async () => { + const app = await appPromise + await app.ready() + + const { agentId, token } = await enrollApprovedLinux( + app, + 'port-hits-01', + 'evofw_port_hits_token_abcdefg', + ) + + 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: 1, + packets_dropped: 30, + packets_accepted: 0, + kernel_method: 'nft', + source: 'agent', + ip_hits: [{ ip: '203.0.113.10', packets: 20 }], + port_hits: [ + { ip: '203.0.113.10', port: 22, protocol: 'tcp', packets: 12 }, + { ip: '203.0.113.10', port: 53, protocol: 'udp', packets: 8 }, + { ip: '198.51.100.7', port: 22, protocol: 'tcp', packets: 5 }, + ], + }, + }) + expect(report1.statusCode).toBe(200) + + const ports1 = await app.inject({ + method: 'GET', + url: `/api/v1/agents/${agentId}/blocked-ports`, + }) + expect(ports1.statusCode).toBe(200) + const agg1 = ports1.json() as { + items: { + port: number + protocol: string + packets: number + last_seen_at: string + }[] + } + expect(agg1.items[0]?.port).toBe(22) + expect(agg1.items[0]?.protocol).toBe('tcp') + expect(agg1.items[0]?.packets).toBe(17) // 12+5 + expect(agg1.items.find((i) => i.port === 53)?.packets).toBe(8) + + const ips1 = await app.inject({ + method: 'GET', + url: `/api/v1/agents/${agentId}/blocked-ips`, + }) + const ipBody = ips1.json() as { + items: { + ip: string + ports?: { port: number; protocol: string; packets: number }[] + }[] + } + const row10 = ipBody.items.find((i) => i.ip === '203.0.113.10') + expect(row10?.ports?.map((p) => `${p.protocol}/${p.port}`)).toEqual([ + 'tcp/22', + 'udp/53', + ]) + + const report2 = await app.inject({ + method: 'POST', + url: '/v1/agent/apply-report', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + payload: { + status: 'ok', + packets_dropped: 40, + kernel_method: 'nft', + source: 'agent', + port_hits: [ + { ip: '203.0.113.10', port: 22, protocol: 'tcp', packets: 15 }, + ], + }, + }) + expect(report2.statusCode).toBe(200) + + const ports2 = await app.inject({ + method: 'GET', + url: `/api/v1/agents/${agentId}/blocked-ports`, + }) + const agg2 = ports2.json() as { + items: { port: number; protocol: string; packets: number }[] + } + // tcp/22: 17 + (15-12) = 20 + expect( + agg2.items.find((i) => i.port === 22 && i.protocol === 'tcp')?.packets, + ).toBe(20) + + const reset = await app.inject({ + method: 'POST', + url: `/api/v1/agents/${agentId}/stats/reset`, + }) + expect(reset.statusCode).toBe(200) + + const ports3 = await app.inject({ + method: 'GET', + url: `/api/v1/agents/${agentId}/blocked-ports`, + }) + expect((ports3.json() as { items: unknown[] }).items).toEqual([]) + }) + + it('rejects port_hits longer than 500', async () => { + const app = await appPromise + await app.ready() + + const { token } = await enrollApprovedLinux( + app, + 'port-hits-max', + 'evofw_port_hits_max_token_abc', + ) + + const hits = Array.from({ length: 501 }, (_, i) => ({ + ip: `203.0.113.${(i % 254) + 1}`, + port: (i % 65535) + 1, + protocol: i % 2 === 0 ? 'tcp' : 'udp', + 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: 501, + port_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 index f62e1c7..a2454ed 100644 --- a/apps/web/src/components/agents/agent-blocked-ips.tsx +++ b/apps/web/src/components/agents/agent-blocked-ips.tsx @@ -26,11 +26,18 @@ import { Skeleton } from '@evofw/ui/components/skeleton' * · https://reui.io/preview/base/empty-state-12 */ +export type BlockedIpPort = { + port: number + protocol: string + packets: number +} + export type BlockedIpRow = { ip: string packets: number first_seen_at: string last_seen_at: string + ports?: BlockedIpPort[] } type AgentBlockedIpsProps = { @@ -54,17 +61,25 @@ function formatSeen(iso: string): string { return seenFmt.format(t) } +function formatPorts(ports: BlockedIpPort[] | undefined): string { + if (!ports?.length) return '—' + return ports + .map((p) => `${p.protocol}/${p.port}`) + .join(', ') +} + export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) { const isMikrotik = platform === 'mikrotik' + const showPorts = !isMikrotik const q = useQuery(agentBlockedIpsQueryOptions(agentId)) const packetsTitle = isMikrotik ? 'Hits' : 'Packets' const description = isMikrotik ? 'Src /32 из EVOFW_HITS (add-src при deny, timeout 1h). Hits — входы в список (не каждый sync); Last seen обновляется, пока IP в hits.' - : 'Drop-пакеты по записям deny (nft/ipset). Top по накопленным packets.' + : 'Drop-пакеты по записям deny (nft/ipset). Top по накопленным packets. Ports — top-5 dport (nft).' - const columns = useMemo[]>( - () => [ + const columns = useMemo[]>(() => { + const cols: ColumnDef[] = [ { accessorKey: 'ip', id: 'ip', @@ -89,22 +104,37 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) { ), meta: { headerTitle: packetsTitle }, }, - { - accessorKey: 'last_seen_at', - id: 'last_seen_at', + ] + if (showPorts) { + cols.push({ + id: 'ports', + accessorFn: (row) => formatPorts(row.ports), header: ({ column }) => ( - + ), cell: ({ row }) => ( - - {formatSeen(row.original.last_seen_at)} + + {formatPorts(row.original.ports)} ), - meta: { headerTitle: 'Last seen' }, - }, - ], - [packetsTitle], - ) + meta: { headerTitle: 'Ports' }, + }) + } + cols.push({ + accessorKey: 'last_seen_at', + id: 'last_seen_at', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatSeen(row.original.last_seen_at)} + + ), + meta: { headerTitle: 'Last seen' }, + }) + return cols + }, [packetsTitle, showPorts]) const data = q.data?.items ?? [] const table = useReactTable({ diff --git a/apps/web/src/components/agents/agent-blocked-ports.tsx b/apps/web/src/components/agents/agent-blocked-ports.tsx new file mode 100644 index 0000000..9d5ba78 --- /dev/null +++ b/apps/web/src/components/agents/agent-blocked-ports.tsx @@ -0,0 +1,169 @@ +import { useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + getCoreRowModel, + useReactTable, + type ColumnDef, +} from '@tanstack/react-table' +import { NetworkIcon } 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 { agentBlockedPortsQueryOptions } from '@/queries' +import { Skeleton } from '@evofw/ui/components/skeleton' + +/** + * Aggregate destination ports hit by denied sources (Linux nft). + * Preview: https://reui.io/preview/base/data-grid-filtering-2 + * · https://reui.io/preview/base/empty-state-12 + */ + +export type BlockedPortRow = { + port: number + protocol: string + packets: number + last_seen_at: string +} + +type AgentBlockedPortsProps = { + agentId: 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 AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) { + const q = useQuery(agentBlockedPortsQueryOptions(agentId)) + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'port', + id: 'port', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.port} + + ), + meta: { headerTitle: 'Port' }, + }, + { + accessorKey: 'protocol', + id: 'protocol', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.protocol} + + ), + meta: { headerTitle: 'Proto' }, + }, + { + 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.protocol}/${r.port}`, + }) + + return ( + + + Top ports + + Destination ports (tcp/udp), в которые слали запросы blocked IP. nft + dynamic set deny_port_hits. + + + + {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 28fe1c2..622b3b8 100644 --- a/apps/web/src/components/agents/agent-detail-view.tsx +++ b/apps/web/src/components/agents/agent-detail-view.tsx @@ -34,6 +34,7 @@ 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 { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports' import { AgentCloneSetsSheet, AgentOverrideSheet, @@ -104,6 +105,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) { 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: ['agents', agentId, 'blocked-ports'] }) void qc.invalidateQueries({ queryKey: ['stats'] }) void qc.invalidateQueries({ queryKey: ['dashboard'] }) }, @@ -320,6 +322,10 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) { isLoading={previewQ.isLoading} /> + {a.platform === 'linux' ? ( + + ) : null} + diff --git a/apps/web/src/queries/index.ts b/apps/web/src/queries/index.ts index 687dd56..3072f2f 100644 --- a/apps/web/src/queries/index.ts +++ b/apps/web/src/queries/index.ts @@ -175,10 +175,29 @@ export const agentBlockedIpsQueryOptions = (id: string) => packets: number first_seen_at: string last_seen_at: string + ports?: { + port: number + protocol: string + packets: number + }[] }[] }>(`/api/v1/agents/${id}/blocked-ips`), }) +export const agentBlockedPortsQueryOptions = (id: string) => + queryOptions({ + queryKey: ['agents', id, 'blocked-ports'], + queryFn: () => + apiFetch<{ + items: { + port: number + protocol: string + packets: number + last_seen_at: string + }[] + }>(`/api/v1/agents/${id}/blocked-ports`), + }) + export const recentStatsQueryOptions = () => queryOptions({ queryKey: ['stats-recent'], diff --git a/docs/agents.md b/docs/agents.md index 627ab97..f88e0c7 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -20,7 +20,7 @@ curl -fsSL https:///agent-install/ | bash 3. После enroll статус станет **Pending** — одобрите агента (Approve). 4. **Approved** — агент синхронизирует политику. -**Повторный запуск той же install-ссылки** на хосте, где агент уже стоит: обновляет sync-скрипт / timer (или MikroTik scheduler), **без** повторного enroll — `CLIENT_ID`/`token` сохраняются. Полный переустановки с новым токеном: `EVOFW_INSTALL_FORCE=1` (Linux). +**Повторный запуск той же install-ссылки** на хосте, где агент уже стоит: обновляет sync-скрипт / timer (или MikroTik scheduler) **и пересоздаёт nft rules** (в т.ч. `deny_port_hits`), **без** повторного enroll — `CLIENT_ID`/`token` сохраняются. Сбрасывается `last_hash`, затем сразу force sync. Полный переустановки с новым токеном: `EVOFW_INSTALL_FORCE=1` (Linux). API (auth): `POST /api/v1/install-links` `{ "name": "web-01", "platform": "linux" | "mikrotik" }`. @@ -58,7 +58,7 @@ Whitelist: nft chain policy drop + allow set. Blacklist: policy accept + deny se Linux agent reports optional `ip_hits` in `POST /v1/agent/apply-report`: - **nft:** tries set `deny_v4` with `flags interval; counter;`. If the kernel rejects counters on interval sets, falls back to plain interval (aggregate Traffic ↓ still works; per-IP empty). -- Upgrade path: on install-link re-run, `last_hash` is cleared once so sets can be recreated (chain deleted before set replace). +- Upgrade path: on install-link re-run, `last_hash` is cleared once so sets/chain can be recreated (chain deleted before set replace) — **обновляет и port-hit правила**. - **ipset:** prefers `hash:net … counters` on create; existing sets without counters are left as-is. - Payload: only entries with `packets > 0`, **top 200** by packets. - Control plane: `agent_ip_block_stats`, accumulates **deltas** of absolute kernel counters (как Traffic ↓). После flush set/chain (policy apply) CP сбрасывает per-IP baseline (`last_reported`), иначе вторая эпоха счётчиков теряется (Traffic растёт, Blocked IPs — нет). @@ -67,6 +67,20 @@ Linux agent reports optional `ip_hits` in `POST /v1/agent/apply-report`: IPv6 skipped. +## Destination ports (Linux nft) + +На **nft** агент ведёт dynamic set `deny_port_hits` (`ipv4_addr . inet_proto . inet_service`, timeout 1h, counter): + +- Deny rule для tcp/udp: `update @deny_port_hits { ip saddr . meta l4proto . th dport }` + drop; прочие протоколы — plain drop. +- В `apply-report`: optional `port_hits` top **500** `{ ip, port, protocol, packets }`. +- CP: `agent_port_block_stats` (absolute deltas). Set **не** flush’ится на каждый policy apply (элементы живут по timeout) — baseline не сбрасывается при Traffic flush. +- `GET /api/v1/agents/:id/blocked-ports` — aggregate top 50 портов; в `blocked-ips` у каждого IP — `ports` top 5. +- UI: **Top ports** + колонка Ports в Blocked IPs (только `platform=linux`). +- **ipset/iptables:** `port_hits: []`. MikroTik — без port hits. +- Чтобы подтянуть правила на уже установленном агенте: **re-run install one-liner** (см. выше). + +IPv6 skipped. + ## MikroTik (RouterOS 7.21+) В UI `/agents` → **Добавить агента** → platform **MikroTik**. Скопируйте one-liner: diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 588d203..efcbbf5 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -397,14 +397,14 @@ paths: /api/v1/agents/{id}/blocked-ips: get: - summary: Per-IP/CIDR drop counters (Linux nft/ipset) + summary: Per-IP/CIDR drop counters (Linux nft/ipset; MikroTik HITS) tags: [ops] security: [{ bearerAuth: [] }] parameters: - $ref: '#/components/parameters/Id' responses: '200': - description: Top blocked IPs by accumulated packets + description: Top blocked IPs by accumulated packets (+ optional top ports per IP on Linux) content: application/json: schema: @@ -420,6 +420,43 @@ paths: packets: { type: integer } first_seen_at: { type: string, format: date-time } last_seen_at: { type: string, format: date-time } + ports: + type: array + description: Top destination ports for this IP (Linux nft) + items: + type: object + required: [port, protocol, packets] + properties: + port: { type: integer, minimum: 1, maximum: 65535 } + protocol: { type: string, enum: [tcp, udp] } + packets: { type: integer } + + /api/v1/agents/{id}/blocked-ports: + get: + summary: Aggregate destination ports hit by denied sources (Linux nft) + tags: [ops] + security: [{ bearerAuth: [] }] + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: Top ports by accumulated packets across all blocked IPs + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + required: [port, protocol, packets, last_seen_at] + properties: + port: { type: integer, minimum: 1, maximum: 65535 } + protocol: { type: string, enum: [tcp, udp] } + packets: { type: integer } + last_seen_at: { type: string, format: date-time } + /api/v1/integrations/evobgp/communities: get: summary: Proxy EvoBGP communities @@ -520,7 +557,7 @@ paths: /v1/agent/apply-report: post: - summary: Apply report + packet stats (+ optional ip_hits) + summary: Apply report + packet stats (+ optional ip_hits / port_hits) tags: [agent] security: [{ agentToken: [] }] requestBody: @@ -548,6 +585,18 @@ paths: properties: ip: { type: string, maxLength: 64 } packets: { type: integer, minimum: 0 } + port_hits: + type: array + maxItems: 500 + description: Linux nft deny_port_hits (ip × proto × dport, packets > 0) + items: + type: object + required: [ip, port, protocol, packets] + properties: + ip: { type: string, maxLength: 64 } + port: { type: integer, minimum: 1, maximum: 65535 } + protocol: { type: string, enum: [tcp, udp] } + packets: { type: integer, minimum: 0 } responses: '200': description: OK diff --git a/packages/db/migrations/010_agent_port_block_stats.sql b/packages/db/migrations/010_agent_port_block_stats.sql new file mode 100644 index 0000000..d4b96ca --- /dev/null +++ b/packages/db/migrations/010_agent_port_block_stats.sql @@ -0,0 +1,21 @@ +-- Per-(ip, protocol, port) deny drop counters from Linux nft dynamic set. +CREATE TABLE IF NOT EXISTS agent_port_block_stats ( + id TEXT PRIMARY KEY NOT NULL, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + protocol 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_port_block_stats_agent_ip_port_proto + ON agent_port_block_stats(agent_id, ip, port, protocol); + +CREATE INDEX IF NOT EXISTS idx_agent_port_block_stats_agent_packets + ON agent_port_block_stats(agent_id, packets); + +CREATE INDEX IF NOT EXISTS idx_agent_port_block_stats_agent_port_proto + ON agent_port_block_stats(agent_id, port, protocol); diff --git a/packages/db/src/repositories/index.ts b/packages/db/src/repositories/index.ts index 1144d1c..17244f9 100644 --- a/packages/db/src/repositories/index.ts +++ b/packages/db/src/repositories/index.ts @@ -61,9 +61,21 @@ export { listIpBlockStats, deleteIpBlockStatsForAgent, resetIpBlockStatsBaselines, + upsertPortBlockStats, + listPortBlockStatsAggregate, + listPortBlockStatsForIps, + mapTopPortsByIp, + deletePortBlockStatsForAgent, + resetPortBlockStatsBaselines, PRESENCE_REHIT_STALE_MS, } from './stats.js' -export type { UpsertIpBlockStatsOptions, IpHitInput } from './stats.js' +export type { + UpsertIpBlockStatsOptions, + IpHitInput, + PortHitInput, + PortBlockAggregateRow, + PortBlockPerIpRow, +} from './stats.js' export { getSetting, @@ -142,6 +154,12 @@ import { listIpBlockStats, deleteIpBlockStatsForAgent, resetIpBlockStatsBaselines, + upsertPortBlockStats, + listPortBlockStatsAggregate, + listPortBlockStatsForIps, + mapTopPortsByIp, + deletePortBlockStatsForAgent, + resetPortBlockStatsBaselines, } from './stats.js' import { getSetting, @@ -212,6 +230,12 @@ export const repos = { listIpBlockStats, deleteIpBlockStatsForAgent, resetIpBlockStatsBaselines, + upsertPortBlockStats, + listPortBlockStatsAggregate, + listPortBlockStatsForIps, + mapTopPortsByIp, + deletePortBlockStatsForAgent, + resetPortBlockStatsBaselines, getSetting, setSetting, listSettings, diff --git a/packages/db/src/repositories/stats.ts b/packages/db/src/repositories/stats.ts index 83e40f7..7a00e76 100644 --- a/packages/db/src/repositories/stats.ts +++ b/packages/db/src/repositories/stats.ts @@ -1,6 +1,10 @@ -import { and, eq, desc } from 'drizzle-orm' +import { and, eq, desc, sql, inArray } from 'drizzle-orm' import type { Db } from '../client.js' -import { agentIpBlockStats, agentStatsSamples } from '../schema.js' +import { + agentIpBlockStats, + agentPortBlockStats, + agentStatsSamples, +} from '../schema.js' export function insertStatsSample( db: Db, @@ -172,3 +176,169 @@ export function resetIpBlockStatsBaselines(db: Db, agentId: string) { .where(eq(agentIpBlockStats.agentId, agentId)) .run() } + +export type PortHitInput = { + ip: string + port: number + protocol: 'tcp' | 'udp' | string + packets: number +} + +/** + * Upsert per-(ip, proto, port) deny counters (Linux nft absolute deltas). + * deny_port_hits is not flushed on policy apply (timeout ages elements), so + * baselines are not auto-reset on Traffic flush — only on stats/reset delete. + */ +export function upsertPortBlockStats( + db: Db, + agentId: string, + hits: PortHitInput[], + now = new Date().toISOString(), +) { + for (const hit of hits) { + const ip = hit.ip.trim() + const protocol = String(hit.protocol).toLowerCase() + if (!ip || (protocol !== 'tcp' && protocol !== 'udp')) continue + const port = Math.floor(hit.port) + if (port < 1 || port > 65535) continue + const reported = Math.max(0, Math.floor(hit.packets)) + const existing = db + .select() + .from(agentPortBlockStats) + .where( + and( + eq(agentPortBlockStats.agentId, agentId), + eq(agentPortBlockStats.ip, ip), + eq(agentPortBlockStats.port, port), + eq(agentPortBlockStats.protocol, protocol), + ), + ) + .get() + + if (!existing) { + db.insert(agentPortBlockStats) + .values({ + id: crypto.randomUUID(), + agentId, + ip, + port, + protocol, + 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(agentPortBlockStats) + .set({ + packets, + lastReportedPackets: reported, + ...(delta > 0 ? { lastSeenAt: now } : {}), + }) + .where(eq(agentPortBlockStats.id, existing.id)) + .run() + } +} + +export type PortBlockAggregateRow = { + port: number + protocol: string + packets: number + lastSeenAt: string +} + +export function listPortBlockStatsAggregate( + db: Db, + agentId: string, + limit = 50, +): PortBlockAggregateRow[] { + const rows = db + .select({ + port: agentPortBlockStats.port, + protocol: agentPortBlockStats.protocol, + packets: sql`sum(${agentPortBlockStats.packets})`.mapWith(Number), + lastSeenAt: sql`max(${agentPortBlockStats.lastSeenAt})`, + }) + .from(agentPortBlockStats) + .where(eq(agentPortBlockStats.agentId, agentId)) + .groupBy(agentPortBlockStats.port, agentPortBlockStats.protocol) + .orderBy(sql`sum(${agentPortBlockStats.packets}) desc`) + .limit(limit) + .all() + return rows.map((r) => ({ + port: r.port, + protocol: r.protocol, + packets: r.packets ?? 0, + lastSeenAt: r.lastSeenAt, + })) +} + +export type PortBlockPerIpRow = { + ip: string + port: number + protocol: string + packets: number +} + +/** Raw rows for a set of IPs (caller picks top-N per IP). */ +export function listPortBlockStatsForIps( + db: Db, + agentId: string, + ips: string[], +): PortBlockPerIpRow[] { + if (ips.length === 0) return [] + return db + .select({ + ip: agentPortBlockStats.ip, + port: agentPortBlockStats.port, + protocol: agentPortBlockStats.protocol, + packets: agentPortBlockStats.packets, + }) + .from(agentPortBlockStats) + .where( + and( + eq(agentPortBlockStats.agentId, agentId), + inArray(agentPortBlockStats.ip, ips), + ), + ) + .orderBy(desc(agentPortBlockStats.packets)) + .all() +} + +/** Top `perIpLimit` ports per IP, keyed by IP. */ +export function mapTopPortsByIp( + db: Db, + agentId: string, + ips: string[], + perIpLimit = 5, +): Map { + const all = listPortBlockStatsForIps(db, agentId, ips) + const map = new Map() + for (const row of all) { + const list = map.get(row.ip) ?? [] + if (list.length >= perIpLimit) continue + list.push(row) + map.set(row.ip, list) + } + return map +} + +export function deletePortBlockStatsForAgent(db: Db, agentId: string) { + db.delete(agentPortBlockStats) + .where(eq(agentPortBlockStats.agentId, agentId)) + .run() +} + +export function resetPortBlockStatsBaselines(db: Db, agentId: string) { + db.update(agentPortBlockStats) + .set({ lastReportedPackets: 0 }) + .where(eq(agentPortBlockStats.agentId, agentId)) + .run() +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index d7b0a47..c85c28f 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -228,6 +228,42 @@ export const agentIpBlockStats = sqliteTable( }), ) +/** Per-(ip, proto, dport) deny hits from Linux nft dynamic concat set. */ +export const agentPortBlockStats = sqliteTable( + 'agent_port_block_stats', + { + id: text('id').primaryKey(), + agentId: text('agent_id') + .notNull() + .references(() => agents.id, { onDelete: 'cascade' }), + ip: text('ip').notNull(), + port: integer('port').notNull(), + protocol: text('protocol').notNull(), // tcp | udp + 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) => ({ + agentIpPortProto: uniqueIndex( + 'idx_agent_port_block_stats_agent_ip_port_proto', + ).on(t.agentId, t.ip, t.port, t.protocol), + agentPackets: index('idx_agent_port_block_stats_agent_packets').on( + t.agentId, + t.packets, + ), + agentPortProto: index('idx_agent_port_block_stats_agent_port_proto').on( + t.agentId, + t.port, + t.protocol, + ), + }), +) + /** Short install invite links (`/agent-install/:id` and `/:slug`). */ export const agentInstallLinks = sqliteTable( 'agent_install_links', @@ -291,6 +327,7 @@ export const schema = { ipOverrides, agentStatsSamples, agentIpBlockStats, + agentPortBlockStats, agentInstallLinks, auditLog, } diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 580fc8d..87d7112 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -226,6 +226,13 @@ export const applyReportIpHitSchema = z.object({ packets: z.number().int().nonnegative(), }) +export const applyReportPortHitSchema = z.object({ + ip: z.string().min(1).max(64), + port: z.number().int().min(1).max(65535), + protocol: z.enum(['tcp', 'udp']), + packets: z.number().int().nonnegative(), +}) + export const applyReportBodySchema = z.object({ status: z.string(), prefix_count: z.number().int().optional(), @@ -236,6 +243,14 @@ export const applyReportBodySchema = z.object({ source: z.string().optional(), /** Linux nft/ipset per-element drop counters (top-N, packets > 0). */ ip_hits: z.array(applyReportIpHitSchema).max(200).optional(), + /** Linux nft dynamic set per-(ip, proto, dport) deny hits (top-N). */ + port_hits: z.array(applyReportPortHitSchema).max(500).optional(), +}) + +export const agentIpPortStatSchema = z.object({ + port: z.number().int(), + protocol: z.enum(['tcp', 'udp']), + packets: z.number().int(), }) export const agentIpBlockStatSchema = z.object({ @@ -243,6 +258,14 @@ export const agentIpBlockStatSchema = z.object({ packets: z.number().int(), first_seen_at: z.string(), last_seen_at: z.string(), + ports: z.array(agentIpPortStatSchema).optional(), +}) + +export const agentPortBlockStatSchema = z.object({ + port: z.number().int(), + protocol: z.enum(['tcp', 'udp']), + packets: z.number().int(), + last_seen_at: z.string(), }) export const agentPolicySchema = z.object({