From 13889005f80d7d4e26329688f42fda19d7bf352e Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 6 Sep 2026 23:20:12 +0700 Subject: [PATCH] refactor(traffic): update traffic flow host file generation and configuration scripts - Renamed and refactored functions for better clarity, including `buildHostComposeSnippet` to `buildHostComposeOverride` and `buildHostNftSnippet` to `buildHostLinuxInstallSh`. - Introduced a new script for Linux installation of WireGuard, enhancing the setup process for Docker hosts. - Updated the `generateNativeConf` function to conditionally include the listen port in the configuration. - Adjusted the `FlowOverlaySheet` component to default to the new Linux tab and improved the user interface for selecting jump-hosts. Co-authored-by: Cursor --- .../src/services/traffic-flow-host-files.ts | 116 ++++++++++++------ backend/src/services/wireguard-config.ts | 6 +- components/reui-kit/code-export-sheet.tsx | 6 +- components/traffic/flow-overlay-sheet.tsx | 109 ++++++++-------- mikrotik.db | Bin 0 -> 413696 bytes tsconfig.tsbuildinfo | 2 +- 6 files changed, 149 insertions(+), 90 deletions(-) create mode 100644 mikrotik.db diff --git a/backend/src/services/traffic-flow-host-files.ts b/backend/src/services/traffic-flow-host-files.ts index 5a51810..aa73805 100644 --- a/backend/src/services/traffic-flow-host-files.ts +++ b/backend/src/services/traffic-flow-host-files.ts @@ -2,12 +2,13 @@ import { generateNativeConf } from "./wireguard-config.js" import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js" import type { TrafficFlowHostFile } from "@mmapp/contracts/traffic-flow" +const COMPOSE_DIR = "/opt/cdn-mm" + export function buildHostWgQuickConf(): string { const row = getTrafficFlowSettingsRow() const peers = listHostPeers() return generateNativeConf({ name: "wg-flow", - listenPort: row.wgListenPort, mtu: 1420, privateKey: row.hostPrivateKey || undefined, address: `${row.collectorIp}/24`, @@ -22,47 +23,92 @@ export function buildHostWgQuickConf(): string { }) } -export function buildHostComposeSnippet(): string { - const row = getTrafficFlowSettingsRow() - return `# Вставить в /opt/cdn-mm/docker-compose.yml под services.backend -# На хосте сначала: wg-quick up wg-flow (адрес ${row.collectorIp}) -# затем: docker compose up -d backend -# Traefik не трогать. UDP ${row.flowListenPort} не публиковать на 0.0.0.0. - - environment: - FLOW_LISTEN_HOST: "0.0.0.0" - ports: - - "${row.collectorIp}:${row.flowListenPort}:${row.flowListenPort}/udp" -` -} - -export function buildHostNftSnippet(): string { - const row = getTrafficFlowSettingsRow() - return `# Firewall хоста Docker MM. WG — клиент к JH:13232 (исходящий). -# UDP ${row.flowListenPort} наружу НЕ открывать. -table inet filter { - chain input { - type filter hook input priority 0; - iifname "wg-flow" udp dport ${row.flowListenPort} accept - udp dport ${row.flowListenPort} drop - } -} -` -} - -export function buildHostUfwSnippet(): string { +export function buildHostComposeOverride(): string { const row = getTrafficFlowSettingsRow() return [ - `# WG клиент: входящий listen не нужен`, - `ufw deny ${row.flowListenPort}/udp comment 'ipfix-not-public'`, + "# Docker Compose merge для /opt/cdn-mm", + "# Не править docker-compose.yml. Traefik не трогать.", + "# Сначала: wg-quick up wg-flow (адрес " + row.collectorIp + ")", + "# затем: docker compose up -d backend", + "", + "services:", + " backend:", + " environment:", + " FLOW_LISTEN_HOST: \"0.0.0.0\"", + " ports:", + ` - "${row.collectorIp}:${row.flowListenPort}:${row.flowListenPort}/udp"`, + "", ].join("\n") } +export function buildHostLinuxInstallSh(): string { + const row = getTrafficFlowSettingsRow() + const conf = buildHostWgQuickConf().replace(/\s+$/, "") + "\n" + const override = buildHostComposeOverride() + const collector = row.collectorIp + const flowPort = row.flowListenPort + + return `#!/usr/bin/env bash +# WG-клиент на хосте /opt/cdn-mm → JH:13232, IPFIX в контейнер backend. +# Запуск: sudo bash install-wg-flow.sh +set -euo pipefail + +if [[ \${EUID:-$(id -u)} -ne 0 ]]; then + echo "Запустите от root: sudo bash $0" >&2 + exit 1 +fi + +COLLECTOR_IP="${collector}" +FLOW_PORT="${flowPort}" +COMPOSE_DIR="${COMPOSE_DIR}" + +if ! command -v wg >/dev/null 2>&1; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard +fi + +install -d -m 700 /etc/wireguard +cat > /etc/wireguard/wg-flow.conf <<'WGEOF' +${conf}WGEOF +chmod 600 /etc/wireguard/wg-flow.conf + +systemctl enable --now wg-quick@wg-flow +echo "=== wg show wg-flow ===" +wg show wg-flow +echo "=== адрес (ожидаем \${COLLECTOR_IP}/24) ===" +ip -4 addr show dev wg-flow + +if [[ ! -d "\$COMPOSE_DIR" ]]; then + echo "Нет \$COMPOSE_DIR — положите override.yml туда вручную (вкладка compose)." >&2 + exit 1 +fi + +cat > "\$COMPOSE_DIR/docker-compose.override.yml" <<'OVEOF' +${override}OVEOF + +cd "\$COMPOSE_DIR" +docker compose up -d backend + +echo "=== UDP \${FLOW_PORT} на хосте ===" +ss -ulnp | grep -E "\${FLOW_PORT}" || true +echo "=== PortBindings mmapp-backend ===" +docker inspect -f '{{json .HostConfig.PortBindings}}' mmapp-backend +echo "=== handshake (keepalive 25s к JH:13232) ===" +wg show wg-flow + +# ufw: исходящий WG не открывать; 4739 на WAN не публиковать +if command -v ufw >/dev/null 2>&1; then + ufw deny "\${FLOW_PORT}/udp" comment 'ipfix-not-public' || true +fi + +echo "Готово. Traefik не трогали. UDP \${FLOW_PORT} только на \${COLLECTOR_IP}, не на 0.0.0.0." +` +} + export function listTrafficFlowHostFiles(): TrafficFlowHostFile[] { return [ + { id: "linux", label: "Linux", filename: "install-wg-flow.sh", code: buildHostLinuxInstallSh() }, { id: "wg-quick", label: "wg-flow.conf", filename: "wg-flow.conf", code: buildHostWgQuickConf() }, - { id: "compose", label: "docker-compose", filename: "docker-compose.flow.yml", code: buildHostComposeSnippet() }, - { id: "nft", label: "nftables", filename: "wg-flow.nft", code: buildHostNftSnippet() }, - { id: "ufw", label: "ufw", filename: "wg-flow.ufw.sh", code: buildHostUfwSnippet() }, + { id: "compose", label: "compose", filename: "docker-compose.override.yml", code: buildHostComposeOverride() }, ] } diff --git a/backend/src/services/wireguard-config.ts b/backend/src/services/wireguard-config.ts index e2b1f9b..78d2d63 100644 --- a/backend/src/services/wireguard-config.ts +++ b/backend/src/services/wireguard-config.ts @@ -35,7 +35,7 @@ export type WgParsedConfig = { export type WgExportIface = { name: string - listenPort: number + listenPort?: number mtu: number comment?: string enabled?: boolean @@ -258,7 +258,7 @@ export function generateNativeConf(iface: WgExportIface, opts?: { includePrivate lines.push(`# PrivateKey = <заполните приватный ключ с роутера>`) } if (iface.address) lines.push(`Address = ${iface.address}`) - lines.push(`ListenPort = ${iface.listenPort}`) + if (iface.listenPort) lines.push(`ListenPort = ${iface.listenPort}`) if (iface.mtu) lines.push(`MTU = ${iface.mtu}`) lines.push(``) @@ -312,7 +312,7 @@ export function generateMikrotikRsc(iface: WgExportIface): string { lines.push(``) lines.push(`/interface wireguard add \\`) lines.push(` name=${iface.name} \\`) - lines.push(` listen-port=${iface.listenPort} \\`) + lines.push(` listen-port=${iface.listenPort ?? 13231} \\`) lines.push(` mtu=${iface.mtu} \\`) if (iface.privateKey) lines.push(` private-key="${iface.privateKey}" \\`) if (iface.comment) lines.push(` comment="${iface.comment.replace(/"/g, '\\"')}" \\`) diff --git a/components/reui-kit/code-export-sheet.tsx b/components/reui-kit/code-export-sheet.tsx index f17ab6f..cc66565 100644 --- a/components/reui-kit/code-export-sheet.tsx +++ b/components/reui-kit/code-export-sheet.tsx @@ -60,10 +60,10 @@ function highlightLine(line: string): string { return "text-foreground/90" } -function CodeBlock({ code }: { code: string }) { +function CodeBlock({ code, className }: { code: string; className?: string }) { const lines = code.length ? code.split("\n") : [""] return ( -
+    
       {lines.map((line, i) => (
         
           {line || " "}
@@ -281,4 +281,4 @@ function CodeExportSheet({
   )
 }
 
-export { CodeExportSheet, downloadText }
+export { CodeExportSheet, CodeBlock, downloadText }
diff --git a/components/traffic/flow-overlay-sheet.tsx b/components/traffic/flow-overlay-sheet.tsx
index 8f8fa19..84f67ee 100644
--- a/components/traffic/flow-overlay-sheet.tsx
+++ b/components/traffic/flow-overlay-sheet.tsx
@@ -5,9 +5,17 @@ import { toast } from "sonner"
 import { FormField } from "@/components/form-kit"
 import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
 import { Frame, FramePanel } from "@/components/reui/frame"
-import { downloadText } from "@/components/reui-kit/code-export-sheet"
+import { CodeBlock, downloadText } from "@/components/reui-kit/code-export-sheet"
 import { Button } from "@/components/ui/button"
 import { Input } from "@/components/ui/input"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select"
 import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
 import {
   Sheet, SheetContent, SheetHeader, SheetTitle,
@@ -15,7 +23,7 @@ import {
 } from "@/components/ui/sheet"
 import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
 import type { ServerRead } from "@mmapp/contracts/servers"
-import type { TrafficFlowHostFile, TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
+import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
 import {
   CheckIcon,
   CopyIcon,
@@ -45,13 +53,13 @@ function FlowOverlaySheet({
   const [busy, setBusy] = useState(false)
   const [result, setResult] = useState(null)
   const [copied, setCopied] = useState(false)
-  const [tab, setTab] = useState("wg-quick")
+  const [tab, setTab] = useState("linux")
 
   useEffect(() => {
     if (!open) return
     setResult(null)
     setCopied(false)
-    setTab("wg-quick")
+    setTab("linux")
     const first = jumpHosts[0]
     const nextId = first ? String(first.id) : ""
     setServerId(nextId)
@@ -64,20 +72,12 @@ function FlowOverlaySheet({
     if (selected) setEndpoint(selected.host)
   }
 
-  const formats = useMemo((): TrafficFlowHostFile[] => {
-    if (!result) return []
-    return [
-      ...result.hostFiles,
-      {
-        id: "peer",
-        label: "[Peer]",
-        filename: "wg-flow-peer.conf",
-        code: result.linuxPeerBlock,
-      },
-    ]
-  }, [result])
-
+  const formats = result?.hostFiles ?? []
   const active = formats.find((f) => f.id === tab) ?? formats[0]
+  const selectedHost = jumpHosts.find((s) => String(s.id) === serverId)
+  const selectedLabel = selectedHost
+    ? `${selectedHost.name || selectedHost.host} (${selectedHost.host})`
+    : "Выберите сервер…"
   const canSubmit = Boolean(serverId && endpoint.trim()) && !busy
 
   async function handleSubmit() {
@@ -86,7 +86,7 @@ function FlowOverlaySheet({
     try {
       const res = await applyTrafficFlowOverlay(backendUrl, serverId, endpoint.trim())
       setResult(res)
-      setTab(res.hostFiles[0]?.id ?? "peer")
+      setTab(res.hostFiles[0]?.id ?? "linux")
       toast.success(`wg-flow на ${res.address}`)
       onDone?.(res)
     } catch (e) {
@@ -108,27 +108,35 @@ function FlowOverlaySheet({
 
   return (
     
-      
-        
-          Подключить jump-host
+      
+        
+          
+            Подключить jump-host
+          
           
-            Создаст wg-flow на выбранном MikroTik (сервер, listen 13232) и сразу выдаст wg-quick / compose для Linux-хоста Docker MM (клиент).
+            Создаст wg-flow на MikroTik (сервер, listen 13232) и выдаст готовый bash для Linux-хоста Docker MM (клиент).
           
         
-        
+
- handleServerChange(String(v ?? ""))} > - - {jumpHosts.map((s) => ( - - ))} - + + {selectedLabel} + + + {jumpHosts.map((s) => ( + + {s.name || s.host} ({s.host}) + + ))} + + {result ? ( -
- +
+ - Ключи и UDP 4739 + Linux-хост /opt/cdn-mm - Приватный ключ хоста в SQLite панели — не кладите в git. UDP 4739 публикуйте только на WG-IP хоста, не на 0.0.0.0 контейнера. + Скопируйте вкладку Linux и выполните от root. WG — клиент к JH:13232; контейнер слушает только WG-IP:4739, не 0.0.0.0. Ключ не кладите в git. -
    +
      {result.steps.map((s) => ( -
    • {s}
    • +
    • + + {s} +
    • ))}
    {formats.length > 0 && active ? ( -
    +
    { @@ -169,17 +180,17 @@ function FlowOverlaySheet({ > {formats.map((f) => ( - + {f.label} ))} - + -
    -                        {active.code}
    -                      
    + + +
    @@ -202,8 +213,10 @@ function FlowOverlaySheet({
    ) : null}
    - - }>Закрыть + + }> + Закрыть + diff --git a/mikrotik.db b/mikrotik.db new file mode 100644 index 0000000000000000000000000000000000000000..c334714e8e3f6fe37ac613e093655ecd12407a4e GIT binary patch literal 413696 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 z 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