feat(api, web): add Linux nft destination port hits for blocked IPs
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m59s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

Track tcp/udp dports via deny_port_hits, expose aggregate and per-IP ports in UI; install-link re-run refreshes nft rules.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-11 00:33:03 +07:00
co-authored by Cursor
parent 402182195f
commit c5069fbdaf
16 changed files with 897 additions and 30 deletions
@@ -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)
})
})