Compare commits

..
3 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 <[email protected]>
2026-09-06 22:58:31 +07:00
DenozordecandCursor 63bed28251 fix(ci): импортировать TrafficFlowHostFile в клиенте overlay
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 56s
Docker images / frontend-image (push) Successful in 3m6s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / publish-release (push) Successful in 16s
Co-authored-by: Cursor <[email protected]>
2026-09-06 22:24:44 +07:00
DenozordecandCursor 95dcd3df58 fix(traffic): спрашивать endpoint MM в sheet подключения JH
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m31s
Docker images / frontend-image (push) Failing after 2m38s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-09-06 22:16:41 +07:00
8 changed files with 242 additions and 113 deletions
+12 -15
View File
@@ -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)
+21 -13
View File
@@ -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()
@@ -15,46 +16,53 @@ 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")
}
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() },
]
}
+58 -50
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,
@@ -18,6 +18,7 @@ import {
getTrafficFlowSettingsRow,
upsertHostPeer,
} from "./traffic-flow-settings.js"
import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js"
const IFACE_NAME = "wg-flow"
const JH_LISTEN_PORT = 13232
@@ -40,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")
}
@@ -89,63 +92,57 @@ 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 async function applyFlowOverlay(serverIdRaw: string | number): Promise<TrafficFlowOverlayResult> {
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<TrafficFlowOverlayResult> {
const steps: string[] = []
const settings = getTrafficFlowSettingsRow()
const keys = ensureHostKeys()
if (!settings.hostPublicKey && !keys.publicKey) {
throw Object.assign(new Error("Сначала сгенерируйте ключи хоста MM в настройках NetFlow"), { statusCode: 400 })
}
const settings = getTrafficFlowSettingsRow()
const hostPublicKey = settings.hostPublicKey || keys.publicKey
if (!settings.publicEndpoint.trim()) {
throw Object.assign(new Error("Укажите публичный endpoint хоста MM (IP или DNS)"), { statusCode: 400 })
if (!hostPublicKey) {
throw Object.assign(new Error("Не удалось создать ключи хоста MM"), { statusCode: 500 })
}
const server = getEnabledServerById(String(serverIdRaw))
@@ -153,6 +150,12 @@ export async function applyFlowOverlay(serverIdRaw: string | number): Promise<Tr
throw Object.assign(new Error("Сервер не найден или выключен"), { statusCode: 404 })
}
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()
.map((r) => r.ip)
@@ -187,24 +190,27 @@ export async function applyFlowOverlay(serverIdRaw: string | number): Promise<Tr
}
const peer = await findPeer(client, IFACE_NAME, hostPublicKey)
const endpointHost = settings.publicEndpoint.trim()
const peerBody = {
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`
@@ -247,6 +253,7 @@ export async function applyFlowOverlay(serverIdRaw: string | number): Promise<Tr
publicKey,
allowedIps: [`${address}/32`],
address,
endpoint: peerEndpoint,
})
return {
@@ -255,9 +262,10 @@ export async function applyFlowOverlay(serverIdRaw: string | number): Promise<Tr
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(),
}
} catch (e) {
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
@@ -31,4 +31,11 @@ const taken = new Set(["10.255.254.2"])
assert.equal(allocateOverlayAddress("10.255.254.0/24", "10.255.254.1", 1, taken), "10.255.254.3")
assert.equal(allocateOverlayAddress("10.255.254.0/24", "10.255.254.1", 2, new Set()), "10.255.254.3")
import { usablePublicHost } from "./traffic-flow-overlay.js"
assert.equal(usablePublicHost("localhost:8000"), "")
assert.equal(usablePublicHost("127.0.0.1"), "")
assert.equal(usablePublicHost("192.168.1.10"), "")
assert.equal(usablePublicHost("mm.example.com:443"), "mm.example.com")
assert.equal(usablePublicHost("203.0.113.10"), "203.0.113.10")
console.log("traffic-flow-parse.test.ts: ok")
+124 -27
View File
@@ -3,15 +3,25 @@
import { useEffect, useMemo, useState } from "react"
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 { Button } from "@/components/ui/button"
import { CopyIcon } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
import type { ServerRead } from "@mmapp/contracts/servers"
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
import type { TrafficFlowHostFile, TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
import {
CheckIcon,
CopyIcon,
DownloadIcon,
InfoIcon,
} from "lucide-react"
function FlowOverlaySheet({
open,
@@ -31,21 +41,52 @@ function FlowOverlaySheet({
[servers],
)
const [serverId, setServerId] = useState("")
const [endpoint, setEndpoint] = useState("")
const [busy, setBusy] = useState(false)
const [result, setResult] = useState<TrafficFlowOverlayResult | null>(null)
const [copied, setCopied] = useState(false)
const [tab, setTab] = useState("wg-quick")
useEffect(() => {
if (!open) return
setResult(null)
setServerId(jumpHosts[0] ? String(jumpHosts[0].id) : "")
setCopied(false)
setTab("wg-quick")
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 []
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,21 +96,31 @@ 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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetContent side="right" className="w-full sm:max-w-xl flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Подключить jump-host</SheetTitle>
<SheetDescription>
Создать wg-flow на выбранном MikroTik и направить Traffic Flow на collector MM. Хост Docker уже должен слушать WireGuard.
Создаст wg-flow на выбранном MikroTik (сервер, listen 13232) и сразу выдаст wg-quick / compose для Linux-хоста Docker MM (клиент).
</SheetDescription>
</SheetHeader>
<div className="flex-1 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">
<FormField label="Jump-host" required>
<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) => (
@@ -79,35 +130,81 @@ function FlowOverlaySheet({
))}
</select>
</FormField>
<FormField
label="Публичный IP или DNS jump-host"
required
hint="Куда хост MM (wg-quick) стучится по UDP 13232. Не контейнер backend."
>
<Input
className="font-mono"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder="jh.example.com"
autoComplete="off"
/>
</FormField>
{result ? (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">Пир для хоста MM (`wg set` или допишите conf):</p>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
void navigator.clipboard.writeText(result.linuxPeerBlock)
toast.success("Скопировано")
}}
>
<CopyIcon className="size-3.5" />
Копировать
</Button>
</div>
<pre className="text-[11px] font-mono bg-muted/40 border rounded-md p-3 whitespace-pre-wrap">{result.linuxPeerBlock}</pre>
<div className="flex flex-col gap-4 min-h-0">
<Alert>
<InfoIcon />
<AlertTitle>Ключи и UDP 4739</AlertTitle>
<AlertDescription>
Приватный ключ хоста в SQLite панели не кладите в git. UDP 4739 публикуйте только на WG-IP хоста, не на 0.0.0.0 контейнера.
</AlertDescription>
</Alert>
<ul className="text-xs text-muted-foreground flex flex-col gap-1">
{result.steps.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
{formats.length > 0 && active ? (
<div className="flex flex-col gap-3 min-h-0">
<Tabs
value={tab}
onValueChange={(v) => {
setTab(String(v))
setCopied(false)
}}
className="shrink-0 gap-0"
>
<TabsList className="h-9 w-full">
{formats.map((f) => (
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-1.5 text-xs sm:text-sm">
{f.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Frame dense className="flex min-h-0 flex-col">
<FramePanel className="relative flex min-h-0 flex-col overflow-hidden p-0">
<pre className="px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all min-h-[12rem]">
{active.code}
</pre>
</FramePanel>
</Frame>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => downloadText(active.filename, active.code)}
>
<DownloadIcon className="size-3.5" />
Файл
</Button>
<Button type="button" size="sm" onClick={handleCopy}>
{copied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
{copied ? "Скопировано" : "Копировать"}
</Button>
</div>
</div>
) : null}
</div>
) : null}
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" />}>Закрыть</SheetClose>
<Button disabled={!serverId || busy} onClick={() => { void handleSubmit() }}>
<Button disabled={!canSubmit} onClick={() => { void handleSubmit() }}>
{busy ? "Подключение…" : "Подключить"}
</Button>
</SheetFooter>
+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:
+11
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({
@@ -43,6 +44,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 +63,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 +94,6 @@ export type FlowHostPeer = z.infer<typeof flowHostPeerSchema>
export type TrafficFlowSettingsDto = z.infer<typeof trafficFlowSettingsDtoSchema>
export type TrafficFlowSettingsPatch = z.infer<typeof trafficFlowSettingsPatchSchema>
export type TrafficFlowOverlayResult = z.infer<typeof trafficFlowOverlayResultSchema>
export type TrafficFlowHostFile = z.infer<typeof trafficFlowHostFileSchema>
export type FlowTalkerDto = z.infer<typeof flowTalkerDtoSchema>
export type FlowStatsDto = z.infer<typeof flowStatsDtoSchema>
+5 -8
View File
@@ -1,11 +1,14 @@
import type {
FlowStatsDto,
TrafficFlowHostFile,
TrafficFlowOverlayResult,
TrafficFlowSettingsDto,
TrafficFlowSettingsPatch,
} from "@mmapp/contracts/traffic-flow"
import { requestJson } from "@/shared/api/http-client"
export type { TrafficFlowHostFile }
export async function getTrafficFlowSettings(baseUrl: string): Promise<TrafficFlowSettingsDto> {
return requestJson<TrafficFlowSettingsDto>(baseUrl, "/api/traffic/flow/settings")
}
@@ -29,13 +32,6 @@ 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 async function getTrafficFlowHostFiles(baseUrl: string): Promise<{ files: TrafficFlowHostFile[] }> {
return requestJson(baseUrl, "/api/traffic/flow/host-files")
}
@@ -43,10 +39,11 @@ export async function getTrafficFlowHostFiles(baseUrl: string): Promise<{ files:
export async function applyTrafficFlowOverlay(
baseUrl: string,
serverId: string | number,
publicEndpoint?: string,
): Promise<TrafficFlowOverlayResult> {
return requestJson(baseUrl, "/api/traffic/flow-overlay", {
method: "POST",
body: JSON.stringify({ serverId }),
body: JSON.stringify({ serverId, publicEndpoint }),
})
}