feat(api, test, web): enhance IP hit tracking and reset logic for agents
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m26s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Implemented a reset mechanism for per-IP baselines in the agent routes, ensuring accurate tracking after policy application.
- Updated tests to simulate traffic flush scenarios, verifying that IP hit statistics reset correctly and accumulate as expected.
- Modified the UI to reflect changes in terminology from "Sync windows" to "Hits" for better clarity in agent details.
- Enhanced documentation to explain the new behavior of IP hit tracking and baseline resets, improving user understanding.

These changes improve the accuracy and usability of IP hit tracking for agents, particularly in scenarios involving policy changes.
This commit is contained in:
Denozordec
2026-08-07 15:44:27 +07:00
parent 3815575799
commit 402182195f
6 changed files with 171 additions and 44 deletions
+6
View File
@@ -199,6 +199,12 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
const totalDropped = (prev?.totalPacketsDropped ?? 0) + deltaDropped
const totalAccepted = (prev?.totalPacketsAccepted ?? 0) + deltaAccepted
// 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).
if (reportedDropped < prevDropped) {
repos.resetIpBlockStatsBaselines(app.db, agentId)
}
repos.updateAgent(app.db, agentId, {
lastApplyAt: now,
lastApplyStatus: body.status,
+120 -33
View File
@@ -140,6 +140,73 @@ describe('apply-report ip_hits / blocked-ips', () => {
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)
// Simulate nft flush: Traffic absolute drops; ip_hits omit zeros → baselines must reset
const reportReset = 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: 0,
packets_accepted: 0,
kernel_method: 'nft',
source: 'agent',
ip_hits: [],
},
})
expect(reportReset.statusCode).toBe(200)
const report3 = 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: 7,
packets_accepted: 0,
kernel_method: 'nft',
source: 'agent',
ip_hits: [
{ ip: '203.0.113.10', packets: 4 },
{ ip: '198.51.100.0/24', packets: 3 },
],
},
})
expect(report3.statusCode).toBe(200)
const listAfterFlush = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}/blocked-ips`,
})
const bodyFlush = listAfterFlush.json() as {
items: { ip: string; packets: number }[]
}
// First epoch 18+5 plus second epoch 4+3
expect(bodyFlush.items.find((i) => i.ip === '203.0.113.10')?.packets).toBe(
22,
)
expect(
bodyFlush.items.find((i) => i.ip === '198.51.100.0/24')?.packets,
).toBe(8)
const agentAfter = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}`,
})
const agentBody = agentAfter.json() as {
total_packets_dropped?: number
}
// Traffic: 25 + 0 + 7 = 32
expect(agentBody.total_packets_dropped).toBe(32)
const reset = await app.inject({
method: 'POST',
url: `/api/v1/agents/${agentId}/stats/reset`,
@@ -186,7 +253,7 @@ describe('apply-report ip_hits / blocked-ips', () => {
expect(report.statusCode).toBeGreaterThanOrEqual(400)
})
it('mikrotik presence mode increments packets and refreshes last_seen', async () => {
it('mikrotik presence: continuous sync refreshes last_seen without +1; rehit after stale gap', async () => {
const app = await appPromise
await app.ready()
@@ -217,22 +284,27 @@ describe('apply-report ip_hits / blocked-ips', () => {
url: `/api/v1/agents/${link.agent_id}/approve`,
})
const report1 = await app.inject({
method: 'POST',
url: '/v1/agent/apply-report',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
payload: {
status: 'ok',
packets_dropped: 3,
kernel_method: 'address-list',
source: 'mikrotik',
ip_hits: [{ ip: '203.0.113.50', packets: 1 }],
},
const reportPayload = (dropped: number) => ({
status: 'ok',
packets_dropped: dropped,
kernel_method: 'address-list',
source: 'mikrotik',
ip_hits: [{ ip: '203.0.113.50', packets: 1 }],
})
expect(report1.statusCode).toBe(200)
expect(
(
await app.inject({
method: 'POST',
url: '/v1/agent/apply-report',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
payload: reportPayload(3),
})
).statusCode,
).toBe(200)
const list1 = await app.inject({
method: 'GET',
@@ -247,22 +319,19 @@ describe('apply-report ip_hits / blocked-ips', () => {
await new Promise((r) => setTimeout(r, 5))
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: 5,
kernel_method: 'address-list',
source: 'mikrotik',
ip_hits: [{ ip: '203.0.113.50', packets: 1 }],
},
})
expect(report2.statusCode).toBe(200)
expect(
(
await app.inject({
method: 'POST',
url: '/v1/agent/apply-report',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
payload: reportPayload(5),
})
).statusCode,
).toBe(200)
const list2 = await app.inject({
method: 'GET',
@@ -271,7 +340,25 @@ describe('apply-report ip_hits / blocked-ips', () => {
const body2 = list2.json() as {
items: { ip: string; packets: number; last_seen_at: string }[]
}
expect(body2.items[0]?.packets).toBe(2)
expect(body2.items[0]?.packets).toBe(1)
expect(body2.items[0]!.last_seen_at >= firstSeen).toBe(true)
// Re-entry after stale gap (> PRESENCE_REHIT_STALE_MS): simulate via future `now`
const { repos } = await import('@evofw/db')
repos.upsertIpBlockStats(
app.db,
link.agent_id,
[{ ip: '203.0.113.50', packets: 1 }],
new Date(Date.now() + 200_000).toISOString(),
{ mode: 'presence' },
)
const list3 = await app.inject({
method: 'GET',
url: `/api/v1/agents/${link.agent_id}/blocked-ips`,
})
expect(
(list3.json() as { items: { packets: number }[] }).items[0]?.packets,
).toBe(2)
})
})