Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
2600 lines
114 KiB
TypeScript
2600 lines
114 KiB
TypeScript
"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,
|
||
} 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"
|
||
|
||
// ─── 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<string, string[]> = {
|
||
// 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<string, string> = {
|
||
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<PacketDef>
|
||
}> = [
|
||
{ 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<IpFamily, string> = {
|
||
ip: "IPv4",
|
||
ip6: "IPv6",
|
||
}
|
||
|
||
// ─── Chain groups config ─────────────────────────────────────────────────────
|
||
|
||
const CHAIN_GROUPS: {
|
||
id: ChainGroup; label: string; icon: React.ReactNode; chains: string[]
|
||
}[] = [
|
||
{
|
||
id: "filter", label: "Filter", icon: <ShieldIcon className="size-3.5" />,
|
||
chains: ["input","forward","output"],
|
||
},
|
||
{ id: "nat", label: "NAT", icon: <ArrowRightLeftIcon className="size-3.5" />, chains: ["srcnat","dstnat"] },
|
||
{ id: "mangle", label: "Mangle", icon: <WrenchIcon className="size-3.5" />, chains: ["prerouting","postrouting","forward","input","output"] },
|
||
{ id: "raw", label: "Raw", icon: <LayersIcon className="size-3.5" />, chains: ["prerouting","output"] },
|
||
{ id: "address-lists", label: "Адр. листы", icon: <ListFilterIcon className="size-3.5" />, chains: [] },
|
||
{ id: "simulator", label: "Симулятор", icon: <PlayIcon className="size-3.5" />, chains: [] },
|
||
]
|
||
|
||
// ─── Action styles ────────────────────────────────────────────────────────────
|
||
|
||
const ACTION_STYLES: Record<string, string> = {
|
||
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<string, string> = {
|
||
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 (
|
||
<select value={value} onChange={(e) => onChange(e.target.value)}
|
||
className={cn(
|
||
"h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none",
|
||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
|
||
className,
|
||
)}>
|
||
{children}
|
||
</select>
|
||
)
|
||
}
|
||
|
||
// ─── 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<string, FirewallRule[]> = {}
|
||
for (const r of rules) {
|
||
if (!byChain[r.chain]) byChain[r.chain] = []
|
||
byChain[r.chain].push(r)
|
||
}
|
||
|
||
const TABLE_MAP: Record<string, string> = {
|
||
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<FirewallRule> | null
|
||
chainGroup: ChainGroup
|
||
family: IpFamily
|
||
onSave: (payload: RuleWritePayload) => void
|
||
saving?: boolean
|
||
}) {
|
||
const isNew = !initialRule?.id
|
||
const [form, setForm] = useState<RuleForm>(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 extends keyof RuleForm>(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 (
|
||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-lg">
|
||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
||
<SheetTitle>{isNew ? "Новое правило" : "Редактировать правило"}</SheetTitle>
|
||
<SheetDescription>
|
||
{familyPath} {tableName} · {IP_FAMILY_LABELS[family]} · RouterOS 7
|
||
</SheetDescription>
|
||
</SheetHeader>
|
||
|
||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||
|
||
{/* Chain + Action */}
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Цепочка и действие</SectionTitle>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<FormField label="Цепочка" required>
|
||
<NativeSelect value={form.chain} onChange={(v) => set("chain", v)}>
|
||
{chainsForGroup.map((c) => <option key={c} value={c}>{c}</option>)}
|
||
</NativeSelect>
|
||
</FormField>
|
||
<FormField label="Действие" required>
|
||
<NativeSelect value={form.action} onChange={(v) => set("action", v)}>
|
||
{actions.map((a) => <option key={a} value={a}>{a}</option>)}
|
||
</NativeSelect>
|
||
</FormField>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Matching */}
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Условие совпадения</SectionTitle>
|
||
<FormField label="Протокол">
|
||
<NativeSelect value={form.proto} onChange={(v) => set("proto", v)}>
|
||
{["all","tcp","udp","icmp","gre","esp","ah","ipencap","ospf"].map((p) =>
|
||
<option key={p} value={p}>{p}</option>
|
||
)}
|
||
</NativeSelect>
|
||
</FormField>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<FormField label="Src-address / Address-list" hint="IP, CIDR или имя address-list">
|
||
<Input className="font-mono h-8" placeholder="10.0.0.0/8"
|
||
value={form.srcAddrList} onChange={(e) => set("srcAddrList", e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Dst-address / Address-list">
|
||
<Input className="font-mono h-8" placeholder="0.0.0.0/0"
|
||
value={form.dstAddrList} onChange={(e) => set("dstAddrList", e.target.value)} />
|
||
</FormField>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<FormField label="Src-port" hint="TCP/UDP, напр. 1024-65535">
|
||
<Input className="font-mono h-8" placeholder="—"
|
||
value={form.srcPort} onChange={(e) => set("srcPort", e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Dst-port">
|
||
<Input className="font-mono h-8" placeholder="443"
|
||
value={form.dstPort} onChange={(e) => set("dstPort", e.target.value)} />
|
||
</FormField>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<FormField label="In-interface" hint="Входящий интерфейс">
|
||
<Input className="font-mono h-8" placeholder="wan-msk"
|
||
value={form.inIface} onChange={(e) => set("inIface", e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Out-interface">
|
||
<Input className="font-mono h-8" placeholder="lan"
|
||
value={form.outIface} onChange={(e) => set("outIface", e.target.value)} />
|
||
</FormField>
|
||
</div>
|
||
|
||
<FormField label="Connection-state" hint="Через запятую: new, established, related, invalid">
|
||
<Input className="font-mono h-8" placeholder="new,established"
|
||
value={form.connState} onChange={(e) => set("connState", e.target.value)} />
|
||
</FormField>
|
||
</div>
|
||
|
||
{/* Log + Comment */}
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Логирование и комментарий</SectionTitle>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium">Log</p>
|
||
<p className="text-xs text-muted-foreground">Записывать совпадения в системный лог</p>
|
||
</div>
|
||
<FormToggle checked={form.log} onChange={(v) => set("log", v)} />
|
||
</div>
|
||
{form.log && (
|
||
<FormField label="Log-prefix" hint="Метка в логе, например FW-DROP">
|
||
<Input className="font-mono h-8" placeholder="FW-RULE"
|
||
value={form.logPrefix} onChange={(e) => set("logPrefix", e.target.value)} />
|
||
</FormField>
|
||
)}
|
||
<FormField label="Комментарий">
|
||
<Input className="h-8" placeholder="Описание правила"
|
||
value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
||
</FormField>
|
||
</div>
|
||
|
||
{/* Enabled */}
|
||
<div className="flex items-center justify-between rounded-lg border bg-muted/30 px-4 py-3">
|
||
<div>
|
||
<p className="text-sm font-medium">Правило включено</p>
|
||
<p className="text-xs text-muted-foreground">Отключённые правила сохраняются, но не применяются</p>
|
||
</div>
|
||
<FormToggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
|
||
</div>
|
||
|
||
{/* CLI preview */}
|
||
<div className="rounded-lg bg-zinc-950 dark:bg-zinc-900 border border-zinc-800 px-4 py-3">
|
||
<p className="text-[10px] font-mono text-zinc-500 mb-2">RouterOS CLI preview</p>
|
||
<pre className="text-[11px] font-mono text-zinc-300 whitespace-pre-wrap leading-relaxed">
|
||
{[
|
||
`${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")}
|
||
</pre>
|
||
</div>
|
||
</div>
|
||
|
||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||
<Button
|
||
className="flex-1"
|
||
disabled={saving}
|
||
onClick={() => onSave(formToWritePayload(form))}
|
||
>
|
||
{saving ? "Сохранение…" : isNew ? "Создать правило" : "Сохранить"}
|
||
</Button>
|
||
</SheetFooter>
|
||
</SheetContent>
|
||
</Sheet>
|
||
)
|
||
}
|
||
|
||
// ─── 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 (
|
||
<CodeExportSheet
|
||
open={open}
|
||
onClose={onClose}
|
||
title="Экспорт Firewall"
|
||
description="RouterOS .rsc · /ip firewall filter, nat, mangle"
|
||
formats={[
|
||
{
|
||
id: "rsc",
|
||
label: "MikroTik .rsc",
|
||
filename: `firewall-${new Date().toISOString().slice(0, 10)}.rsc`,
|
||
code,
|
||
},
|
||
]}
|
||
/>
|
||
)
|
||
}
|
||
|
||
// ─── 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<string, AddressListEntry[]> = {}
|
||
for (const e of filtered) {
|
||
if (!g[e.list]) g[e.list] = []
|
||
g[e.list].push(e)
|
||
}
|
||
return g
|
||
}, [filtered, listFilter])
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
|
||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||
<input className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||
placeholder="Поиск по адресу, листу…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||
</div>
|
||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 overflow-x-auto">
|
||
{lists.map((l) => (
|
||
<button key={l} onClick={() => setListFilter(l)}
|
||
className={cn("px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
|
||
listFilter === l ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||
)}>
|
||
{l === "all" ? "Все листы" : l}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} записей</span>
|
||
<Button size="sm" onClick={onAdd}><PlusIcon className="size-4" />Добавить</Button>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-3">
|
||
{Object.entries(grouped).map(([listName, listEntries]) => (
|
||
<Frame key={listName} dense className="w-full">
|
||
<FramePanel className="p-0">
|
||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/20">
|
||
<div className="flex items-center gap-2">
|
||
<ListFilterIcon className="size-3.5 text-muted-foreground" />
|
||
<span className="text-sm font-mono font-medium">{listName}</span>
|
||
<span className="text-xs text-muted-foreground">({listEntries.length})</span>
|
||
</div>
|
||
<Button variant="ghost" size="sm" className="h-6 text-xs gap-1" onClick={() => onAddToList(listName)}>
|
||
<PlusIcon className="size-3" />Адрес
|
||
</Button>
|
||
</div>
|
||
<div className="divide-y divide-border">
|
||
{listEntries.map((e) => (
|
||
<div key={e.id} className={cn(
|
||
"flex items-center gap-3 px-4 py-2.5",
|
||
e.disabled && "opacity-50",
|
||
)}>
|
||
<span className={cn("size-1.5 rounded-full shrink-0", e.disabled ? "bg-muted-foreground" : "bg-emerald-500")} />
|
||
<span className="font-mono text-sm font-medium min-w-[150px]">{e.address}</span>
|
||
<span className="text-xs text-muted-foreground flex-1 truncate">{e.comment}</span>
|
||
{showServer && e.serverName && (
|
||
<span className="text-[10px] text-muted-foreground shrink-0">{e.serverName}</span>
|
||
)}
|
||
{e.timeout && (
|
||
<span className="text-[10px] font-mono bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 px-2 py-0.5 rounded shrink-0">
|
||
⏱ {e.timeout}
|
||
</span>
|
||
)}
|
||
<FormToggle checked={!e.disabled} onChange={() => onToggle(e)} />
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger render={
|
||
<Button variant="ghost" size="icon" className="size-7 shrink-0">
|
||
<MoreHorizontalIcon className="size-4" />
|
||
</Button>
|
||
} />
|
||
<DropdownMenuContent side="bottom" align="end">
|
||
<DropdownMenuItem onClick={() => onEdit(e)}><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={() => onToggle(e)}>
|
||
<PowerIcon className="size-4" />{e.disabled ? "Включить" : "Отключить"}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem variant="destructive" onClick={() => onDelete(e)}>
|
||
<Trash2Icon className="size-4" />Удалить
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</FramePanel>
|
||
</Frame>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const defaultAddrForm = {
|
||
list: "",
|
||
address: "",
|
||
comment: "",
|
||
timeout: "",
|
||
enabled: true,
|
||
}
|
||
|
||
function AddressListSheet({
|
||
open, onClose, initial, family, onSave, saving = false,
|
||
}: {
|
||
open: boolean
|
||
onClose: () => void
|
||
initial: Partial<AddressListEntry> | 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 (
|
||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-md">
|
||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
||
<SheetTitle>{isNew ? "Запись address-list" : "Редактировать запись"}</SheetTitle>
|
||
<SheetDescription>
|
||
{family === "ip6" ? "/ipv6 firewall address-list" : "/ip firewall address-list"} · {IP_FAMILY_LABELS[family]}
|
||
</SheetDescription>
|
||
</SheetHeader>
|
||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-4">
|
||
<FormField label="Лист" required>
|
||
<Input className="font-mono h-8" placeholder="cdn-bypass"
|
||
value={form.list} onChange={(e) => setForm((f) => ({ ...f, list: e.target.value }))} />
|
||
</FormField>
|
||
<FormField label="Адрес" required>
|
||
<Input className="font-mono h-8" placeholder="10.0.0.0/8"
|
||
value={form.address} onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))} />
|
||
</FormField>
|
||
<FormField label="Комментарий">
|
||
<Input className="h-8" value={form.comment}
|
||
onChange={(e) => setForm((f) => ({ ...f, comment: e.target.value }))} />
|
||
</FormField>
|
||
<FormField label="Timeout" hint="например 01:00:00">
|
||
<Input className="font-mono h-8" placeholder="опционально" value={form.timeout}
|
||
onChange={(e) => setForm((f) => ({ ...f, timeout: e.target.value }))} />
|
||
</FormField>
|
||
<div className="flex items-center justify-between gap-3 rounded-lg border px-3 py-2.5">
|
||
<div>
|
||
<p className="text-sm font-medium">Запись включена</p>
|
||
<p className="text-xs text-muted-foreground">Отключённые адреса не матчятся</p>
|
||
</div>
|
||
<FormToggle checked={form.enabled} onChange={(v) => setForm((f) => ({ ...f, enabled: v }))} />
|
||
</div>
|
||
</div>
|
||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||
<Button
|
||
className="flex-1"
|
||
disabled={saving || !form.list.trim() || !form.address.trim()}
|
||
onClick={() => onSave({
|
||
list: form.list.trim(),
|
||
address: form.address.trim(),
|
||
comment: form.comment.trim() || undefined,
|
||
timeout: form.timeout.trim() || undefined,
|
||
disabled: !form.enabled,
|
||
})}
|
||
>
|
||
{saving ? "Сохранение…" : isNew ? "Добавить" : "Сохранить"}
|
||
</Button>
|
||
</SheetFooter>
|
||
</SheetContent>
|
||
</Sheet>
|
||
)
|
||
}
|
||
|
||
// ─── Verdict helpers ──────────────────────────────────────────────────────────
|
||
|
||
function getVerdictIcon(action: string) {
|
||
if (action === "drop" || action === "reject") return <XCircleIcon className="size-5" />
|
||
if (action === "fasttrack-connection") return <ZapIcon className="size-5" />
|
||
if (action.startsWith("mark-")) return <SkipForwardIcon className="size-5" />
|
||
return <CheckCircle2Icon className="size-5" />
|
||
}
|
||
|
||
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 (
|
||
<div className={cn(
|
||
"border-l-2 transition-all duration-200",
|
||
step.status === "evaluating" && "border-amber-500 bg-amber-500/5",
|
||
step.status === "match" && "border-emerald-500 bg-emerald-500/5",
|
||
step.status === "passthrough" && "border-violet-500 bg-violet-500/5",
|
||
(step.status === "skip" || step.status === "disabled" || step.status === "pending") && "border-transparent",
|
||
)}>
|
||
<button
|
||
type="button"
|
||
onClick={() => isClickable && setExpanded(e => !e)}
|
||
className={cn(
|
||
"w-full flex items-center gap-3 px-5 py-2.5 text-left transition-colors",
|
||
isClickable ? "hover:bg-muted/30 cursor-pointer" : "cursor-default",
|
||
step.status === "pending" && "opacity-40",
|
||
step.status === "disabled" && "opacity-25",
|
||
)}
|
||
>
|
||
<span className="text-xs font-mono text-muted-foreground w-6 shrink-0 tabular-nums">
|
||
{index + 1}
|
||
</span>
|
||
<div className="shrink-0"><ChainBadge chain={step.rule.chain} /></div>
|
||
<div className="shrink-0"><ActionBadge action={step.rule.action} /></div>
|
||
<span className="text-xs font-mono text-muted-foreground flex-1 truncate min-w-0">
|
||
{step.rule.src || "any"} → {step.rule.dst || "any"}
|
||
{step.rule.proto && step.rule.proto !== "all" && (
|
||
<span className="opacity-50"> {step.rule.proto}</span>
|
||
)}
|
||
{step.rule.port && step.rule.port !== "—" && (
|
||
<span className="opacity-50">:{step.rule.port}</span>
|
||
)}
|
||
</span>
|
||
{step.rule.comment && (
|
||
<span className="hidden lg:block text-xs text-muted-foreground/50 truncate max-w-[200px]">
|
||
{step.rule.comment}
|
||
</span>
|
||
)}
|
||
|
||
{/* Status badge */}
|
||
<div className="shrink-0 min-w-[130px] flex justify-end">
|
||
{step.status === "pending" && (
|
||
<span className="text-[10px] text-muted-foreground/40">ожидание</span>
|
||
)}
|
||
{step.status === "evaluating" && (
|
||
<span className="text-[10px] text-amber-500 font-mono font-semibold flex items-center gap-1 animate-pulse">
|
||
<ZapIcon className="size-3" />проверяется…
|
||
</span>
|
||
)}
|
||
{step.status === "match" && (
|
||
<span className="text-[10px] text-emerald-600 dark:text-emerald-400 font-mono font-semibold flex items-center gap-1">
|
||
<CheckCircle2Icon className="size-3" />совпадение!
|
||
</span>
|
||
)}
|
||
{step.status === "passthrough" && (
|
||
<span className="text-[10px] text-violet-500 font-mono font-semibold flex items-center gap-1">
|
||
<SkipForwardIcon className="size-3" />passthrough
|
||
</span>
|
||
)}
|
||
{step.status === "disabled" && (
|
||
<span className="text-[10px] text-muted-foreground/40 font-mono">⊝ выключено</span>
|
||
)}
|
||
{step.status === "skip" && (
|
||
<span className="text-[10px] text-muted-foreground font-mono flex items-center gap-1">
|
||
<MinusCircleIcon className="size-3" />пропуск
|
||
{step.failedAt && <span className="opacity-60">({step.failedAt})</span>}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</button>
|
||
|
||
{/* Expanded checks */}
|
||
{expanded && step.checks.length > 0 && (
|
||
<div className="px-5 pb-3 pl-[52px] flex flex-wrap gap-2">
|
||
{step.checks.map((c, ci) => (
|
||
<div key={ci} className={cn(
|
||
"inline-flex items-center gap-1.5 rounded border px-2.5 py-1 text-[11px] font-mono",
|
||
c.passed
|
||
? "bg-emerald-500/5 border-emerald-500/20 text-emerald-700 dark:text-emerald-300"
|
||
: "bg-red-500/5 border-red-500/20 text-red-700 dark:text-red-300",
|
||
)}>
|
||
{c.passed
|
||
? <CheckCircle2Icon className="size-3 shrink-0" />
|
||
: <XCircleIcon className="size-3 shrink-0" />}
|
||
<span className="text-muted-foreground">{c.field}:</span>
|
||
<span className="font-semibold">{c.ruleVal}</span>
|
||
{!c.passed && (
|
||
<>
|
||
<span className="text-muted-foreground/40 mx-0.5">≠</span>
|
||
<span>{c.packetVal}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Scenario constants + helpers ────────────────────────────────────────────
|
||
|
||
const SCENARIO_STORAGE_KEY = "routerlists-fw-scenarios"
|
||
|
||
const DEFAULT_SRULE: Omit<ScenarioRule, "id"> = {
|
||
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<PacketDef>(initial?.packet ?? DEFAULT_PKT)
|
||
const [rules, setRules] = useState<ScenarioRule[]>(initial?.rules ?? [])
|
||
const [addOpen, setAddOpen]= useState(false)
|
||
const [addForm, setAddForm]= useState<Omit<ScenarioRule, "id">>(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 extends keyof PacketDef>(k: K, v: PacketDef[K]) =>
|
||
setPkt2(p => ({ ...p, [k]: v }))
|
||
const setAF = <K extends keyof Omit<ScenarioRule, "id">>(k: K, v: Omit<ScenarioRule, "id">[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 (
|
||
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
|
||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
||
<SheetTitle>{isNew ? "Новый сценарий" : `Редактировать: ${initial?.name}`}</SheetTitle>
|
||
<SheetDescription>Набор правил firewall/mangle/nat + тестовый пакет</SheetDescription>
|
||
</SheetHeader>
|
||
|
||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-6">
|
||
|
||
{/* Meta */}
|
||
<div className="flex flex-col gap-3">
|
||
<SectionTitle>Название</SectionTitle>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<FormField label="Название сценария" required>
|
||
<Input className="h-8" placeholder="Блокировка Tor Exit"
|
||
value={name} onChange={e => setName(e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Описание">
|
||
<Input className="h-8" placeholder="Краткое описание"
|
||
value={desc} onChange={e => setDesc(e.target.value)} />
|
||
</FormField>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Default packet */}
|
||
<div className="flex flex-col gap-3">
|
||
<SectionTitle>Тестовый пакет по умолчанию</SectionTitle>
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||
<FormField label="Направление / цепочка">
|
||
<NativeSelect value={pkt.chain} onChange={v => setP("chain", v)}>
|
||
<optgroup label="Полный маршрут">
|
||
<option value="forward">forward — транзит</option>
|
||
<option value="input">input — на роутер</option>
|
||
<option value="output">output — от роутера</option>
|
||
<option value="dstnat">dstnat — port-forward</option>
|
||
</optgroup>
|
||
<optgroup label="Одна цепочка">
|
||
{["srcnat","prerouting","postrouting","ip6-forward","ip6-input","ip6-output"]
|
||
.map(c => <option key={c} value={c}>{c}</option>)}
|
||
</optgroup>
|
||
</NativeSelect>
|
||
</FormField>
|
||
<FormField label="Протокол">
|
||
<NativeSelect value={pkt.proto} onChange={v => setP("proto", v)}>
|
||
{PROTO_OPTS.map(p => <option key={p} value={p}>{p}</option>)}
|
||
</NativeSelect>
|
||
</FormField>
|
||
<FormField label="Conn-state">
|
||
<Input className="font-mono h-8" value={pkt.connState}
|
||
placeholder="new" onChange={e => setP("connState", e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Src IP">
|
||
<Input className="font-mono h-8" value={pkt.srcAddr}
|
||
onChange={e => setP("srcAddr", e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Dst IP">
|
||
<Input className="font-mono h-8" value={pkt.dstAddr}
|
||
onChange={e => setP("dstAddr", e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Dst Port">
|
||
<Input className="font-mono h-8" value={pkt.dstPort}
|
||
placeholder="443" onChange={e => setP("dstPort", e.target.value)} />
|
||
</FormField>
|
||
<FormField label="In-interface">
|
||
<Input className="font-mono h-8" value={pkt.inIface}
|
||
placeholder="lan" onChange={e => setP("inIface", e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Out-interface">
|
||
<Input className="font-mono h-8" value={pkt.outIface}
|
||
placeholder="wan-msk" onChange={e => setP("outIface", e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Dst addr-list">
|
||
<Input className="font-mono h-8" value={pkt.dstAddrList}
|
||
placeholder="" onChange={e => setP("dstAddrList", e.target.value)} />
|
||
</FormField>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Rules */}
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex items-center gap-2 py-0.5">
|
||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||
Правила сценария
|
||
</span>
|
||
<span className="text-xs font-mono text-muted-foreground">({rules.length})</span>
|
||
<div className="flex-1 h-px bg-border" />
|
||
<Button size="sm" variant="outline" className="h-6 text-xs gap-1 px-2"
|
||
onClick={() => setAddOpen(o => !o)}>
|
||
<PlusIcon className="size-3" />
|
||
{addOpen ? "Отмена" : "Добавить правило"}
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Add-rule form */}
|
||
{addOpen && (
|
||
<div className="rounded-lg border bg-muted/20 p-4 flex flex-col gap-3">
|
||
<p className="text-xs font-medium text-muted-foreground">Новое правило</p>
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs text-muted-foreground">Цепочка</label>
|
||
<NativeSelect value={addForm.chain} onChange={v => setAF("chain", v)}>
|
||
{CHAIN_OPTS.map(c => <option key={c} value={c}>{c}</option>)}
|
||
</NativeSelect>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs text-muted-foreground">Действие</label>
|
||
<NativeSelect value={addForm.action} onChange={v => setAF("action", v)}>
|
||
{ACTION_OPTS.map(a => <option key={a} value={a}>{a}</option>)}
|
||
</NativeSelect>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs text-muted-foreground">Протокол</label>
|
||
<NativeSelect value={addForm.proto} onChange={v => setAF("proto", v)}>
|
||
{PROTO_OPTS.map(p => <option key={p} value={p}>{p}</option>)}
|
||
</NativeSelect>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs text-muted-foreground">Dst Port</label>
|
||
<Input className="font-mono h-8 text-xs" value={addForm.port}
|
||
placeholder="443" onChange={e => setAF("port", e.target.value)} />
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs text-muted-foreground">Src address / list</label>
|
||
<Input className="font-mono h-8 text-xs" value={addForm.src}
|
||
placeholder="10.0.0.0/8" onChange={e => setAF("src", e.target.value)} />
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs text-muted-foreground">Dst address / list</label>
|
||
<Input className="font-mono h-8 text-xs" value={addForm.dst}
|
||
placeholder="any" onChange={e => setAF("dst", e.target.value)} />
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs text-muted-foreground">In-interface</label>
|
||
<Input className="font-mono h-8 text-xs" value={addForm.iface}
|
||
placeholder="lan" onChange={e => setAF("iface", e.target.value)} />
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs text-muted-foreground">Комментарий</label>
|
||
<Input className="h-8 text-xs" value={addForm.comment}
|
||
onChange={e => setAF("comment", e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<FormToggle checked={addForm.enabled} onChange={v => setAF("enabled", v)} />
|
||
<span className="text-xs text-muted-foreground">Включено</span>
|
||
</div>
|
||
<Button size="sm" onClick={addRule}><PlusIcon className="size-4" />Добавить</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Rules table */}
|
||
{rules.length > 0 ? (
|
||
<FirewallScenarioRulesDataGrid
|
||
rules={rules}
|
||
onToggleEnabled={toggleEnabled}
|
||
onMoveUp={(id) => moveRule(id, -1)}
|
||
onMoveDown={(id) => moveRule(id, 1)}
|
||
onRemove={removeRule}
|
||
/>
|
||
) : (
|
||
!addOpen && (
|
||
<div className="text-center py-6 text-sm text-muted-foreground border rounded-lg border-dashed">
|
||
Нет правил — нажмите «Добавить правило»
|
||
</div>
|
||
)
|
||
)}
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||
<Button className="flex-1" onClick={handleSave} disabled={!name.trim()}>
|
||
{isNew ? "Создать сценарий" : "Сохранить изменения"}
|
||
</Button>
|
||
</SheetFooter>
|
||
</SheetContent>
|
||
</Sheet>
|
||
)
|
||
}
|
||
|
||
// ─── Simulator Tab ────────────────────────────────────────────────────────────
|
||
|
||
const SIM_SPEED_MS: Record<SimSpeed, number> = { slow: 700, normal: 350, fast: 120, instant: 0 }
|
||
const SIM_SPEED_LABELS: Record<SimSpeed, string> = {
|
||
slow: "Медленно", normal: "Нормально", fast: "Быстро", instant: "Мгновенно",
|
||
}
|
||
|
||
function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
|
||
// ── Core sim state ──────────────────────────────────────────────────────────
|
||
const [packet, setPacket] = useState<PacketDef>(DEFAULT_PKT)
|
||
const [activePreset, setPreset] = useState<string | null>("https")
|
||
const [simState, setSimState] = useState<"idle" | "running" | "done">("idle")
|
||
const [steps, setSteps] = useState<SimStep[]>([])
|
||
const [speed, setSpeed] = useState<SimSpeed>("normal")
|
||
const [verdict, setVerdict] = useState<{ action: string; ruleIndex: number | null } | null>(null)
|
||
const cancelRef = useRef(false)
|
||
|
||
// ── Scenarios state ─────────────────────────────────────────────────────────
|
||
const [scenarios, setScenarios] = useState<SimScenario[]>(() => {
|
||
if (typeof window === "undefined") return []
|
||
try { return JSON.parse(localStorage.getItem(SCENARIO_STORAGE_KEY) ?? "[]") }
|
||
catch { return [] }
|
||
})
|
||
const [activeScenarioId, setActiveScenarioId] = useState<string | null>(null)
|
||
const [scenarioSheetOpen, setScenarioSheetOpen] = useState(false)
|
||
const [editingScenario, setEditingScenario] = useState<SimScenario | null>(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 extends keyof PacketDef>(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<void>(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<void>(res => setTimeout(res, Math.max(delay * 0.5, 200)))
|
||
break
|
||
}
|
||
if (delay > 0) await new Promise<void>(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 (
|
||
<div className="flex flex-col gap-4">
|
||
|
||
{/* ── Presets + Scenarios card ─────────────────────────────────────────── */}
|
||
<OpsPanel title="Пресеты трафика" contentClassName="px-5 py-4 flex flex-col gap-4">
|
||
<div className="flex flex-wrap gap-2">
|
||
{SIM_PRESETS.map(p => (
|
||
<button key={p.id} type="button" onClick={() => applyPreset(p)}
|
||
className={cn(
|
||
"flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm transition-colors",
|
||
activePreset === p.id && !activeScenarioId
|
||
? "bg-primary/90 text-primary-foreground border-primary"
|
||
: "border-border bg-background hover:bg-muted/60 text-foreground",
|
||
)}>
|
||
<span className="leading-none">{p.emoji}</span>
|
||
<span className="font-medium leading-none">{p.label}</span>
|
||
<span className={cn("text-xs leading-none",
|
||
activePreset === p.id && !activeScenarioId
|
||
? "text-primary-foreground/60" : "text-muted-foreground",
|
||
)}>{p.desc}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="h-px bg-border" />
|
||
|
||
{/* Saved scenarios */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2.5">
|
||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||
Мои сценарии
|
||
</p>
|
||
<Button size="sm" variant="outline" className="h-6 text-xs gap-1 px-2"
|
||
onClick={() => { setEditingScenario(null); setScenarioSheetOpen(true) }}>
|
||
<PlusIcon className="size-3" />Новый сценарий
|
||
</Button>
|
||
</div>
|
||
|
||
{scenarios.length === 0 ? (
|
||
<p className="text-xs text-muted-foreground/60 py-1">
|
||
Нет сохранённых сценариев — создайте набор правил firewall/mangle/nat для тестирования
|
||
</p>
|
||
) : (
|
||
<div className="flex flex-wrap gap-2">
|
||
{scenarios.map(sc => {
|
||
const isActive = activeScenarioId === sc.id
|
||
return (
|
||
<div key={sc.id} className={cn(
|
||
"flex items-center gap-2.5 rounded-lg border px-3 py-2 transition-colors",
|
||
isActive
|
||
? "bg-violet-500/10 border-violet-500/40"
|
||
: "border-border bg-background hover:bg-muted/40",
|
||
)}>
|
||
<div className="flex flex-col min-w-0 leading-tight">
|
||
<span className="text-sm font-medium">{sc.name}</span>
|
||
<span className="text-[11px] text-muted-foreground font-mono">
|
||
{sc.rules.length} правил · {sc.packet.chain}
|
||
{sc.description ? ` · ${sc.description}` : ""}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-1 shrink-0">
|
||
<button type="button"
|
||
onClick={() => activateScenario(isActive ? null : sc.id)}
|
||
className={cn(
|
||
"text-[11px] font-mono px-2 py-0.5 rounded border transition-colors",
|
||
isActive
|
||
? "bg-violet-500 text-white border-violet-500"
|
||
: "border-border hover:bg-muted",
|
||
)}>
|
||
{isActive ? "Активен" : "Выбрать"}
|
||
</button>
|
||
<button type="button"
|
||
onClick={() => { setEditingScenario(sc); setScenarioSheetOpen(true) }}
|
||
className="p-1 text-muted-foreground/50 hover:text-foreground transition-colors">
|
||
<PencilIcon className="size-3.5" />
|
||
</button>
|
||
<button type="button" onClick={() => deleteScenario(sc.id)}
|
||
className="p-1 text-muted-foreground/50 hover:text-red-500 transition-colors">
|
||
<Trash2Icon className="size-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</OpsPanel>
|
||
|
||
{/* ── Packet editor ───────────────────────────────────────────────────── */}
|
||
<OpsPanel title="Параметры пакета" contentClassName="px-5 py-4 flex flex-col gap-4">
|
||
<div className="flex items-center justify-end flex-wrap gap-2">
|
||
<div className="flex items-center gap-3">
|
||
<span className={cn(
|
||
"text-[11px] font-mono px-2 py-0.5 rounded border",
|
||
activeScenario
|
||
? "bg-violet-500/10 border-violet-500/30 text-violet-600 dark:text-violet-400"
|
||
: "bg-muted border-border text-muted-foreground",
|
||
)}>
|
||
{activeScenario ? `🎭 ${activeScenario.name}` : "🔴 Живые правила"}
|
||
</span>
|
||
<div className="flex flex-col items-end gap-0.5">
|
||
<span className="text-xs text-muted-foreground font-mono">
|
||
{simRules.length} правил ·{" "}
|
||
<span className="text-foreground font-semibold">{packet.chain}</span>
|
||
</span>
|
||
{DIR_PATHS[packet.chain] && (
|
||
<span className="text-[10px] text-muted-foreground/50 font-mono">
|
||
{DIR_PATHS[packet.chain].join(" → ")}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 1 */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs text-muted-foreground">Направление / цепочка</label>
|
||
<NativeSelect value={packet.chain} onChange={v => { setPkt("chain", v); resetSim() }}>
|
||
<optgroup label="Полный маршрут (все таблицы)">
|
||
<option value="forward">forward — транзит LAN→WAN</option>
|
||
<option value="input">input — на роутер</option>
|
||
<option value="output">output — от роутера</option>
|
||
<option value="dstnat">dstnat — port-forward</option>
|
||
</optgroup>
|
||
<optgroup label="Одна цепочка">
|
||
<option value="prerouting">prerouting</option>
|
||
<option value="postrouting">postrouting</option>
|
||
<option value="srcnat">srcnat</option>
|
||
<option value="ip6-forward">ip6-forward</option>
|
||
<option value="ip6-input">ip6-input</option>
|
||
<option value="ip6-output">ip6-output</option>
|
||
</optgroup>
|
||
</NativeSelect>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs text-muted-foreground">Протокол</label>
|
||
<NativeSelect value={packet.proto} onChange={v => setPkt("proto", v)}>
|
||
{["tcp","udp","icmp","gre","esp","ah"].map(p => <option key={p} value={p}>{p}</option>)}
|
||
</NativeSelect>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs text-muted-foreground">Src IP</label>
|
||
<Input className="font-mono h-8 text-xs" value={packet.srcAddr}
|
||
onChange={e => setPkt("srcAddr", e.target.value)} />
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs text-muted-foreground">Dst IP</label>
|
||
<Input className="font-mono h-8 text-xs" value={packet.dstAddr}
|
||
onChange={e => setPkt("dstAddr", e.target.value)} />
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs text-muted-foreground">Src Port</label>
|
||
<Input className="font-mono h-8 text-xs" value={packet.srcPort}
|
||
placeholder="—" onChange={e => setPkt("srcPort", e.target.value)} />
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs text-muted-foreground">Dst Port</label>
|
||
<Input className="font-mono h-8 text-xs" value={packet.dstPort}
|
||
placeholder="—" onChange={e => setPkt("dstPort", e.target.value)} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 2 */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs text-muted-foreground">In-interface</label>
|
||
<Input className="font-mono h-8 text-xs" value={packet.inIface}
|
||
placeholder="lan" onChange={e => setPkt("inIface", e.target.value)} />
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs text-muted-foreground">Out-interface</label>
|
||
<Input className="font-mono h-8 text-xs" value={packet.outIface}
|
||
placeholder="wan-msk" onChange={e => setPkt("outIface", e.target.value)} />
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs text-muted-foreground">Conn-state</label>
|
||
<Input className="font-mono h-8 text-xs" value={packet.connState}
|
||
placeholder="new" onChange={e => setPkt("connState", e.target.value)} />
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-xs text-muted-foreground">Dst addr-list</label>
|
||
<Input className="font-mono h-8 text-xs" value={packet.dstAddrList}
|
||
placeholder="youtube-bypass" onChange={e => setPkt("dstAddrList", e.target.value)} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Speed + Run controls */}
|
||
<div className="flex items-center gap-3 pt-2 border-t border-border flex-wrap">
|
||
<SlidersHorizontalIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||
{(["slow","normal","fast","instant"] as SimSpeed[]).map(s => (
|
||
<button key={s} type="button"
|
||
onClick={() => setSpeed(s)} disabled={simState === "running"}
|
||
className={cn(
|
||
"px-3 py-1 text-xs rounded transition-colors disabled:opacity-50",
|
||
speed === s ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
||
)}>
|
||
{SIM_SPEED_LABELS[s]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="flex items-center gap-2 ml-auto">
|
||
<Button variant="outline" size="sm" onClick={resetSim} disabled={simState === "idle"}>
|
||
<RotateCcwIcon className="size-4" />Сбросить
|
||
</Button>
|
||
{simState === "running" ? (
|
||
<Button size="sm" variant="destructive"
|
||
onClick={() => { cancelRef.current = true; setSimState("done") }}>
|
||
<SquareIcon className="size-4" />Стоп
|
||
</Button>
|
||
) : (
|
||
<Button size="sm" onClick={runSimulation} disabled={simRules.length === 0}>
|
||
<PlayIcon className="size-4" />
|
||
{simState === "done" ? "Снова" : "Запустить"}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</OpsPanel>
|
||
|
||
{/* ── Verdict banner ──────────────────────────────────────────────────── */}
|
||
{verdict && simState === "done" && verdictColors && (
|
||
<div className={cn(
|
||
"rounded-xl border px-5 py-4 flex items-center gap-4",
|
||
verdictColors.bg, verdictColors.border,
|
||
)}>
|
||
<div className={verdictColors.text}>{getVerdictIcon(verdict.action)}</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p className={cn("font-semibold text-base", verdictColors.text)}>
|
||
{verdict.action === "drop" || verdict.action === "reject"
|
||
? "Пакет заблокирован"
|
||
: verdict.ruleIndex === null ? "Пакет принят (по умолчанию)" : "Пакет принят"}
|
||
{verdict.ruleIndex !== null && (
|
||
<span className="font-normal text-sm ml-2 opacity-70">— правило #{verdict.ruleIndex + 1}</span>
|
||
)}
|
||
</p>
|
||
<p className="text-sm text-muted-foreground mt-0.5">
|
||
Действие: <span className={cn("font-mono font-medium", verdictColors.text)}>{verdict.action}</span>
|
||
{verdict.ruleIndex === null && <span className="ml-2 opacity-70">· ни одно правило не совпало</span>}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Trace list ──────────────────────────────────────────────────────── */}
|
||
{steps.length > 0 && (
|
||
<Frame dense className="w-full">
|
||
<FramePanel className="p-0">
|
||
<div className="px-5 py-3 border-b flex items-center justify-between">
|
||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||
Трассировка · цепочка: <span className="text-foreground normal-case font-mono">{packet.chain}</span>
|
||
{activeScenario && (
|
||
<span className="ml-2 text-violet-500 font-sans normal-case">· {activeScenario.name}</span>
|
||
)}
|
||
</p>
|
||
<span className="text-xs text-muted-foreground">
|
||
<span className="text-emerald-500">{steps.filter(s => s.status === "match").length} совпало</span>
|
||
{" · "}{steps.filter(s => s.status === "skip").length} пропущено
|
||
{" · "}{steps.filter(s => s.status === "disabled").length} выкл
|
||
</span>
|
||
</div>
|
||
<div className="divide-y divide-border/60">
|
||
{steps.map((step, i) => {
|
||
const prevChain = i > 0 ? steps[i - 1].rule.chain : null
|
||
const showHeader = step.rule.chain !== prevChain
|
||
return (
|
||
<div key={step.rule.id}>
|
||
{showHeader && (
|
||
<div className="px-5 py-1.5 bg-muted/40 border-b border-border/50 flex items-center gap-2">
|
||
<span className="text-[10px] font-mono font-semibold text-foreground/70 uppercase tracking-wider">
|
||
{step.rule.chain}
|
||
</span>
|
||
{CHAIN_TABLE_HINT[step.rule.chain] && (
|
||
<span className="text-[10px] text-muted-foreground/50">
|
||
· {CHAIN_TABLE_HINT[step.rule.chain]}
|
||
</span>
|
||
)}
|
||
<span className="text-[10px] text-muted-foreground/40 ml-auto">
|
||
{steps.filter(s => s.rule.chain === step.rule.chain).length} правил
|
||
</span>
|
||
</div>
|
||
)}
|
||
<SimStepRow step={step} index={i} />
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</FramePanel>
|
||
</Frame>
|
||
)}
|
||
|
||
{/* ── Empty state ─────────────────────────────────────────────────────── */}
|
||
{simState === "idle" && simRules.length === 0 && (
|
||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||
<ShieldOffIcon className="size-10 mb-3 opacity-30" />
|
||
<p className="text-sm font-medium">Нет правил в цепочке «{packet.chain}»</p>
|
||
<p className="text-xs mt-1 opacity-60">
|
||
{activeScenario
|
||
? "Добавьте правила в сценарий через кнопку «Редактировать»"
|
||
: "Выберите другой пресет или измените цепочку"}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Scenario Sheet ───────────────────────────────────────────────────── */}
|
||
<ScenarioSheet
|
||
open={scenarioSheetOpen}
|
||
onClose={() => setScenarioSheetOpen(false)}
|
||
initial={editingScenario}
|
||
onSave={saveScenario}
|
||
/>
|
||
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── 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<T>(path: string, init?: RequestInit): Promise<T> {
|
||
return requestJson<T>(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<FirewallRule, "family">): IpFamily {
|
||
return r.family === "ip6" ? "ip6" : "ip"
|
||
}
|
||
|
||
function addressFamily(e: Pick<AddressListEntry, "family">): 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<T>(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<Server[]>([])
|
||
const [liveRules, setLiveRules] = useState<FirewallRule[]>([])
|
||
const [liveAddrLists, setLiveAddrLists] = useState<AddressListEntry[]>([])
|
||
const [mockRules, setMockRules] = useState<FirewallRule[]>(firewallRules)
|
||
const [mockAddrLists, setMockAddrLists] = useState<AddressListEntry[]>(INIT_ADDRESS_LISTS)
|
||
const [dataLoading, setDataLoading] = useState(false)
|
||
const [dataError, setDataError] = useState<string | null>(null)
|
||
const [busy, setBusy] = useState(false)
|
||
|
||
const [selectedServerId, setSelectedServerId] = useState(serverFromQuery ?? ALL_SERVERS_ID)
|
||
const appliedQueryRef = useRef(false)
|
||
|
||
const [chainGroup, setChainGroup] = useState<ChainGroup>("filter")
|
||
const [chainFilter, setChainFilter] = useState<ChainFilter>("all")
|
||
const [ipFamily, setIpFamily] = useState<IpFamily>("ip")
|
||
const [search, setSearch] = useState("")
|
||
|
||
const [editingRule, setEditingRule] = useState<Partial<FirewallRule> | null>(null)
|
||
const [ruleSheetOpen, setRuleSheetOpen] = useState(false)
|
||
const [exportOpen, setExportOpen] = useState(false)
|
||
const [editingAddr, setEditingAddr] = useState<Partial<AddressListEntry> | null>(null)
|
||
const [addrSheetOpen, setAddrSheetOpen] = useState(false)
|
||
|
||
const loadLive = useCallback(async () => {
|
||
if (!isLive) return
|
||
setDataLoading(true)
|
||
setDataError(null)
|
||
try {
|
||
const [backendServers, fw] = await Promise.all([
|
||
apiFetch<BackendServer[]>("/api/servers"),
|
||
apiFetch<FirewallAllResponse>("/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])
|
||
|
||
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<ServerTileItem[]>(() => (
|
||
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<string, number> = { 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 (
|
||
<>
|
||
<ServerRailLayout
|
||
items={firewallRailItems}
|
||
selectedId={selectedServerId}
|
||
onSelect={setSelectedServerId}
|
||
showAll
|
||
allCount={displayServers.length}
|
||
loading={isLive && dataLoading && displayServers.length === 0}
|
||
header={
|
||
<PageHeader
|
||
crumbs={[{ label: "Управление" }, { label: "Firewall" }]}
|
||
actions={
|
||
<>
|
||
<ServerRailMobileButton />
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => { void loadLive() }}
|
||
disabled={!isLive || dataLoading}
|
||
title={!isLive ? "Включите Live в источнике данных" : "Обновить правила с устройств"}
|
||
>
|
||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||
Обновить
|
||
</Button>
|
||
<Button variant="outline" size="sm" onClick={() => setExportOpen(true)}>
|
||
<CodeXmlIcon className="size-4" />Экспорт .rsc
|
||
</Button>
|
||
<Button size="sm" onClick={openAdd}>
|
||
<PlusIcon className="size-4" />Новое правило
|
||
</Button>
|
||
</>
|
||
}
|
||
/>
|
||
}
|
||
>
|
||
<div className="flex flex-col gap-5">
|
||
{dataError && isLive && (
|
||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||
{dataError}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<SegmentedControl
|
||
value={ipFamily}
|
||
onChange={(v) => { setIpFamily(v); setChainFilter("all") }}
|
||
options={(["ip", "ip6"] as IpFamily[]).map((f) => ({
|
||
value: f,
|
||
label: IP_FAMILY_LABELS[f],
|
||
}))}
|
||
/>
|
||
<span className="text-xs text-muted-foreground">
|
||
{ipFamily === "ip6" ? "/ipv6 firewall" : "/ip firewall"}
|
||
</span>
|
||
</div>
|
||
|
||
<KpiStatGrid
|
||
aria-label="Сводка Firewall"
|
||
items={[
|
||
{
|
||
id: "rules",
|
||
label: "Всего правил",
|
||
value: familyRules.length,
|
||
icon: <ShieldIcon className="size-4" />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
{
|
||
id: "enabled",
|
||
label: "Активных",
|
||
value: totalEnabled,
|
||
icon: <CheckCircleIcon className="size-4" />,
|
||
iconClassName: "text-success",
|
||
},
|
||
{
|
||
id: "drop",
|
||
label: "Блокирующих",
|
||
value: dropRules,
|
||
icon: <ShieldOffIcon className="size-4" />,
|
||
iconClassName: "text-destructive",
|
||
},
|
||
{
|
||
id: "hits",
|
||
label: "Срабатываний",
|
||
value: fmtHits(totalHits),
|
||
icon: <ListFilterIcon className="size-4" />,
|
||
iconClassName: "text-info",
|
||
},
|
||
]}
|
||
/>
|
||
|
||
<div className="flex items-center gap-1 border-b">
|
||
{CHAIN_GROUPS.map((g) => (
|
||
<button key={g.id}
|
||
onClick={() => { setChainGroup(g.id); setChainFilter("all"); setSearch("") }}
|
||
className={cn(
|
||
"flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||
chainGroup === g.id
|
||
? "border-foreground text-foreground"
|
||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||
)}>
|
||
{g.icon}
|
||
{g.label}
|
||
{g.id !== "address-lists" && g.id !== "simulator" && (
|
||
<span className="text-[10px] font-mono opacity-50">
|
||
{familyRules.filter((r) => r.table === g.id).length}
|
||
</span>
|
||
)}
|
||
{g.id === "address-lists" && (
|
||
<span className="text-[10px] font-mono opacity-50">{familyAddrLists.length}</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{chainGroup === "address-lists" ? (
|
||
<AddressListsTab
|
||
entries={familyAddrLists}
|
||
onAdd={() => openAddAddr()}
|
||
onAddToList={(list) => openAddAddr(list)}
|
||
onToggle={(e) => { void toggleAddr(e) }}
|
||
onEdit={openEditAddr}
|
||
onDelete={(e) => { void deleteAddr(e) }}
|
||
showServer={showServerCol}
|
||
/>
|
||
) : chainGroup === "simulator" ? (
|
||
<SimulatorTab rules={familyRules} />
|
||
) : (
|
||
<DataPageCard>
|
||
<DataPageToolbarFrame>
|
||
<SegmentedControl
|
||
value={chainFilter}
|
||
onChange={setChainFilter}
|
||
options={[
|
||
{ value: "all", label: "Все", count: chainCounts.all ?? 0 },
|
||
...chainsInGroup.map((c) => ({
|
||
value: c,
|
||
label: c,
|
||
count: chainCounts[c] ?? 0,
|
||
})),
|
||
]}
|
||
/>
|
||
<InputGroup className="min-w-[220px] max-w-sm">
|
||
<InputGroupAddon>
|
||
<SearchIcon className="size-3.5" />
|
||
</InputGroupAddon>
|
||
<InputGroupInput
|
||
placeholder="Поиск по адресу, действию…"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
/>
|
||
</InputGroup>
|
||
<span className="text-sm text-muted-foreground ml-auto">
|
||
{filteredRules.length} правил
|
||
</span>
|
||
</DataPageToolbarFrame>
|
||
|
||
<FirewallRulesDataGrid
|
||
rules={filteredRules}
|
||
onToggle={(id) => { void toggleRule(id) }}
|
||
onEdit={openEdit}
|
||
onDelete={(r) => { void deleteRule(r) }}
|
||
onReorder={canReorder ? (activeId, overId) => { void reorderRules(activeId, overId) } : undefined}
|
||
showServer={showServerCol}
|
||
/>
|
||
</DataPageCard>
|
||
)}
|
||
|
||
<OpsPanel title="RouterOS 7.20+ · /ip firewall + /ipv6 firewall — цепочки и новые матчеры" contentClassName="px-5 py-4">
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 text-xs">
|
||
{[
|
||
{
|
||
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) => (
|
||
<div key={t.title}>
|
||
<p className="font-mono font-semibold text-foreground/80 mb-1.5 text-[11px]">{t.title}</p>
|
||
<ul className="flex flex-col gap-1">
|
||
{t.lines.map((c) => (
|
||
<li key={c} className="text-muted-foreground font-mono flex items-start gap-1.5">
|
||
<span className="text-muted-foreground/40 mt-0.5">›</span>{c}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</OpsPanel>
|
||
|
||
</div>
|
||
</ServerRailLayout>
|
||
|
||
<RuleSheet
|
||
open={ruleSheetOpen}
|
||
onClose={() => setRuleSheetOpen(false)}
|
||
initialRule={editingRule}
|
||
chainGroup={sheetChainGroup}
|
||
family={editingRule?.id ? ruleFamily(editingRule as FirewallRule) : ipFamily}
|
||
onSave={(payload) => { void saveRule(payload) }}
|
||
saving={busy}
|
||
/>
|
||
|
||
<AddressListSheet
|
||
open={addrSheetOpen}
|
||
onClose={() => setAddrSheetOpen(false)}
|
||
initial={editingAddr}
|
||
family={editingAddr?.id ? addressFamily(editingAddr as AddressListEntry) : ipFamily}
|
||
onSave={(payload) => { void saveAddr(payload) }}
|
||
saving={busy}
|
||
/>
|
||
|
||
<ExportSheet
|
||
open={exportOpen}
|
||
onClose={() => setExportOpen(false)}
|
||
rules={familyRules}
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
|
||
export default function FirewallPage() {
|
||
return (
|
||
<Suspense fallback={null}>
|
||
<FirewallPageInner />
|
||
</Suspense>
|
||
)
|
||
}
|