Compare commits

..
1 Commits
Author SHA1 Message Date
DenozordecandCursor f0dc5acfd3 fix(traffic): сделать JH сервером WG и патчить traffic-flow по id
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m52s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 45s
Docker images / publish-release (push) Successful in 9s
RouterOS не принимает PATCH без .id у singleton. Пир на jump-host без endpoint: клиент — хост MM с Endpoint JH:13232.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 22:58:31 +07:00
5 changed files with 71 additions and 81 deletions
+11 -13
View File
@@ -16,46 +16,44 @@ export function buildHostWgQuickConf(): string {
publicKey: p.publicKey,
allowedIps: p.allowedIps,
comment: p.name,
endpoint: p.endpoint,
persistentKeepalive: p.endpoint ? 25 : undefined,
})),
})
}
export function buildHostComposeSnippet(): string {
const row = getTrafficFlowSettingsRow()
return `# IPFIX listener: публиковать UDP только на WG-адресе хоста, не на 0.0.0.0
# Поднимите wg-quick@wg-flow, затем раскомментируйте ports у backend.
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.
services:
backend:
ports:
- "${row.collectorIp}:${row.flowListenPort}:${row.flowListenPort}/udp"
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 (nftables). UDP ${row.flowListenPort} наружу НЕ открывать.
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.wgListenPort} accept comment "WireGuard handshake"
udp dport ${row.flowListenPort} drop
}
}
# ufw (если используете):
# ufw allow ${row.wgListenPort}/udp comment 'mm-wg-flow'
# ufw deny ${row.flowListenPort}/udp comment 'ipfix-not-public'
`
}
export function buildHostUfwSnippet(): string {
const row = getTrafficFlowSettingsRow()
return [
`ufw allow ${row.wgListenPort}/udp comment 'mm-wg-flow'`,
`# WG клиент: входящий listen не нужен`,
`ufw deny ${row.flowListenPort}/udp comment 'ipfix-not-public'`,
].join("\n")
}
+37 -56
View File
@@ -2,7 +2,7 @@ import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
import { MikrotikClient, MikrotikError } from "./mikrotik.js"
import { encodeRosId, MikrotikClient, MikrotikError } from "./mikrotik.js"
import { getEnabledServerById, listWireGuardInterfaces } from "./wireguard-live.js"
import {
asRosArray,
@@ -16,7 +16,6 @@ import {
import {
ensureHostKeys,
getTrafficFlowSettingsRow,
updateTrafficFlowSettings,
upsertHostPeer,
} from "./traffic-flow-settings.js"
import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js"
@@ -42,11 +41,13 @@ export function allocateOverlayAddress(prefix: string, collectorIp: string, serv
throw new Error("Нет свободных адресов в префиксе wg-flow")
}
function linuxPeerBlock(publicKey: string, address: string, comment: string): string {
function linuxPeerBlock(publicKey: string, address: string, comment: string, endpoint: string): string {
return [
`[Peer]`,
`PublicKey = ${publicKey}`,
`AllowedIPs = ${address}/32`,
`Endpoint = ${endpoint}:${JH_LISTEN_PORT}`,
`PersistentKeepalive = 25`,
comment ? `# ${comment}` : "",
].filter(Boolean).join("\n")
}
@@ -91,51 +92,34 @@ async function ensureWgInputAccept(client: MikrotikClient, listenPort: number):
return true
}
async function listFlowInterfaces(client: MikrotikClient): Promise<string> {
const ifaces = asRosArray<{ name?: string; type?: string; disabled?: string }>(await client.get("/interface"))
const names = ifaces
.filter((i) => {
if ((i.disabled ?? "false") === "true") return false
const name = i.name ?? ""
if (!name || name === IFACE_NAME || /^lo/i.test(name)) return false
const type = (i.type ?? "").toLowerCase()
return type.includes("ether") || type.includes("gre") || type === "vlan"
})
.map((i) => i.name ?? "")
.filter(Boolean)
.slice(0, 8)
return names.join(",") || "all"
}
async function ensureTrafficFlow(client: MikrotikClient, collectorIp: string, port: number): Promise<void> {
const interfaces = await listFlowInterfaces(client)
try {
await client.patch("/ip/traffic-flow", toRosBody({
enabled: "yes",
interfaces,
"active-flow-timeout": "1m",
"inactive-flow-timeout": "15s",
}))
} catch {
await client.put("/ip/traffic-flow", toRosBody({
enabled: "yes",
interfaces,
}))
const body = toRosBody({
enabled: "yes",
interfaces: "all",
"active-flow-timeout": "1m",
"inactive-flow-timeout": "15s",
})
const rows = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow"))
const id = rows[0] ? rosRowId(rows[0]) : ""
if (id) {
await patchRosPath(client, `/ip/traffic-flow/${encodeRosId(id)}`, body)
} else {
await client.post("/ip/traffic-flow/set", body)
}
const targets = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow/target"))
const existing = targets.find((t) => String(t["dst-address"] ?? "") === collectorIp)
const body = toRosBody({
const targetBody = toRosBody({
"dst-address": collectorIp,
port: String(port),
version: "ipfix",
})
if (existing) {
const id = rosRowId(existing)
if (id) await patchRosPath(client, `/ip/traffic-flow/target/${encodeURIComponent(id)}`, body)
const targetId = rosRowId(existing)
if (targetId) await patchRosPath(client, `/ip/traffic-flow/target/${encodeRosId(targetId)}`, targetBody)
return
}
await client.put("/ip/traffic-flow/target", body)
await client.put("/ip/traffic-flow/target", targetBody)
}
export function usablePublicHost(raw: string | undefined): string {
@@ -155,30 +139,22 @@ export async function applyFlowOverlay(
): Promise<TrafficFlowOverlayResult> {
const steps: string[] = []
const keys = ensureHostKeys()
let settings = getTrafficFlowSettingsRow()
const settings = getTrafficFlowSettingsRow()
const hostPublicKey = settings.hostPublicKey || keys.publicKey
if (!hostPublicKey) {
throw Object.assign(new Error("Не удалось создать ключи хоста MM"), { statusCode: 500 })
}
const endpointHost = (
opts?.publicEndpoint?.trim()
|| settings.publicEndpoint.trim()
|| usablePublicHost(opts?.requestHost)
).trim()
if (!endpointHost) {
throw Object.assign(new Error("Укажите публичный endpoint хоста MM (IP или DNS)"), { statusCode: 400 })
}
const server = getEnabledServerById(String(serverIdRaw))
if (!server || !server.enabled) {
throw Object.assign(new Error("Сервер не найден или выключен"), { statusCode: 404 })
}
if (endpointHost !== settings.publicEndpoint.trim()) {
updateTrafficFlowSettings({ publicEndpoint: endpointHost })
settings = getTrafficFlowSettingsRow()
const endpointHost = (opts?.publicEndpoint?.trim() || server.host.trim()).trim()
if (!endpointHost) {
throw Object.assign(new Error("Укажите публичный IP или DNS jump-host"), { statusCode: 400 })
}
const peerEndpoint = `${endpointHost}:${JH_LISTEN_PORT}`
const taken = new Set(
db.select({ ip: servers.mgmtTunnelIp }).from(servers).all()
@@ -218,19 +194,23 @@ export async function applyFlowOverlay(
interface: IFACE_NAME,
"public-key": hostPublicKey,
"allowed-address": `${settings.collectorIp}/32`,
"endpoint-address": endpointHost,
"endpoint-port": String(settings.wgListenPort),
"persistent-keepalive": "25",
comment: "MM traffic-flow collector",
name: "mm-collector",
}
if (!peer) {
await putWireguardPeer(client, peerBody)
steps.push("Добавлен пир на pubkey хоста MM")
steps.push("Добавлен пир на pubkey хоста MM (сервер, без endpoint)")
} else {
const id = rosRowId(peer)
if (id) await patchRosPath(client, `/interface/wireguard/peers/${encodeURIComponent(id)}`, peerBody)
steps.push("Пир хоста MM обновлён")
const hadEndpoint = Boolean(String(peer["endpoint-address"] ?? "").trim())
if (hadEndpoint && id) {
await client.delete(`/interface/wireguard/peers/${encodeURIComponent(id)}`)
await putWireguardPeer(client, peerBody)
steps.push("Пир пересоздан как сервер (endpoint снят)")
} else if (id) {
await patchRosPath(client, `/interface/wireguard/peers/${encodeURIComponent(id)}`, peerBody)
steps.push("Пир хоста MM обновлён")
}
}
const routeDst = `${settings.collectorIp}/32`
@@ -273,6 +253,7 @@ export async function applyFlowOverlay(
publicKey,
allowedIps: [`${address}/32`],
address,
endpoint: peerEndpoint,
})
return {
@@ -281,7 +262,7 @@ export async function applyFlowOverlay(
interfaceName: IFACE_NAME,
address,
publicKey,
linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host),
linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host, endpointHost),
trafficFlow: true,
steps,
hostFiles: listTrafficFlowHostFiles(),
+18 -12
View File
@@ -13,7 +13,7 @@ import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { applyTrafficFlowOverlay, getTrafficFlowSettings } from "@/shared/api/traffic-flow"
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
import type { ServerRead } from "@mmapp/contracts/servers"
import type { TrafficFlowHostFile, TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
import {
@@ -52,11 +52,17 @@ function FlowOverlaySheet({
setResult(null)
setCopied(false)
setTab("wg-quick")
setServerId(jumpHosts[0] ? String(jumpHosts[0].id) : "")
void getTrafficFlowSettings(backendUrl)
.then((s) => setEndpoint(s.publicEndpoint))
.catch(() => setEndpoint(""))
}, [open, jumpHosts, backendUrl])
const first = jumpHosts[0]
const nextId = first ? String(first.id) : ""
setServerId(nextId)
setEndpoint(first?.host ?? "")
}, [open, jumpHosts])
function handleServerChange(id: string) {
setServerId(id)
const selected = jumpHosts.find((s) => String(s.id) === id)
if (selected) setEndpoint(selected.host)
}
const formats = useMemo((): TrafficFlowHostFile[] => {
if (!result) return []
@@ -106,7 +112,7 @@ function FlowOverlaySheet({
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Подключить jump-host</SheetTitle>
<SheetDescription>
Настроит wg-flow и Traffic Flow на MikroTik и сразу выдаст файлы для Linux-хоста Docker MM (wg-quick / compose / firewall).
Создаст wg-flow на выбранном MikroTik (сервер, listen 13232) и сразу выдаст wg-quick / compose для Linux-хоста Docker MM (клиент).
</SheetDescription>
</SheetHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 flex flex-col gap-5">
@@ -114,7 +120,7 @@ function FlowOverlaySheet({
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none"
value={serverId}
onChange={(e) => setServerId(e.target.value)}
onChange={(e) => handleServerChange(e.target.value)}
>
<option value="">Выберите сервер</option>
{jumpHosts.map((s) => (
@@ -125,15 +131,15 @@ function FlowOverlaySheet({
</select>
</FormField>
<FormField
label="Публичный IP или DNS хоста Docker MM"
label="Публичный IP или DNS jump-host"
required
hint="Откуда JH стучится на WG listen (51821). Хост с wg-quick, не контейнер backend."
hint="Куда хост MM (wg-quick) стучится по UDP 13232. Не контейнер backend."
>
<Input
className="font-mono"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder="203.0.113.10"
placeholder="jh.example.com"
autoComplete="off"
/>
</FormField>
@@ -143,7 +149,7 @@ function FlowOverlaySheet({
<InfoIcon />
<AlertTitle>Ключи и UDP 4739</AlertTitle>
<AlertDescription>
Приватный ключ хоста в SQLite панели не кладите в git. UDP 4739 публикуйте только на WG-IP, не на 0.0.0.0.
Приватный ключ хоста в SQLite панели не кладите в git. UDP 4739 публикуйте только на WG-IP хоста, не на 0.0.0.0 контейнера.
</AlertDescription>
</Alert>
<ul className="text-xs text-muted-foreground flex flex-col gap-1">
+4
View File
@@ -134,6 +134,10 @@ services:
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
# IPFIX: внутри контейнера слушать все iface; на хосте bind только WG-IP после wg-quick@wg-flow
FLOW_LISTEN_HOST: "0.0.0.0"
# ports:
# - "10.255.254.1:4739:4739/udp"
volumes:
- ./data/mm:/app/data
networks:
+1
View File
@@ -6,6 +6,7 @@ export const flowHostPeerSchema = z.object({
publicKey: z.string().min(1),
allowedIps: z.array(z.string().min(1)).min(1),
address: z.string().min(1),
endpoint: z.string().optional(),
})
export const trafficFlowSettingsDtoSchema = z.object({