diff --git a/backend/src/routes/traffic-flow.ts b/backend/src/routes/traffic-flow.ts index 780a499..c514ec6 100644 --- a/backend/src/routes/traffic-flow.ts +++ b/backend/src/routes/traffic-flow.ts @@ -16,12 +16,7 @@ import { startTrafficFlowListener, } from "../services/traffic-flow-ingest.js" import { applyFlowOverlay } from "../services/traffic-flow-overlay.js" -import { - buildHostComposeSnippet, - buildHostNftSnippet, - buildHostUfwSnippet, - buildHostWgQuickConf, -} from "../services/traffic-flow-host-files.js" +import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.js" function rangeToMinutes(range: string | undefined): number { switch ((range ?? "5m").toLowerCase()) { @@ -39,13 +34,22 @@ async function sendFlowTalkers(req: FastifyRequest, reply: FastifyReply) { return reply.send(listFlowTalkers(rangeToMinutes(q.range))) } +function requestPublicHost(req: FastifyRequest): string { + const forwarded = req.headers["x-forwarded-host"] + const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded + return raw || req.hostname || "" +} + async function applyOverlayHandler(req: FastifyRequest, reply: FastifyReply) { const parsed = trafficFlowOverlayRequestSchema.safeParse(req.body ?? {}) if (!parsed.success) { return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) } try { - const result = await applyFlowOverlay(parsed.data.serverId) + const result = await applyFlowOverlay(parsed.data.serverId, { + publicEndpoint: parsed.data.publicEndpoint, + requestHost: requestPublicHost(req), + }) return reply.send(result) } catch (e) { const status = (e as { statusCode?: number }).statusCode ?? 502 @@ -82,14 +86,7 @@ const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => { app.get("/traffic/flow/host-files", async (_req, reply) => { const row = getTrafficFlowSettingsRow() if (!row.hostPrivateKey) ensureHostKeys() - return reply.send({ - files: [ - { 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() }, - ], - }) + return reply.send({ files: listTrafficFlowHostFiles() }) }) app.post("/traffic/flow/overlay", applyOverlayHandler) diff --git a/backend/src/services/traffic-flow-host-files.ts b/backend/src/services/traffic-flow-host-files.ts index 39bf9df..68a2b34 100644 --- a/backend/src/services/traffic-flow-host-files.ts +++ b/backend/src/services/traffic-flow-host-files.ts @@ -1,5 +1,6 @@ import { generateNativeConf } from "./wireguard-config.js" import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js" +import type { TrafficFlowHostFile } from "@mmapp/contracts/traffic-flow" export function buildHostWgQuickConf(): string { const row = getTrafficFlowSettingsRow() @@ -58,3 +59,12 @@ export function buildHostUfwSnippet(): string { `ufw deny ${row.flowListenPort}/udp comment 'ipfix-not-public'`, ].join("\n") } + +export function listTrafficFlowHostFiles(): TrafficFlowHostFile[] { + return [ + { 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() }, + ] +} diff --git a/backend/src/services/traffic-flow-overlay.ts b/backend/src/services/traffic-flow-overlay.ts index c8dcb40..04f7923 100644 --- a/backend/src/services/traffic-flow-overlay.ts +++ b/backend/src/services/traffic-flow-overlay.ts @@ -16,8 +16,10 @@ import { import { ensureHostKeys, getTrafficFlowSettingsRow, + updateTrafficFlowSettings, upsertHostPeer, } from "./traffic-flow-settings.js" +import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js" const IFACE_NAME = "wg-flow" const JH_LISTEN_PORT = 13232 @@ -136,15 +138,35 @@ async function ensureTrafficFlow(client: MikrotikClient, collectorIp: string, po await client.put("/ip/traffic-flow/target", body) } -export async function applyFlowOverlay(serverIdRaw: string | number): Promise { +export function usablePublicHost(raw: string | undefined): string { + if (!raw) return "" + const host = raw.split(",")[0]?.trim().replace(/^\[/, "").replace(/\]:\d+$/, "").split(":")[0]?.trim() ?? "" + const lower = host.toLowerCase() + if (!host) return "" + if (lower === "localhost" || lower === "127.0.0.1" || lower === "::1" || lower === "0.0.0.0") return "" + if (lower.endsWith(".local") || lower.endsWith(".internal") || lower.endsWith(".lan")) return "" + if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host)) return "" + return host +} + +export async function applyFlowOverlay( + serverIdRaw: string | number, + opts?: { publicEndpoint?: string; requestHost?: string }, +): Promise { const steps: string[] = [] - const settings = getTrafficFlowSettingsRow() const keys = ensureHostKeys() - if (!settings.hostPublicKey && !keys.publicKey) { - throw Object.assign(new Error("Сначала сгенерируйте ключи хоста MM в настройках NetFlow"), { statusCode: 400 }) - } + let settings = getTrafficFlowSettingsRow() const hostPublicKey = settings.hostPublicKey || keys.publicKey - if (!settings.publicEndpoint.trim()) { + 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 }) } @@ -153,6 +175,11 @@ export async function applyFlowOverlay(serverIdRaw: string | number): Promise r.ip) @@ -187,7 +214,6 @@ export async function applyFlowOverlay(serverIdRaw: string | number): Promise(null) + const [copied, setCopied] = useState(false) + const [tab, setTab] = useState("wg-quick") useEffect(() => { if (!open) return setResult(null) + setCopied(false) + setTab("wg-quick") setServerId(jumpHosts[0] ? String(jumpHosts[0].id) : "") - }, [open, jumpHosts]) + void getTrafficFlowSettings(backendUrl) + .then((s) => setEndpoint(s.publicEndpoint)) + .catch(() => setEndpoint("")) + }, [open, jumpHosts, backendUrl]) + + const formats = useMemo((): TrafficFlowHostFile[] => { + if (!result) return [] + return [ + ...result.hostFiles, + { + id: "peer", + label: "[Peer]", + filename: "wg-flow-peer.conf", + code: result.linuxPeerBlock, + }, + ] + }, [result]) + + const active = formats.find((f) => f.id === tab) ?? formats[0] + const canSubmit = Boolean(serverId && endpoint.trim()) && !busy async function handleSubmit() { - if (!serverId) return + if (!serverId || !endpoint.trim()) return setBusy(true) try { - const res = await applyTrafficFlowOverlay(backendUrl, serverId) + const res = await applyTrafficFlowOverlay(backendUrl, serverId, endpoint.trim()) setResult(res) + setTab(res.hostFiles[0]?.id ?? "peer") toast.success(`wg-flow на ${res.address}`) onDone?.(res) } catch (e) { @@ -55,16 +90,26 @@ function FlowOverlaySheet({ } } + function handleCopy() { + const code = active?.code ?? "" + if (!code) return + void navigator.clipboard.writeText(code).then(() => { + setCopied(true) + toast.success("Скопировано") + setTimeout(() => setCopied(false), 1800) + }) + } + return ( - + Подключить jump-host - Создать wg-flow на выбранном MikroTik и направить Traffic Flow на collector MM. Хост Docker уже должен слушать WireGuard. + Настроит wg-flow и Traffic Flow на MikroTik и сразу выдаст файлы для Linux-хоста Docker MM (wg-quick / compose / firewall). -
+
setEndpoint(e.target.value)} + placeholder="203.0.113.10" + autoComplete="off" + /> + {result ? ( -
-
-

Пир для хоста MM (`wg set` или допишите conf):

- -
-
{result.linuxPeerBlock}
+
+ + + Ключи и UDP 4739 + + Приватный ключ хоста в SQLite панели — не кладите в git. UDP 4739 публикуйте только на WG-IP, не на 0.0.0.0. + +
    {result.steps.map((s) => (
  • {s}
  • ))}
+ {formats.length > 0 && active ? ( +
+ { + setTab(String(v)) + setCopied(false) + }} + className="shrink-0 gap-0" + > + + {formats.map((f) => ( + + {f.label} + + ))} + + + + +
+                        {active.code}
+                      
+
+ +
+ + +
+
+ ) : null}
) : null}
}>Закрыть - diff --git a/packages/contracts/src/traffic-flow.ts b/packages/contracts/src/traffic-flow.ts index 34e84a4..1c7131b 100644 --- a/packages/contracts/src/traffic-flow.ts +++ b/packages/contracts/src/traffic-flow.ts @@ -43,6 +43,14 @@ export const trafficFlowSettingsPatchSchema = z.object({ export const trafficFlowOverlayRequestSchema = z.object({ serverId: z.union([z.string(), z.number()]), + publicEndpoint: z.string().optional(), +}) + +export const trafficFlowHostFileSchema = z.object({ + id: z.string(), + label: z.string(), + filename: z.string(), + code: z.string(), }) export const trafficFlowOverlayResultSchema = z.object({ @@ -54,6 +62,7 @@ export const trafficFlowOverlayResultSchema = z.object({ linuxPeerBlock: z.string(), trafficFlow: z.boolean(), steps: z.array(z.string()), + hostFiles: z.array(trafficFlowHostFileSchema), }) export const flowTalkerDtoSchema = z.object({ @@ -84,5 +93,6 @@ export type FlowHostPeer = z.infer export type TrafficFlowSettingsDto = z.infer export type TrafficFlowSettingsPatch = z.infer export type TrafficFlowOverlayResult = z.infer +export type TrafficFlowHostFile = z.infer export type FlowTalkerDto = z.infer export type FlowStatsDto = z.infer diff --git a/shared/api/traffic-flow.ts b/shared/api/traffic-flow.ts index 01a0619..ad2c119 100644 --- a/shared/api/traffic-flow.ts +++ b/shared/api/traffic-flow.ts @@ -29,12 +29,7 @@ export async function generateTrafficFlowKeys(baseUrl: string): Promise<{ return requestJson(baseUrl, "/api/traffic/flow/settings/generate-keys", { method: "POST" }) } -export type TrafficFlowHostFile = { - id: string - label: string - filename: string - code: string -} +export type { TrafficFlowHostFile } from "@mmapp/contracts/traffic-flow" export async function getTrafficFlowHostFiles(baseUrl: string): Promise<{ files: TrafficFlowHostFile[] }> { return requestJson(baseUrl, "/api/traffic/flow/host-files") @@ -43,10 +38,11 @@ export async function getTrafficFlowHostFiles(baseUrl: string): Promise<{ files: export async function applyTrafficFlowOverlay( baseUrl: string, serverId: string | number, + publicEndpoint?: string, ): Promise { return requestJson(baseUrl, "/api/traffic/flow-overlay", { method: "POST", - body: JSON.stringify({ serverId }), + body: JSON.stringify({ serverId, publicEndpoint }), }) }