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

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

These changes provide a comprehensive view of blocked IPs, improving the overall management and monitoring of agents.
This commit is contained in:
Denozordec
2026-08-07 14:15:10 +07:00
parent 9fd7ddb26c
commit 4ee78032c4
14 changed files with 743 additions and 15 deletions
@@ -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)
})
})