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:
@@ -8,6 +8,7 @@ STATE_DIR=/var/lib/evofw
|
|||||||
HASH_FILE="${STATE_DIR}/last_hash"
|
HASH_FILE="${STATE_DIR}/last_hash"
|
||||||
POLICY_FILE="${STATE_DIR}/last_policy.json"
|
POLICY_FILE="${STATE_DIR}/last_policy.json"
|
||||||
IP_HITS_TOP=200
|
IP_HITS_TOP=200
|
||||||
|
PORT_HITS_TOP=500
|
||||||
|
|
||||||
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" | tee -a "$LOG_FILE"; }
|
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" | tee -a "$LOG_FILE"; }
|
||||||
|
|
||||||
@@ -99,6 +100,9 @@ PACKETS_ACCEPTED=0
|
|||||||
KERNEL_METHOD=""
|
KERNEL_METHOD=""
|
||||||
APPLIED=0
|
APPLIED=0
|
||||||
IP_HITS_JSON="[]"
|
IP_HITS_JSON="[]"
|
||||||
|
PORT_HITS_JSON="[]"
|
||||||
|
# 1 when deny_port_hits dynamic set is available for this apply.
|
||||||
|
PORT_HITS_ENABLED=0
|
||||||
|
|
||||||
nft_join() {
|
nft_join() {
|
||||||
local out="" p
|
local out="" p
|
||||||
@@ -223,18 +227,110 @@ collect_ip_hits() {
|
|||||||
fi
|
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() {
|
apply_nft() {
|
||||||
local table=inet name=evofw
|
local table=inet name=evofw
|
||||||
local deny_v4=() allow_v4=() p
|
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 "${DENY[@]+"${DENY[@]}"}"; do [[ "$p" == *:* ]] && continue; deny_v4+=("$p"); done
|
||||||
for p in "${ALLOW[@]+"${ALLOW[@]}"}"; do [[ "$p" == *:* ]] && continue; allow_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"
|
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.
|
# Stats were already captured by the caller before apply_nft.
|
||||||
nft delete chain "$table" "$name" input 2>/dev/null || true
|
nft delete chain "$table" "$name" input 2>/dev/null || true
|
||||||
ensure_nft_set "$table" "$name" deny_v4
|
ensure_nft_set "$table" "$name" deny_v4
|
||||||
ensure_nft_set "$table" "$name" allow_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" deny_v4 2>>"$LOG_FILE" || true
|
||||||
nft flush set "$table" "$name" allow_v4 2>>"$LOG_FILE" || true
|
nft flush set "$table" "$name" allow_v4 2>>"$LOG_FILE" || true
|
||||||
|
|
||||||
@@ -259,7 +355,21 @@ apply_nft() {
|
|||||||
fi
|
fi
|
||||||
nft add rule "$table" "$name" input ct state established,related counter accept
|
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 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
|
nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept
|
||||||
if [[ "$DEFAULT_ACTION" == "drop" ]]; then
|
if [[ "$DEFAULT_ACTION" == "drop" ]]; then
|
||||||
nft add rule "$table" "$name" input counter drop
|
nft add rule "$table" "$name" input counter drop
|
||||||
@@ -323,9 +433,12 @@ send_report() {
|
|||||||
if [[ -z "${IP_HITS_CAPTURED:-}" ]]; then
|
if [[ -z "${IP_HITS_CAPTURED:-}" ]]; then
|
||||||
collect_ip_hits
|
collect_ip_hits
|
||||||
fi
|
fi
|
||||||
|
if [[ -z "${PORT_HITS_CAPTURED:-}" ]]; then
|
||||||
|
collect_port_hits
|
||||||
|
fi
|
||||||
local report
|
local report
|
||||||
report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent","ip_hits":%s}' \
|
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:-[]}")
|
"${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" \
|
curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/apply-report" \
|
||||||
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
|
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-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
|
if command -v nft >/dev/null 2>&1 && nft list table inet evofw >/dev/null 2>&1; then
|
||||||
collect_nft_stats
|
collect_nft_stats
|
||||||
collect_nft_ip_hits
|
collect_nft_ip_hits
|
||||||
|
collect_nft_port_hits
|
||||||
STATS_CAPTURED=1
|
STATS_CAPTURED=1
|
||||||
IP_HITS_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
|
elif command -v ipset >/dev/null 2>&1 && ipset list evofw_deny_v4 >/dev/null 2>&1; then
|
||||||
collect_ipset_ip_hits
|
collect_ipset_ip_hits
|
||||||
IP_HITS_CAPTURED=1
|
IP_HITS_CAPTURED=1
|
||||||
|
PORT_HITS_JSON="[]"
|
||||||
|
PORT_HITS_CAPTURED=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
case "$BACKEND" in
|
case "$BACKEND" in
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# EvoFirewall Linux install one-liner
|
# 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).
|
# (unless EVOFW_INSTALL_FORCE=1 → full re-enroll).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -250,10 +251,10 @@ if [[ -f "$CONF_FILE" && "${EVOFW_INSTALL_FORCE:-}" != "1" ]]; then
|
|||||||
download_sync_script "$SYNC_TMP" || exit 1
|
download_sync_script "$SYNC_TMP" || exit 1
|
||||||
write_conf "$CLIENT_ID" "$CLIENT_TOKEN" "$CLIENT_NAME" "$BACKEND"
|
write_conf "$CLIENT_ID" "$CLIENT_TOKEN" "$CLIENT_NAME" "$BACKEND"
|
||||||
install_sync_and_uninstall "$SYNC_TMP"
|
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
|
rm -f /var/lib/evofw/last_hash
|
||||||
enable_scheduler_and_run
|
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 "Force sync: $SYNC_SCRIPT"
|
||||||
echo "Uninstall: $UNINSTALL_SCRIPT (or: curl -fsSL ${CP_URL}/v1/agent/uninstall.sh | bash)"
|
echo "Uninstall: $UNINSTALL_SCRIPT (or: curl -fsSL ${CP_URL}/v1/agent/uninstall.sh | bash)"
|
||||||
exit 0
|
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
|
// 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).
|
// 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) {
|
if (reportedDropped < prevDropped) {
|
||||||
repos.resetIpBlockStatsBaselines(app.db, agentId)
|
repos.resetIpBlockStatsBaselines(app.db, agentId)
|
||||||
}
|
}
|
||||||
@@ -233,6 +234,9 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
mode: presence ? 'presence' : 'absolute',
|
mode: presence ? 'presence' : 'absolute',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (body.port_hits?.length && body.source !== 'mikrotik') {
|
||||||
|
repos.upsertPortBlockStats(app.db, agentId, body.port_hits, now)
|
||||||
|
}
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,40 @@ export const statsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
async (req) => {
|
async (req) => {
|
||||||
const agent = repos.getAgent(app.db, req.params.id)
|
const agent = repos.getAgent(app.db, req.params.id)
|
||||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
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 {
|
return {
|
||||||
items: repos.listIpBlockStats(app.db, agent.id).map((s) => ({
|
items: items.map((s) => ({
|
||||||
ip: s.ip,
|
ip: s.ip,
|
||||||
packets: s.packets,
|
packets: s.packets,
|
||||||
first_seen_at: s.firstSeenAt,
|
first_seen_at: s.firstSeenAt,
|
||||||
last_seen_at: s.lastSeenAt,
|
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.deleteStatsSamplesForAgent(app.db, agent.id)
|
||||||
repos.deleteIpBlockStatsForAgent(app.db, agent.id)
|
repos.deleteIpBlockStatsForAgent(app.db, agent.id)
|
||||||
|
repos.deletePortBlockStatsForAgent(app.db, agent.id)
|
||||||
auditMutation(app, config, req, {
|
auditMutation(app, config, req, {
|
||||||
action: 'agent.stats_reset',
|
action: 'agent.stats_reset',
|
||||||
severity: 'info',
|
severity: 'info',
|
||||||
|
|||||||
@@ -362,3 +362,157 @@ describe('apply-report ip_hits / blocked-ips', () => {
|
|||||||
).toBe(2)
|
).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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -26,11 +26,18 @@ import { Skeleton } from '@evofw/ui/components/skeleton'
|
|||||||
* · https://reui.io/preview/base/empty-state-12
|
* · https://reui.io/preview/base/empty-state-12
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export type BlockedIpPort = {
|
||||||
|
port: number
|
||||||
|
protocol: string
|
||||||
|
packets: number
|
||||||
|
}
|
||||||
|
|
||||||
export type BlockedIpRow = {
|
export type BlockedIpRow = {
|
||||||
ip: string
|
ip: string
|
||||||
packets: number
|
packets: number
|
||||||
first_seen_at: string
|
first_seen_at: string
|
||||||
last_seen_at: string
|
last_seen_at: string
|
||||||
|
ports?: BlockedIpPort[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentBlockedIpsProps = {
|
type AgentBlockedIpsProps = {
|
||||||
@@ -54,17 +61,25 @@ function formatSeen(iso: string): string {
|
|||||||
return seenFmt.format(t)
|
return seenFmt.format(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPorts(ports: BlockedIpPort[] | undefined): string {
|
||||||
|
if (!ports?.length) return '—'
|
||||||
|
return ports
|
||||||
|
.map((p) => `${p.protocol}/${p.port}`)
|
||||||
|
.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
||||||
const isMikrotik = platform === 'mikrotik'
|
const isMikrotik = platform === 'mikrotik'
|
||||||
|
const showPorts = !isMikrotik
|
||||||
const q = useQuery(agentBlockedIpsQueryOptions(agentId))
|
const q = useQuery(agentBlockedIpsQueryOptions(agentId))
|
||||||
|
|
||||||
const packetsTitle = isMikrotik ? 'Hits' : 'Packets'
|
const packetsTitle = isMikrotik ? 'Hits' : 'Packets'
|
||||||
const description = isMikrotik
|
const description = isMikrotik
|
||||||
? 'Src /32 из EVOFW_HITS (add-src при deny, timeout 1h). Hits — входы в список (не каждый sync); Last seen обновляется, пока IP в hits.'
|
? 'Src /32 из EVOFW_HITS (add-src при deny, timeout 1h). Hits — входы в список (не каждый sync); Last seen обновляется, пока IP в hits.'
|
||||||
: 'Drop-пакеты по записям deny (nft/ipset). Top по накопленным packets.'
|
: 'Drop-пакеты по записям deny (nft/ipset). Top по накопленным packets. Ports — top-5 dport (nft).'
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<BlockedIpRow>[]>(
|
const columns = useMemo<ColumnDef<BlockedIpRow>[]>(() => {
|
||||||
() => [
|
const cols: ColumnDef<BlockedIpRow>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'ip',
|
accessorKey: 'ip',
|
||||||
id: 'ip',
|
id: 'ip',
|
||||||
@@ -89,22 +104,37 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
|||||||
),
|
),
|
||||||
meta: { headerTitle: packetsTitle },
|
meta: { headerTitle: packetsTitle },
|
||||||
},
|
},
|
||||||
{
|
]
|
||||||
accessorKey: 'last_seen_at',
|
if (showPorts) {
|
||||||
id: 'last_seen_at',
|
cols.push({
|
||||||
|
id: 'ports',
|
||||||
|
accessorFn: (row) => formatPorts(row.ports),
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Last seen" />
|
<DataGridColumnHeader column={column} title="Ports" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-muted-foreground text-xs tabular-nums">
|
<span className="font-mono text-muted-foreground text-xs">
|
||||||
{formatSeen(row.original.last_seen_at)}
|
{formatPorts(row.original.ports)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Last seen' },
|
meta: { headerTitle: 'Ports' },
|
||||||
},
|
})
|
||||||
],
|
}
|
||||||
[packetsTitle],
|
cols.push({
|
||||||
)
|
accessorKey: 'last_seen_at',
|
||||||
|
id: 'last_seen_at',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Last seen" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
{formatSeen(row.original.last_seen_at)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Last seen' },
|
||||||
|
})
|
||||||
|
return cols
|
||||||
|
}, [packetsTitle, showPorts])
|
||||||
|
|
||||||
const data = q.data?.items ?? []
|
const data = q.data?.items ?? []
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type ColumnDef,
|
||||||
|
} from '@tanstack/react-table'
|
||||||
|
import { NetworkIcon } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||||
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
|
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { agentBlockedPortsQueryOptions } from '@/queries'
|
||||||
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate destination ports hit by denied sources (Linux nft).
|
||||||
|
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||||
|
* · https://reui.io/preview/base/empty-state-12
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type BlockedPortRow = {
|
||||||
|
port: number
|
||||||
|
protocol: string
|
||||||
|
packets: number
|
||||||
|
last_seen_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentBlockedPortsProps = {
|
||||||
|
agentId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const packetFmt = new Intl.NumberFormat('ru-RU')
|
||||||
|
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatSeen(iso: string): string {
|
||||||
|
const t = Date.parse(iso)
|
||||||
|
if (Number.isNaN(t)) return '—'
|
||||||
|
return seenFmt.format(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
|
||||||
|
const q = useQuery(agentBlockedPortsQueryOptions(agentId))
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<BlockedPortRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'port',
|
||||||
|
id: 'port',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Port" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs tabular-nums">
|
||||||
|
{row.original.port}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Port' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'protocol',
|
||||||
|
id: 'protocol',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Proto" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs uppercase">
|
||||||
|
{row.original.protocol}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Proto' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'packets',
|
||||||
|
id: 'packets',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Packets" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{packetFmt.format(row.original.packets)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Packets' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'last_seen_at',
|
||||||
|
id: 'last_seen_at',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Last seen" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
{formatSeen(row.original.last_seen_at)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Last seen' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = q.data?.items ?? []
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getRowId: (r) => `${r.protocol}/${r.port}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Top ports</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Destination ports (tcp/udp), в которые слали запросы blocked IP. nft
|
||||||
|
dynamic set deny_port_hits.
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="p-0">
|
||||||
|
{q.isLoading ? (
|
||||||
|
<div className="flex flex-col gap-2 p-4">
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-8 w-2/3" />
|
||||||
|
</div>
|
||||||
|
) : q.isError ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={NetworkIcon}
|
||||||
|
title="Не удалось загрузить"
|
||||||
|
description={q.error?.message ?? 'Ошибка API'}
|
||||||
|
centered={false}
|
||||||
|
className="py-8"
|
||||||
|
/>
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={NetworkIcon}
|
||||||
|
title="Пока нет port hit’ов"
|
||||||
|
description="Нужен nft + deny_port_hits. После drop с deny появятся tcp/udp dport. Re-run install-ссылки обновляет правила."
|
||||||
|
centered={false}
|
||||||
|
className="py-8"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataGrid
|
||||||
|
table={table}
|
||||||
|
recordCount={data.length}
|
||||||
|
tableLayout={{ dense: true }}
|
||||||
|
>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGrid>
|
||||||
|
)}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
|
|||||||
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
|
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
|
||||||
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
|
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
|
||||||
import { AgentBlockedIps } from '@/components/agents/agent-blocked-ips'
|
import { AgentBlockedIps } from '@/components/agents/agent-blocked-ips'
|
||||||
|
import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports'
|
||||||
import {
|
import {
|
||||||
AgentCloneSetsSheet,
|
AgentCloneSetsSheet,
|
||||||
AgentOverrideSheet,
|
AgentOverrideSheet,
|
||||||
@@ -104,6 +105,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] })
|
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] })
|
||||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ips'] })
|
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ips'] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ports'] })
|
||||||
void qc.invalidateQueries({ queryKey: ['stats'] })
|
void qc.invalidateQueries({ queryKey: ['stats'] })
|
||||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||||
},
|
},
|
||||||
@@ -320,6 +322,10 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
isLoading={previewQ.isLoading}
|
isLoading={previewQ.isLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{a.platform === 'linux' ? (
|
||||||
|
<AgentBlockedPorts agentId={agentId} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<AgentBlockedIps agentId={agentId} platform={a.platform} />
|
<AgentBlockedIps agentId={agentId} platform={a.platform} />
|
||||||
</div>
|
</div>
|
||||||
</DetailPanel.Section>
|
</DetailPanel.Section>
|
||||||
|
|||||||
@@ -175,10 +175,29 @@ export const agentBlockedIpsQueryOptions = (id: string) =>
|
|||||||
packets: number
|
packets: number
|
||||||
first_seen_at: string
|
first_seen_at: string
|
||||||
last_seen_at: string
|
last_seen_at: string
|
||||||
|
ports?: {
|
||||||
|
port: number
|
||||||
|
protocol: string
|
||||||
|
packets: number
|
||||||
|
}[]
|
||||||
}[]
|
}[]
|
||||||
}>(`/api/v1/agents/${id}/blocked-ips`),
|
}>(`/api/v1/agents/${id}/blocked-ips`),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const agentBlockedPortsQueryOptions = (id: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['agents', id, 'blocked-ports'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{
|
||||||
|
items: {
|
||||||
|
port: number
|
||||||
|
protocol: string
|
||||||
|
packets: number
|
||||||
|
last_seen_at: string
|
||||||
|
}[]
|
||||||
|
}>(`/api/v1/agents/${id}/blocked-ports`),
|
||||||
|
})
|
||||||
|
|
||||||
export const recentStatsQueryOptions = () =>
|
export const recentStatsQueryOptions = () =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['stats-recent'],
|
queryKey: ['stats-recent'],
|
||||||
|
|||||||
+16
-2
@@ -20,7 +20,7 @@ curl -fsSL https://<cp>/agent-install/<id> | bash
|
|||||||
3. После enroll статус станет **Pending** — одобрите агента (Approve).
|
3. После enroll статус станет **Pending** — одобрите агента (Approve).
|
||||||
4. **Approved** — агент синхронизирует политику.
|
4. **Approved** — агент синхронизирует политику.
|
||||||
|
|
||||||
**Повторный запуск той же install-ссылки** на хосте, где агент уже стоит: обновляет sync-скрипт / timer (или MikroTik scheduler), **без** повторного enroll — `CLIENT_ID`/`token` сохраняются. Полный переустановки с новым токеном: `EVOFW_INSTALL_FORCE=1` (Linux).
|
**Повторный запуск той же install-ссылки** на хосте, где агент уже стоит: обновляет sync-скрипт / timer (или MikroTik scheduler) **и пересоздаёт nft rules** (в т.ч. `deny_port_hits`), **без** повторного enroll — `CLIENT_ID`/`token` сохраняются. Сбрасывается `last_hash`, затем сразу force sync. Полный переустановки с новым токеном: `EVOFW_INSTALL_FORCE=1` (Linux).
|
||||||
|
|
||||||
API (auth): `POST /api/v1/install-links` `{ "name": "web-01", "platform": "linux" | "mikrotik" }`.
|
API (auth): `POST /api/v1/install-links` `{ "name": "web-01", "platform": "linux" | "mikrotik" }`.
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ Whitelist: nft chain policy drop + allow set. Blacklist: policy accept + deny se
|
|||||||
Linux agent reports optional `ip_hits` in `POST /v1/agent/apply-report`:
|
Linux agent reports optional `ip_hits` in `POST /v1/agent/apply-report`:
|
||||||
|
|
||||||
- **nft:** tries set `deny_v4` with `flags interval; counter;`. If the kernel rejects counters on interval sets, falls back to plain interval (aggregate Traffic ↓ still works; per-IP empty).
|
- **nft:** tries set `deny_v4` with `flags interval; counter;`. If the kernel rejects counters on interval sets, falls back to plain interval (aggregate Traffic ↓ still works; per-IP empty).
|
||||||
- Upgrade path: on install-link re-run, `last_hash` is cleared once so sets can be recreated (chain deleted before set replace).
|
- Upgrade path: on install-link re-run, `last_hash` is cleared once so sets/chain can be recreated (chain deleted before set replace) — **обновляет и port-hit правила**.
|
||||||
- **ipset:** prefers `hash:net … counters` on create; existing sets without counters are left as-is.
|
- **ipset:** prefers `hash:net … counters` on create; existing sets without counters are left as-is.
|
||||||
- Payload: only entries with `packets > 0`, **top 200** by packets.
|
- Payload: only entries with `packets > 0`, **top 200** by packets.
|
||||||
- Control plane: `agent_ip_block_stats`, accumulates **deltas** of absolute kernel counters (как Traffic ↓). После flush set/chain (policy apply) CP сбрасывает per-IP baseline (`last_reported`), иначе вторая эпоха счётчиков теряется (Traffic растёт, Blocked IPs — нет).
|
- Control plane: `agent_ip_block_stats`, accumulates **deltas** of absolute kernel counters (как Traffic ↓). После flush set/chain (policy apply) CP сбрасывает per-IP baseline (`last_reported`), иначе вторая эпоха счётчиков теряется (Traffic растёт, Blocked IPs — нет).
|
||||||
@@ -67,6 +67,20 @@ Linux agent reports optional `ip_hits` in `POST /v1/agent/apply-report`:
|
|||||||
|
|
||||||
IPv6 skipped.
|
IPv6 skipped.
|
||||||
|
|
||||||
|
## Destination ports (Linux nft)
|
||||||
|
|
||||||
|
На **nft** агент ведёт dynamic set `deny_port_hits` (`ipv4_addr . inet_proto . inet_service`, timeout 1h, counter):
|
||||||
|
|
||||||
|
- Deny rule для tcp/udp: `update @deny_port_hits { ip saddr . meta l4proto . th dport }` + drop; прочие протоколы — plain drop.
|
||||||
|
- В `apply-report`: optional `port_hits` top **500** `{ ip, port, protocol, packets }`.
|
||||||
|
- CP: `agent_port_block_stats` (absolute deltas). Set **не** flush’ится на каждый policy apply (элементы живут по timeout) — baseline не сбрасывается при Traffic flush.
|
||||||
|
- `GET /api/v1/agents/:id/blocked-ports` — aggregate top 50 портов; в `blocked-ips` у каждого IP — `ports` top 5.
|
||||||
|
- UI: **Top ports** + колонка Ports в Blocked IPs (только `platform=linux`).
|
||||||
|
- **ipset/iptables:** `port_hits: []`. MikroTik — без port hits.
|
||||||
|
- Чтобы подтянуть правила на уже установленном агенте: **re-run install one-liner** (см. выше).
|
||||||
|
|
||||||
|
IPv6 skipped.
|
||||||
|
|
||||||
## MikroTik (RouterOS 7.21+)
|
## MikroTik (RouterOS 7.21+)
|
||||||
|
|
||||||
В UI `/agents` → **Добавить агента** → platform **MikroTik**. Скопируйте one-liner:
|
В UI `/agents` → **Добавить агента** → platform **MikroTik**. Скопируйте one-liner:
|
||||||
|
|||||||
+52
-3
@@ -397,14 +397,14 @@ paths:
|
|||||||
|
|
||||||
/api/v1/agents/{id}/blocked-ips:
|
/api/v1/agents/{id}/blocked-ips:
|
||||||
get:
|
get:
|
||||||
summary: Per-IP/CIDR drop counters (Linux nft/ipset)
|
summary: Per-IP/CIDR drop counters (Linux nft/ipset; MikroTik HITS)
|
||||||
tags: [ops]
|
tags: [ops]
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/Id'
|
- $ref: '#/components/parameters/Id'
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Top blocked IPs by accumulated packets
|
description: Top blocked IPs by accumulated packets (+ optional top ports per IP on Linux)
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -420,6 +420,43 @@ paths:
|
|||||||
packets: { type: integer }
|
packets: { type: integer }
|
||||||
first_seen_at: { type: string, format: date-time }
|
first_seen_at: { type: string, format: date-time }
|
||||||
last_seen_at: { type: string, format: date-time }
|
last_seen_at: { type: string, format: date-time }
|
||||||
|
ports:
|
||||||
|
type: array
|
||||||
|
description: Top destination ports for this IP (Linux nft)
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [port, protocol, packets]
|
||||||
|
properties:
|
||||||
|
port: { type: integer, minimum: 1, maximum: 65535 }
|
||||||
|
protocol: { type: string, enum: [tcp, udp] }
|
||||||
|
packets: { type: integer }
|
||||||
|
|
||||||
|
/api/v1/agents/{id}/blocked-ports:
|
||||||
|
get:
|
||||||
|
summary: Aggregate destination ports hit by denied sources (Linux nft)
|
||||||
|
tags: [ops]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Id'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Top ports by accumulated packets across all blocked IPs
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [port, protocol, packets, last_seen_at]
|
||||||
|
properties:
|
||||||
|
port: { type: integer, minimum: 1, maximum: 65535 }
|
||||||
|
protocol: { type: string, enum: [tcp, udp] }
|
||||||
|
packets: { type: integer }
|
||||||
|
last_seen_at: { type: string, format: date-time }
|
||||||
|
|
||||||
/api/v1/integrations/evobgp/communities:
|
/api/v1/integrations/evobgp/communities:
|
||||||
get:
|
get:
|
||||||
summary: Proxy EvoBGP communities
|
summary: Proxy EvoBGP communities
|
||||||
@@ -520,7 +557,7 @@ paths:
|
|||||||
|
|
||||||
/v1/agent/apply-report:
|
/v1/agent/apply-report:
|
||||||
post:
|
post:
|
||||||
summary: Apply report + packet stats (+ optional ip_hits)
|
summary: Apply report + packet stats (+ optional ip_hits / port_hits)
|
||||||
tags: [agent]
|
tags: [agent]
|
||||||
security: [{ agentToken: [] }]
|
security: [{ agentToken: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -548,6 +585,18 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
ip: { type: string, maxLength: 64 }
|
ip: { type: string, maxLength: 64 }
|
||||||
packets: { type: integer, minimum: 0 }
|
packets: { type: integer, minimum: 0 }
|
||||||
|
port_hits:
|
||||||
|
type: array
|
||||||
|
maxItems: 500
|
||||||
|
description: Linux nft deny_port_hits (ip × proto × dport, packets > 0)
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [ip, port, protocol, packets]
|
||||||
|
properties:
|
||||||
|
ip: { type: string, maxLength: 64 }
|
||||||
|
port: { type: integer, minimum: 1, maximum: 65535 }
|
||||||
|
protocol: { type: string, enum: [tcp, udp] }
|
||||||
|
packets: { type: integer, minimum: 0 }
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: OK
|
description: OK
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Per-(ip, protocol, port) deny drop counters from Linux nft dynamic set.
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_port_block_stats (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
protocol TEXT NOT NULL,
|
||||||
|
packets INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_reported_packets INTEGER NOT NULL DEFAULT 0,
|
||||||
|
first_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_port_block_stats_agent_ip_port_proto
|
||||||
|
ON agent_port_block_stats(agent_id, ip, port, protocol);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_port_block_stats_agent_packets
|
||||||
|
ON agent_port_block_stats(agent_id, packets);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_port_block_stats_agent_port_proto
|
||||||
|
ON agent_port_block_stats(agent_id, port, protocol);
|
||||||
@@ -61,9 +61,21 @@ export {
|
|||||||
listIpBlockStats,
|
listIpBlockStats,
|
||||||
deleteIpBlockStatsForAgent,
|
deleteIpBlockStatsForAgent,
|
||||||
resetIpBlockStatsBaselines,
|
resetIpBlockStatsBaselines,
|
||||||
|
upsertPortBlockStats,
|
||||||
|
listPortBlockStatsAggregate,
|
||||||
|
listPortBlockStatsForIps,
|
||||||
|
mapTopPortsByIp,
|
||||||
|
deletePortBlockStatsForAgent,
|
||||||
|
resetPortBlockStatsBaselines,
|
||||||
PRESENCE_REHIT_STALE_MS,
|
PRESENCE_REHIT_STALE_MS,
|
||||||
} from './stats.js'
|
} from './stats.js'
|
||||||
export type { UpsertIpBlockStatsOptions, IpHitInput } from './stats.js'
|
export type {
|
||||||
|
UpsertIpBlockStatsOptions,
|
||||||
|
IpHitInput,
|
||||||
|
PortHitInput,
|
||||||
|
PortBlockAggregateRow,
|
||||||
|
PortBlockPerIpRow,
|
||||||
|
} from './stats.js'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getSetting,
|
getSetting,
|
||||||
@@ -142,6 +154,12 @@ import {
|
|||||||
listIpBlockStats,
|
listIpBlockStats,
|
||||||
deleteIpBlockStatsForAgent,
|
deleteIpBlockStatsForAgent,
|
||||||
resetIpBlockStatsBaselines,
|
resetIpBlockStatsBaselines,
|
||||||
|
upsertPortBlockStats,
|
||||||
|
listPortBlockStatsAggregate,
|
||||||
|
listPortBlockStatsForIps,
|
||||||
|
mapTopPortsByIp,
|
||||||
|
deletePortBlockStatsForAgent,
|
||||||
|
resetPortBlockStatsBaselines,
|
||||||
} from './stats.js'
|
} from './stats.js'
|
||||||
import {
|
import {
|
||||||
getSetting,
|
getSetting,
|
||||||
@@ -212,6 +230,12 @@ export const repos = {
|
|||||||
listIpBlockStats,
|
listIpBlockStats,
|
||||||
deleteIpBlockStatsForAgent,
|
deleteIpBlockStatsForAgent,
|
||||||
resetIpBlockStatsBaselines,
|
resetIpBlockStatsBaselines,
|
||||||
|
upsertPortBlockStats,
|
||||||
|
listPortBlockStatsAggregate,
|
||||||
|
listPortBlockStatsForIps,
|
||||||
|
mapTopPortsByIp,
|
||||||
|
deletePortBlockStatsForAgent,
|
||||||
|
resetPortBlockStatsBaselines,
|
||||||
getSetting,
|
getSetting,
|
||||||
setSetting,
|
setSetting,
|
||||||
listSettings,
|
listSettings,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { and, eq, desc } from 'drizzle-orm'
|
import { and, eq, desc, sql, inArray } from 'drizzle-orm'
|
||||||
import type { Db } from '../client.js'
|
import type { Db } from '../client.js'
|
||||||
import { agentIpBlockStats, agentStatsSamples } from '../schema.js'
|
import {
|
||||||
|
agentIpBlockStats,
|
||||||
|
agentPortBlockStats,
|
||||||
|
agentStatsSamples,
|
||||||
|
} from '../schema.js'
|
||||||
|
|
||||||
export function insertStatsSample(
|
export function insertStatsSample(
|
||||||
db: Db,
|
db: Db,
|
||||||
@@ -172,3 +176,169 @@ export function resetIpBlockStatsBaselines(db: Db, agentId: string) {
|
|||||||
.where(eq(agentIpBlockStats.agentId, agentId))
|
.where(eq(agentIpBlockStats.agentId, agentId))
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PortHitInput = {
|
||||||
|
ip: string
|
||||||
|
port: number
|
||||||
|
protocol: 'tcp' | 'udp' | string
|
||||||
|
packets: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert per-(ip, proto, port) deny counters (Linux nft absolute deltas).
|
||||||
|
* deny_port_hits is not flushed on policy apply (timeout ages elements), so
|
||||||
|
* baselines are not auto-reset on Traffic flush — only on stats/reset delete.
|
||||||
|
*/
|
||||||
|
export function upsertPortBlockStats(
|
||||||
|
db: Db,
|
||||||
|
agentId: string,
|
||||||
|
hits: PortHitInput[],
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
) {
|
||||||
|
for (const hit of hits) {
|
||||||
|
const ip = hit.ip.trim()
|
||||||
|
const protocol = String(hit.protocol).toLowerCase()
|
||||||
|
if (!ip || (protocol !== 'tcp' && protocol !== 'udp')) continue
|
||||||
|
const port = Math.floor(hit.port)
|
||||||
|
if (port < 1 || port > 65535) continue
|
||||||
|
const reported = Math.max(0, Math.floor(hit.packets))
|
||||||
|
const existing = db
|
||||||
|
.select()
|
||||||
|
.from(agentPortBlockStats)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(agentPortBlockStats.agentId, agentId),
|
||||||
|
eq(agentPortBlockStats.ip, ip),
|
||||||
|
eq(agentPortBlockStats.port, port),
|
||||||
|
eq(agentPortBlockStats.protocol, protocol),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
db.insert(agentPortBlockStats)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
agentId,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
protocol,
|
||||||
|
packets: reported,
|
||||||
|
lastReportedPackets: reported,
|
||||||
|
firstSeenAt: now,
|
||||||
|
lastSeenAt: now,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevReported = existing.lastReportedPackets ?? 0
|
||||||
|
const delta =
|
||||||
|
reported >= prevReported ? reported - prevReported : reported
|
||||||
|
const packets = (existing.packets ?? 0) + delta
|
||||||
|
db.update(agentPortBlockStats)
|
||||||
|
.set({
|
||||||
|
packets,
|
||||||
|
lastReportedPackets: reported,
|
||||||
|
...(delta > 0 ? { lastSeenAt: now } : {}),
|
||||||
|
})
|
||||||
|
.where(eq(agentPortBlockStats.id, existing.id))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PortBlockAggregateRow = {
|
||||||
|
port: number
|
||||||
|
protocol: string
|
||||||
|
packets: number
|
||||||
|
lastSeenAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPortBlockStatsAggregate(
|
||||||
|
db: Db,
|
||||||
|
agentId: string,
|
||||||
|
limit = 50,
|
||||||
|
): PortBlockAggregateRow[] {
|
||||||
|
const rows = db
|
||||||
|
.select({
|
||||||
|
port: agentPortBlockStats.port,
|
||||||
|
protocol: agentPortBlockStats.protocol,
|
||||||
|
packets: sql<number>`sum(${agentPortBlockStats.packets})`.mapWith(Number),
|
||||||
|
lastSeenAt: sql<string>`max(${agentPortBlockStats.lastSeenAt})`,
|
||||||
|
})
|
||||||
|
.from(agentPortBlockStats)
|
||||||
|
.where(eq(agentPortBlockStats.agentId, agentId))
|
||||||
|
.groupBy(agentPortBlockStats.port, agentPortBlockStats.protocol)
|
||||||
|
.orderBy(sql`sum(${agentPortBlockStats.packets}) desc`)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
return rows.map((r) => ({
|
||||||
|
port: r.port,
|
||||||
|
protocol: r.protocol,
|
||||||
|
packets: r.packets ?? 0,
|
||||||
|
lastSeenAt: r.lastSeenAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PortBlockPerIpRow = {
|
||||||
|
ip: string
|
||||||
|
port: number
|
||||||
|
protocol: string
|
||||||
|
packets: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw rows for a set of IPs (caller picks top-N per IP). */
|
||||||
|
export function listPortBlockStatsForIps(
|
||||||
|
db: Db,
|
||||||
|
agentId: string,
|
||||||
|
ips: string[],
|
||||||
|
): PortBlockPerIpRow[] {
|
||||||
|
if (ips.length === 0) return []
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
ip: agentPortBlockStats.ip,
|
||||||
|
port: agentPortBlockStats.port,
|
||||||
|
protocol: agentPortBlockStats.protocol,
|
||||||
|
packets: agentPortBlockStats.packets,
|
||||||
|
})
|
||||||
|
.from(agentPortBlockStats)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(agentPortBlockStats.agentId, agentId),
|
||||||
|
inArray(agentPortBlockStats.ip, ips),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(agentPortBlockStats.packets))
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top `perIpLimit` ports per IP, keyed by IP. */
|
||||||
|
export function mapTopPortsByIp(
|
||||||
|
db: Db,
|
||||||
|
agentId: string,
|
||||||
|
ips: string[],
|
||||||
|
perIpLimit = 5,
|
||||||
|
): Map<string, PortBlockPerIpRow[]> {
|
||||||
|
const all = listPortBlockStatsForIps(db, agentId, ips)
|
||||||
|
const map = new Map<string, PortBlockPerIpRow[]>()
|
||||||
|
for (const row of all) {
|
||||||
|
const list = map.get(row.ip) ?? []
|
||||||
|
if (list.length >= perIpLimit) continue
|
||||||
|
list.push(row)
|
||||||
|
map.set(row.ip, list)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePortBlockStatsForAgent(db: Db, agentId: string) {
|
||||||
|
db.delete(agentPortBlockStats)
|
||||||
|
.where(eq(agentPortBlockStats.agentId, agentId))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetPortBlockStatsBaselines(db: Db, agentId: string) {
|
||||||
|
db.update(agentPortBlockStats)
|
||||||
|
.set({ lastReportedPackets: 0 })
|
||||||
|
.where(eq(agentPortBlockStats.agentId, agentId))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|||||||
@@ -228,6 +228,42 @@ export const agentIpBlockStats = sqliteTable(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Per-(ip, proto, dport) deny hits from Linux nft dynamic concat set. */
|
||||||
|
export const agentPortBlockStats = sqliteTable(
|
||||||
|
'agent_port_block_stats',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
agentId: text('agent_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => agents.id, { onDelete: 'cascade' }),
|
||||||
|
ip: text('ip').notNull(),
|
||||||
|
port: integer('port').notNull(),
|
||||||
|
protocol: text('protocol').notNull(), // tcp | udp
|
||||||
|
packets: integer('packets').notNull().default(0),
|
||||||
|
lastReportedPackets: integer('last_reported_packets').notNull().default(0),
|
||||||
|
firstSeenAt: text('first_seen_at')
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||||
|
lastSeenAt: text('last_seen_at')
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
agentIpPortProto: uniqueIndex(
|
||||||
|
'idx_agent_port_block_stats_agent_ip_port_proto',
|
||||||
|
).on(t.agentId, t.ip, t.port, t.protocol),
|
||||||
|
agentPackets: index('idx_agent_port_block_stats_agent_packets').on(
|
||||||
|
t.agentId,
|
||||||
|
t.packets,
|
||||||
|
),
|
||||||
|
agentPortProto: index('idx_agent_port_block_stats_agent_port_proto').on(
|
||||||
|
t.agentId,
|
||||||
|
t.port,
|
||||||
|
t.protocol,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
/** Short install invite links (`/agent-install/:id` and `/:slug`). */
|
/** Short install invite links (`/agent-install/:id` and `/:slug`). */
|
||||||
export const agentInstallLinks = sqliteTable(
|
export const agentInstallLinks = sqliteTable(
|
||||||
'agent_install_links',
|
'agent_install_links',
|
||||||
@@ -291,6 +327,7 @@ export const schema = {
|
|||||||
ipOverrides,
|
ipOverrides,
|
||||||
agentStatsSamples,
|
agentStatsSamples,
|
||||||
agentIpBlockStats,
|
agentIpBlockStats,
|
||||||
|
agentPortBlockStats,
|
||||||
agentInstallLinks,
|
agentInstallLinks,
|
||||||
auditLog,
|
auditLog,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,6 +226,13 @@ export const applyReportIpHitSchema = z.object({
|
|||||||
packets: z.number().int().nonnegative(),
|
packets: z.number().int().nonnegative(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const applyReportPortHitSchema = z.object({
|
||||||
|
ip: z.string().min(1).max(64),
|
||||||
|
port: z.number().int().min(1).max(65535),
|
||||||
|
protocol: z.enum(['tcp', 'udp']),
|
||||||
|
packets: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
|
||||||
export const applyReportBodySchema = z.object({
|
export const applyReportBodySchema = z.object({
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
prefix_count: z.number().int().optional(),
|
prefix_count: z.number().int().optional(),
|
||||||
@@ -236,6 +243,14 @@ export const applyReportBodySchema = z.object({
|
|||||||
source: z.string().optional(),
|
source: z.string().optional(),
|
||||||
/** Linux nft/ipset per-element drop counters (top-N, packets > 0). */
|
/** Linux nft/ipset per-element drop counters (top-N, packets > 0). */
|
||||||
ip_hits: z.array(applyReportIpHitSchema).max(200).optional(),
|
ip_hits: z.array(applyReportIpHitSchema).max(200).optional(),
|
||||||
|
/** Linux nft dynamic set per-(ip, proto, dport) deny hits (top-N). */
|
||||||
|
port_hits: z.array(applyReportPortHitSchema).max(500).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const agentIpPortStatSchema = z.object({
|
||||||
|
port: z.number().int(),
|
||||||
|
protocol: z.enum(['tcp', 'udp']),
|
||||||
|
packets: z.number().int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const agentIpBlockStatSchema = z.object({
|
export const agentIpBlockStatSchema = z.object({
|
||||||
@@ -243,6 +258,14 @@ export const agentIpBlockStatSchema = z.object({
|
|||||||
packets: z.number().int(),
|
packets: z.number().int(),
|
||||||
first_seen_at: z.string(),
|
first_seen_at: z.string(),
|
||||||
last_seen_at: z.string(),
|
last_seen_at: z.string(),
|
||||||
|
ports: z.array(agentIpPortStatSchema).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const agentPortBlockStatSchema = z.object({
|
||||||
|
port: z.number().int(),
|
||||||
|
protocol: z.enum(['tcp', 'udp']),
|
||||||
|
packets: z.number().int(),
|
||||||
|
last_seen_at: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const agentPolicySchema = z.object({
|
export const agentPolicySchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user