From ee3429b74758414197a94772d2eee21f1896a3af Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 7 Aug 2026 14:46:06 +0700 Subject: [PATCH] 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. --- apps/api/src/agent-scripts/evofw-firewall.sh | 51 ++++++++--- apps/api/src/agent-scripts/install.sh | 2 + .../src/agent-scripts/mikrotik-install.rsc | 31 +++++-- apps/api/src/routes/agent.ts | 5 +- apps/api/src/services/ip-block-stats.test.ts | 89 +++++++++++++++++++ .../components/agents/agent-blocked-ips.tsx | 41 ++++----- docs/agents.md | 30 ++++--- packages/db/src/repositories/stats.ts | 42 ++++++++- 8 files changed, 237 insertions(+), 54 deletions(-) diff --git a/apps/api/src/agent-scripts/evofw-firewall.sh b/apps/api/src/agent-scripts/evofw-firewall.sh index 30ba774..2195771 100644 --- a/apps/api/src/agent-scripts/evofw-firewall.sh +++ b/apps/api/src/agent-scripts/evofw-firewall.sh @@ -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 diff --git a/apps/api/src/agent-scripts/install.sh b/apps/api/src/agent-scripts/install.sh index 36c1e8c..6e8660e 100644 --- a/apps/api/src/agent-scripts/install.sh +++ b/apps/api/src/agent-scripts/install.sh @@ -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" diff --git a/apps/api/src/agent-scripts/mikrotik-install.rsc b/apps/api/src/agent-scripts/mikrotik-install.rsc index e1ccd27..211f56d 100644 --- a/apps/api/src/agent-scripts/mikrotik-install.rsc +++ b/apps/api/src/agent-scripts/mikrotik-install.rsc @@ -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) } } diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts index bf81663..a1bdb6f 100644 --- a/apps/api/src/routes/agent.ts +++ b/apps/api/src/routes/agent.ts @@ -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 } }) diff --git a/apps/api/src/services/ip-block-stats.test.ts b/apps/api/src/services/ip-block-stats.test.ts index 235149c..10d90a3 100644 --- a/apps/api/src/services/ip-block-stats.test.ts +++ b/apps/api/src/services/ip-block-stats.test.ts @@ -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) + }) }) diff --git a/apps/web/src/components/agents/agent-blocked-ips.tsx b/apps/web/src/components/agents/agent-blocked-ips.tsx index 041126a..3999e24 100644 --- a/apps/web/src/components/agents/agent-blocked-ips.tsx +++ b/apps/web/src/components/agents/agent-blocked-ips.tsx @@ -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[]>( () => [ @@ -79,14 +80,14 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) { accessorKey: 'packets', id: 'packets', header: ({ column }) => ( - + ), cell: ({ row }) => ( {packetFmt.format(row.original.packets)} ), - 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) { Blocked IPs - - Drop-пакеты по записям deny (nft/ipset). Top по накопленным packets. - + {description} - {isMikrotik ? ( - - ) : q.isLoading ? ( + {q.isLoading ? (
@@ -148,7 +139,11 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) { diff --git a/docs/agents.md b/docs/agents.md index 000c448..c5df52b 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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 diff --git a/packages/db/src/repositories/stats.ts b/packages/db/src/repositories/stats.ts index dda0aca..9c89701 100644 --- a/packages/db/src/repositories/stats.ts +++ b/packages/db/src/repositories/stats.ts @@ -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({