feat(api, web): enhance MikroTik integration and IP hit tracking
- Updated the `evofw-firewall.sh` script to improve the handling of NFT sets, ensuring compatibility with kernel limitations on counters and enhancing logging for better diagnostics. - Introduced a new presence mode for MikroTik, allowing for real-time tracking of IP hits with updated last seen timestamps and packet counts. - Enhanced the API to support the new presence mode, updating the database interactions to reflect the changes in how IP hits are recorded. - Updated the agent detail view to display sync windows for MikroTik, providing clearer insights into blocked IPs and their activity. - Improved documentation to reflect the new features and changes in the MikroTik handling process, ensuring clarity for users and developers. These changes significantly enhance the monitoring capabilities and user experience for agents, particularly those using MikroTik devices.
This commit is contained in:
@@ -118,18 +118,34 @@ nft_add_chunk() {
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure inet set exists with interval + counter (recreate if missing counter).
|
||||
# Ensure inet set exists. Prefer per-element counters; many kernels reject
|
||||
# `counter` on interval sets — fall back to plain interval (no per-IP hits).
|
||||
# Caller must delete referencing chains before recreating a set.
|
||||
ensure_nft_set() {
|
||||
local table=$1 name=$2 setname=$3
|
||||
local def
|
||||
def=$(nft -a list set "$table" "$name" "$setname" 2>/dev/null || true)
|
||||
def=$(nft list set "$table" "$name" "$setname" 2>/dev/null || true)
|
||||
if [[ -n "$def" ]] && [[ "$def" == *"counter"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "$def" ]]; then
|
||||
# Upgrade path: drop old set without counters (chain must already be gone).
|
||||
nft delete set "$table" "$name" "$setname" 2>>"$LOG_FILE" || true
|
||||
fi
|
||||
nft add set "$table" "$name" "$setname" '{ type ipv4_addr; flags interval; counter; }' 2>>"$LOG_FILE"
|
||||
if nft add set "$table" "$name" "$setname" '{ type ipv4_addr; flags interval; counter; }' 2>>"$LOG_FILE"; then
|
||||
return 0
|
||||
fi
|
||||
# Set may still exist if delete failed — try plain create only if missing.
|
||||
if nft list set "$table" "$name" "$setname" >/dev/null 2>&1; then
|
||||
log "nft: keep existing set $setname (no per-element counter)"
|
||||
return 0
|
||||
fi
|
||||
if nft add set "$table" "$name" "$setname" '{ type ipv4_addr; flags interval; }' 2>>"$LOG_FILE"; then
|
||||
log "nft: set $setname without counter (interval+counter unsupported)"
|
||||
return 0
|
||||
fi
|
||||
log "nft: failed to create set $setname"
|
||||
return 1
|
||||
}
|
||||
|
||||
collect_nft_stats() {
|
||||
@@ -214,10 +230,13 @@ apply_nft() {
|
||||
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).
|
||||
# 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
|
||||
nft flush set "$table" "$name" deny_v4
|
||||
nft flush set "$table" "$name" allow_v4
|
||||
nft flush set "$table" "$name" deny_v4 2>>"$LOG_FILE" || true
|
||||
nft flush set "$table" "$name" allow_v4 2>>"$LOG_FILE" || true
|
||||
|
||||
local batch=() chunk=64
|
||||
for p in "${deny_v4[@]}"; do
|
||||
@@ -232,7 +251,6 @@ apply_nft() {
|
||||
done
|
||||
((${#batch[@]})) && nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}"
|
||||
|
||||
nft delete chain "$table" "$name" input 2>/dev/null || true
|
||||
# Unified chain: deny → allow → default_action
|
||||
if [[ "$DEFAULT_ACTION" == "drop" ]]; then
|
||||
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy drop; }'
|
||||
@@ -255,16 +273,23 @@ apply_nft() {
|
||||
ensure_ipset_counters() {
|
||||
local name=$1
|
||||
if ! ipset list "$name" >/dev/null 2>&1; then
|
||||
ipset create "$name" hash:net family inet counters
|
||||
return
|
||||
if ipset create "$name" hash:net family inet counters 2>>"$LOG_FILE"; then
|
||||
return 0
|
||||
fi
|
||||
ipset create "$name" hash:net family inet 2>>"$LOG_FILE" || {
|
||||
log "ipset: failed to create $name"
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
fi
|
||||
# Recreate once if set has no packet counters (Header lacks "counters").
|
||||
local header
|
||||
header=$(ipset list "$name" 2>/dev/null | head -n 5 || true)
|
||||
if [[ "$header" != *"counters"* && "$header" != *"packet"* ]]; then
|
||||
ipset destroy "$name" 2>>"$LOG_FILE" || true
|
||||
ipset create "$name" hash:net family inet counters
|
||||
if [[ "$header" == *"counters"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
# Cannot safely destroy while iptables may reference the set — leave as-is.
|
||||
log "ipset: $name has no counters (leave existing; per-IP hits unavailable)"
|
||||
}
|
||||
|
||||
apply_ipset() {
|
||||
@@ -324,11 +349,11 @@ if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH
|
||||
if [[ "$KERNEL_METHOD" == "nft" ]]; then
|
||||
APPLIED=$(nft list set inet evofw deny_v4 2>/dev/null | grep -cE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' || true)
|
||||
local_allow=$(nft list set inet evofw allow_v4 2>/dev/null | grep -cE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' || true)
|
||||
APPLIED=$((APPLIED + local_allow))
|
||||
APPLIED=$((${APPLIED:-0} + ${local_allow:-0}))
|
||||
elif [[ "$KERNEL_METHOD" == "ipset" ]]; then
|
||||
APPLIED=$(ipset list evofw_deny_v4 2>/dev/null | awk '/^[0-9]/{c++} END{print c+0}')
|
||||
local_allow=$(ipset list evofw_allow_v4 2>/dev/null | awk '/^[0-9]/{c++} END{print c+0}')
|
||||
APPLIED=$((APPLIED + local_allow))
|
||||
APPLIED=$((${APPLIED:-0} + ${local_allow:-0}))
|
||||
fi
|
||||
send_report
|
||||
exit 0
|
||||
|
||||
@@ -250,6 +250,8 @@ 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.).
|
||||
rm -f /var/lib/evofw/last_hash
|
||||
enable_scheduler_and_run
|
||||
echo "Updated. Client id=${CLIENT_ID}. Sync script + timer refreshed."
|
||||
echo "Force sync: $SYNC_SCRIPT"
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
:set token ("evofw_" . [:tostr [/system clock get time]] . [:tostr [/system resource get cpu-load]] . [:tostr [/system resource get free-memory]] . [:tostr [:rndnum from=100000 to=999999]])
|
||||
}
|
||||
|
||||
:local body ("{\"name\":\"" . $EvofwName . "\",\"hostname\":\"" . [/system identity get name] . "\",\"platform\":\"mikrotik\",\"token\":\"" . $token . "\",\"client_version\":\"rsc/4\"")
|
||||
:local body ("{\"name\":\"" . $EvofwName . "\",\"hostname\":\"" . [/system identity get name] . "\",\"platform\":\"mikrotik\",\"token\":\"" . $token . "\",\"client_version\":\"rsc/5\"")
|
||||
:if ([:typeof $EvofwInstallLinkId] != "nothing" && [:len $EvofwInstallLinkId] > 0) do={
|
||||
:set body ($body . ",\"install_link_id\":\"" . $EvofwInstallLinkId . "\"")
|
||||
}
|
||||
@@ -68,8 +68,11 @@
|
||||
:set EvofwLastHash ""
|
||||
|
||||
# Filter rules (idempotent by comment)
|
||||
# Deny path: hit (add-src → EVOFW_HITS) then drop. Allow/default unchanged.
|
||||
:do { /ip firewall filter remove [find comment~"^evofw-"] } on-error={}
|
||||
/ip firewall filter add chain=input action=add-src-to-address-list address-list=EVOFW_HITS address-list-timeout=1h src-address-list=EVOFW_DENY comment=evofw-deny-hit-input disabled=no
|
||||
/ip firewall filter add chain=input action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-input disabled=no
|
||||
/ip firewall filter add chain=forward action=add-src-to-address-list address-list=EVOFW_HITS address-list-timeout=1h src-address-list=EVOFW_DENY comment=evofw-deny-hit-forward disabled=no
|
||||
/ip firewall filter add chain=forward action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-forward disabled=no
|
||||
/ip firewall filter add chain=forward action=accept src-address-list=EVOFW_ALLOW comment=evofw-allow-accept-forward disabled=no
|
||||
/ip firewall filter add chain=forward action=drop comment=evofw-default-drop-forward disabled=yes
|
||||
@@ -114,7 +117,9 @@
|
||||
:do { /ip firewall address-list add list=EVOFW_ALLOW address=$a comment=evofw } on-error={}
|
||||
}
|
||||
:local da ($p->"default_action")
|
||||
:do { /ip firewall filter set [find comment=evofw-deny-hit-input] disabled=no } on-error={}
|
||||
:do { /ip firewall filter set [find comment=evofw-deny-drop-input] disabled=no } on-error={}
|
||||
:do { /ip firewall filter set [find comment=evofw-deny-hit-forward] disabled=no } on-error={}
|
||||
:do { /ip firewall filter set [find comment=evofw-deny-drop-forward] disabled=no } on-error={}
|
||||
:do { /ip firewall filter set [find comment=evofw-allow-accept-forward] disabled=no } on-error={}
|
||||
:if ($da = "drop") do={
|
||||
@@ -134,11 +139,11 @@
|
||||
:local denyCnt [:len [/ip firewall address-list find list=EVOFW_DENY]]
|
||||
:local allowCnt [:len [/ip firewall address-list find list=EVOFW_ALLOW]]
|
||||
:local cnt ($denyCnt + $allowCnt)
|
||||
# Cumulative counters from permanent filter rules (survive address-list rebuild).
|
||||
# Cumulative counters from permanent drop/accept rules (not hit passthrough).
|
||||
:local dropped 0
|
||||
:local accepted 0
|
||||
:do {
|
||||
:foreach i in=[/ip firewall filter find where comment~"^evofw-deny-"] do={
|
||||
:foreach i in=[/ip firewall filter find where comment~"^evofw-deny-drop-"] do={
|
||||
:set dropped ($dropped + [/ip firewall filter get $i packets])
|
||||
}
|
||||
} on-error={}
|
||||
@@ -152,14 +157,30 @@
|
||||
:set accepted ($accepted + [/ip firewall filter get $i packets])
|
||||
}
|
||||
} on-error={}
|
||||
:local report ("{\"status\":\"ok\",\"prefix_count\":" . $cnt . ",\"packets_dropped\":" . $dropped . ",\"packets_accepted\":" . $accepted . ",\"kernel_method\":\"address-list\",\"source\":\"mikrotik\"}")
|
||||
# EVOFW_HITS: real src /32 that matched deny (timeout 1h). Do not wipe on policy rebuild.
|
||||
:local hits "["
|
||||
:local hitN 0
|
||||
:do {
|
||||
:foreach i in=[/ip firewall address-list find list=EVOFW_HITS] do={
|
||||
:if ($hitN < 200) do={
|
||||
:local a [/ip firewall address-list get $i address]
|
||||
:if ([:len $a] > 0) do={
|
||||
:if ($hitN > 0) do={ :set hits ($hits . ",") }
|
||||
:set hits ($hits . "{\"ip\":\"" . $a . "\",\"packets\":1}")
|
||||
:set hitN ($hitN + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
} on-error={}
|
||||
:set hits ($hits . "]")
|
||||
:local report ("{\"status\":\"ok\",\"prefix_count\":" . $cnt . ",\"packets_dropped\":" . $dropped . ",\"packets_accepted\":" . $accepted . ",\"kernel_method\":\"address-list\",\"source\":\"mikrotik\",\"ip_hits\":" . $hits . "}")
|
||||
:do {
|
||||
/tool fetch url=($EvofwCpUrl . "/v1/agent/apply-report") http-method=post http-header-field=("Authorization: Bearer " . $EvofwToken . ",Content-Type: application/json") http-data=$report keep-result=no
|
||||
} on-error={}
|
||||
:do {
|
||||
/tool fetch url=($EvofwCpUrl . "/v1/agent/heartbeat") http-method=post http-header-field=("Authorization: Bearer " . $EvofwToken . ",Content-Type: application/json") http-data="{\"source\":\"mikrotik\"}" keep-result=no
|
||||
} on-error={}
|
||||
:log info ("evofw: sync done deny=" . $denyCnt . " allow=" . $allowCnt . " dropPkts=" . $dropped . " acceptPkts=" . $accepted)
|
||||
:log info ("evofw: sync done deny=" . $denyCnt . " allow=" . $allowCnt . " hits=" . $hitN . " dropPkts=" . $dropped . " acceptPkts=" . $accepted)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -222,7 +222,10 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
recordedAt: now,
|
||||
})
|
||||
if (body.ip_hits?.length) {
|
||||
repos.upsertIpBlockStats(app.db, agentId, body.ip_hits, now)
|
||||
const presence = body.source === 'mikrotik'
|
||||
repos.upsertIpBlockStats(app.db, agentId, body.ip_hits, now, {
|
||||
mode: presence ? 'presence' : 'absolute',
|
||||
})
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
@@ -185,4 +185,93 @@ describe('apply-report ip_hits / blocked-ips', () => {
|
||||
})
|
||||
expect(report.statusCode).toBeGreaterThanOrEqual(400)
|
||||
})
|
||||
|
||||
it('mikrotik presence mode increments packets and refreshes last_seen', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/install-links',
|
||||
payload: { name: 'mt-hits', platform: 'mikrotik' },
|
||||
})
|
||||
const link = created.json() as { id: string; agent_id: string }
|
||||
const token = 'evofw_mt_hits_token_abcdefghijk'
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/enroll',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-evofw-seed': 'test-seed',
|
||||
},
|
||||
payload: {
|
||||
name: 'mt-hits',
|
||||
platform: 'mikrotik',
|
||||
token,
|
||||
install_link_id: link.id,
|
||||
},
|
||||
})
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
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 }],
|
||||
},
|
||||
})
|
||||
expect(report1.statusCode).toBe(200)
|
||||
|
||||
const list1 = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${link.agent_id}/blocked-ips`,
|
||||
})
|
||||
const body1 = list1.json() as {
|
||||
items: { ip: string; packets: number; last_seen_at: string }[]
|
||||
}
|
||||
expect(body1.items).toHaveLength(1)
|
||||
expect(body1.items[0]?.packets).toBe(1)
|
||||
const firstSeen = body1.items[0]!.last_seen_at
|
||||
|
||||
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)
|
||||
|
||||
const list2 = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${link.agent_id}/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]!.last_seen_at >= firstSeen).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from '@tanstack/react-table'
|
||||
import { BanIcon, RouterIcon } from 'lucide-react'
|
||||
import { BanIcon } from 'lucide-react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
@@ -21,10 +21,9 @@ import { agentBlockedIpsQueryOptions } from '@/queries'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
|
||||
/**
|
||||
* Per-IP/CIDR drop counters from Linux nft/ipset.
|
||||
* Per-IP blocked stats — Linux nft/ipset counters or MikroTik EVOFW_HITS.
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* · https://reui.io/preview/base/empty-state-12
|
||||
* MikroTik: address-list has no per-entry counters — empty hint only.
|
||||
*/
|
||||
|
||||
export type BlockedIpRow = {
|
||||
@@ -57,10 +56,12 @@ function formatSeen(iso: string): string {
|
||||
|
||||
export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
||||
const isMikrotik = platform === 'mikrotik'
|
||||
const q = useQuery({
|
||||
...agentBlockedIpsQueryOptions(agentId),
|
||||
enabled: !isMikrotik,
|
||||
})
|
||||
const q = useQuery(agentBlockedIpsQueryOptions(agentId))
|
||||
|
||||
const packetsTitle = isMikrotik ? 'Sync windows' : 'Packets'
|
||||
const description = isMikrotik
|
||||
? 'Src /32 из EVOFW_HITS (add-src при deny, timeout 1h). Sync windows — сколько минут IP был в hits.'
|
||||
: 'Drop-пакеты по записям deny (nft/ipset). Top по накопленным packets.'
|
||||
|
||||
const columns = useMemo<ColumnDef<BlockedIpRow>[]>(
|
||||
() => [
|
||||
@@ -79,14 +80,14 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
||||
accessorKey: 'packets',
|
||||
id: 'packets',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Packets" />
|
||||
<DataGridColumnHeader column={column} title={packetsTitle} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">
|
||||
{packetFmt.format(row.original.packets)}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Packets' },
|
||||
meta: { headerTitle: packetsTitle },
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_seen_at',
|
||||
@@ -102,7 +103,7 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
||||
meta: { headerTitle: 'Last seen' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
[packetsTitle],
|
||||
)
|
||||
|
||||
const data = q.data?.items ?? []
|
||||
@@ -117,20 +118,10 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Blocked IPs</FrameTitle>
|
||||
<FrameDescription>
|
||||
Drop-пакеты по записям deny (nft/ipset). Top по накопленным packets.
|
||||
</FrameDescription>
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
{isMikrotik ? (
|
||||
<EmptyState
|
||||
icon={RouterIcon}
|
||||
title="Per-IP недоступен на MikroTik"
|
||||
description="У address-list в RouterOS нет counters по записи. Доступны только суммарные Traffic ↓/↑ с filter-правил."
|
||||
centered={false}
|
||||
className="py-8"
|
||||
/>
|
||||
) : q.isLoading ? (
|
||||
{q.isLoading ? (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
@@ -148,7 +139,11 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
||||
<EmptyState
|
||||
icon={BanIcon}
|
||||
title="Пока нет hit’ов"
|
||||
description="Когда deny-префиксы начнут дропать пакеты, здесь появятся IP/CIDR с counters."
|
||||
description={
|
||||
isMikrotik
|
||||
? 'Когда трафик попадёт в EVOFW_DENY, src появится в EVOFW_HITS и здесь после sync.'
|
||||
: 'Когда deny-префиксы начнут дропать пакеты, здесь появятся IP/CIDR с counters.'
|
||||
}
|
||||
centered={false}
|
||||
className="py-8"
|
||||
/>
|
||||
|
||||
+20
-10
@@ -57,13 +57,14 @@ Whitelist: nft chain policy drop + allow set. Blacklist: policy accept + deny se
|
||||
|
||||
Linux agent reports optional `ip_hits` in `POST /v1/agent/apply-report`:
|
||||
|
||||
- **nft:** set `deny_v4` with `flags interval; counter;` — per-element packets; collected **before** flush/recreate and on unchanged-hash sync.
|
||||
- **ipset:** `hash:net … counters` — same idea from `ipset list`.
|
||||
- **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).
|
||||
- **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.
|
||||
- Control plane stores cumulative totals in `agent_ip_block_stats` (delta vs last absolute report). `GET /api/v1/agents/:id/blocked-ips`. Reset via `POST …/stats/reset`.
|
||||
- UI: agent detail → **Blocked IPs** (Frame + DataGrid).
|
||||
- Control plane: `agent_ip_block_stats`, `GET /api/v1/agents/:id/blocked-ips`, reset via `POST …/stats/reset`.
|
||||
- UI: agent detail → **Blocked IPs**.
|
||||
|
||||
IPv6 skipped (as in apply). MikroTik: see below — no per-IP in v1.
|
||||
IPv6 skipped.
|
||||
|
||||
## MikroTik (RouterOS 7.21+)
|
||||
|
||||
@@ -78,20 +79,29 @@ IPv6 skipped (as in apply). MikroTik: see below — no per-IP in v1.
|
||||
Install RSC:
|
||||
|
||||
1. Enroll (с `install_link_id` → агент Invited → Pending).
|
||||
2. Создаёт filter-правила `evofw-*` и address-list `EVOFW_DENY` / `EVOFW_ALLOW`.
|
||||
3. Scheduler `evofw-sync` каждую минуту: `GET /v1/agent/policy` (JSON) → rebuild address-list + toggle default. Не использует `/import` огромного `.rsc` (на больших списках часто падает молча).
|
||||
2. Создаёт filter-правила `evofw-*` и address-list `EVOFW_DENY` / `EVOFW_ALLOW` / dynamic **`EVOFW_HITS`**.
|
||||
3. Scheduler `evofw-sync` каждую минуту: `GET /v1/agent/policy` (JSON) → rebuild deny/allow + report (+ `ip_hits` из HITS). Не использует `/import` огромного `.rsc`.
|
||||
|
||||
Лог: `/log print where message~"evofw"`. Ручной sync: `/system script run evofw-sync`.
|
||||
Traffic ↓/↑ в UI — сумма `packets` с filter-правил `evofw-deny-*` / `evofw-allow-*` / `evofw-default-drop-*` (накопительно, пока правила не пересозданы re-install).
|
||||
Traffic ↓/↑ в UI — сумма `packets` с `evofw-deny-drop-*` / `evofw-allow-*` / `evofw-default-drop-*` (накопительно, пока правила не пересозданы re-install).
|
||||
|
||||
**Per-IP / blocked IPs:** на MikroTik **нет**. У `/ip firewall address-list` в ROS 7 нет `packets`/`bytes` на записи — только суммарные counters filter-правил. В карточке агента секция Blocked IPs показывает пояснение.
|
||||
### Per-IP / Blocked IPs (MikroTik)
|
||||
|
||||
Цепочка deny: **hit → drop** (семантика drop/allow/default как раньше):
|
||||
|
||||
1. `evofw-deny-hit-input/forward` — `add-src-to-address-list` → `EVOFW_HITS`, `address-list-timeout=1h` (passthrough).
|
||||
2. `evofw-deny-drop-input/forward` — `drop` по `EVOFW_DENY`.
|
||||
|
||||
В `EVOFW_HITS` попадают реальные src **/32**. Policy rebuild **не** чистит HITS (только DENY/ALLOW). Sync шлёт top-200 в `ip_hits`; CP mode **presence**: `last_seen` каждый report, `packets` = число sync-окон (~минут), пока IP в HITS.
|
||||
|
||||
UI: agent detail → **Blocked IPs** (колонка Sync windows).
|
||||
|
||||
**Default action** задаётся на **агенте** (`default_action: accept | drop`):
|
||||
|
||||
- **accept** — пакет вне deny/allow пропускается
|
||||
- **drop** — пакет вне deny/allow отбрасывается (forward)
|
||||
|
||||
Цепочка всегда: deny-drop → allow-accept → default. Наборы несут только правила deny/allow, без exclusive mode.
|
||||
Цепочка: deny-hit → deny-drop → allow-accept → default. Наборы несут только правила deny/allow, без exclusive mode.
|
||||
|
||||
## Force sync
|
||||
|
||||
|
||||
@@ -36,16 +36,28 @@ export function deleteStatsSamplesForAgent(db: Db, agentId: string) {
|
||||
|
||||
export type IpHitInput = { ip: string; packets: number }
|
||||
|
||||
export type UpsertIpBlockStatsOptions = {
|
||||
/**
|
||||
* MikroTik EVOFW_HITS presence: always refresh last_seen;
|
||||
* packets += 1 per report (sync-window sightings).
|
||||
* Linux keeps absolute counter deltas (default).
|
||||
*/
|
||||
mode?: 'absolute' | 'presence'
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert per-IP drop counters. Agent reports absolute kernel counters;
|
||||
* CP accumulates deltas (mirrors totalPacketsDropped logic).
|
||||
* Upsert per-IP drop counters.
|
||||
* - absolute (Linux): agent reports kernel counters; CP accumulates deltas.
|
||||
* - presence (MikroTik): each report sighting → last_seen=now, packets+=1.
|
||||
*/
|
||||
export function upsertIpBlockStats(
|
||||
db: Db,
|
||||
agentId: string,
|
||||
hits: IpHitInput[],
|
||||
now = new Date().toISOString(),
|
||||
opts: UpsertIpBlockStatsOptions = {},
|
||||
) {
|
||||
const mode = opts.mode ?? 'absolute'
|
||||
for (const hit of hits) {
|
||||
const ip = hit.ip.trim()
|
||||
if (!ip) continue
|
||||
@@ -61,6 +73,32 @@ export function upsertIpBlockStats(
|
||||
)
|
||||
.get()
|
||||
|
||||
if (mode === 'presence') {
|
||||
if (!existing) {
|
||||
db.insert(agentIpBlockStats)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
agentId,
|
||||
ip,
|
||||
packets: 1,
|
||||
lastReportedPackets: 1,
|
||||
firstSeenAt: now,
|
||||
lastSeenAt: now,
|
||||
})
|
||||
.run()
|
||||
continue
|
||||
}
|
||||
db.update(agentIpBlockStats)
|
||||
.set({
|
||||
packets: (existing.packets ?? 0) + 1,
|
||||
lastReportedPackets: (existing.lastReportedPackets ?? 0) + 1,
|
||||
lastSeenAt: now,
|
||||
})
|
||||
.where(eq(agentIpBlockStats.id, existing.id))
|
||||
.run()
|
||||
continue
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
db.insert(agentIpBlockStats)
|
||||
.values({
|
||||
|
||||
Reference in New Issue
Block a user