feat(api, web): implement per-IP blocked stats for agents
- 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:
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 пропускается
|
||||
|
||||
+73
-1
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user