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:
@@ -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