"use client" import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react" import { useSearchParams } from "next/navigation" import { PageHeader } from "@/components/page-header" import { FirewallRulesDataGrid, ActionBadge, ChainBadge, } from "@/components/data-grids/firewall-rules-data-grid" import { FirewallScenarioRulesDataGrid } from "@/components/data-grids/firewall-scenario-rules-data-grid" import { DataPageCard } from "@/components/data-page-card" import { DataPageToolbarFrame } from "@/components/data-page-toolbar" import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit" import { firewallRules, servers as mockServers, type FirewallAddressListEntry, type FirewallRule, type FirewallTable, type Server, } from "@/lib/data" import { useDataSource } from "@/lib/data-source" import { requestJson } from "@/shared/api/http-client" import { Frame, FramePanel } from "@/components/reui/frame" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { InputGroup, InputGroupAddon, InputGroupInput, } from "@/components/ui/input-group" import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, SheetClose, } from "@/components/ui/sheet" import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu" import { cn } from "@/lib/utils" import { PlusIcon, SearchIcon, ShieldIcon, ShieldOffIcon, ListFilterIcon, ArrowRightLeftIcon, WrenchIcon, LayersIcon, PencilIcon, Trash2Icon, CodeXmlIcon, MoreHorizontalIcon, PowerIcon, CheckCircleIcon, PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon, CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon, SlidersHorizontalIcon, RefreshCwIcon, HistoryIcon, } from "lucide-react" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail" import { toast } from "sonner" import { ConfigHistorySheet } from "@/components/config-history-sheet" import type { ConfigRevisionDto } from "@/lib/config-revisions" // ─── Types ──────────────────────────────────────────────────────────────────── type ChainGroup = "filter" | "nat" | "mangle" | "raw" | "address-lists" | "simulator" type ChainFilter = "all" | string // ─── Simulator types ────────────────────────────────────────────────────────── interface PacketDef { chain: string proto: string srcAddr: string dstAddr: string srcPort: string dstPort: string inIface: string outIface: string connState: string srcAddrList: string dstAddrList: string tlsHost: string } type SimSpeed = "slow" | "normal" | "fast" | "instant" interface SimCheck { field: string ruleVal: string packetVal: string passed: boolean } interface SimStep { rule: FirewallRule index: number status: "pending" | "evaluating" | "match" | "skip" | "disabled" | "passthrough" checks: SimCheck[] failedAt?: string } // ─── Scenario types ─────────────────────────────────────────────────────────── interface ScenarioRule { id: string chain: string action: string proto: string src: string dst: string port: string iface: string comment: string enabled: boolean } interface SimScenario { id: string name: string description: string packet: PacketDef rules: ScenarioRule[] createdAt: number } // ─── Packet presets ─────────────────────────────────────────────────────────── const DEFAULT_PKT: PacketDef = { chain: "forward", proto: "tcp", srcAddr: "10.10.0.100", dstAddr: "142.250.74.110", srcPort: "54321", dstPort: "443", inIface: "lan", outIface: "wan-msk", connState: "new", srcAddrList: "", dstAddrList: "", tlsHost: "", } // ─── RouterOS traversal paths ──────────────────────────────────────────────── // Maps packet "flow type" (= packet.chain value) → ordered chain names to evaluate. // Mirrors the real RouterOS pipeline so Mangle, Raw, NAT rules are all included. const DIR_PATHS: Record = { // Forwarded traffic (LAN → WAN, etc.) forward: ["prerouting", "forward", "postrouting", "srcnat"], // Traffic destined for the router itself input: ["prerouting", "input"], // Traffic originating from the router output: ["output", "postrouting", "srcnat"], // DNAT / port-forward dstnat: ["prerouting", "dstnat", "forward", "postrouting", "srcnat"], } // Human-readable table hint shown next to each chain header in the trace const CHAIN_TABLE_HINT: Record = { prerouting: "Raw + Mangle", forward: "Mangle + Filter", input: "Mangle + Filter", output: "Mangle + Filter", postrouting: "Mangle", srcnat: "NAT", dstnat: "NAT", } // ─── Packet presets ─────────────────────────────────────────────────────────── const SIM_PRESETS: Array<{ id: string; label: string; desc: string; emoji: string; pkt: Partial }> = [ { id: "https", label: "HTTPS → интернет", desc: "TCP 443, forward, новый", emoji: "🌐", pkt: { chain: "forward", proto: "tcp", dstPort: "443", srcAddr: "10.10.0.100", dstAddr: "142.250.74.110", inIface: "lan", connState: "new" } }, { id: "http", label: "HTTP → интернет", desc: "TCP 80, forward", emoji: "📡", pkt: { chain: "forward", proto: "tcp", dstPort: "80", srcAddr: "10.10.0.100", dstAddr: "93.184.216.34", inIface: "lan", connState: "new" } }, { id: "ssh-lan", label: "SSH управление", desc: "TCP 22, input, с LAN", emoji: "🔐", pkt: { chain: "input", proto: "tcp", dstPort: "22", srcAddr: "10.10.0.5", dstAddr: "10.0.0.1", inIface: "lan", connState: "new" } }, { id: "ssh-wan", label: "SSH с WAN", desc: "TCP 22, input, внешний", emoji: "🚨", pkt: { chain: "input", proto: "tcp", dstPort: "22", srcAddr: "185.220.101.45", dstAddr: "10.0.0.1", inIface: "wan-msk", connState: "new" } }, { id: "winbox", label: "WinBox", desc: "TCP 8291, input, с LAN", emoji: "🖥️", pkt: { chain: "input", proto: "tcp", dstPort: "8291",srcAddr: "10.10.0.5", dstAddr: "10.0.0.1", inIface: "lan", connState: "new" } }, { id: "youtube", label: "YouTube bypass", desc: "TCP 443, dst-list youtube", emoji: "▶️", pkt: { chain: "forward", proto: "tcp", dstPort: "443", srcAddr: "10.10.0.100", dstAddr: "142.250.74.110", dstAddrList: "youtube-bypass", inIface: "lan", connState: "new" } }, { id: "cdn", label: "CDN mark-routing", desc: "TCP, dst-list cdn-bypass", emoji: "🚀", pkt: { chain: "forward", proto: "tcp", dstPort: "443", srcAddr: "10.10.0.100", dstAddr: "104.16.132.229", dstAddrList: "cdn-bypass", inIface: "lan", connState: "new" } }, { id: "nat-out", label: "Masquerade", desc: "srcnat, LAN → WAN", emoji: "🔄", pkt: { chain: "srcnat", proto: "tcp", srcAddr: "10.10.0.100", dstAddr: "8.8.8.8", dstPort: "443", outIface: "wan-msk" } }, { id: "icmp", label: "Ping ICMP", desc: "ICMP echo, forward", emoji: "📶", pkt: { chain: "forward", proto: "icmp", srcAddr: "10.10.0.100", dstAddr: "8.8.8.8", inIface: "lan" } }, { id: "established", label: "Established flow", desc: "TCP established, reverse", emoji: "↩️", pkt: { chain: "forward", proto: "tcp", dstPort: "54321", srcAddr: "142.250.74.110", dstAddr: "10.10.0.100", inIface: "wan-msk", connState: "established" } }, ] // ─── Matching engine ────────────────────────────────────────────────────────── function _ipNum(ip: string): number { return ip.split(".").reduce((a, o) => (a << 8) + parseInt(o, 10), 0) >>> 0 } function _inCidr(ip: string, cidr: string): boolean { if (!ip) return false if (!cidr.includes("/")) return ip === cidr const [net, b] = cidr.split("/"); const bits = parseInt(b) if (bits === 0) return true const mask = bits >= 32 ? 0xFFFFFFFF : (~(0xFFFFFFFF >>> bits)) >>> 0 return (_ipNum(ip) & mask) === (_ipNum(net) & mask) } function _mAddr(pAddr: string, rAddr: string): boolean { if (!rAddr || rAddr === "—" || rAddr === "") return true return rAddr.includes("/") ? _inCidr(pAddr, rAddr) : pAddr === rAddr } function _mPort(pPort: string, rPort: string): boolean { if (!rPort || rPort === "—") return true if (!pPort) return false const n = parseInt(pPort) for (const p of rPort.split(",")) { const t = p.trim() if (t.includes("-")) { const [lo, hi] = t.split("-").map(Number); if (n >= lo && n <= hi) return true } else if (Number(t) === n) return true } return false } function _mList(pList: string, rList: string): boolean { return !rList || rList === "—" || pList === rList } function _mIface(pI: string, rI: string): boolean { return !rI || rI === "—" || !pI || pI === rI || rI === "*" } function evalRule(rule: FirewallRule, pkt: PacketDef): { verdict: SimStep["status"]; checks: SimCheck[]; failedAt?: string } { if (!rule.enabled) return { verdict: "disabled", checks: [] } const checks: SimCheck[] = [] const fail = (c: SimCheck) => { checks.push(c); return { verdict: "skip" as const, checks, failedAt: c.field } } if (rule.chain !== pkt.chain) return fail({ field: "chain", ruleVal: rule.chain, packetVal: pkt.chain, passed: false }) checks.push({ field: "chain", ruleVal: rule.chain, packetVal: pkt.chain, passed: true }) if (rule.proto && rule.proto !== "all") { const ok = pkt.proto === rule.proto if (!ok) return fail({ field: "protocol", ruleVal: rule.proto, packetVal: pkt.proto, passed: false }) checks.push({ field: "protocol", ruleVal: rule.proto, packetVal: pkt.proto, passed: true }) } if (rule.src && rule.src !== "—") { const ok = _mAddr(pkt.srcAddr, rule.src) || _mList(pkt.srcAddrList, rule.src) if (!ok) return fail({ field: "src-address", ruleVal: rule.src, packetVal: pkt.srcAddr || "—", passed: false }) checks.push({ field: "src-address", ruleVal: rule.src, packetVal: pkt.srcAddr || pkt.srcAddrList, passed: true }) } if (rule.dst && rule.dst !== "—") { const ok = _mAddr(pkt.dstAddr, rule.dst) || _mList(pkt.dstAddrList, rule.dst) if (!ok) return fail({ field: "dst-address", ruleVal: rule.dst, packetVal: pkt.dstAddr || "—", passed: false }) checks.push({ field: "dst-address", ruleVal: rule.dst, packetVal: pkt.dstAddr || pkt.dstAddrList, passed: true }) } if (rule.port && rule.port !== "—") { const ok = _mPort(pkt.dstPort, rule.port) if (!ok) return fail({ field: "dst-port", ruleVal: rule.port, packetVal: pkt.dstPort || "—", passed: false }) checks.push({ field: "dst-port", ruleVal: rule.port, packetVal: pkt.dstPort, passed: true }) } if (rule.iface && rule.iface !== "—") { const ok = _mIface(pkt.inIface, rule.iface) if (!ok) return fail({ field: "in-interface", ruleVal: rule.iface, packetVal: pkt.inIface || "—", passed: false }) checks.push({ field: "in-interface", ruleVal: rule.iface, packetVal: pkt.inIface, passed: true }) } if (rule.tlsHost && pkt.tlsHost) { const pat = "^" + rule.tlsHost.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$" const ok = new RegExp(pat).test(pkt.tlsHost) if (!ok) return fail({ field: "tls-host", ruleVal: rule.tlsHost, packetVal: pkt.tlsHost, passed: false }) checks.push({ field: "tls-host", ruleVal: rule.tlsHost, packetVal: pkt.tlsHost, passed: true }) } return { verdict: rule.action === "passthrough" ? "passthrough" : "match", checks } } type AddressListEntry = FirewallAddressListEntry // ─── Mock address-list data ─────────────────────────────────────────────────── const INIT_ADDRESS_LISTS: AddressListEntry[] = [ { id: "al1", list: "youtube-bypass", address: "142.250.0.0/15", comment: "Google/YouTube AS15169", disabled: false, family: "ip", serverId: "srv1", rosId: "*1" }, { id: "al2", list: "youtube-bypass", address: "172.217.0.0/16", comment: "Google/YouTube AS15169", disabled: false, family: "ip", serverId: "srv1", rosId: "*2" }, { id: "al3", list: "cdn-bypass", address: "104.16.0.0/12", comment: "Cloudflare AS13335", disabled: false, family: "ip", serverId: "srv1", rosId: "*3" }, { id: "al4", list: "cdn-bypass", address: "23.32.0.0/11", comment: "Akamai AS20940", disabled: false, family: "ip", serverId: "srv1", rosId: "*4" }, { id: "al5", list: "cdn-bypass", address: "151.101.0.0/16", comment: "Fastly AS54113", disabled: false, family: "ip", serverId: "srv1", rosId: "*5" }, { id: "al6", list: "streaming-eu", address: "54.246.0.0/16", comment: "Netflix AS2906", disabled: false, family: "ip", serverId: "srv1", rosId: "*6" }, { id: "al7", list: "streaming-eu", address: "45.57.0.0/16", comment: "Netflix AS2906 NL", disabled: false, family: "ip", serverId: "srv1", rosId: "*7" }, { id: "al8", list: "social-block", address: "157.240.0.0/17", comment: "Facebook/Meta AS32934", disabled: false, family: "ip", serverId: "srv1", rosId: "*8" }, { id: "al9", list: "social-block", address: "31.13.64.0/18", comment: "Facebook/Meta AS32934", disabled: false, family: "ip", serverId: "srv1", rosId: "*9" }, { id: "al10", list: "gaming-low-latency",address: "162.159.128.0/19", comment: "Discord/Cloudflare AS13335", disabled: false, family: "ip", serverId: "srv1", rosId: "*10" }, { id: "al11", list: "management-access", address: "10.10.0.0/16", comment: "LAN", disabled: false, family: "ip", serverId: "srv1", rosId: "*11" }, { id: "al12", list: "management-access", address: "192.168.100.0/24", comment: "MGMT VLAN", disabled: false, family: "ip", serverId: "srv1", rosId: "*12" }, { id: "al13", list: "blocklist-dynamic", address: "185.220.101.45", comment: "Tor exit — dynamic ban", disabled: false, timeout: "01:00:00", family: "ip", serverId: "srv1", rosId: "*13" }, { id: "al14", list: "blocklist-dynamic", address: "80.82.70.118", comment: "Shodan scanner — dynamic ban", disabled: false, timeout: "00:42:18", family: "ip", serverId: "srv1", rosId: "*14" }, { id: "al15", list: "v6-allow", address: "2a01:4f8::/32", comment: "Hetzner v6", disabled: false, family: "ip6", serverId: "srv1", rosId: "*1" }, ] // ─── IP family filter ───────────────────────────────────────────────────────── type IpFamily = "ip" | "ip6" const IP_FAMILY_LABELS: Record = { ip: "IPv4", ip6: "IPv6", } // ─── Chain groups config ───────────────────────────────────────────────────── const CHAIN_GROUPS: { id: ChainGroup; label: string; icon: React.ReactNode; chains: string[] }[] = [ { id: "filter", label: "Filter", icon: , chains: ["input","forward","output"], }, { id: "nat", label: "NAT", icon: , chains: ["srcnat","dstnat"] }, { id: "mangle", label: "Mangle", icon: , chains: ["prerouting","postrouting","forward","input","output"] }, { id: "raw", label: "Raw", icon: , chains: ["prerouting","output"] }, { id: "address-lists", label: "Адр. листы", icon: , chains: [] }, { id: "simulator", label: "Симулятор", icon: , chains: [] }, ] // ─── Action styles ──────────────────────────────────────────────────────────── const ACTION_STYLES: Record = { accept: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20", drop: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", reject: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", masquerade: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20", "mark-routing": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20", "mark-conn": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20", "fasttrack-connection": "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20", "dst-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20", "src-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20", "add-src-to-address-list": "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20", } const CHAIN_STYLES: Record = { forward: "bg-foreground/5 text-foreground/70", input: "bg-violet-500/10 text-violet-600 dark:text-violet-400", output: "bg-sky-500/10 text-sky-600 dark:text-sky-400", srcnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400", dstnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400", prerouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400", postrouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400", "ip6-input": "bg-violet-500/10 text-violet-600 dark:text-violet-400", "ip6-forward": "bg-foreground/5 text-foreground/70", "ip6-output": "bg-sky-500/10 text-sky-600 dark:text-sky-400", } // ─── Default form ───────────────────────────────────────────────────────────── const defaultForm = { chain: "forward", action: "accept", proto: "all", src: "", dst: "", srcPort: "", dstPort: "", iface: "", inIface: "", outIface: "", connState: "", srcAddrList: "", dstAddrList: "", comment: "", enabled: true, log: false, logPrefix: "", } type RuleForm = typeof defaultForm function looksLikeAddress(value: string): boolean { const v = value.trim() if (!v || v === "—") return false return v.includes(".") || v.includes(":") || v.includes("/") } function tableOfGroup(group: ChainGroup): FirewallTable | null { if (group === "filter" || group === "nat" || group === "mangle" || group === "raw") return group return null } interface RuleWritePayload { chain: string action: string protocol?: string srcAddress?: string dstAddress?: string srcAddressList?: string dstAddressList?: string srcPort?: string dstPort?: string inInterface?: string outInterface?: string connectionState?: string comment?: string disabled?: boolean log?: boolean logPrefix?: string } function formToWritePayload(form: RuleForm): RuleWritePayload { const src = form.srcAddrList.trim() const dst = form.dstAddrList.trim() return { chain: form.chain, action: form.action, protocol: form.proto, srcAddress: src && looksLikeAddress(src) ? src : undefined, srcAddressList: src && !looksLikeAddress(src) ? src : undefined, dstAddress: dst && looksLikeAddress(dst) ? dst : undefined, dstAddressList: dst && !looksLikeAddress(dst) ? dst : undefined, srcPort: form.srcPort.trim() || undefined, dstPort: form.dstPort.trim() || undefined, inInterface: form.inIface.trim() || undefined, outInterface: form.outIface.trim() || undefined, connectionState: form.connState.trim() || undefined, comment: form.comment.trim() || undefined, disabled: !form.enabled, log: form.log, logPrefix: form.log && form.logPrefix.trim() ? form.logPrefix.trim() : undefined, } } // ─── Helpers ────────────────────────────────────────────────────────────────── function fmtHits(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}М` if (n >= 1_000) return `${(n / 1_000).toFixed(0)}к` return String(n) } // ActionBadge, ChainBadge — из firewall-rules-data-grid function NativeSelect({ value, onChange, children, className }: { value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string }) { return ( ) } // ─── RSC Export generator ───────────────────────────────────────────────────── function generateRsc(rules: FirewallRule[], timestamp?: string): string { const lines: string[] = [] lines.push(`# MikrotikManager Firewall Export`) if (timestamp) lines.push(`# Сгенерировано: ${timestamp}`) lines.push(`# Правил: ${rules.length}`) lines.push("") const byChain: Record = {} for (const r of rules) { if (!byChain[r.chain]) byChain[r.chain] = [] byChain[r.chain].push(r) } const TABLE_MAP: Record = { input: "filter", forward: "filter", output: "filter", srcnat: "nat", dstnat: "nat", prerouting: "mangle", postrouting: "mangle", } for (const [chain, chainRules] of Object.entries(byChain)) { const table = chainRules[0]?.table ?? TABLE_MAP[chain] ?? "filter" const familyPath = chainRules[0]?.family === "ip6" ? "/ipv6 firewall" : "/ip firewall" lines.push(`# ── ${familyPath} ${table} chain=${chain} ──────────────────────────────`) for (const r of chainRules) { const parts = [`${familyPath} ${table} add`] parts.push(`chain=${r.chain}`) parts.push(`action=${r.action}`) if (r.proto && r.proto !== "all") parts.push(`protocol=${r.proto}`) if (r.src && r.src !== "—") parts.push(`src-address-list=${r.src}`) if (r.dst && r.dst !== "—") parts.push(`dst-address-list=${r.dst}`) if (r.port && r.port !== "—") parts.push(`dst-port=${r.port}`) if (r.iface && r.iface !== "—") parts.push(`in-interface=${r.iface}`) if (r.comment) parts.push(`comment="${r.comment}"`) if (!r.enabled) parts.push(`disabled=yes`) lines.push(parts.join(" \\\n ")) lines.push("") } } return lines.join("\n") } // ─── Rule Sheet ─────────────────────────────────────────────────────────────── function RuleSheet({ open, onClose, initialRule, chainGroup, family, onSave, saving = false }: { open: boolean onClose: () => void initialRule: Partial | null chainGroup: ChainGroup family: IpFamily onSave: (payload: RuleWritePayload) => void saving?: boolean }) { const isNew = !initialRule?.id const [form, setForm] = useState(defaultForm) useEffect(() => { if (!open) return setForm({ ...defaultForm, chain: initialRule?.chain ?? (CHAIN_GROUPS.find(g => g.id === chainGroup)?.chains[0] ?? "forward"), action: (initialRule?.action as string) ?? defaultForm.action, proto: initialRule?.proto ?? defaultForm.proto, comment: initialRule?.comment ?? "", enabled: initialRule?.enabled ?? true, srcAddrList: initialRule?.src && initialRule.src !== "—" ? initialRule.src : "", dstAddrList: initialRule?.dst && initialRule.dst !== "—" ? initialRule.dst : "", dstPort: initialRule?.port && initialRule.port !== "—" ? initialRule.port : "", inIface: initialRule?.iface && initialRule.iface !== "—" ? initialRule.iface : "", log: initialRule?.log ?? false, logPrefix: initialRule?.logPrefix ?? "", }) }, [open, initialRule, chainGroup]) const set = (k: K, v: RuleForm[K]) => setForm((f) => ({ ...f, [k]: v })) const chainsForGroup = CHAIN_GROUPS.find(g => g.id === chainGroup)?.chains ?? ["forward"] const tableName = tableOfGroup(chainGroup) ?? "filter" const familyPath = family === "ip6" ? "/ipv6 firewall" : "/ip firewall" const showNatActions = chainGroup === "nat" const actions = showNatActions ? ["masquerade", "dst-nat", "src-nat", "netmap", "same", "passthrough"] : chainGroup === "mangle" ? ["mark-routing", "mark-conn", "mark-packet", "strip-ipv4-options", "passthrough", "add-src-to-address-list", "add-dst-to-address-list"] : ["accept", "drop", "reject", "fasttrack-connection", "add-src-to-address-list", "add-dst-to-address-list", "passthrough"] return ( { if (!v) onClose() }}> {isNew ? "Новое правило" : "Редактировать правило"} {familyPath} {tableName} · {IP_FAMILY_LABELS[family]} · RouterOS 7
{/* Chain + Action */}
Цепочка и действие
set("chain", v)}> {chainsForGroup.map((c) => )} set("action", v)}> {actions.map((a) => )}
{/* Matching */}
Условие совпадения set("proto", v)}> {["all","tcp","udp","icmp","gre","esp","ah","ipencap","ospf"].map((p) => )}
set("srcAddrList", e.target.value)} /> set("dstAddrList", e.target.value)} />
set("srcPort", e.target.value)} /> set("dstPort", e.target.value)} />
set("inIface", e.target.value)} /> set("outIface", e.target.value)} />
set("connState", e.target.value)} />
{/* Log + Comment */}
Логирование и комментарий

