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({