feat(api, web): add Linux nft destination port hits for blocked IPs
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user