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 <cursoragent@cursor.com>
This commit is contained in:
@@ -8,6 +8,7 @@ STATE_DIR=/var/lib/evofw
|
||||
HASH_FILE="${STATE_DIR}/last_hash"
|
||||
POLICY_FILE="${STATE_DIR}/last_policy.json"
|
||||
IP_HITS_TOP=200
|
||||
PORT_HITS_TOP=500
|
||||
|
||||
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" | tee -a "$LOG_FILE"; }
|
||||
|
||||
@@ -99,6 +100,9 @@ PACKETS_ACCEPTED=0
|
||||
KERNEL_METHOD=""
|
||||
APPLIED=0
|
||||
IP_HITS_JSON="[]"
|
||||
PORT_HITS_JSON="[]"
|
||||
# 1 when deny_port_hits dynamic set is available for this apply.
|
||||
PORT_HITS_ENABLED=0
|
||||
|
||||
nft_join() {
|
||||
local out="" p
|
||||
@@ -223,18 +227,110 @@ collect_ip_hits() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Parse nft dynamic concat set → [{"ip","port","protocol","packets"},...]
|
||||
build_port_hits_json() {
|
||||
local text="$1"
|
||||
PORT_HITS_JSON="[]"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
PORT_HITS_JSON=$(PORT_HITS_TOP="$PORT_HITS_TOP" python3 -c '
|
||||
import json, os, re, sys
|
||||
text = sys.stdin.read()
|
||||
top = int(os.environ.get("PORT_HITS_TOP", "500"))
|
||||
hits = {}
|
||||
# Elements look like:
|
||||
# 1.2.3.4 . tcp . 22 counter packets 10 bytes 100
|
||||
# 1.2.3.4 . 6 . 443 timeout 1h counter packets 5 bytes 20
|
||||
proto_map = {"6": "tcp", "17": "udp", "tcp": "tcp", "udp": "udp"}
|
||||
pat = re.compile(
|
||||
r"([0-9]{1,3}(?:\.[0-9]{1,3}){3})\s*\.\s*([A-Za-z0-9]+)\s*\.\s*(\d+)\s+"
|
||||
r"(?:timeout\s+\S+\s+)?(?:counter\s+)?packets\s+(\d+)",
|
||||
re.I,
|
||||
)
|
||||
for m in pat.finditer(text):
|
||||
ip, raw_proto, port_s, pkts_s = m.group(1), m.group(2).lower(), m.group(3), m.group(4)
|
||||
proto = proto_map.get(raw_proto)
|
||||
if not proto:
|
||||
continue
|
||||
pkts = int(pkts_s)
|
||||
if pkts <= 0:
|
||||
continue
|
||||
port = int(port_s)
|
||||
if port < 1 or port > 65535:
|
||||
continue
|
||||
key = (ip, port, proto)
|
||||
hits[key] = max(hits.get(key, 0), pkts)
|
||||
items = [
|
||||
{"ip": ip, "port": port, "protocol": proto, "packets": pkts}
|
||||
for (ip, port, proto), pkts in hits.items()
|
||||
]
|
||||
items.sort(key=lambda x: x["packets"], reverse=True)
|
||||
print(json.dumps(items[:top], separators=(",", ":")))
|
||||
' <<<"$text" 2>/dev/null) || PORT_HITS_JSON="[]"
|
||||
return
|
||||
fi
|
||||
PORT_HITS_JSON="[]"
|
||||
}
|
||||
|
||||
collect_nft_port_hits() {
|
||||
local text
|
||||
PORT_HITS_JSON="[]"
|
||||
text=$(nft list set inet evofw deny_port_hits 2>/dev/null || true)
|
||||
[[ -z "$text" ]] && return
|
||||
build_port_hits_json "$text"
|
||||
}
|
||||
|
||||
collect_port_hits() {
|
||||
PORT_HITS_JSON="[]"
|
||||
if [[ "${KERNEL_METHOD:-}" == "nft" ]] || { [[ -z "${KERNEL_METHOD:-}" || "${KERNEL_METHOD:-}" == "auto" ]] && command -v nft >/dev/null 2>&1 && nft list set inet evofw deny_port_hits >/dev/null 2>&1; }; then
|
||||
collect_nft_port_hits
|
||||
fi
|
||||
}
|
||||
|
||||
# Dynamic concat set for per-(ip, proto, dport) deny hits. Returns 0 if usable.
|
||||
ensure_nft_port_hits_set() {
|
||||
local table=$1 name=$2
|
||||
local setname=deny_port_hits
|
||||
local def
|
||||
def=$(nft list set "$table" "$name" "$setname" 2>/dev/null || true)
|
||||
if [[ -n "$def" ]] && { [[ "$def" == *"dynamic"* ]] || [[ "$def" == *"timeout"* ]]; }; then
|
||||
# Keep existing; elements age out via timeout — do not flush on every apply
|
||||
# (counters survive policy CIDR refresh when chain is recreated).
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "$def" ]]; then
|
||||
nft delete set "$table" "$name" "$setname" 2>>"$LOG_FILE" || true
|
||||
fi
|
||||
if nft add set "$table" "$name" "$setname" \
|
||||
'{ type ipv4_addr . inet_proto . inet_service; flags dynamic,timeout; timeout 1h; counter; }' \
|
||||
2>>"$LOG_FILE"; then
|
||||
return 0
|
||||
fi
|
||||
# Older kernels may need slightly different flag spelling.
|
||||
if nft add set "$table" "$name" "$setname" \
|
||||
'{ type ipv4_addr . inet_proto . inet_service; flags dynamic; timeout 1h; counter; }' \
|
||||
2>>"$LOG_FILE"; then
|
||||
return 0
|
||||
fi
|
||||
log "nft: deny_port_hits unsupported — port hits disabled"
|
||||
return 1
|
||||
}
|
||||
|
||||
apply_nft() {
|
||||
local table=inet name=evofw
|
||||
local deny_v4=() allow_v4=() p
|
||||
PORT_HITS_ENABLED=0
|
||||
for p in "${DENY[@]+"${DENY[@]}"}"; do [[ "$p" == *:* ]] && continue; deny_v4+=("$p"); done
|
||||
for p in "${ALLOW[@]+"${ALLOW[@]}"}"; do [[ "$p" == *:* ]] && continue; allow_v4+=("$p"); done
|
||||
|
||||
nft list table "$table" "$name" >/dev/null 2>&1 || nft add table "$table" "$name"
|
||||
# Drop chain first so sets can be deleted/recreated (upgrade to counters).
|
||||
# Drop chain first so sets can be deleted/recreated (upgrade to counters / port hits).
|
||||
# Stats were already captured by the caller before apply_nft.
|
||||
nft delete chain "$table" "$name" input 2>/dev/null || true
|
||||
ensure_nft_set "$table" "$name" deny_v4
|
||||
ensure_nft_set "$table" "$name" allow_v4
|
||||
if ensure_nft_port_hits_set "$table" "$name"; then
|
||||
PORT_HITS_ENABLED=1
|
||||
fi
|
||||
nft flush set "$table" "$name" deny_v4 2>>"$LOG_FILE" || true
|
||||
nft flush set "$table" "$name" allow_v4 2>>"$LOG_FILE" || true
|
||||
|
||||
@@ -259,7 +355,21 @@ apply_nft() {
|
||||
fi
|
||||
nft add rule "$table" "$name" input ct state established,related counter accept
|
||||
nft add rule "$table" "$name" input iif lo counter accept
|
||||
nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop
|
||||
if [[ "$PORT_HITS_ENABLED" -eq 1 ]]; then
|
||||
# TCP/UDP: learn (ip, proto, dport) then drop; other L4: plain drop.
|
||||
if ! nft add rule "$table" "$name" input \
|
||||
ip saddr @deny_v4 meta l4proto '{ tcp, udp }' \
|
||||
update @deny_port_hits '{ ip saddr . meta l4proto . th dport }' \
|
||||
counter drop 2>>"$LOG_FILE"; then
|
||||
log "nft: port-hit deny rule failed — fallback to plain deny drop"
|
||||
PORT_HITS_ENABLED=0
|
||||
nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop
|
||||
else
|
||||
nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop
|
||||
fi
|
||||
else
|
||||
nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop
|
||||
fi
|
||||
nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept
|
||||
if [[ "$DEFAULT_ACTION" == "drop" ]]; then
|
||||
nft add rule "$table" "$name" input counter drop
|
||||
@@ -323,9 +433,12 @@ send_report() {
|
||||
if [[ -z "${IP_HITS_CAPTURED:-}" ]]; then
|
||||
collect_ip_hits
|
||||
fi
|
||||
if [[ -z "${PORT_HITS_CAPTURED:-}" ]]; then
|
||||
collect_port_hits
|
||||
fi
|
||||
local report
|
||||
report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent","ip_hits":%s}' \
|
||||
"${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}" "${IP_HITS_JSON:-[]}")
|
||||
report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent","ip_hits":%s,"port_hits":%s}' \
|
||||
"${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}" "${IP_HITS_JSON:-[]}" "${PORT_HITS_JSON:-[]}")
|
||||
curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/apply-report" \
|
||||
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -363,11 +476,15 @@ fi
|
||||
if command -v nft >/dev/null 2>&1 && nft list table inet evofw >/dev/null 2>&1; then
|
||||
collect_nft_stats
|
||||
collect_nft_ip_hits
|
||||
collect_nft_port_hits
|
||||
STATS_CAPTURED=1
|
||||
IP_HITS_CAPTURED=1
|
||||
PORT_HITS_CAPTURED=1
|
||||
elif command -v ipset >/dev/null 2>&1 && ipset list evofw_deny_v4 >/dev/null 2>&1; then
|
||||
collect_ipset_ip_hits
|
||||
IP_HITS_CAPTURED=1
|
||||
PORT_HITS_JSON="[]"
|
||||
PORT_HITS_CAPTURED=1
|
||||
fi
|
||||
|
||||
case "$BACKEND" in
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# EvoFirewall Linux install one-liner
|
||||
# Re-run on an already-installed host updates scripts/timer and keeps credentials
|
||||
# Re-run on an already-installed host updates scripts/timer **and forces nft
|
||||
# rule re-apply** (clears last_hash), keeping credentials
|
||||
# (unless EVOFW_INSTALL_FORCE=1 → full re-enroll).
|
||||
set -euo pipefail
|
||||
|
||||
@@ -250,10 +251,10 @@ if [[ -f "$CONF_FILE" && "${EVOFW_INSTALL_FORCE:-}" != "1" ]]; then
|
||||
download_sync_script "$SYNC_TMP" || exit 1
|
||||
write_conf "$CLIENT_ID" "$CLIENT_TOKEN" "$CLIENT_NAME" "$BACKEND"
|
||||
install_sync_and_uninstall "$SYNC_TMP"
|
||||
# Force one apply after script refresh (nft set upgrade, counters, etc.).
|
||||
# Force one apply after script refresh (nft set upgrade, counters, port hits, etc.).
|
||||
rm -f /var/lib/evofw/last_hash
|
||||
enable_scheduler_and_run
|
||||
echo "Updated. Client id=${CLIENT_ID}. Sync script + timer refreshed."
|
||||
echo "Updated. Client id=${CLIENT_ID}. Sync script + timer refreshed; nft rules re-applied."
|
||||
echo "Force sync: $SYNC_SCRIPT"
|
||||
echo "Uninstall: $UNINSTALL_SCRIPT (or: curl -fsSL ${CP_URL}/v1/agent/uninstall.sh | bash)"
|
||||
exit 0
|
||||
|
||||
@@ -201,6 +201,7 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
|
||||
// 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).
|
||||
// Port hits use a separate dynamic set that is not flushed on apply — leave baselines.
|
||||
if (reportedDropped < prevDropped) {
|
||||
repos.resetIpBlockStatsBaselines(app.db, agentId)
|
||||
}
|
||||
@@ -233,6 +234,9 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
mode: presence ? 'presence' : 'absolute',
|
||||
})
|
||||
}
|
||||
if (body.port_hits?.length && body.source !== 'mikrotik') {
|
||||
repos.upsertPortBlockStats(app.db, agentId, body.port_hits, now)
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
|
||||
@@ -28,12 +28,40 @@ export const statsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const items = repos.listIpBlockStats(app.db, agent.id)
|
||||
const portsByIp = repos.mapTopPortsByIp(
|
||||
app.db,
|
||||
agent.id,
|
||||
items.map((s) => s.ip),
|
||||
5,
|
||||
)
|
||||
return {
|
||||
items: repos.listIpBlockStats(app.db, agent.id).map((s) => ({
|
||||
items: items.map((s) => ({
|
||||
ip: s.ip,
|
||||
packets: s.packets,
|
||||
first_seen_at: s.firstSeenAt,
|
||||
last_seen_at: s.lastSeenAt,
|
||||
ports: (portsByIp.get(s.ip) ?? []).map((p) => ({
|
||||
port: p.port,
|
||||
protocol: p.protocol,
|
||||
packets: p.packets,
|
||||
})),
|
||||
})),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/agents/:id/blocked-ports',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
return {
|
||||
items: repos.listPortBlockStatsAggregate(app.db, agent.id, 50).map((s) => ({
|
||||
port: s.port,
|
||||
protocol: s.protocol,
|
||||
packets: s.packets,
|
||||
last_seen_at: s.lastSeenAt,
|
||||
})),
|
||||
}
|
||||
},
|
||||
@@ -52,6 +80,7 @@ export const statsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
})
|
||||
repos.deleteStatsSamplesForAgent(app.db, agent.id)
|
||||
repos.deleteIpBlockStatsForAgent(app.db, agent.id)
|
||||
repos.deletePortBlockStatsForAgent(app.db, agent.id)
|
||||
auditMutation(app, config, req, {
|
||||
action: 'agent.stats_reset',
|
||||
severity: 'info',
|
||||
|
||||
@@ -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