Log

Записывать совпадения в системный лог

set("log", v)} />
{form.log && ( set("logPrefix", e.target.value)} /> )} set("comment", e.target.value)} />
{/* Enabled */}

Правило включено

Отключённые правила сохраняются, но не применяются

set("enabled", v)} />
{/* CLI preview */}

RouterOS CLI preview

              {[
                `${familyPath} ${tableName} add \\`,
                `  chain=${form.chain} \\`,
                `  action=${form.action}`,
                form.proto !== "all" ? `  protocol=${form.proto} \\` : null,
                form.srcAddrList
                  ? looksLikeAddress(form.srcAddrList)
                    ? `  src-address=${form.srcAddrList} \\`
                    : `  src-address-list=${form.srcAddrList} \\`
                  : null,
                form.dstAddrList
                  ? looksLikeAddress(form.dstAddrList)
                    ? `  dst-address=${form.dstAddrList} \\`
                    : `  dst-address-list=${form.dstAddrList} \\`
                  : null,
                form.dstPort     ? `  dst-port=${form.dstPort} \\`             : null,
                form.inIface     ? `  in-interface=${form.inIface} \\`         : null,
                form.outIface    ? `  out-interface=${form.outIface} \\`        : null,
                form.connState   ? `  connection-state=${form.connState} \\`   : null,
                form.log         ? `  log=yes \\`                              : null,
                form.log && form.logPrefix ? `  log-prefix="${form.logPrefix}" \\` : null,
                form.comment     ? `  comment="${form.comment}"`               : null,
                !form.enabled    ? `  disabled=yes`                            : null,
              ].filter(Boolean).join("\n")}
            
}>Отмена
) } // ─── Export Sheet ───────────────────────────────────────────────────────────── function ExportSheet({ open, onClose, rules }: { open: boolean; onClose: () => void; rules: FirewallRule[] }) { const timestamp = useMemo( () => open ? new Date().toLocaleString("ru") : undefined, [open], ) const code = useMemo(() => generateRsc(rules, timestamp), [rules, timestamp]) return ( ) } // ─── Address List tab ───────────────────────────────────────────────────────── function AddressListsTab({ entries, onAdd, onAddToList, onToggle, onEdit, onDelete, showServer = false, }: { entries: AddressListEntry[] onAdd: () => void onAddToList: (list: string) => void onToggle: (entry: AddressListEntry) => void onEdit: (entry: AddressListEntry) => void onDelete: (entry: AddressListEntry) => void showServer?: boolean }) { const [search, setSearch] = useState("") const [listFilter, setListFilter] = useState("all") const lists = useMemo(() => { const s = new Set(entries.map((e) => e.list)) return ["all", ...Array.from(s)] }, [entries]) useEffect(() => { if (listFilter !== "all" && !lists.includes(listFilter)) setListFilter("all") }, [lists, listFilter]) const filtered = useMemo(() => { return entries.filter((e) => { if (listFilter !== "all" && e.list !== listFilter) return false if (!search) return true const q = search.toLowerCase() return e.address.includes(q) || e.list.includes(q) || e.comment.toLowerCase().includes(q) || (e.serverName ?? "").toLowerCase().includes(q) }) }, [entries, search, listFilter]) const grouped = useMemo(() => { if (listFilter !== "all") return { [listFilter]: filtered } const g: Record = {} for (const e of filtered) { if (!g[e.list]) g[e.list] = [] g[e.list].push(e) } return g }, [filtered, listFilter]) return (
setSearch(e.target.value)} />
{lists.map((l) => ( ))}
{filtered.length} записей
{Object.entries(grouped).map(([listName, listEntries]) => (
{listName} ({listEntries.length})
{listEntries.map((e) => (
{e.address} {e.comment} {showServer && e.serverName && ( {e.serverName} )} {e.timeout && ( ⏱ {e.timeout} )} onToggle(e)} /> } /> onEdit(e)}>Редактировать onToggle(e)}> {e.disabled ? "Включить" : "Отключить"} onDelete(e)}> Удалить
))}
))}
) } const defaultAddrForm = { list: "", address: "", comment: "", timeout: "", enabled: true, } function AddressListSheet({ open, onClose, initial, family, onSave, saving = false, }: { open: boolean onClose: () => void initial: Partial | null family: IpFamily onSave: (payload: { list: string; address: string; comment?: string; timeout?: string; disabled?: boolean }) => void saving?: boolean }) { const isNew = !initial?.id const [form, setForm] = useState(defaultAddrForm) useEffect(() => { if (!open) return setForm({ list: initial?.list ?? "", address: initial?.address ?? "", comment: initial?.comment ?? "", timeout: initial?.timeout ?? "", enabled: !(initial?.disabled ?? false), }) }, [open, initial]) return ( { if (!v) onClose() }}> {isNew ? "Запись address-list" : "Редактировать запись"} {family === "ip6" ? "/ipv6 firewall address-list" : "/ip firewall address-list"} · {IP_FAMILY_LABELS[family]}
setForm((f) => ({ ...f, list: e.target.value }))} /> setForm((f) => ({ ...f, address: e.target.value }))} /> setForm((f) => ({ ...f, comment: e.target.value }))} /> setForm((f) => ({ ...f, timeout: e.target.value }))} />

