feat(api, web): enhance MikroTik integration and IP hit tracking
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m54s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- 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:
Denozordec
2026-08-07 14:46:06 +07:00
parent 4ee78032c4
commit ee3429b747
8 changed files with 237 additions and 54 deletions
+38 -13
View File
@@ -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() { ensure_nft_set() {
local table=$1 name=$2 setname=$3 local table=$1 name=$2 setname=$3
local def 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 if [[ -n "$def" ]] && [[ "$def" == *"counter"* ]]; then
return 0 return 0
fi fi
if [[ -n "$def" ]]; then 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 nft delete set "$table" "$name" "$setname" 2>>"$LOG_FILE" || true
fi 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() { collect_nft_stats() {
@@ -214,10 +230,13 @@ apply_nft() {
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).
# 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" deny_v4
ensure_nft_set "$table" "$name" allow_v4 ensure_nft_set "$table" "$name" allow_v4
nft flush set "$table" "$name" deny_v4 nft flush set "$table" "$name" deny_v4 2>>"$LOG_FILE" || true
nft flush set "$table" "$name" allow_v4 nft flush set "$table" "$name" allow_v4 2>>"$LOG_FILE" || true
local batch=() chunk=64 local batch=() chunk=64
for p in "${deny_v4[@]}"; do for p in "${deny_v4[@]}"; do
@@ -232,7 +251,6 @@ apply_nft() {
done done
((${#batch[@]})) && nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}" ((${#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 # Unified chain: deny → allow → default_action
if [[ "$DEFAULT_ACTION" == "drop" ]]; then if [[ "$DEFAULT_ACTION" == "drop" ]]; then
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy drop; }' nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy drop; }'
@@ -255,16 +273,23 @@ apply_nft() {
ensure_ipset_counters() { ensure_ipset_counters() {
local name=$1 local name=$1
if ! ipset list "$name" >/dev/null 2>&1; then if ! ipset list "$name" >/dev/null 2>&1; then
ipset create "$name" hash:net family inet counters if ipset create "$name" hash:net family inet counters 2>>"$LOG_FILE"; then
return return 0
fi
ipset create "$name" hash:net family inet 2>>"$LOG_FILE" || {
log "ipset: failed to create $name"
return 1
}
return 0
fi fi
# Recreate once if set has no packet counters (Header lacks "counters"). # Recreate once if set has no packet counters (Header lacks "counters").
local header local header
header=$(ipset list "$name" 2>/dev/null | head -n 5 || true) header=$(ipset list "$name" 2>/dev/null | head -n 5 || true)
if [[ "$header" != *"counters"* && "$header" != *"packet"* ]]; then if [[ "$header" == *"counters"* ]]; then
ipset destroy "$name" 2>>"$LOG_FILE" || true return 0
ipset create "$name" hash:net family inet counters
fi 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() { apply_ipset() {
@@ -324,11 +349,11 @@ if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH
if [[ "$KERNEL_METHOD" == "nft" ]]; then 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) 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) 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 elif [[ "$KERNEL_METHOD" == "ipset" ]]; then
APPLIED=$(ipset list evofw_deny_v4 2>/dev/null | awk '/^[0-9]/{c++} END{print c+0}') 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}') 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 fi
send_report send_report
exit 0 exit 0
+2
View File
@@ -250,6 +250,8 @@ 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.).
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."
echo "Force sync: $SYNC_SCRIPT" 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]]) :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={ :if ([:typeof $EvofwInstallLinkId] != "nothing" && [:len $EvofwInstallLinkId] > 0) do={
:set body ($body . ",\"install_link_id\":\"" . $EvofwInstallLinkId . "\"") :set body ($body . ",\"install_link_id\":\"" . $EvofwInstallLinkId . "\"")
} }
@@ -68,8 +68,11 @@
:set EvofwLastHash "" :set EvofwLastHash ""
# Filter rules (idempotent by comment) # 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={} :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=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=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=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 /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={} :do { /ip firewall address-list add list=EVOFW_ALLOW address=$a comment=evofw } on-error={}
} }
:local da ($p->"default_action") :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-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-deny-drop-forward] disabled=no } on-error={}
:do { /ip firewall filter set [find comment=evofw-allow-accept-forward] disabled=no } on-error={} :do { /ip firewall filter set [find comment=evofw-allow-accept-forward] disabled=no } on-error={}
:if ($da = "drop") do={ :if ($da = "drop") do={
@@ -134,11 +139,11 @@
:local denyCnt [:len [/ip firewall address-list find list=EVOFW_DENY]] :local denyCnt [:len [/ip firewall address-list find list=EVOFW_DENY]]
:local allowCnt [:len [/ip firewall address-list find list=EVOFW_ALLOW]] :local allowCnt [:len [/ip firewall address-list find list=EVOFW_ALLOW]]
:local cnt ($denyCnt + $allowCnt) :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 dropped 0
:local accepted 0 :local accepted 0
:do { :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]) :set dropped ($dropped + [/ip firewall filter get $i packets])
} }
} on-error={} } on-error={}
@@ -152,14 +157,30 @@
:set accepted ($accepted + [/ip firewall filter get $i packets]) :set accepted ($accepted + [/ip firewall filter get $i packets])
} }
} on-error={} } 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 { :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 /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={} } on-error={}
:do { :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 /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={} } 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)
} }
} }
+4 -1
View File
@@ -222,7 +222,10 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
recordedAt: now, recordedAt: now,
}) })
if (body.ip_hits?.length) { 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 } return { ok: true }
}) })
@@ -185,4 +185,93 @@ describe('apply-report ip_hits / blocked-ips', () => {
}) })
expect(report.statusCode).toBeGreaterThanOrEqual(400) 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, useReactTable,
type ColumnDef, type ColumnDef,
} from '@tanstack/react-table' } from '@tanstack/react-table'
import { BanIcon, RouterIcon } from 'lucide-react' import { BanIcon } from 'lucide-react'
import { import {
Frame, Frame,
FrameDescription, FrameDescription,
@@ -21,10 +21,9 @@ import { agentBlockedIpsQueryOptions } from '@/queries'
import { Skeleton } from '@evofw/ui/components/skeleton' 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 * Preview: https://reui.io/preview/base/data-grid-filtering-2
* · https://reui.io/preview/base/empty-state-12 * · https://reui.io/preview/base/empty-state-12
* MikroTik: address-list has no per-entry counters — empty hint only.
*/ */
export type BlockedIpRow = { export type BlockedIpRow = {
@@ -57,10 +56,12 @@ function formatSeen(iso: string): string {
export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) { export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
const isMikrotik = platform === 'mikrotik' const isMikrotik = platform === 'mikrotik'
const q = useQuery({ const q = useQuery(agentBlockedIpsQueryOptions(agentId))
...agentBlockedIpsQueryOptions(agentId),
enabled: !isMikrotik, 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>[]>( const columns = useMemo<ColumnDef<BlockedIpRow>[]>(
() => [ () => [
@@ -79,14 +80,14 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
accessorKey: 'packets', accessorKey: 'packets',
id: 'packets', id: 'packets',
header: ({ column }) => ( header: ({ column }) => (
<DataGridColumnHeader column={column} title="Packets" /> <DataGridColumnHeader column={column} title={packetsTitle} />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span className="tabular-nums"> <span className="tabular-nums">
{packetFmt.format(row.original.packets)} {packetFmt.format(row.original.packets)}
</span> </span>
), ),
meta: { headerTitle: 'Packets' }, meta: { headerTitle: packetsTitle },
}, },
{ {
accessorKey: 'last_seen_at', accessorKey: 'last_seen_at',
@@ -102,7 +103,7 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
meta: { headerTitle: 'Last seen' }, meta: { headerTitle: 'Last seen' },
}, },
], ],
[], [packetsTitle],
) )
const data = q.data?.items ?? [] const data = q.data?.items ?? []
@@ -117,20 +118,10 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
<Frame dense spacing="sm"> <Frame dense spacing="sm">
<FrameHeader> <FrameHeader>
<FrameTitle>Blocked IPs</FrameTitle> <FrameTitle>Blocked IPs</FrameTitle>
<FrameDescription> <FrameDescription>{description}</FrameDescription>
Drop-пакеты по записям deny (nft/ipset). Top по накопленным packets.
</FrameDescription>
</FrameHeader> </FrameHeader>
<FramePanel className="p-0"> <FramePanel className="p-0">
{isMikrotik ? ( {q.isLoading ? (
<EmptyState
icon={RouterIcon}
title="Per-IP недоступен на MikroTik"
description="У address-list в RouterOS нет counters по записи. Доступны только суммарные Traffic ↓/↑ с filter-правил."
centered={false}
className="py-8"
/>
) : q.isLoading ? (
<div className="flex flex-col gap-2 p-4"> <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-full" /> <Skeleton className="h-8 w-full" />
@@ -148,7 +139,11 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
<EmptyState <EmptyState
icon={BanIcon} icon={BanIcon}
title="Пока нет hit’ов" title="Пока нет hit’ов"
description="Когда deny-префиксы начнут дропать пакеты, здесь появятся IP/CIDR с counters." description={
isMikrotik
? 'Когда трафик попадёт в EVOFW_DENY, src появится в EVOFW_HITS и здесь после sync.'
: 'Когда deny-префиксы начнут дропать пакеты, здесь появятся IP/CIDR с counters.'
}
centered={false} centered={false}
className="py-8" className="py-8"
/> />
+20 -10
View File
@@ -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`: 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. - **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).
- **ipset:** `hash:net … counters` — same idea from `ipset list`. - 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. - 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`. - Control plane: `agent_ip_block_stats`, `GET /api/v1/agents/:id/blocked-ips`, reset via `POST …/stats/reset`.
- UI: agent detail → **Blocked IPs** (Frame + DataGrid). - UI: agent detail → **Blocked IPs**.
IPv6 skipped (as in apply). MikroTik: see below — no per-IP in v1. IPv6 skipped.
## MikroTik (RouterOS 7.21+) ## MikroTik (RouterOS 7.21+)
@@ -78,20 +79,29 @@ IPv6 skipped (as in apply). MikroTik: see below — no per-IP in v1.
Install RSC: Install RSC:
1. Enroll (с `install_link_id` → агент Invited → Pending). 1. Enroll (с `install_link_id` → агент Invited → Pending).
2. Создаёт filter-правила `evofw-*` и address-list `EVOFW_DENY` / `EVOFW_ALLOW`. 2. Создаёт filter-правила `evofw-*` и address-list `EVOFW_DENY` / `EVOFW_ALLOW` / dynamic **`EVOFW_HITS`**.
3. Scheduler `evofw-sync` каждую минуту: `GET /v1/agent/policy` (JSON) → rebuild address-list + toggle default. Не использует `/import` огромного `.rsc` (на больших списках часто падает молча). 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`. Лог: `/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`): **Default action** задаётся на **агенте** (`default_action: accept | drop`):
- **accept** — пакет вне deny/allow пропускается - **accept** — пакет вне deny/allow пропускается
- **drop** — пакет вне deny/allow отбрасывается (forward) - **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 ## Force sync
+40 -2
View File
@@ -36,16 +36,28 @@ export function deleteStatsSamplesForAgent(db: Db, agentId: string) {
export type IpHitInput = { ip: string; packets: number } 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; * Upsert per-IP drop counters.
* CP accumulates deltas (mirrors totalPacketsDropped logic). * - absolute (Linux): agent reports kernel counters; CP accumulates deltas.
* - presence (MikroTik): each report sighting → last_seen=now, packets+=1.
*/ */
export function upsertIpBlockStats( export function upsertIpBlockStats(
db: Db, db: Db,
agentId: string, agentId: string,
hits: IpHitInput[], hits: IpHitInput[],
now = new Date().toISOString(), now = new Date().toISOString(),
opts: UpsertIpBlockStatsOptions = {},
) { ) {
const mode = opts.mode ?? 'absolute'
for (const hit of hits) { for (const hit of hits) {
const ip = hit.ip.trim() const ip = hit.ip.trim()
if (!ip) continue if (!ip) continue
@@ -61,6 +73,32 @@ export function upsertIpBlockStats(
) )
.get() .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) { if (!existing) {
db.insert(agentIpBlockStats) db.insert(agentIpBlockStats)
.values({ .values({