"use client" import { useEffect, useMemo, useRef, useState } from "react" import { PageHeader } from "@/components/page-header" import { firewallRules, type FirewallRule } from "@/lib/data" import { Card, CardContent } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" 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, MoreHorizontalIcon, PencilIcon, Trash2Icon, CopyIcon, CodeXmlIcon, CheckIcon, PowerIcon, CheckCircleIcon, PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon, CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon, PackageIcon, SlidersHorizontalIcon, } from "lucide-react" // ─── 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 } } interface AddressListEntry { id: string list: string address: string comment: string disabled: boolean timeout?: string } // ─── 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 }, { id: "al2", list: "youtube-bypass", address: "172.217.0.0/16", comment: "Google/YouTube AS15169", disabled: false }, { id: "al3", list: "cdn-bypass", address: "104.16.0.0/12", comment: "Cloudflare AS13335", disabled: false }, { id: "al4", list: "cdn-bypass", address: "23.32.0.0/11", comment: "Akamai AS20940", disabled: false }, { id: "al5", list: "cdn-bypass", address: "151.101.0.0/16", comment: "Fastly AS54113", disabled: false }, { id: "al6", list: "streaming-eu", address: "54.246.0.0/16", comment: "Netflix AS2906", disabled: false }, { id: "al7", list: "streaming-eu", address: "45.57.0.0/16", comment: "Netflix AS2906 NL", disabled: false }, { id: "al8", list: "social-block", address: "157.240.0.0/17", comment: "Facebook/Meta AS32934", disabled: false }, { id: "al9", list: "social-block", address: "31.13.64.0/18", comment: "Facebook/Meta AS32934", disabled: false }, { id: "al10", list: "gaming-low-latency",address: "162.159.128.0/19", comment: "Discord/Cloudflare AS13335", disabled: false }, { id: "al11", list: "management-access", address: "10.10.0.0/16", comment: "LAN", disabled: false }, { id: "al12", list: "management-access", address: "192.168.100.0/24", comment: "MGMT VLAN", disabled: false }, { id: "al13", list: "blocklist-dynamic", address: "185.220.101.45", comment: "Tor exit — dynamic ban", disabled: false, timeout: "01:00:00" }, { id: "al14", list: "blocklist-dynamic", address: "80.82.70.118", comment: "Shodan scanner — dynamic ban", disabled: false, timeout: "00:42:18" }, ] // ─── IP family filter ───────────────────────────────────────────────────────── type IpFamily = "all" | "ip" | "ip6" const IP_FAMILY_LABELS: Record = { all: "IPv4 + IPv6", ip: "IPv4", ip6: "IPv6", } // ─── Chain groups config ───────────────────────────────────────────────────── const CHAIN_GROUPS: { id: ChainGroup; label: string; icon: React.ReactNode; chains: string[]; ip6chains?: string[] }[] = [ { id: "filter", label: "Filter", icon: , chains: ["input","forward","output"], ip6chains: ["ip6-input","ip6-forward","ip6-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 // ─── 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) } function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { return ( ) } function ActionBadge({ action }: { action: string }) { const cls = ACTION_STYLES[action] ?? "bg-muted text-muted-foreground border-border" return ( {action} ) } function ChainBadge({ chain }: { chain: string }) { const cls = CHAIN_STYLES[chain] ?? "bg-muted text-muted-foreground" return ( {chain} ) } function Field({ label, hint, required, children }: { label: string; hint?: string; required?: boolean; children: React.ReactNode }) { return (
{children} {hint &&

{hint}

}
) } function SectionTitle({ children }: { children: React.ReactNode }) { return (
{children}
) } 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 = TABLE_MAP[chain] ?? "filter" lines.push(`# ── /ip firewall ${table} chain=${chain} ──────────────────────────────`) for (const r of chainRules) { const parts = [`/ip firewall ${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 }: { open: boolean onClose: () => void initialRule: Partial | null chainGroup: ChainGroup }) { const isNew = !initialRule?.id const [form, setForm] = useState(() => ({ ...defaultForm, chain: CHAIN_GROUPS.find(g => g.id === chainGroup)?.chains[0] ?? "forward", ...(initialRule ?? {}), srcPort: "", dstPort: initialRule?.port ?? "", inIface: initialRule?.iface ?? "", outIface: "", connState: "", srcAddrList: initialRule?.src ?? "", dstAddrList: initialRule?.dst ?? "", log: false, logPrefix: "", })) const set = (k: K, v: RuleForm[K]) => setForm((f) => ({ ...f, [k]: v })) const chainsForGroup = CHAIN_GROUPS.find(g => g.id === chainGroup)?.chains ?? ["forward"] 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 ? "Новое правило" : "Редактировать правило"} /ip firewall {chainGroup === "nat" ? "nat" : chainGroup === "mangle" ? "mangle" : "filter"} · 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

              {[
                `/ip firewall filter add \\`,
                `  chain=${form.chain} \\`,
                `  action=${form.action}`,
                form.proto !== "all" ? `  protocol=${form.proto} \\` : null,
                form.srcAddrList ? `  src-address-list=${form.srcAddrList} \\` : null,
                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 [copied, setCopied] = useState(false) // Derived from open — new timestamp each time modal opens, undefined when closed const timestamp = useMemo( () => open ? new Date().toLocaleString("ru") : undefined, [open], ) const code = useMemo(() => generateRsc(rules, timestamp), [rules, timestamp]) function handleCopy() { const full = generateRsc(rules, new Date().toLocaleString("ru")) navigator.clipboard.writeText(full).then(() => { setCopied(true) setTimeout(() => setCopied(false), 2000) }) } return ( { if (!v) onClose() }}>
Экспорт Firewall RouterOS .rsc · /ip firewall filter, nat, mangle
            {code.split("\n").map((line, i) => {
              const isComment = line.startsWith("#")
              const isSection = isComment && line.includes("──")
              const isKey     = /^\s+[a-z]/.test(line)
              return (
                
                  {line}{"\n"}
                
              )
            })}
          
}>Закрыть
) } // ─── Address List tab ───────────────────────────────────────────────────────── function AddressListsTab({ entries, onAdd }: { entries: AddressListEntry[] onAdd: () => void }) { 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]) 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) }) }, [entries, search, listFilter]) // Group by list name for display 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 (
{/* toolbar */}
setSearch(e.target.value)} />
{lists.map((l) => ( ))}
{filtered.length} записей
{/* groups */}
{Object.entries(grouped).map(([listName, listEntries]) => (
{listName} ({listEntries.length})
{listEntries.map((e) => (
{e.address} {e.comment} {e.timeout && ( ⏱ {e.timeout} )} } /> Редактировать Удалить
))}
))}
) } // ─── 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 ? (
{rules.map((r, i) => ( ))}
# Цепочка Действие Src Dst Порт Iface Комментарий
{i + 1} {r.src || "any"} {r.dst || "any"} {r.port || "—"} {r.iface || "—"} {r.comment || "—"}
) : ( !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 ─────────────────────────────────────────── */} {/* Built-in traffic presets */}

Пресеты трафика

{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} />
) } // ─── Rules Table ────────────────────────────────────────────────────────────── function RulesTable({ rules, onToggle, onEdit }: { rules: FirewallRule[] onToggle: (id: string) => void onEdit: (r: FirewallRule) => void }) { if (rules.length === 0) { return (

Правила не найдены

Попробуйте изменить фильтр или добавьте новое правило

) } return (
{rules.map((r, i) => ( ))}
# Цепочка Действие Источник Назначение Протокол Порт Интерфейс Пакетов Вкл
{i + 1} {r.src || "any"} {r.dst || "any"} {r.proto} {r.port || "—"} {r.iface || "—"} 1_000_000 ? "text-emerald-600 dark:text-emerald-400 font-semibold" : r.hits > 10_000 ? "text-foreground" : "text-muted-foreground", )}> {fmtHits(r.hits)} onToggle(r.id)} /> } /> onEdit(r)}> Редактировать Дублировать onToggle(r.id)}> {r.enabled ? "Отключить" : "Включить"} Удалить правило
) } // ════════════════════════════════════════════════════════════════════════════ export default function FirewallPage() { const [rules, setRules] = useState(firewallRules) const [addrLists] = useState(INIT_ADDRESS_LISTS) const [chainGroup, setChainGroup] = useState("filter") const [chainFilter, setChainFilter] = useState("all") const [ipFamily, setIpFamily] = useState("all") const [search, setSearch] = useState("") const [editingRule, setEditingRule] = useState | null>(null) const [ruleSheetOpen, setRuleSheetOpen] = useState(false) const [exportOpen, setExportOpen] = useState(false) // ── derived ────────────────────────────────────────────────────────────── const chainsInGroup = useMemo(() => { if (chainGroup === "address-lists") return [] const grp = CHAIN_GROUPS.find((g) => g.id === chainGroup) if (!grp) return [] const v4 = grp.chains const v6 = grp.ip6chains ?? [] if (ipFamily === "ip") return v4 if (ipFamily === "ip6") return v6 return [...v4, ...v6] }, [chainGroup, ipFamily]) const groupRules = useMemo(() => { if (chainGroup === "address-lists") return [] return rules.filter((r) => chainsInGroup.includes(r.chain)) }, [rules, chainGroup, chainsInGroup]) const filteredRules = useMemo(() => { return groupRules.filter((r) => { if (chainFilter !== "all" && r.chain !== chainFilter) return false // ipFamily filtering also respects rules with explicit family field if (ipFamily === "ip" && r.family === "ip6") return false if (ipFamily === "ip6" && r.family === "ip") 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) ) }) }, [groupRules, chainFilter, ipFamily, 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]) // ── stats ──────────────────────────────────────────────────────────────── const totalEnabled = rules.filter((r) => r.enabled).length const totalHits = rules.reduce((s, r) => s + r.hits, 0) const dropRules = rules.filter((r) => r.action === "drop" || r.action === "reject").length // ── handlers ───────────────────────────────────────────────────────────── function toggleRule(id: string) { setRules((rs) => rs.map((r) => r.id === id ? { ...r, enabled: !r.enabled } : r)) } function openAdd() { setEditingRule(null) setRuleSheetOpen(true) } function openEdit(r: FirewallRule) { setEditingRule(r) setRuleSheetOpen(true) } // ───────────────────────────────────────────────────────────────────────── return (
} />
{/* Stats */}
{[ { label: "Всего правил", value: rules.length, icon: }, { label: "Активных", value: totalEnabled, icon: }, { label: "Блокирующих", value: dropRules, icon: }, { label: "Срабатываний", value: fmtHits(totalHits), icon: }, ].map((s) => (

{s.label}

{s.value}

{s.icon}
))}
{/* Table group tabs */}
{CHAIN_GROUPS.map((g) => ( ))}
{chainGroup === "address-lists" ? ( {}} /> ) : chainGroup === "simulator" ? ( ) : ( {/* toolbar */}
{/* IP family selector */}
{(["all", "ip", "ip6"] as IpFamily[]).map((f) => ( ))}
{/* chain sub-tabs */}
{[{ value: "all", label: "Все" }, ...chainsInGroup.map((c) => ({ value: c, label: c }))].map((t) => ( ))}
{/* search */}
setSearch(e.target.value)} />
{filteredRules.length} правил
)} {/* RouterOS reference */}

RouterOS 7.20+ · /ip firewall + /ipv6 firewall — цепочки и новые матчеры

{[ { title: "/ip firewall filter", lines: ["input — трафик на роутер", "forward — транзит", "output — от роутера"], }, { title: "/ipv6 firewall filter", lines: ["ip6-input", "ip6-forward", "ip6-output"], }, { 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}
  • ))}
))}
{/* Sheet: Add/Edit Rule */} setRuleSheetOpen(false)} initialRule={editingRule} chainGroup={chainGroup === "address-lists" ? "filter" : chainGroup} /> {/* Sheet: Export */} setExportOpen(false)} rules={rules} />
) }