feat(traffic): добавить приём Traffic Flow с jump-host
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 2m1s
Docker images / frontend-image (push) Successful in 3m56s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Successful in 12s

Чтобы видеть «кто с кем», а не только объём порта: IPFIX внутри WG на хосте Docker MM, REST-счётчики не трогаем.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DenozordecandCursor committed 2026-09-06 21:54:30 +07:00
1 parent 5884bd8873
commit 1e9312acbd
24 files changed
+2007 -71

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import type { FastifyReply, FastifyRequest } from "fastify"
import {
trafficFlowOverlayRequestSchema,
trafficFlowSettingsPatchSchema,
} from "@mmapp/contracts/traffic-flow"
import {
ensureHostKeys,
getTrafficFlowSettingsRow,
toTrafficFlowSettingsDto,
updateTrafficFlowSettings,
} from "../services/traffic-flow-settings.js"
import {
getFlowListenerState,
listFlowTalkers,
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"
function rangeToMinutes(range: string | undefined): number {
switch ((range ?? "5m").toLowerCase()) {
case "5m": return 5
case "15m": return 15
case "1h": return 60
case "4h": return 240
case "24h": return 1440
default: return 5
}
}
async function sendFlowTalkers(req: FastifyRequest, reply: FastifyReply) {
const q = req.query as { range?: string }
return reply.send(listFlowTalkers(rangeToMinutes(q.range)))
}
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)
return reply.send(result)
} catch (e) {
const status = (e as { statusCode?: number }).statusCode ?? 502
const msg = e instanceof Error ? e.message : String(e)
return reply.status(status).send({ error: msg })
}
}
const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/traffic/flow/settings", async (_req, reply) => {
return reply.send(toTrafficFlowSettingsDto(getFlowListenerState()))
})
app.put("/traffic/flow/settings", async (req, reply) => {
const parsed = trafficFlowSettingsPatchSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
updateTrafficFlowSettings(parsed.data)
startTrafficFlowListener()
return reply.send({ ok: true, settings: toTrafficFlowSettingsDto(getFlowListenerState()) })
})
app.post("/traffic/flow/settings/generate-keys", async (_req, reply) => {
const result = ensureHostKeys()
return reply.send({
ok: true,
created: result.created,
publicKey: result.publicKey,
settings: toTrafficFlowSettingsDto(getFlowListenerState()),
})
})
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() },
],
})
})
app.post("/traffic/flow/overlay", applyOverlayHandler)
app.post("/traffic/flow-overlay", applyOverlayHandler)
app.get("/traffic/flow", sendFlowTalkers)
app.get("/traffic/flows", sendFlowTalkers)
}
export default trafficFlowRoutes
+21 -35
View File
@@ -21,6 +21,12 @@ import {
getEnabledServerById,
listWireGuardInterfaces,
} from "../services/wireguard-live.js"
import {
putIpAddress,
putWireguardInterface,
putWireguardPeer,
toRosBody,
} from "../services/wireguard-ros.js"
function serverIdParam(v: string): string {
return decodeURIComponent(v)
@@ -30,14 +36,6 @@ function rosIdParam(v: string): string {
return decodeURIComponent(v)
}
function toRosBody(obj: Record<string, string | undefined>): Record<string, string> {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(obj)) {
if (v !== undefined && v !== "") out[k] = v
}
return out
}
function peerToRosBody(p: Omit<WgCreatePeerRequest, "serverId" | "interfaceName"> & { interfaceName: string }) {
return toRosBody({
interface: p.interfaceName,
@@ -99,20 +97,17 @@ async function applyParsedConfig(
comment: parsed.interface.comment,
disabled: parsed.interface.disabled ? "yes" : undefined,
})
await client.put("/interface/wireguard", ifaceBody)
await putWireguardInterface(client, ifaceBody)
if (parsed.interface.address) {
await client.put("/ip/address", {
address: parsed.interface.address,
interface: name,
})
await putIpAddress(client, parsed.interface.address, name)
}
let peersCreated = 0
for (const p of parsed.peers) {
if (!p.publicKey) continue
await client.put(
"/interface/wireguard/peers",
await putWireguardPeer(
client,
peerToRosBody({
interfaceName: name,
publicKey: p.publicKey,
@@ -164,30 +159,21 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server)
try {
await client.put(
"/interface/wireguard",
toRosBody({
name: body.name,
"listen-port": String(body.listenPort),
mtu: String(body.mtu),
comment: body.comment,
"private-key": body.privateKey,
disabled: body.disabled ? "yes" : undefined,
}),
)
await putWireguardInterface(client, {
name: body.name,
"listen-port": String(body.listenPort),
mtu: String(body.mtu),
comment: body.comment,
"private-key": body.privateKey,
disabled: body.disabled ? "yes" : undefined,
})
if (body.address) {
await client.put("/ip/address", {
address: body.address,
interface: body.name,
})
await putIpAddress(client, body.address, body.name)
}
if (body.peer) {
await client.put(
"/interface/wireguard/peers",
peerToRosBody({ ...body.peer, interfaceName: body.name }),
)
await putWireguardPeer(client, peerToRosBody({ ...body.peer, interfaceName: body.name }))
}
const list = await listWireGuardInterfaces({
@@ -255,7 +241,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
await client.put("/interface/wireguard/peers", peerToRosBody(body))
await putWireguardPeer(client, peerToRosBody(body))
return reply.status(201).send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)