Запись включена

Отключённые адреса не матчятся

setForm((f) => ({ ...f, enabled: v }))} />
}>Отмена
) } // ─── Verdict helpers ────────────────────────────────────────────────────────── function getVerdictIcon(action: string) { if (action === "drop" || action === "reject") return if (action === "fasttrack-connection") return if (action.startsWith("mark-")) return return } function getVerdictColors(action: string) { if (action === "drop" || action === "reject") return { bg: "bg-red-500/10", border: "border-red-500/30", text: "text-red-600 dark:text-red-400" } if (action === "fasttrack-connection") return { bg: "bg-violet-500/10", border: "border-violet-500/30", text: "text-violet-600 dark:text-violet-400" } if (action === "masquerade" || action.endsWith("-nat")) return { bg: "bg-blue-500/10", border: "border-blue-500/30", text: "text-blue-600 dark:text-blue-400" } if (action.startsWith("mark-")) return { bg: "bg-amber-500/10", border: "border-amber-500/30", text: "text-amber-600 dark:text-amber-400" } return { bg: "bg-emerald-500/10", border: "border-emerald-500/30", text: "text-emerald-600 dark:text-emerald-400" } } // ─── SimStepRow ─────────────────────────────────────────────────────────────── function SimStepRow({ step, index }: { step: SimStep; index: number }) { const [manualExpanded, setExpanded] = useState(false) // Auto-expand on match/evaluating; user can also toggle manually const expanded = manualExpanded || step.status === "match" || step.status === "evaluating" const isClickable = step.checks.length > 0 return (
{/* Expanded checks */} {expanded && step.checks.length > 0 && (
{step.checks.map((c, ci) => (
{c.passed ? : } {c.field}: {c.ruleVal} {!c.passed && ( <> ≠ {c.packetVal} )}
))}
)}
) } // ─── Scenario constants + helpers ──────────────────────────────────────────── const SCENARIO_STORAGE_KEY = "routerlists-fw-scenarios" const DEFAULT_SRULE: Omit = { chain: "forward", action: "accept", proto: "all", src: "", dst: "", port: "", iface: "", comment: "", enabled: true, } function scenarioRuleToFw(r: ScenarioRule): FirewallRule { return { ...r, hits: 0, action: r.action as FirewallRule["action"] } } // ─── Scenario Sheet ─────────────────────────────────────────────────────────── function ScenarioSheet({ open, onClose, initial, onSave }: { open: boolean onClose: () => void initial: SimScenario | null onSave: (s: SimScenario) => void }) { const isNew = !initial const [name, setName] = useState(initial?.name ?? "") const [desc, setDesc] = useState(initial?.description ?? "") const [pkt, setPkt2] = useState(initial?.packet ?? DEFAULT_PKT) const [rules, setRules] = useState(initial?.rules ?? []) const [addOpen, setAddOpen]= useState(false) const [addForm, setAddForm]= useState>(DEFAULT_SRULE) // Reset form fields each time the sheet opens — standard modal initialization pattern useEffect(() => { if (!open) return // eslint-disable-next-line react-hooks/set-state-in-effect setName(initial?.name ?? ""); setDesc(initial?.description ?? "") setPkt2(initial?.packet ?? DEFAULT_PKT); setRules(initial?.rules ?? []) setAddForm(DEFAULT_SRULE); setAddOpen(false) }, [open, initial]) const setP = (k: K, v: PacketDef[K]) => setPkt2(p => ({ ...p, [k]: v })) const setAF = >(k: K, v: Omit[K]) => setAddForm(f => ({ ...f, [k]: v })) function addRule() { setRules(prev => [...prev, { ...addForm, id: `sr-${Date.now()}` }]) setAddForm(DEFAULT_SRULE) setAddOpen(false) } function removeRule(id: string) { setRules(prev => prev.filter(r => r.id !== id)) } function toggleEnabled(id: string) { setRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r)) } function moveRule(id: string, dir: -1 | 1) { setRules(prev => { const idx = prev.findIndex(r => r.id === id) const swap = idx + dir if (idx < 0 || swap < 0 || swap >= prev.length) return prev const next = [...prev]; [next[idx], next[swap]] = [next[swap], next[idx]] return next }) } function handleSave() { if (!name.trim()) return onSave({ id: initial?.id ?? `scenario-${Date.now()}`, name: name.trim(), description: desc.trim(), packet: pkt, rules, createdAt: initial?.createdAt ?? Date.now(), }) onClose() } const CHAIN_OPTS = ["forward","input","output","dstnat","srcnat","prerouting","postrouting"] const PROTO_OPTS = ["all","tcp","udp","icmp","gre","esp"] const ACTION_OPTS = [ "accept","drop","reject","mark-routing","mark-conn","masquerade", "fasttrack-connection","passthrough","add-src-to-address-list","add-dst-to-address-list", ] return ( { if (!v) onClose() }}> {isNew ? "Новый сценарий" : `Редактировать: ${initial?.name}`} Набор правил firewall/mangle/nat + тестовый пакет
{/* Meta */}
Название
setName(e.target.value)} /> setDesc(e.target.value)} />
{/* Default packet */}
Тестовый пакет по умолчанию
setP("chain", v)}> {["srcnat","prerouting","postrouting","ip6-forward","ip6-input","ip6-output"] .map(c => )} setP("proto", v)}> {PROTO_OPTS.map(p => )} setP("connState", e.target.value)} /> setP("srcAddr", e.target.value)} /> setP("dstAddr", e.target.value)} /> setP("dstPort", e.target.value)} /> setP("inIface", e.target.value)} /> setP("outIface", e.target.value)} /> setP("dstAddrList", e.target.value)} />
{/* Rules */}
Правила сценария ({rules.length})
{/* Add-rule form */} {addOpen && (

Новое правило

setAF("chain", v)}> {CHAIN_OPTS.map(c => )}
setAF("action", v)}> {ACTION_OPTS.map(a => )}
setAF("proto", v)}> {PROTO_OPTS.map(p => )}
setAF("port", e.target.value)} />
setAF("src", e.target.value)} />
setAF("dst", e.target.value)} />
setAF("iface", e.target.value)} />
setAF("comment", e.target.value)} />
setAF("enabled", v)} /> Включено
)} {/* Rules table */} {rules.length > 0 ? ( moveRule(id, -1)} onMoveDown={(id) => moveRule(id, 1)} onRemove={removeRule} /> ) : ( !addOpen && (
Нет правил — нажмите «Добавить правило»
) )}
}>Отмена ) } // ─── Simulator Tab ──────────────────────────────────────────────────────────── const SIM_SPEED_MS: Record = { slow: 700, normal: 350, fast: 120, instant: 0 } const SIM_SPEED_LABELS: Record = { slow: "Медленно", normal: "Нормально", fast: "Быстро", instant: "Мгновенно", } function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) { // ── Core sim state ────────────────────────────────────────────────────────── const [packet, setPacket] = useState(DEFAULT_PKT) const [activePreset, setPreset] = useState("https") const [simState, setSimState] = useState<"idle" | "running" | "done">("idle") const [steps, setSteps] = useState([]) const [speed, setSpeed] = useState("normal") const [verdict, setVerdict] = useState<{ action: string; ruleIndex: number | null } | null>(null) const cancelRef = useRef(false) // ── Scenarios state ───────────────────────────────────────────────────────── const [scenarios, setScenarios] = useState(() => { if (typeof window === "undefined") return [] try { return JSON.parse(localStorage.getItem(SCENARIO_STORAGE_KEY) ?? "[]") } catch { return [] } }) const [activeScenarioId, setActiveScenarioId] = useState(null) const [scenarioSheetOpen, setScenarioSheetOpen] = useState(false) const [editingScenario, setEditingScenario] = useState(null) useEffect(() => { localStorage.setItem(SCENARIO_STORAGE_KEY, JSON.stringify(scenarios)) }, [scenarios]) const activeScenario = scenarios.find(s => s.id === activeScenarioId) ?? null // Live rules vs scenario rules const effectiveRules: FirewallRule[] = useMemo( () => activeScenario ? activeScenario.rules.map(scenarioRuleToFw) : allRules, [activeScenario, allRules], ) const setPkt = (k: K, v: PacketDef[K]) => setPacket(p => ({ ...p, [k]: v })) // All rules in traversal order across every chain the packet touches const simRules = useMemo(() => { const path = DIR_PATHS[packet.chain] ?? [packet.chain] return path.flatMap(chain => effectiveRules.filter(r => r.chain === chain)) }, [effectiveRules, packet.chain]) // ── Handlers ──────────────────────────────────────────────────────────────── function resetSim() { cancelRef.current = true setSimState("idle") setSteps([]) setVerdict(null) } function applyPreset(p: typeof SIM_PRESETS[number]) { setActiveScenarioId(null) setPreset(p.id) setPacket({ ...DEFAULT_PKT, ...p.pkt }) cancelRef.current = true setSimState("idle") setSteps([]) setVerdict(null) } function activateScenario(id: string | null) { setActiveScenarioId(id) setPreset(null) cancelRef.current = true setSimState("idle") setSteps([]) setVerdict(null) if (id) { const sc = scenarios.find(s => s.id === id) if (sc) setPacket(sc.packet) } } function saveScenario(sc: SimScenario) { setScenarios(prev => { const idx = prev.findIndex(x => x.id === sc.id) if (idx >= 0) { const next = [...prev]; next[idx] = sc; return next } return [...prev, sc] }) activateScenario(sc.id) } function deleteScenario(id: string) { setScenarios(prev => prev.filter(s => s.id !== id)) if (activeScenarioId === id) { setActiveScenarioId(null); resetSim() } } async function runSimulation() { const path = DIR_PATHS[packet.chain] ?? [packet.chain] const rules = path.flatMap(chain => effectiveRules.filter(r => r.chain === chain)) if (rules.length === 0) { setVerdict({ action: "accept (default)", ruleIndex: null }) setSimState("done") return } cancelRef.current = false setSimState("running") setVerdict(null) setSteps(rules.map((r, i) => ({ rule: r, index: i, status: "pending", checks: [] }))) const delay = SIM_SPEED_MS[speed] let finalVerdict: { action: string; ruleIndex: number | null } | null = null for (let i = 0; i < rules.length; i++) { if (cancelRef.current) return setSteps(prev => prev.map((s, idx) => idx === i ? { ...s, status: "evaluating" } : s)) if (delay > 0) await new Promise(res => setTimeout(res, delay)) if (cancelRef.current) return const result = evalRule(rules[i], packet) setSteps(prev => prev.map((s, idx) => idx === i ? { ...s, status: result.verdict, checks: result.checks, failedAt: result.failedAt } : s, )) if (result.verdict === "match") { finalVerdict = { action: rules[i].action, ruleIndex: i } if (delay > 0) await new Promise(res => setTimeout(res, Math.max(delay * 0.5, 200))) break } if (delay > 0) await new Promise(res => setTimeout(res, Math.max(delay * 0.25, 40))) } if (!cancelRef.current) { setVerdict(finalVerdict ?? { action: "accept (default)", ruleIndex: null }) setSimState("done") } } const verdictColors = verdict ? getVerdictColors(verdict.action) : null return (
{/* ── Presets + Scenarios card ─────────────────────────────────────────── */}
{SIM_PRESETS.map(p => ( ))}
{/* Saved scenarios */}

