Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf68b59b3f | ||
|
|
5e512407e5 | ||
|
|
13889005f8 | ||
|
|
f0dc5acfd3 | ||
|
|
63bed28251 | ||
|
|
95dcd3df58 |
@@ -50,6 +50,31 @@ function addSeries(a: number[], b: number[]): number[] {
|
||||
return a.map((v, i) => v + (b[i] ?? 0))
|
||||
}
|
||||
|
||||
function flowIngestLine(stats: FlowStatsDto | null): string | null {
|
||||
if (!stats) return null
|
||||
const listener = stats.listenerBound
|
||||
? (stats.listenerAddress ?? "слушает")
|
||||
: "не слушает"
|
||||
const last = stats.lastDatagramAt
|
||||
? new Date(stats.lastDatagramAt).toLocaleString("ru-RU")
|
||||
: "—"
|
||||
const exporter = stats.lastExporterIp ? ` · ${stats.lastExporterIp}` : ""
|
||||
const err = stats.lastError ? ` · ${stats.lastError}` : ""
|
||||
return `Коллектор: ${listener} · пакеты ${stats.packetsReceived ?? 0} · последний ${last}${exporter}${err}`
|
||||
}
|
||||
|
||||
function flowEmptyHint(stats: FlowStatsDto | null): string | undefined {
|
||||
if (!stats) return undefined
|
||||
if (stats.lastError) return stats.lastError
|
||||
if (stats.packetsReceived) {
|
||||
return `IPFIX приходит (${stats.lastExporterIp ?? "экспортёр"}), но разговоры ещё не записаны.`
|
||||
}
|
||||
if (stats.listenerBound === false) {
|
||||
return "Коллектор UDP не слушает. Подключите JH ещё раз — ingest включится автоматически."
|
||||
}
|
||||
return "IPFIX ещё не доходит до коллектора. На jump-host у target Src должен быть 0.0.0.0 (авто). На хосте MM проверьте bind 10.255.254.1:4739 после wg-flow."
|
||||
}
|
||||
|
||||
// ─── data model ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface BoundIfaceTraffic {
|
||||
@@ -969,6 +994,7 @@ export default function TrafficPage() {
|
||||
const peakTx = kpiSource.reduce((a, s) => Math.max(a, s.txPeak), 0)
|
||||
|
||||
const visibleSortFields = SORT_FIELDS.filter(s => !s.modesOnly || s.modesOnly.includes(effectiveMode))
|
||||
const ingestLine = flowIngestLine(flowStats)
|
||||
|
||||
const flowKpiItems = [
|
||||
{
|
||||
@@ -1081,9 +1107,16 @@ export default function TrafficPage() {
|
||||
{effectiveMode === "flows" ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
IPFIX top-разговоры. Счётчики интерфейсов — в режимах Серверы / Клиенты / Интерфейсы.
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
IPFIX top-разговоры. Счётчики интерфейсов — в режимах Серверы / Клиенты / Интерфейсы.
|
||||
</p>
|
||||
{ingestLine ? (
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">
|
||||
{ingestLine}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex gap-1">
|
||||
{TRAFFIC_RANGE_KEYS.map((key) => (
|
||||
@@ -1114,7 +1147,10 @@ export default function TrafficPage() {
|
||||
</div>
|
||||
)}
|
||||
<DataPageCard>
|
||||
<TrafficFlowsDataGrid rows={flowStats?.talkers ?? []} />
|
||||
<TrafficFlowsDataGrid
|
||||
rows={flowStats?.talkers ?? []}
|
||||
emptyHint={flowEmptyHint(flowStats)}
|
||||
/>
|
||||
</DataPageCard>
|
||||
<FlowOverlaySheet
|
||||
open={overlayOpen}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
|
||||
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
|
||||
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
|
||||
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts",
|
||||
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts",
|
||||
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { generateNativeConf } from "./wireguard-config.js"
|
||||
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
|
||||
import type { TrafficFlowHostFile } from "@mmapp/contracts/traffic-flow"
|
||||
|
||||
const COMPOSE_DIR = "/opt/cdn-mm"
|
||||
|
||||
export function buildHostWgQuickConf(): string {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
const peers = listHostPeers()
|
||||
return generateNativeConf({
|
||||
name: "wg-flow",
|
||||
listenPort: row.wgListenPort,
|
||||
mtu: 1420,
|
||||
privateKey: row.hostPrivateKey || undefined,
|
||||
address: `${row.collectorIp}/24`,
|
||||
@@ -15,46 +17,97 @@ 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.
|
||||
|
||||
services:
|
||||
backend:
|
||||
ports:
|
||||
- "${row.collectorIp}:${row.flowListenPort}:${row.flowListenPort}/udp"
|
||||
environment:
|
||||
FLOW_LISTEN_HOST: "0.0.0.0"
|
||||
`
|
||||
}
|
||||
|
||||
export function buildHostNftSnippet(): string {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
return `# Firewall хоста Docker MM (nftables). 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 {
|
||||
export function buildHostComposeOverride(): string {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
return [
|
||||
`ufw allow ${row.wgListenPort}/udp comment 'mm-wg-flow'`,
|
||||
`ufw deny ${row.flowListenPort}/udp comment 'ipfix-not-public'`,
|
||||
"# Docker Compose merge для /opt/cdn-mm",
|
||||
"# Не править docker-compose.yml. Traefik не трогать.",
|
||||
"# Сначала: wg-quick up wg-flow (адрес " + row.collectorIp + ")",
|
||||
"# затем: docker compose up -d --force-recreate backend",
|
||||
"# Docker userland-proxy может SNAT UDP source в 172.x — ingest сопоставит единственный JH.",
|
||||
"",
|
||||
"services:",
|
||||
" backend:",
|
||||
" environment:",
|
||||
" FLOW_LISTEN_HOST: \"0.0.0.0\"",
|
||||
" ports:",
|
||||
` - "${row.collectorIp}:${row.flowListenPort}:${row.flowListenPort}/udp"`,
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
export function buildHostLinuxInstallSh(): string {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
const conf = buildHostWgQuickConf().replace(/\s+$/, "") + "\n"
|
||||
const override = buildHostComposeOverride()
|
||||
const collector = row.collectorIp
|
||||
const flowPort = row.flowListenPort
|
||||
|
||||
return `#!/usr/bin/env bash
|
||||
# WG-клиент на хосте /opt/cdn-mm → JH:13232, IPFIX в контейнер backend.
|
||||
# Запуск: sudo bash install-wg-flow.sh
|
||||
set -euo pipefail
|
||||
|
||||
if [[ \${EUID:-$(id -u)} -ne 0 ]]; then
|
||||
echo "Запустите от root: sudo bash $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
COLLECTOR_IP="${collector}"
|
||||
FLOW_PORT="${flowPort}"
|
||||
COMPOSE_DIR="${COMPOSE_DIR}"
|
||||
|
||||
if ! command -v wg >/dev/null 2>&1; then
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard
|
||||
fi
|
||||
|
||||
install -d -m 700 /etc/wireguard
|
||||
cat > /etc/wireguard/wg-flow.conf <<'WGEOF'
|
||||
${conf}WGEOF
|
||||
chmod 600 /etc/wireguard/wg-flow.conf
|
||||
|
||||
systemctl enable --now wg-quick@wg-flow
|
||||
echo "=== wg show wg-flow ==="
|
||||
wg show wg-flow
|
||||
echo "=== адрес (ожидаем \${COLLECTOR_IP}/24) ==="
|
||||
ip -4 addr show dev wg-flow
|
||||
|
||||
if [[ ! -d "\$COMPOSE_DIR" ]]; then
|
||||
echo "Нет \$COMPOSE_DIR — положите override.yml туда вручную (вкладка compose)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "\$COMPOSE_DIR/docker-compose.override.yml" <<'OVEOF'
|
||||
${override}OVEOF
|
||||
|
||||
cd "\$COMPOSE_DIR"
|
||||
docker compose up -d --force-recreate backend
|
||||
|
||||
echo "=== UDP \${FLOW_PORT} на хосте (ожидаем \${COLLECTOR_IP}:\${FLOW_PORT} docker-proxy) ==="
|
||||
ss -ulnp | grep -E "\${FLOW_PORT}" || true
|
||||
echo "=== PortBindings mmapp-backend ==="
|
||||
docker inspect -f '{{json .HostConfig.PortBindings}}' mmapp-backend
|
||||
echo "=== handshake (keepalive 25s к JH:13232) ==="
|
||||
wg show wg-flow
|
||||
|
||||
# nft на хосте MM не трогаем. Bind только на COLLECTOR_IP, не 0.0.0.0.
|
||||
# Если backend стартовал до wg-flow: docker compose up -d --force-recreate backend
|
||||
|
||||
echo "Готово. Traefik не трогали. UDP \${FLOW_PORT} только на \${COLLECTOR_IP}, не на 0.0.0.0."
|
||||
`
|
||||
}
|
||||
|
||||
export function listTrafficFlowHostFiles(): TrafficFlowHostFile[] {
|
||||
return [
|
||||
{ id: "linux", label: "Linux", filename: "install-wg-flow.sh", code: buildHostLinuxInstallSh() },
|
||||
{ id: "wg-quick", label: "wg-flow.conf", filename: "wg-flow.conf", code: buildHostWgQuickConf() },
|
||||
{ id: "compose", label: "compose", filename: "docker-compose.override.yml", code: buildHostComposeOverride() },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import { db } from "../db/index.js"
|
||||
import { flowBuckets, servers } from "../db/schema.js"
|
||||
import type { FlowStatsDto, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||
import { parseFlowPacket, protoName, type ParsedFlow } from "./traffic-flow-parse.js"
|
||||
import { pickServerIdForExporter } from "./traffic-flow-map-exporter.js"
|
||||
import {
|
||||
getTrafficFlowSettingsRow,
|
||||
listHostPeers,
|
||||
recordFlowListenerError,
|
||||
recordFlowPacket,
|
||||
} from "./traffic-flow-settings.js"
|
||||
@@ -37,13 +39,30 @@ function minuteBucketIso(at = Date.now()): string {
|
||||
}
|
||||
|
||||
function resolveServerId(exporterIp: string): number | null {
|
||||
const exact = db.select().from(servers).where(eq(servers.mgmtTunnelIp, exporterIp)).limit(1).all()[0]
|
||||
return exact ? exact.id : null
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
const rows = db.select({
|
||||
id: servers.id,
|
||||
host: servers.host,
|
||||
mgmtTunnelIp: servers.mgmtTunnelIp,
|
||||
}).from(servers).all()
|
||||
const byTunnelIp = new Map<string, number>()
|
||||
const hostIps = new Map<string, number>()
|
||||
for (const row of rows) {
|
||||
if (row.mgmtTunnelIp) byTunnelIp.set(row.mgmtTunnelIp, row.id)
|
||||
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(row.host)) hostIps.set(row.host, row.id)
|
||||
}
|
||||
return pickServerIdForExporter({
|
||||
exporterIp,
|
||||
overlayPrefix: settings.prefix,
|
||||
byTunnelIp,
|
||||
peers: listHostPeers(),
|
||||
hostIps,
|
||||
})
|
||||
}
|
||||
|
||||
function queueFlows(exporterIp: string, flows: ParsedFlow[]) {
|
||||
function queueFlows(exporterIp: string, flows: ParsedFlow[]): boolean {
|
||||
const serverId = resolveServerId(exporterIp)
|
||||
if (serverId == null) return
|
||||
if (serverId == null) return false
|
||||
const bucketAt = minuteBucketIso()
|
||||
for (const flow of flows) {
|
||||
const key = `${serverId}\0${bucketAt}\0${flow.src}\0${flow.dst}\0${flow.proto}\0${flow.srcPort}\0${flow.dstPort}`
|
||||
@@ -61,6 +80,7 @@ function queueFlows(exporterIp: string, flows: ParsedFlow[]) {
|
||||
})
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function flushPending() {
|
||||
@@ -129,7 +149,14 @@ function onMessage(msg: Buffer, rinfo: { address: string }) {
|
||||
try {
|
||||
const flows = parseFlowPacket(msg, rinfo.address)
|
||||
recordFlowPacket(rinfo.address)
|
||||
if (flows.length) queueFlows(rinfo.address, flows)
|
||||
if (!flows.length) return
|
||||
if (!queueFlows(rinfo.address, flows)) {
|
||||
recordFlowListenerError(
|
||||
`IPFIX от ${rinfo.address}: нет jump-host с адресом wg-flow. Docker SNAT (172.x) при нескольких JH не различим.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
recordFlowListenerError("")
|
||||
} catch (e) {
|
||||
recordFlowListenerError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
@@ -172,6 +199,7 @@ export function startTrafficFlowListener() {
|
||||
}
|
||||
|
||||
export function listFlowTalkers(minutes = 5): FlowStatsDto {
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
const rangeStart = new Date(Date.now() - minutes * 60_000).toISOString()
|
||||
const rows = db.select().from(flowBuckets).where(gte(flowBuckets.bucketAt, rangeStart)).all()
|
||||
const serverRows = db.select().from(servers).all()
|
||||
@@ -217,7 +245,7 @@ export function listFlowTalkers(minutes = 5): FlowStatsDto {
|
||||
const talkers = [...agg.values()]
|
||||
.map((t) => ({ ...t, bps: (t.rawBytes * 8) / windowSec }))
|
||||
.sort((a, b) => b.bytes - a.bytes)
|
||||
.slice(0, getTrafficFlowSettingsRow().topN)
|
||||
.slice(0, settings.topN)
|
||||
.map(({ rawBytes: _raw, ...rest }) => rest)
|
||||
let topProto = "—"
|
||||
let topProtoBytes = 0
|
||||
@@ -234,6 +262,12 @@ export function listFlowTalkers(minutes = 5): FlowStatsDto {
|
||||
uniqueDst: dsts.size,
|
||||
topProto,
|
||||
talkers,
|
||||
lastExporterIp: settings.lastExporterIp ?? null,
|
||||
lastError: settings.lastError || null,
|
||||
packetsReceived: settings.packetsReceived,
|
||||
lastDatagramAt: settings.lastDatagramAt ?? null,
|
||||
listenerBound: state.bound,
|
||||
listenerAddress: state.address,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
bareIpv4,
|
||||
ipInCidr,
|
||||
isNatMasqueradeExporter,
|
||||
normalizeExporterIp,
|
||||
pickServerIdForExporter,
|
||||
} from "./traffic-flow-map-exporter.js"
|
||||
|
||||
assert.equal(normalizeExporterIp("::ffff:172.18.0.2"), "172.18.0.2")
|
||||
assert.equal(bareIpv4("10.255.254.3/32"), "10.255.254.3")
|
||||
assert.equal(ipInCidr("10.255.254.3", "10.255.254.0/24"), true)
|
||||
assert.equal(ipInCidr("172.18.0.2", "10.255.254.0/24"), false)
|
||||
assert.equal(isNatMasqueradeExporter("172.18.0.2", "10.255.254.0/24"), true)
|
||||
assert.equal(isNatMasqueradeExporter("10.255.254.3", "10.255.254.0/24"), false)
|
||||
assert.equal(isNatMasqueradeExporter("10.0.0.12", "10.255.254.0/24"), true)
|
||||
|
||||
const byTunnel = new Map([["10.255.254.3", 7]])
|
||||
assert.equal(pickServerIdForExporter({
|
||||
exporterIp: "10.255.254.3",
|
||||
overlayPrefix: "10.255.254.0/24",
|
||||
byTunnelIp: byTunnel,
|
||||
peers: [],
|
||||
hostIps: new Map(),
|
||||
}), 7)
|
||||
|
||||
assert.equal(pickServerIdForExporter({
|
||||
exporterIp: "172.18.0.2",
|
||||
overlayPrefix: "10.255.254.0/24",
|
||||
byTunnelIp: byTunnel,
|
||||
peers: [{ serverId: 7, address: "10.255.254.3", allowedIps: ["10.255.254.3/32"] }],
|
||||
hostIps: new Map(),
|
||||
}), 7)
|
||||
|
||||
assert.equal(pickServerIdForExporter({
|
||||
exporterIp: "172.18.0.2",
|
||||
overlayPrefix: "10.255.254.0/24",
|
||||
byTunnelIp: new Map([["10.255.254.3", 7], ["10.255.254.4", 8]]),
|
||||
peers: [
|
||||
{ serverId: 7, address: "10.255.254.3", allowedIps: ["10.255.254.3/32"] },
|
||||
{ serverId: 8, address: "10.255.254.4", allowedIps: ["10.255.254.4/32"] },
|
||||
],
|
||||
hostIps: new Map(),
|
||||
}), null)
|
||||
|
||||
assert.equal(pickServerIdForExporter({
|
||||
exporterIp: "94.142.140.141",
|
||||
overlayPrefix: "10.255.254.0/24",
|
||||
byTunnelIp: byTunnel,
|
||||
peers: [],
|
||||
hostIps: new Map([["94.142.140.141", 7]]),
|
||||
}), 7)
|
||||
|
||||
console.log("traffic-flow-map-exporter.test.ts: ok")
|
||||
@@ -0,0 +1,82 @@
|
||||
export interface OverlayPeerRef {
|
||||
serverId: number
|
||||
address: string
|
||||
allowedIps: string[]
|
||||
}
|
||||
|
||||
export function normalizeExporterIp(ip: string): string {
|
||||
const trimmed = ip.trim()
|
||||
if (trimmed.toLowerCase().startsWith("::ffff:")) return trimmed.slice(7)
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function bareIpv4(value: string): string {
|
||||
const raw = normalizeExporterIp(value).split("/")[0]?.trim() ?? ""
|
||||
return raw
|
||||
}
|
||||
|
||||
function ipv4ToInt(ip: string): number | null {
|
||||
const parts = ip.split(".")
|
||||
if (parts.length !== 4) return null
|
||||
const n = parts.map((x) => Number(x))
|
||||
if (n.some((x) => !Number.isInteger(x) || x < 0 || x > 255)) return null
|
||||
return ((n[0]! << 24) | (n[1]! << 16) | (n[2]! << 8) | n[3]!) >>> 0
|
||||
}
|
||||
|
||||
export function ipInCidr(ip: string, cidr: string): boolean {
|
||||
const host = bareIpv4(ip)
|
||||
const [base, bitsRaw] = cidr.split("/")
|
||||
const bits = Number(bitsRaw ?? 32)
|
||||
const a = ipv4ToInt(host)
|
||||
const b = ipv4ToInt(bareIpv4(base ?? ""))
|
||||
if (a == null || b == null || !Number.isFinite(bits) || bits < 0 || bits > 32) return false
|
||||
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0
|
||||
return (a & mask) === (b & mask)
|
||||
}
|
||||
|
||||
/** Docker userland-proxy / bridge SNAT, не адрес из оверлея wg-flow. */
|
||||
export function isNatMasqueradeExporter(ip: string, overlayPrefix: string): boolean {
|
||||
const host = bareIpv4(ip)
|
||||
if (!host) return false
|
||||
if (ipInCidr(host, overlayPrefix)) return false
|
||||
return ipInCidr(host, "10.0.0.0/8")
|
||||
|| ipInCidr(host, "172.16.0.0/12")
|
||||
|| ipInCidr(host, "192.168.0.0/16")
|
||||
|| ipInCidr(host, "127.0.0.0/8")
|
||||
}
|
||||
|
||||
export function pickServerIdForExporter(opts: {
|
||||
exporterIp: string
|
||||
overlayPrefix: string
|
||||
byTunnelIp: Map<string, number>
|
||||
peers: OverlayPeerRef[]
|
||||
hostIps: Map<string, number>
|
||||
}): number | null {
|
||||
const exporter = bareIpv4(opts.exporterIp)
|
||||
if (!exporter) return null
|
||||
|
||||
const exact = opts.byTunnelIp.get(exporter)
|
||||
if (exact != null) return exact
|
||||
|
||||
for (const [ip, id] of opts.byTunnelIp) {
|
||||
if (bareIpv4(ip) === exporter) return id
|
||||
}
|
||||
|
||||
for (const peer of opts.peers) {
|
||||
if (bareIpv4(peer.address) === exporter) return peer.serverId
|
||||
if (peer.allowedIps.some((cidr) => ipInCidr(exporter, cidr) || bareIpv4(cidr) === exporter)) {
|
||||
return peer.serverId
|
||||
}
|
||||
}
|
||||
|
||||
const byHost = opts.hostIps.get(exporter)
|
||||
if (byHost != null) return byHost
|
||||
|
||||
if (!isNatMasqueradeExporter(exporter, opts.overlayPrefix)) return null
|
||||
|
||||
const tunnelIds = [...new Set(opts.byTunnelIp.values())]
|
||||
if (tunnelIds.length === 1) return tunnelIds[0] ?? null
|
||||
const peerIds = [...new Set(opts.peers.map((p) => p.serverId))]
|
||||
if (peerIds.length === 1) return peerIds[0] ?? null
|
||||
return null
|
||||
}
|
||||
@@ -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,
|
||||
@@ -14,10 +14,13 @@ import {
|
||||
toRosBody,
|
||||
} from "./wireguard-ros.js"
|
||||
import {
|
||||
enableTrafficFlowIngest,
|
||||
ensureHostKeys,
|
||||
getTrafficFlowSettingsRow,
|
||||
upsertHostPeer,
|
||||
} from "./traffic-flow-settings.js"
|
||||
import { startTrafficFlowListener } from "./traffic-flow-ingest.js"
|
||||
import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js"
|
||||
|
||||
const IFACE_NAME = "wg-flow"
|
||||
const JH_LISTEN_PORT = 13232
|
||||
@@ -40,11 +43,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 +94,65 @@ 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"
|
||||
}
|
||||
/** Официальный авто-source UDP IPFIX, не фильтр 0.0.0.0/0. */
|
||||
export const FLOW_TARGET_SRC_AUTO = "0.0.0.0"
|
||||
|
||||
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,
|
||||
}))
|
||||
async function ensureTrafficFlow(
|
||||
client: MikrotikClient,
|
||||
collectorIp: string,
|
||||
port: number,
|
||||
): Promise<void> {
|
||||
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,
|
||||
"src-address": FLOW_TARGET_SRC_AUTO,
|
||||
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 +160,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 +200,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`
|
||||
@@ -227,7 +243,7 @@ export async function applyFlowOverlay(serverIdRaw: string | number): Promise<Tr
|
||||
}
|
||||
|
||||
await ensureTrafficFlow(client, settings.collectorIp, settings.flowListenPort)
|
||||
steps.push(`Traffic Flow → ${settings.collectorIp}:${settings.flowListenPort} ipfix`)
|
||||
steps.push(`Traffic Flow → ${settings.collectorIp}:${settings.flowListenPort} ipfix (src auto)`)
|
||||
|
||||
const listed = await listWireGuardInterfaces({ serverId: String(server.id), includePrivateKey: false })
|
||||
const created = listed.interfaces.find((i) => i.name === IFACE_NAME)
|
||||
@@ -247,17 +263,23 @@ export async function applyFlowOverlay(serverIdRaw: string | number): Promise<Tr
|
||||
publicKey,
|
||||
allowedIps: [`${address}/32`],
|
||||
address,
|
||||
endpoint: peerEndpoint,
|
||||
})
|
||||
|
||||
enableTrafficFlowIngest()
|
||||
startTrafficFlowListener()
|
||||
steps.push("Коллектор IPFIX на MM включён")
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
serverId: server.id,
|
||||
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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { parseFlowPacket, protoName, resetFlowTemplatesForTests } from "./traffic-flow-parse.js"
|
||||
import { allocateOverlayAddress } from "./traffic-flow-overlay.js"
|
||||
import { allocateOverlayAddress, FLOW_TARGET_SRC_AUTO, usablePublicHost } from "./traffic-flow-overlay.js"
|
||||
|
||||
function netflowV5One(): Buffer {
|
||||
const buf = Buffer.alloc(24 + 48)
|
||||
@@ -31,4 +31,39 @@ 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")
|
||||
|
||||
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")
|
||||
assert.equal(FLOW_TARGET_SRC_AUTO, "0.0.0.0")
|
||||
|
||||
resetFlowTemplatesForTests()
|
||||
{
|
||||
const tpl = Buffer.alloc(16 + 16 + 20)
|
||||
tpl.writeUInt16BE(10, 0)
|
||||
tpl.writeUInt16BE(tpl.length, 2)
|
||||
tpl.writeUInt16BE(2, 16)
|
||||
tpl.writeUInt16BE(16, 18)
|
||||
tpl.writeUInt16BE(256, 20)
|
||||
tpl.writeUInt16BE(2, 22)
|
||||
tpl.writeUInt16BE(8, 24)
|
||||
tpl.writeUInt16BE(4, 26)
|
||||
tpl.writeUInt16BE(12, 28)
|
||||
tpl.writeUInt16BE(4, 30)
|
||||
const data = Buffer.alloc(16 + 12)
|
||||
data.writeUInt16BE(10, 0)
|
||||
data.writeUInt16BE(data.length, 2)
|
||||
data.writeUInt16BE(256, 16)
|
||||
data.writeUInt16BE(12, 18)
|
||||
data[20] = 10; data[21] = 1; data[22] = 1; data[23] = 8
|
||||
data[24] = 8; data[25] = 8; data[26] = 8; data[27] = 8
|
||||
const fromTpl = parseFlowPacket(tpl, "172.18.0.2")
|
||||
assert.equal(fromTpl.length, 0)
|
||||
const fromData = parseFlowPacket(data, "172.18.0.2")
|
||||
assert.equal(fromData.length, 1)
|
||||
assert.equal(fromData[0]?.src, "10.1.1.8")
|
||||
assert.equal(fromData[0]?.dst, "8.8.8.8")
|
||||
}
|
||||
|
||||
console.log("traffic-flow-parse.test.ts: ok")
|
||||
|
||||
@@ -24,6 +24,48 @@ function ipv4(buf: Buffer, offset: number): string {
|
||||
return `${buf[offset]}.${buf[offset + 1]}.${buf[offset + 2]}.${buf[offset + 3]}`
|
||||
}
|
||||
|
||||
function ipv6(buf: Buffer, offset: number): string {
|
||||
const parts: string[] = []
|
||||
for (let i = 0; i < 8; i++) parts.push(buf.readUInt16BE(offset + i * 2).toString(16))
|
||||
return parts.join(":")
|
||||
}
|
||||
|
||||
const VAR_LEN = 0xffff
|
||||
|
||||
function consumeField(
|
||||
buf: Buffer,
|
||||
off: number,
|
||||
length: number,
|
||||
limit: number,
|
||||
): { data: Buffer; next: number } | null {
|
||||
if (length === VAR_LEN) {
|
||||
if (off >= limit) return null
|
||||
const first = buf[off]!
|
||||
if (first < 255) {
|
||||
const end = off + 1 + first
|
||||
if (end > limit) return null
|
||||
return { data: buf.subarray(off + 1, end), next: end }
|
||||
}
|
||||
if (off + 3 > limit) return null
|
||||
const len = buf.readUInt16BE(off + 1)
|
||||
const end = off + 3 + len
|
||||
if (end > limit) return null
|
||||
return { data: buf.subarray(off + 3, end), next: end }
|
||||
}
|
||||
const end = off + length
|
||||
if (end > limit) return null
|
||||
return { data: buf.subarray(off, end), next: end }
|
||||
}
|
||||
|
||||
function fixedRecordSize(fields: FieldSpec[]): number | null {
|
||||
let n = 0
|
||||
for (const f of fields) {
|
||||
if (f.length === VAR_LEN) return null
|
||||
n += f.length
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
function readUint(buf: Buffer, offset: number, length: number): number {
|
||||
if (length === 1) return buf.readUInt8(offset)
|
||||
if (length === 2) return buf.readUInt16BE(offset)
|
||||
@@ -87,7 +129,12 @@ function parseIpfixTemplates(exporter: string, buf: Buffer, setStart: number, se
|
||||
templatesByExporter.set(exporter, map)
|
||||
}
|
||||
|
||||
function recordFromFields(fields: FieldSpec[], buf: Buffer, offset: number): { flow: ParsedFlow; next: number } | null {
|
||||
function recordFromFields(
|
||||
fields: FieldSpec[],
|
||||
buf: Buffer,
|
||||
offset: number,
|
||||
limit: number,
|
||||
): { flow: ParsedFlow; next: number } | null {
|
||||
let off = offset
|
||||
let src = ""
|
||||
let dst = ""
|
||||
@@ -98,41 +145,78 @@ function recordFromFields(fields: FieldSpec[], buf: Buffer, offset: number): { f
|
||||
let packets = 0
|
||||
let inIface = ""
|
||||
for (const f of fields) {
|
||||
if (off + f.length > buf.length) return null
|
||||
const field = consumeField(buf, off, f.length, limit)
|
||||
if (!field) return null
|
||||
const { data } = field
|
||||
switch (f.type) {
|
||||
case 8:
|
||||
if (f.length === 4) src = ipv4(buf, off)
|
||||
if (data.length === 4) src = ipv4(data, 0)
|
||||
break
|
||||
case 12:
|
||||
if (f.length === 4) dst = ipv4(buf, off)
|
||||
if (data.length === 4) dst = ipv4(data, 0)
|
||||
break
|
||||
case 27:
|
||||
if (data.length === 16 && !src) src = ipv6(data, 0)
|
||||
break
|
||||
case 28:
|
||||
if (data.length === 16 && !dst) dst = ipv6(data, 0)
|
||||
break
|
||||
case 225:
|
||||
if (data.length === 4 && !src) src = ipv4(data, 0)
|
||||
break
|
||||
case 226:
|
||||
if (data.length === 4 && !dst) dst = ipv4(data, 0)
|
||||
break
|
||||
case 4:
|
||||
proto = readUint(buf, off, f.length)
|
||||
proto = readUint(data, 0, data.length)
|
||||
break
|
||||
case 7:
|
||||
srcPort = readUint(buf, off, f.length)
|
||||
srcPort = readUint(data, 0, data.length)
|
||||
break
|
||||
case 11:
|
||||
dstPort = readUint(buf, off, f.length)
|
||||
dstPort = readUint(data, 0, data.length)
|
||||
break
|
||||
case 1:
|
||||
bytes = readUint(buf, off, f.length)
|
||||
bytes = readUint(data, 0, data.length)
|
||||
break
|
||||
case 2:
|
||||
packets = readUint(buf, off, f.length)
|
||||
packets = readUint(data, 0, data.length)
|
||||
break
|
||||
case 85:
|
||||
if (!bytes) bytes = readUint(data, 0, data.length)
|
||||
break
|
||||
case 86:
|
||||
if (!packets) packets = readUint(data, 0, data.length)
|
||||
break
|
||||
case 10:
|
||||
inIface = String(readUint(buf, off, f.length))
|
||||
inIface = String(readUint(data, 0, data.length))
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
off += f.length
|
||||
off = field.next
|
||||
}
|
||||
if (!src && !dst) return { flow: { src, dst, proto, srcPort, dstPort, bytes, packets, inIface }, next: off }
|
||||
return { flow: { src, dst, proto, srcPort, dstPort, bytes, packets, inIface }, next: off }
|
||||
}
|
||||
|
||||
function parseDataRecords(
|
||||
tpl: Template,
|
||||
buf: Buffer,
|
||||
recOff: number,
|
||||
setEnd: number,
|
||||
out: ParsedFlow[],
|
||||
) {
|
||||
const size = fixedRecordSize(tpl.fields)
|
||||
while (recOff + 1 < setEnd) {
|
||||
if (size != null && recOff + size > setEnd) break
|
||||
const parsed = recordFromFields(tpl.fields, buf, recOff, setEnd)
|
||||
if (!parsed) break
|
||||
if (parsed.flow.src || parsed.flow.dst) out.push(parsed.flow)
|
||||
if (parsed.next <= recOff) break
|
||||
recOff = parsed.next
|
||||
}
|
||||
}
|
||||
|
||||
function parseIpfix(buf: Buffer, exporter: string): ParsedFlow[] {
|
||||
if (buf.length < 16) return []
|
||||
const total = buf.readUInt16BE(2)
|
||||
@@ -148,16 +232,7 @@ function parseIpfix(buf: Buffer, exporter: string): ParsedFlow[] {
|
||||
parseIpfixTemplates(exporter, buf, off, setEnd, setId)
|
||||
} else if (setId >= 256) {
|
||||
const tpl = templatesByExporter.get(exporter)?.get(setId)
|
||||
if (tpl) {
|
||||
let recOff = off + 4
|
||||
while (recOff + 1 < setEnd) {
|
||||
const parsed = recordFromFields(tpl.fields, buf, recOff)
|
||||
if (!parsed) break
|
||||
if (parsed.flow.src || parsed.flow.dst) out.push(parsed.flow)
|
||||
if (parsed.next <= recOff) break
|
||||
recOff = parsed.next
|
||||
}
|
||||
}
|
||||
if (tpl) parseDataRecords(tpl, buf, off + 4, setEnd, out)
|
||||
}
|
||||
off = setEnd
|
||||
}
|
||||
@@ -191,16 +266,7 @@ function parseNetflowV9(buf: Buffer, exporter: string): ParsedFlow[] {
|
||||
templatesByExporter.set(exporter, map)
|
||||
} else if (setId >= 256) {
|
||||
const tpl = map.get(setId)
|
||||
if (tpl) {
|
||||
let recOff = off + 4
|
||||
while (recOff + 1 < setEnd) {
|
||||
const parsed = recordFromFields(tpl.fields, buf, recOff)
|
||||
if (!parsed) break
|
||||
if (parsed.flow.src || parsed.flow.dst) out.push(parsed.flow)
|
||||
if (parsed.next <= recOff) break
|
||||
recOff = parsed.next
|
||||
}
|
||||
}
|
||||
if (tpl) parseDataRecords(tpl, buf, off + 4, setEnd, out)
|
||||
}
|
||||
off = setEnd
|
||||
}
|
||||
|
||||
@@ -110,7 +110,6 @@ export function recordFlowPacket(exporterIp: string) {
|
||||
lastDatagramAt: nowIso(),
|
||||
lastExporterIp: exporterIp,
|
||||
packetsReceived: row.packetsReceived + 1,
|
||||
lastError: "",
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
}
|
||||
@@ -122,6 +121,13 @@ export function recordFlowListenerError(message: string) {
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
}
|
||||
|
||||
export function enableTrafficFlowIngest() {
|
||||
db.update(trafficFlowSettings).set({
|
||||
enabled: true,
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
}
|
||||
|
||||
export function listHostPeers(): FlowHostPeer[] {
|
||||
return parsePeers(getTrafficFlowSettingsRow().peersJson)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export type WgParsedConfig = {
|
||||
|
||||
export type WgExportIface = {
|
||||
name: string
|
||||
listenPort: number
|
||||
listenPort?: number
|
||||
mtu: number
|
||||
comment?: string
|
||||
enabled?: boolean
|
||||
@@ -258,7 +258,7 @@ export function generateNativeConf(iface: WgExportIface, opts?: { includePrivate
|
||||
lines.push(`# PrivateKey = <заполните приватный ключ с роутера>`)
|
||||
}
|
||||
if (iface.address) lines.push(`Address = ${iface.address}`)
|
||||
lines.push(`ListenPort = ${iface.listenPort}`)
|
||||
if (iface.listenPort) lines.push(`ListenPort = ${iface.listenPort}`)
|
||||
if (iface.mtu) lines.push(`MTU = ${iface.mtu}`)
|
||||
lines.push(``)
|
||||
|
||||
@@ -312,7 +312,7 @@ export function generateMikrotikRsc(iface: WgExportIface): string {
|
||||
lines.push(``)
|
||||
lines.push(`/interface wireguard add \\`)
|
||||
lines.push(` name=${iface.name} \\`)
|
||||
lines.push(` listen-port=${iface.listenPort} \\`)
|
||||
lines.push(` listen-port=${iface.listenPort ?? 13231} \\`)
|
||||
lines.push(` mtu=${iface.mtu} \\`)
|
||||
if (iface.privateKey) lines.push(` private-key="${iface.privateKey}" \\`)
|
||||
if (iface.comment) lines.push(` comment="${iface.comment.replace(/"/g, '\\"')}" \\`)
|
||||
|
||||
@@ -19,7 +19,13 @@ function formatBytes(n: number): string {
|
||||
return `${n} Б`
|
||||
}
|
||||
|
||||
function TrafficFlowsDataGrid({ rows }: { rows: FlowTalkerDto[] }) {
|
||||
function TrafficFlowsDataGrid({
|
||||
rows,
|
||||
emptyHint,
|
||||
}: {
|
||||
rows: FlowTalkerDto[]
|
||||
emptyHint?: string
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<FlowTalkerDto>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -96,7 +102,10 @@ function TrafficFlowsDataGrid({ rows }: { rows: FlowTalkerDto[] }) {
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
emptyMessage="Пока нет IPFIX. Поднимите wg-flow на хосте MM и подключите jump-host одним кликом."
|
||||
emptyMessage={
|
||||
emptyHint
|
||||
|| "Пока нет IPFIX. Поднимите wg-flow на хосте MM и подключите jump-host одним кликом."
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,10 +60,10 @@ function highlightLine(line: string): string {
|
||||
return "text-foreground/90"
|
||||
}
|
||||
|
||||
function CodeBlock({ code }: { code: string }) {
|
||||
function CodeBlock({ code, className }: { code: string; className?: string }) {
|
||||
const lines = code.length ? code.split("\n") : [""]
|
||||
return (
|
||||
<pre className="px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all">
|
||||
<pre className={cn("px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all", className)}>
|
||||
{lines.map((line, i) => (
|
||||
<span key={i} className={cn("block", highlightLine(line))}>
|
||||
{line || " "}
|
||||
@@ -281,4 +281,4 @@ function CodeExportSheet({
|
||||
)
|
||||
}
|
||||
|
||||
export { CodeExportSheet, downloadText }
|
||||
export { CodeExportSheet, CodeBlock, downloadText }
|
||||
|
||||
@@ -3,8 +3,20 @@
|
||||
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 { CodeBlock, 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 { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
@@ -12,6 +24,12 @@ import {
|
||||
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
|
||||
import type { ServerRead } from "@mmapp/contracts/servers"
|
||||
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
InfoIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
function FlowOverlaySheet({
|
||||
open,
|
||||
@@ -31,21 +49,44 @@ 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("linux")
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setResult(null)
|
||||
setServerId(jumpHosts[0] ? String(jumpHosts[0].id) : "")
|
||||
setCopied(false)
|
||||
setTab("linux")
|
||||
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 = result?.hostFiles ?? []
|
||||
const active = formats.find((f) => f.id === tab) ?? formats[0]
|
||||
const selectedHost = jumpHosts.find((s) => String(s.id) === serverId)
|
||||
const selectedLabel = selectedHost
|
||||
? `${selectedHost.name || selectedHost.host} (${selectedHost.host})`
|
||||
: "Выберите сервер…"
|
||||
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 ?? "linux")
|
||||
toast.success(`wg-flow на ${res.address}`)
|
||||
onDone?.(res)
|
||||
} catch (e) {
|
||||
@@ -55,59 +96,128 @@ 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">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>Подключить jump-host</SheetTitle>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl"
|
||||
>
|
||||
<SheetHeader className="shrink-0 gap-1 border-b px-5 pt-5 pb-4 pr-12">
|
||||
<SheetTitle className="text-base font-semibold tracking-tight">
|
||||
Подключить jump-host
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Создать wg-flow на выбранном MikroTik и направить Traffic Flow на collector MM. Хост Docker уже должен слушать WireGuard.
|
||||
Создаст wg-flow на MikroTik (сервер, listen 13232) и выдаст готовый bash для Linux-хоста Docker MM (клиент).
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
|
||||
<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)}
|
||||
<Select
|
||||
value={serverId || undefined}
|
||||
onValueChange={(v) => handleServerChange(String(v ?? ""))}
|
||||
>
|
||||
<option value="">Выберите сервер…</option>
|
||||
{jumpHosts.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name || s.host} ({s.host})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger className="h-9 w-full min-w-0">
|
||||
<SelectValue>{selectedLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" className="min-w-(--anchor-width)">
|
||||
{jumpHosts.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.name || s.host} ({s.host})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</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>
|
||||
<ul className="text-xs text-muted-foreground flex flex-col gap-1">
|
||||
<div className="flex min-h-0 flex-col gap-4">
|
||||
<Alert variant="success">
|
||||
<InfoIcon />
|
||||
<AlertTitle>Linux-хост /opt/cdn-mm</AlertTitle>
|
||||
<AlertDescription>
|
||||
Скопируйте вкладку Linux и выполните от root. WG — клиент к JH:13232; контейнер слушает только WG-IP:4739, не 0.0.0.0. Ключ не кладите в git.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{result.steps.map((s) => (
|
||||
<li key={s}>{s}</li>
|
||||
<li key={s} className="flex items-start gap-2 text-sm">
|
||||
<CheckIcon className="mt-0.5 size-3.5 shrink-0 text-success" />
|
||||
<span>{s}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{formats.length > 0 && active ? (
|
||||
<div className="flex min-h-0 flex-col gap-3">
|
||||
<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-2 text-xs sm:text-sm">
|
||||
{f.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Frame dense className="flex min-h-0 flex-col overflow-hidden">
|
||||
<FramePanel className="relative flex min-h-0 flex-col overflow-hidden p-0">
|
||||
<ScrollArea className="h-full min-h-0 max-h-[min(52vh,22rem)]">
|
||||
<CodeBlock code={active.code} className="whitespace-pre break-normal" />
|
||||
</ScrollArea>
|
||||
</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() }}>
|
||||
<SheetFooter className="shrink-0 flex-row items-center justify-between gap-3 border-t px-5 py-3.5 sm:flex-row">
|
||||
<SheetClose render={<Button type="button" variant="outline" className="shrink-0" />}>
|
||||
Закрыть
|
||||
</SheetClose>
|
||||
<Button disabled={!canSubmit} onClick={() => { void handleSubmit() }}>
|
||||
{busy ? "Подключение…" : "Подключить"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
|
||||
@@ -134,6 +134,11 @@ 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"
|
||||
# Сначала wg-quick@wg-flow (адрес 10.255.254.1), затем recreate backend.
|
||||
ports:
|
||||
- "10.255.254.1:4739:4739/udp"
|
||||
volumes:
|
||||
- ./data/mm:/app/data
|
||||
networks:
|
||||
|
||||
BIN
Binary file not shown.
@@ -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({
|
||||
@@ -78,11 +88,18 @@ export const flowStatsDtoSchema = z.object({
|
||||
uniqueDst: z.number().int().nonnegative(),
|
||||
topProto: z.string(),
|
||||
talkers: z.array(flowTalkerDtoSchema),
|
||||
lastExporterIp: z.string().nullable().optional(),
|
||||
lastError: z.string().nullable().optional(),
|
||||
packetsReceived: z.number().int().nonnegative().optional(),
|
||||
lastDatagramAt: z.string().nullable().optional(),
|
||||
listenerBound: z.boolean().optional(),
|
||||
listenerAddress: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user