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