Мои сценарии

{scenarios.length === 0 ? (

Нет сохранённых сценариев — создайте набор правил firewall/mangle/nat для тестирования

) : (
{scenarios.map(sc => { const isActive = activeScenarioId === sc.id return (
{sc.name} {sc.rules.length} правил · {sc.packet.chain} {sc.description ? ` · ${sc.description}` : ""}
) })}
)}
{/* ── Packet editor ───────────────────────────────────────────────────── */}
{activeScenario ? `🎭 ${activeScenario.name}` : "🔴 Живые правила"}
{simRules.length} правил ·{" "} {packet.chain} {DIR_PATHS[packet.chain] && ( {DIR_PATHS[packet.chain].join(" → ")} )}
{/* Row 1 */}
{ setPkt("chain", v); resetSim() }}>
setPkt("proto", v)}> {["tcp","udp","icmp","gre","esp","ah"].map(p => )}
setPkt("srcAddr", e.target.value)} />
setPkt("dstAddr", e.target.value)} />
setPkt("srcPort", e.target.value)} />
setPkt("dstPort", e.target.value)} />
{/* Row 2 */}
setPkt("inIface", e.target.value)} />
setPkt("outIface", e.target.value)} />
setPkt("connState", e.target.value)} />
setPkt("dstAddrList", e.target.value)} />
{/* Speed + Run controls */}
{(["slow","normal","fast","instant"] as SimSpeed[]).map(s => ( ))}
{simState === "running" ? ( ) : ( )}
{/* ── Verdict banner ──────────────────────────────────────────────────── */} {verdict && simState === "done" && verdictColors && (
{getVerdictIcon(verdict.action)}

{verdict.action === "drop" || verdict.action === "reject" ? "Пакет заблокирован" : verdict.ruleIndex === null ? "Пакет принят (по умолчанию)" : "Пакет принят"} {verdict.ruleIndex !== null && ( — правило #{verdict.ruleIndex + 1} )}

Действие: {verdict.action} {verdict.ruleIndex === null && · ни одно правило не совпало}

)} {/* ── Trace list ──────────────────────────────────────────────────────── */} {steps.length > 0 && (

Трассировка · цепочка: {packet.chain} {activeScenario && ( · {activeScenario.name} )}

{steps.filter(s => s.status === "match").length} совпало {" · "}{steps.filter(s => s.status === "skip").length} пропущено {" · "}{steps.filter(s => s.status === "disabled").length} выкл
{steps.map((step, i) => { const prevChain = i > 0 ? steps[i - 1].rule.chain : null const showHeader = step.rule.chain !== prevChain return (
{showHeader && (
{step.rule.chain} {CHAIN_TABLE_HINT[step.rule.chain] && ( · {CHAIN_TABLE_HINT[step.rule.chain]} )} {steps.filter(s => s.rule.chain === step.rule.chain).length} правил
)}
) })}
)} {/* ── Empty state ─────────────────────────────────────────────────────── */} {simState === "idle" && simRules.length === 0 && (

Нет правил в цепочке «{packet.chain}»

{activeScenario ? "Добавьте правила в сценарий через кнопку «Редактировать»" : "Выберите другой пресет или измените цепочку"}

)} {/* ── Scenario Sheet ───────────────────────────────────────────────────── */} setScenarioSheetOpen(false)} initial={editingScenario} onSave={saveScenario} />
) } // ─── Live API ───────────────────────────────────────────────────────────────── interface BackendServer { id: number name: string host: string type: "jump-host" | "exit-node" | "home-router" site: string country: string asn: string enabled: boolean status: "online" | "offline" | null latency: number | null } interface FirewallAllResponse { rules: FirewallRule[] addressLists: AddressListEntry[] } function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { return requestJson(backendUrl, path, init) } } function mapBackendToServer(s: BackendServer): Server { return { id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—", site: s.site, country: s.country || "UN", asn: s.asn, type: s.type, enabled: s.enabled, status: (s.status ?? "offline") as Server["status"], latency: s.latency != null ? Math.round(s.latency) : null, sessions: 0, } } function ruleFamily(r: Pick): IpFamily { return r.family === "ip6" ? "ip6" : "ip" } function addressFamily(e: Pick): IpFamily { return e.family === "ip6" ? "ip6" : "ip" } function ruleKey(r: FirewallRule) { if (!r.serverId || !r.rosId || !r.table) return null return { serverId: r.serverId, family: ruleFamily(r), table: r.table, rosId: r.rosId } } function sameRuleGroup(a: FirewallRule, b: FirewallRule): boolean { return ( a.serverId === b.serverId && ruleFamily(a) === ruleFamily(b) && (a.table ?? "filter") === (b.table ?? "filter") ) } function moveInArray(arr: T[], from: number, to: number): T[] { if (from < 0 || to < 0 || from >= arr.length || to >= arr.length) return arr const copy = [...arr] const [moved] = copy.splice(from, 1) if (moved === undefined) return arr copy.splice(to, 0, moved) return copy } function reorderWithinGroup(list: FirewallRule[], activeId: string, overId: string): FirewallRule[] | null { const active = list.find((r) => r.id === activeId) const over = list.find((r) => r.id === overId) if (!active || !over || !sameRuleGroup(active, over)) return null const same = (r: FirewallRule) => sameRuleGroup(r, active) const group = list.filter(same) const from = group.findIndex((r) => r.id === activeId) const to = group.findIndex((r) => r.id === overId) if (from < 0 || to < 0 || from === to) return null const nextGroup = moveInArray(group, from, to) let i = 0 return list.map((r) => (same(r) ? nextGroup[i++]! : r)) } /** RouterOS move: insert-before destination. Omit dest = конец таблицы. */ function rosMoveDestination(list: FirewallRule[], activeId: string, overId: string): { dest?: string } | null { const active = list.find((r) => r.id === activeId) const over = list.find((r) => r.id === overId) if (!active || !over || !sameRuleGroup(active, over)) return null const group = list.filter((r) => sameRuleGroup(r, active)) const from = group.findIndex((r) => r.id === activeId) const to = group.findIndex((r) => r.id === overId) if (from < 0 || to < 0 || from === to) return null if (from < to) { const dest = group[to + 1]?.rosId return dest ? { dest } : {} } const dest = group[to]?.rosId return dest ? { dest } : {} } function addressKey(e: AddressListEntry) { if (!e.serverId || !e.rosId) return null return { serverId: e.serverId, family: addressFamily(e), rosId: e.rosId } } function payloadToMockRule( payload: RuleWritePayload, opts: { id: string; table: FirewallTable; family: IpFamily; serverId: string; serverName: string; rosId: string }, ): FirewallRule { return { id: opts.id, table: opts.table, family: opts.family, serverId: opts.serverId, serverName: opts.serverName, rosId: opts.rosId, chain: payload.chain, action: payload.action, proto: payload.protocol ?? "all", src: payload.srcAddress ?? payload.srcAddressList ?? "—", dst: payload.dstAddress ?? payload.dstAddressList ?? "—", port: payload.dstPort ?? payload.srcPort ?? "—", iface: payload.inInterface ?? payload.outInterface ?? "—", comment: payload.comment ?? "", enabled: !payload.disabled, hits: 0, log: payload.log, logPrefix: payload.logPrefix, } } // ════════════════════════════════════════════════════════════════════════════ function FirewallPageInner() { const { mode, backendUrl } = useDataSource() const isLive = mode === "live" const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) const searchParams = useSearchParams() const serverFromQuery = searchParams.get("server") const [liveServers, setLiveServers] = useState([]) const [liveRules, setLiveRules] = useState([]) const [liveAddrLists, setLiveAddrLists] = useState([]) const [mockRules, setMockRules] = useState(firewallRules) const [mockAddrLists, setMockAddrLists] = useState(INIT_ADDRESS_LISTS) const [dataLoading, setDataLoading] = useState(false) const [dataError, setDataError] = useState(null) const [busy, setBusy] = useState(false) const [selectedServerId, setSelectedServerId] = useState(serverFromQuery ?? ALL_SERVERS_ID) const appliedQueryRef = useRef(false) const [chainGroup, setChainGroup] = useState("filter") const [chainFilter, setChainFilter] = useState("all") const [ipFamily, setIpFamily] = useState("ip") const [search, setSearch] = useState("") const [editingRule, setEditingRule] = useState | null>(null) const [ruleSheetOpen, setRuleSheetOpen] = useState(false) const [exportOpen, setExportOpen] = useState(false) const [editingAddr, setEditingAddr] = useState | null>(null) const [addrSheetOpen, setAddrSheetOpen] = useState(false) const [historyOpen, setHistoryOpen] = useState(false) const [revisions, setRevisions] = useState([]) const [historyLoading, setHistoryLoading] = useState(false) const [historyRestoring, setHistoryRestoring] = useState(false) const loadLive = useCallback(async () => { if (!isLive) return setDataLoading(true) setDataError(null) try { const [backendServers, fw] = await Promise.all([ apiFetch("/api/servers"), apiFetch("/api/firewall/all"), ]) setLiveServers(backendServers.map(mapBackendToServer)) setLiveRules(fw.rules) setLiveAddrLists(fw.addressLists) } catch (e) { setDataError(e instanceof Error ? e.message : "Ошибка загрузки") setLiveServers([]) setLiveRules([]) setLiveAddrLists([]) } finally { setDataLoading(false) } }, [isLive, apiFetch]) const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId const loadRevisions = useCallback(async () => { if (!isLive || !historyServerId) return setHistoryLoading(true) try { const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>( `/api/firewall/revisions?serverId=${encodeURIComponent(historyServerId)}`, ) setRevisions(res.revisions) } catch (err) { toast.error("Не удалось загрузить историю", { description: String(err) }) setRevisions([]) } finally { setHistoryLoading(false) } }, [isLive, historyServerId, apiFetch]) const restoreRevision = useCallback(async (id: string) => { if (!isLive || !historyServerId) return setHistoryRestoring(true) try { await apiFetch( `/api/firewall/revisions/${encodeURIComponent(id)}/restore`, { method: "POST", body: JSON.stringify({ serverId: historyServerId }) }, ) toast.success("Версия применена на роутер") await loadLive() await loadRevisions() } catch (err) { toast.error("Не удалось откатить", { description: String(err) }) } finally { setHistoryRestoring(false) } }, [isLive, historyServerId, apiFetch, loadLive, loadRevisions]) useEffect(() => { if (!isLive) { queueMicrotask(() => { setLiveServers([]) setLiveRules([]) setLiveAddrLists([]) setDataError(null) }) return } queueMicrotask(() => { void loadLive() }) }, [isLive, loadLive]) useEffect(() => { if (dataError) toast.error(dataError) }, [dataError]) const displayRules = isLive ? liveRules : mockRules const displayAddrLists = isLive ? liveAddrLists : mockAddrLists const displayServers = isLive ? liveServers : mockServers useEffect(() => { if (appliedQueryRef.current || !serverFromQuery) return if (displayServers.length === 0) return if (displayServers.some((s) => s.id === serverFromQuery)) { setSelectedServerId(serverFromQuery) } appliedQueryRef.current = true }, [displayServers, serverFromQuery]) const scopedRules = useMemo(() => { if (selectedServerId === ALL_SERVERS_ID) return displayRules return displayRules.filter((r) => r.serverId === selectedServerId) }, [displayRules, selectedServerId]) const scopedAddrLists = useMemo(() => { if (selectedServerId === ALL_SERVERS_ID) return displayAddrLists return displayAddrLists.filter((e) => e.serverId === selectedServerId) }, [displayAddrLists, selectedServerId]) const familyRules = useMemo( () => scopedRules.filter((r) => ruleFamily(r) === ipFamily), [scopedRules, ipFamily], ) const familyAddrLists = useMemo( () => scopedAddrLists.filter((e) => addressFamily(e) === ipFamily), [scopedAddrLists, ipFamily], ) const firewallRailItems = useMemo(() => ( displayServers.map((s) => ({ id: s.id, name: s.name, host: s.host, site: s.site, country: s.country, status: s.status, type: s.type, enabled: s.enabled, meta: String(displayRules.filter((r) => r.serverId === s.id).length), })) ), [displayServers, displayRules]) const writeServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId const writeServer = displayServers.find((s) => s.id === writeServerId) const chainsInGroup = useMemo(() => { if (chainGroup === "address-lists" || chainGroup === "simulator") return [] return CHAIN_GROUPS.find((g) => g.id === chainGroup)?.chains ?? [] }, [chainGroup]) const groupRules = useMemo(() => { const table = tableOfGroup(chainGroup) if (!table) return [] return familyRules.filter((r) => r.table === table) }, [familyRules, chainGroup]) const filteredRules = useMemo(() => { return groupRules.filter((r) => { if (chainFilter !== "all" && r.chain !== chainFilter) return false if (!search) return true const q = search.toLowerCase() return ( r.chain.toLowerCase().includes(q) || r.action.toLowerCase().includes(q) || r.src.toLowerCase().includes(q) || r.dst.toLowerCase().includes(q) || r.comment.toLowerCase().includes(q) || (r.serverName ?? "").toLowerCase().includes(q) ) }) }, [groupRules, chainFilter, search]) const chainCounts = useMemo(() => { const counts: Record = { all: groupRules.length } for (const r of groupRules) { counts[r.chain] = (counts[r.chain] ?? 0) + 1 } return counts }, [groupRules]) const totalEnabled = familyRules.filter((r) => r.enabled).length const totalHits = familyRules.reduce((s, r) => s + r.hits, 0) const dropRules = familyRules.filter((r) => r.action === "drop" || r.action === "reject").length const showServerCol = selectedServerId === ALL_SERVERS_ID function requireWriteServer(): string | null { if (writeServerId) return writeServerId toast.info("Выберите сервер в панели слева") return null } async function toggleRule(id: string) { const r = displayRules.find((x) => x.id === id) if (!r) return if (isLive) { const key = ruleKey(r) if (!key) return try { await apiFetch("/api/firewall/rules", { method: "PATCH", body: JSON.stringify({ ...key, disabled: r.enabled }), }) await loadLive() } catch (e) { toast.error(e instanceof Error ? e.message : "Не удалось переключить правило") } return } setMockRules((rs) => rs.map((x) => x.id === id ? { ...x, enabled: !x.enabled } : x)) } async function deleteRule(r: FirewallRule) { if (isLive) { const key = ruleKey(r) if (!key) return try { await apiFetch("/api/firewall/rules", { method: "DELETE", body: JSON.stringify(key) }) toast.success("Правило удалено") await loadLive() } catch (e) { toast.error(e instanceof Error ? e.message : "Не удалось удалить правило") } return } setMockRules((rs) => rs.filter((x) => x.id !== r.id)) } const canReorder = useMemo(() => { if (filteredRules.length < 2) return false const servers = new Set(filteredRules.map((r) => r.serverId).filter(Boolean)) return servers.size === 1 }, [filteredRules]) async function reorderRules(activeId: string, overId: string) { const source = isLive ? liveRules : mockRules const next = reorderWithinGroup(source, activeId, overId) if (!next) { toast.info("Правила можно переставлять только в пределах одного сервера и таблицы") return } if (!isLive) { setMockRules(next) return } const active = source.find((r) => r.id === activeId) const key = active ? ruleKey(active) : null if (!key) { toast.error("У правила нет идентификатора RouterOS") return } const dest = rosMoveDestination(source, activeId, overId) if (!dest) return const prev = source setLiveRules(next) try { await apiFetch("/api/firewall/rules/move", { method: "POST", body: JSON.stringify({ ...key, ...(dest.dest ? { destinationRosId: dest.dest } : {}), }), }) await loadLive() } catch (e) { setLiveRules(prev) toast.error(e instanceof Error ? e.message : "Не удалось переместить правило") } } function openAdd() { if (!requireWriteServer()) return const table = tableOfGroup(chainGroup) if (!table) { setChainGroup("filter") } setEditingRule(null) setRuleSheetOpen(true) } function openEdit(r: FirewallRule) { setEditingRule(r) setRuleSheetOpen(true) } async function saveRule(payload: RuleWritePayload) { const table = (editingRule?.table ?? tableOfGroup(chainGroup) ?? "filter") as FirewallTable const family = editingRule?.id ? ruleFamily(editingRule as FirewallRule) : ipFamily const serverId = editingRule?.serverId ?? requireWriteServer() if (!serverId) return const serverName = editingRule?.serverName ?? displayServers.find((s) => s.id === serverId)?.name ?? writeServer?.name ?? serverId if (isLive) { setBusy(true) try { if (editingRule?.rosId) { await apiFetch("/api/firewall/rules", { method: "PUT", body: JSON.stringify({ serverId, family, table, rosId: editingRule.rosId, ...payload }), }) toast.success("Правило сохранено") } else { await apiFetch("/api/firewall/rules", { method: "POST", body: JSON.stringify({ serverId, family, table, ...payload }), }) toast.success("Правило создано") } setRuleSheetOpen(false) await loadLive() } catch (e) { toast.error(e instanceof Error ? e.message : "Не удалось сохранить правило") } finally { setBusy(false) } return } if (editingRule?.id) { setMockRules((rs) => rs.map((x) => x.id === editingRule.id ? { ...x, ...payloadToMockRule(payload, { id: x.id, table, family, serverId, serverName, rosId: x.rosId ?? "*0", }) } : x)) } else { const id = `fw-${Date.now()}` setMockRules((rs) => [...rs, payloadToMockRule(payload, { id, table, family, serverId, serverName, rosId: `*${Date.now()}`, })]) } setRuleSheetOpen(false) } function openAddAddr(list?: string) { if (!requireWriteServer()) return setEditingAddr(list ? { list } : null) setAddrSheetOpen(true) } function openEditAddr(e: AddressListEntry) { setEditingAddr(e) setAddrSheetOpen(true) } async function toggleAddr(e: AddressListEntry) { if (isLive) { const key = addressKey(e) if (!key) return try { await apiFetch("/api/firewall/address-lists", { method: "PATCH", body: JSON.stringify({ ...key, disabled: !e.disabled }), }) await loadLive() } catch (err) { toast.error(err instanceof Error ? err.message : "Не удалось переключить запись") } return } setMockAddrLists((rows) => rows.map((x) => x.id === e.id ? { ...x, disabled: !x.disabled } : x)) } async function deleteAddr(e: AddressListEntry) { if (isLive) { const key = addressKey(e) if (!key) return try { await apiFetch("/api/firewall/address-lists", { method: "DELETE", body: JSON.stringify(key) }) toast.success("Запись удалена") await loadLive() } catch (err) { toast.error(err instanceof Error ? err.message : "Не удалось удалить запись") } return } setMockAddrLists((rows) => rows.filter((x) => x.id !== e.id)) } async function saveAddr(payload: { list: string address: string comment?: string timeout?: string disabled?: boolean }) { const family = editingAddr?.id ? addressFamily(editingAddr as AddressListEntry) : ipFamily const serverId = editingAddr?.serverId ?? requireWriteServer() if (!serverId) return const serverName = editingAddr?.serverName ?? displayServers.find((s) => s.id === serverId)?.name ?? writeServer?.name ?? serverId if (isLive) { setBusy(true) try { if (editingAddr?.rosId) { await apiFetch("/api/firewall/address-lists", { method: "PUT", body: JSON.stringify({ serverId, family, rosId: editingAddr.rosId, ...payload }), }) toast.success("Запись сохранена") } else { await apiFetch("/api/firewall/address-lists", { method: "POST", body: JSON.stringify({ serverId, family, ...payload }), }) toast.success("Запись создана") } setAddrSheetOpen(false) await loadLive() } catch (err) { toast.error(err instanceof Error ? err.message : "Не удалось сохранить запись") } finally { setBusy(false) } return } if (editingAddr?.id) { setMockAddrLists((rows) => rows.map((x) => x.id === editingAddr.id ? { ...x, ...payload, family, serverId, serverName } : x)) } else { setMockAddrLists((rows) => [...rows, { id: `al-${Date.now()}`, list: payload.list, address: payload.address, comment: payload.comment ?? "", timeout: payload.timeout, disabled: payload.disabled ?? false, family, serverId, serverName, rosId: `*${Date.now()}`, }]) } setAddrSheetOpen(false) } const sheetChainGroup: ChainGroup = chainGroup === "address-lists" || chainGroup === "simulator" ? "filter" : chainGroup return ( <> } /> } >
{dataError && isLive && (
{dataError}
)}
{ setIpFamily(v); setChainFilter("all") }} options={(["ip", "ip6"] as IpFamily[]).map((f) => ({ value: f, label: IP_FAMILY_LABELS[f], }))} /> {ipFamily === "ip6" ? "/ipv6 firewall" : "/ip firewall"}
, iconClassName: "text-muted-foreground", }, { id: "enabled", label: "Активных", value: totalEnabled, icon: , iconClassName: "text-success", }, { id: "drop", label: "Блокирующих", value: dropRules, icon: , iconClassName: "text-destructive", }, { id: "hits", label: "Срабатываний", value: fmtHits(totalHits), icon: , iconClassName: "text-info", }, ]} />
{CHAIN_GROUPS.map((g) => ( ))}
{chainGroup === "address-lists" ? ( openAddAddr()} onAddToList={(list) => openAddAddr(list)} onToggle={(e) => { void toggleAddr(e) }} onEdit={openEditAddr} onDelete={(e) => { void deleteAddr(e) }} showServer={showServerCol} /> ) : chainGroup === "simulator" ? ( ) : ( ({ value: c, label: c, count: chainCounts[c] ?? 0, })), ]} /> setSearch(e.target.value)} /> {filteredRules.length} правил { void toggleRule(id) }} onEdit={openEdit} onDelete={(r) => { void deleteRule(r) }} onReorder={canReorder ? (activeId, overId) => { void reorderRules(activeId, overId) } : undefined} showServer={showServerCol} /> )}
{[ { title: "/ip firewall filter", lines: ["input — трафик на роутер", "forward — транзит", "output — от роутера"], }, { title: "/ipv6 firewall filter", lines: ["input / forward / output", "без префикса ip6-", "семейство — отдельный сегмент"], }, { title: "Новые матчеры 7.x", lines: [ "tls-host=*.google.com", "connection-rate=100/s", "layer7-protocol=...", ], }, { title: "Новые действия 7.x", lines: [ "fasttrack-connection", "nfqueue (queue-num=0)", "return", "passthrough", ], }, ].map((t) => (

{t.title}

    {t.lines.map((c) => (
  • ›{c}
  • ))}
))}
setRuleSheetOpen(false)} initialRule={editingRule} chainGroup={sheetChainGroup} family={editingRule?.id ? ruleFamily(editingRule as FirewallRule) : ipFamily} onSave={(payload) => { void saveRule(payload) }} saving={busy} /> setAddrSheetOpen(false)} initial={editingAddr} family={editingAddr?.id ? addressFamily(editingAddr as AddressListEntry) : ipFamily} onSave={(payload) => { void saveAddr(payload) }} saving={busy} /> setExportOpen(false)} rules={familyRules} /> ) } export default function FirewallPage() { return ( ) }