feat(netflow): автоматизировать настройку туннеля коллектора

This commit is contained in:
Denozordec committed 2026-10-16 15:03:23 +07:00
1 parent fcca92706b
commit db81cf0f97
33 files changed
+1503 -22

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
deploy/netflow-agent/*.sh text eol=lf
deploy/netflow-agent/*.py text eol=lf
+60
View File
@@ -155,6 +155,8 @@ jobs:
cp -R packages/contracts/src "$STAGING/packages/contracts/"
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
cp -R backend/src "$STAGING/backend/"
mkdir -p "$STAGING/deploy/netflow-agent"
cp deploy/netflow-agent/install.sh "$STAGING/deploy/netflow-agent/"
if [ -d backend/drizzle ]; then
cp -R backend/drizzle "$STAGING/backend/"
fi
@@ -329,12 +331,69 @@ jobs:
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
netflow-agent-image:
needs: prepare-release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure netflow-agent image name
shell: bash
run: |
set -euo pipefail
owner=$(echo "${{ gitea.repository }}" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
name=$(echo "${{ gitea.repository }}" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
echo "IMAGE_NAME=${{ env.REGISTRY }}/${owner}/${stem}-netflow-agent" >> "${GITHUB_ENV}"
- name: Test controller and installer
run: |
python3 -m unittest discover -s deploy/netflow-agent -p "test_*.py" -v
bash -n deploy/netflow-agent/install.sh
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
- name: Verify image with real WireGuard
run: |
docker build --target test -t mm-netflow-agent-test deploy/netflow-agent
docker run --rm --cap-drop ALL --cap-add NET_ADMIN --read-only --tmpfs /tmp -e FLOW_AGENT_KERNEL_TEST=1 --entrypoint python3 mm-netflow-agent-test -m unittest discover -s /app -p "test_agent.py" -v
- name: Build and push netflow-agent image
uses: docker/build-push-action@v5
with:
context: deploy/netflow-agent
file: deploy/netflow-agent/Dockerfile
platforms: linux/amd64
push: true
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ gitea.sha }}
${{ env.IMAGE_NAME }}:${{ needs.prepare-release.outputs.version }}
cache-from: type=gha,scope=netflow-agent
cache-to: type=gha,mode=max,scope=netflow-agent
labels: |
org.opencontainers.image.title=mmapp-netflow-agent
org.opencontainers.image.description=MikroTik Manager local WireGuard controller
org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }}
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
publish-release:
needs:
- prepare-release
- backend-image
- frontend-image
- updater-image
- netflow-agent-image
if: needs.prepare-release.outputs.should_release == 'true'
runs-on: ubuntu-latest
steps:
@@ -420,6 +479,7 @@ jobs:
- prepare-release
- backend-image
- frontend-image
- netflow-agent-image
runs-on: ubuntu-latest
env:
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
+8
View File
@@ -62,3 +62,11 @@ opencode.json
# Local codegraph process
.codegraph/daemon.pid
# Python controller tests
__pycache__/
*.pyc
# NetFlow agent host state (when Compose is installed in a checkout)
/data/netflow-wireguard/
/data/netflow-agent-socket/
+4
View File
@@ -568,3 +568,7 @@ docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
10. Проверить `curl` на `:8000/health` и `:3000/`.
11. В UI: режим live и URL бэкенда; сверить с `CORS_ORIGIN`.
12. Просмотреть `docker logs mmapp-updater`; настроить регулярный backup тома `backend-data`.
### Автоматизация NetFlow через Docker-агент
[Установка, схема взаимодействия и production-состав](docs/netflow-agent.md).
+2
View File
@@ -17,3 +17,5 @@ AUTH_REQUIRED=false
AUTH_JWT_SECRET=dev-secret-change-me
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=http://localhost:5175
# Optional: enabled by the NetFlow agent Compose installer. Linux Unix socket only.
# FLOW_AGENT_SOCKET=/run/mm-netflow-host/agent.sock
+1
View File
@@ -57,6 +57,7 @@ COPY --from=build /app/packages/contracts ./packages/contracts
COPY --from=build /app/backend/dist ./backend/dist
COPY --from=build /app/backend/package.json ./backend/package.json
COPY --from=build /app/backend/drizzle ./backend/drizzle
COPY deploy/netflow-agent/install.sh ./deploy/netflow-agent/install.sh
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
COPY --from=build /app/backend/node_modules ./backend/node_modules
EXPOSE 8000
+1 -1
View File
@@ -33,7 +33,7 @@
"test": "node scripts/test.mjs",
"test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts",
"test:netflow-purge": "tsx src/db/operation-lock.test.ts && tsx src/services/traffic-flow-purge-execution.test.ts && tsx src/routes/traffic-flow-purge.test.ts",
"test:netflow-settings": "tsx src/services/traffic-flow-host-file-builders.test.ts && tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts",
"test:netflow-settings": "tsx src/services/traffic-flow-host-file-builders.test.ts && tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts && tsx src/services/traffic-flow-agent-install.test.ts && tsx src/services/traffic-flow-agent-client.test.ts && tsx src/services/traffic-flow-operation-queue.test.ts && tsx src/routes/traffic-flow-host.test.ts",
"test:geoip": "tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/plugins/auth.geoip.test.ts",
"test:ospf-optimizer": "tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/services/ospf-effective-disabled.test.ts && tsx src/routes/ospf-optimizer.test.ts",
"test:containers": "tsx src/services/containers-live.test.ts && tsx src/services/containers-state.test.ts && tsx src/services/container-removal.test.ts && tsx src/routes/containers-state.test.ts",
+6
View File
@@ -39,6 +39,8 @@ import usersRoutes from "./routes/users.js"
import statisticsRoutes from "./routes/statistics.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
import { getFlowWorkerHealth, startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
import { agentSocket } from "./services/traffic-flow-agent-client.js"
import { reconcileFlowHost, withFlowConfigurationLock } from "./services/traffic-flow-host-agent.js"
import { initGeoip } from "./services/traffic-flow-geoip.js"
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 })
@@ -147,6 +149,10 @@ export async function buildApp(opts?: {
await refreshScheduler()
await initGeoip()
await startTrafficFlowListener()
if (agentSocket()) {
try { await withFlowConfigurationLock(reconcileFlowHost) }
catch { app.log.warn("NetFlow: агент хоста не синхронизирован; проверьте состояние в настройках NetFlow") }
}
app.addHook("onClose", async () => {
stopScheduler()
stopTrafficFlowListener()
@@ -0,0 +1,46 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import { SignJWT } from "jose"
import { createTrafficFlowHostRoutes } from "./traffic-flow-host.js"
import { emptyHostStatus } from "../services/traffic-flow-agent-client.js"
process.env.AUTH_REQUIRED = "true"
process.env.AUTH_JWT_SECRET = "flow-host-test-secret"
process.env.AUTH_ISSUER = "https://auth.test.local"
process.env.NODE_ENV = "test"
const { default: authPlugin, requireAuth } = await import("../plugins/auth.js")
const app = Fastify()
await app.register(authPlugin)
app.addHook("preHandler", requireAuth)
let applied = 0, fail = false
let status = emptyHostStatus(false)
await app.register(createTrafficFlowHostRoutes({
status: async () => status,
reconcile: async () => { applied++; if (fail) throw Error("Туннель не изменён: конфликт адресов"); return status },
}), { prefix: "/api" })
async function token(permission: string) {
return new SignJWT({ sub: "fixture", apps: ["mm"], permissions: [permission] }).setProtectedHeader({ alg: "HS256" })
.setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("flow-host-test-secret"))
}
for (const permission of ["mm:dashboard:read", "mm:traffic:read", "mm:traffic:write", "mm:traffic:admin"]) {
const headers = { authorization: "Bearer " + await token(permission) }
assert.equal((await app.inject({ method: "GET", url: "/api/traffic/flow/host-status", headers })).statusCode, permission === "mm:dashboard:read" ? 403 : 200)
const before = applied
const response = await app.inject({ method: "POST", url: "/api/traffic/flow/host-reconcile", headers })
const allowed = permission.endsWith(":write") || permission.endsWith(":admin")
assert.equal(response.statusCode, allowed ? 409 : 403)
assert.equal(applied, before + Number(allowed))
}
assert.equal((await app.inject({ method: "GET", url: "/api/traffic/flow/host-status" })).statusCode, 401)
const headers = { authorization: "Bearer " + await token("mm:traffic:write") }
status = { ...emptyHostStatus(true), reachable: true, interfaceUp: true, configMatches: true }
let response = await app.inject({ method: "POST", url: "/api/traffic/flow/host-reconcile", headers })
assert.equal(response.statusCode, 200); assert.equal(response.json().hostStatus.configMatches, true)
fail = true
response = await app.inject({ method: "POST", url: "/api/traffic/flow/host-reconcile", headers })
assert.equal(response.statusCode, 502); assert.match(response.json().error, /конфликт адресов/)
status = emptyHostStatus(true, "offline")
response = await app.inject({ method: "GET", url: "/api/traffic/flow/host-status", headers })
assert.equal(response.json().configMatches, null); assert.equal(response.json().reachable, false)
await app.close()
console.log("NetFlow host HTTP/JWT: read/write permissions, manual mode, reconcile success/conflict and offline status")
+20
View File
@@ -0,0 +1,20 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import type { TrafficFlowHostStatus } from "@mmapp/contracts/traffic-flow"
export function createTrafficFlowHostRoutes(deps: {
status: () => Promise<TrafficFlowHostStatus>
reconcile: () => Promise<TrafficFlowHostStatus>
}): FastifyPluginAsyncZod {
return async app => {
app.get("/traffic/flow/host-status", async () => deps.status())
app.post("/traffic/flow/host-reconcile", async (_req, reply) => {
try {
const hostStatus = await deps.reconcile()
if (!hostStatus.configured) return reply.status(409).send({ error: "Автоматизация хоста NetFlow не настроена" })
return { ok: true, hostStatus }
} catch (error) {
return reply.status(502).send({ error: error instanceof Error ? error.message : "Настройки хоста не применены" })
}
})
}
}
+15 -2
View File
@@ -8,6 +8,9 @@ import {
} from "@mmapp/contracts/traffic-flow"
import { createTrafficFlowPurgeRoutes } from "./traffic-flow-purge.js"
import { createTrafficFlowSettingsRoutes } from "./traffic-flow-settings.js"
import { createTrafficFlowHostRoutes } from "./traffic-flow-host.js"
import { getFlowHostStatus, reconcileFlowHost, withFlowConfigurationLock } from "../services/traffic-flow-host-agent.js"
import { agentSocket } from "../services/traffic-flow-agent-client.js"
import {
ensureHostKeys,
getTrafficFlowSettingsRow,
@@ -150,11 +153,21 @@ function sleep(ms: number, signal: AbortSignal): Promise<void> {
}
const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
await app.register(createTrafficFlowHostRoutes({
status: getFlowHostStatus,
reconcile: () => withFlowConfigurationLock(reconcileFlowHost),
}))
await app.register(createTrafficFlowSettingsRoutes({
read: () => toTrafficFlowSettingsDto(getFlowListenerState()),
update: async patch => { await updateTrafficFlowSettings(patch) },
update: async patch => { await withFlowConfigurationLock(async () => {
const current = await getTrafficFlowSettingsRow()
if (agentSocket() && (["collectorIp", "prefix", "flowListenPort", "wgListenPort"] as const).some(key => patch[key] !== undefined && patch[key] !== current[key])) {
throw Object.assign(new Error("Адрес, префикс и порты закреплены установкой агента; повторите установщик хоста"), { statusCode: 409 })
}
await updateTrafficFlowSettings(patch)
}) },
startListener: startTrafficFlowListener,
ensureKeys: ensureHostKeys,
ensureKeys: () => withFlowConfigurationLock(ensureHostKeys),
hasPrivateKey: async () => Boolean((await getTrafficFlowSettingsRow()).hostPrivateKey),
hostFiles: listTrafficFlowHostFiles,
}))
@@ -0,0 +1,42 @@
import assert from "node:assert/strict"
import { createServer } from "node:http"
import { randomUUID } from "node:crypto"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { callFlowAgent, emptyHostStatus, type FlowAgentSnapshot } from "./traffic-flow-agent-client.js"
const socket = process.platform === "win32" ? `\\\\.\\pipe\\flow-agent-${randomUUID()}` : join(tmpdir(), `flow-agent-${randomUUID()}.sock`)
const snapshot: FlowAgentSnapshot = {
collectorIp: "10.255.254.1", prefix: "10.255.254.0/24", flowListenPort: 4739, wgListenPort: 51821,
hostPublicKey: "public-fixture", hostPrivateKey: "private-fixture", peers: [],
}
let mode = "ok"
const server = createServer((req, res) => {
let body = ""
req.on("data", chunk => { body += chunk })
req.on("end", () => {
assert.equal(req.url, "/check"); assert.equal(req.method, "POST")
assert.deepEqual(JSON.parse(body), snapshot)
if (mode === "conflict") { res.writeHead(409); res.end(JSON.stringify({ error: "Конфликт адресов" })); return }
if (mode === "invalid") { res.end("not-json"); return }
if (mode === "oversize") { res.end("x".repeat(300_000)); return }
res.end(JSON.stringify({
interfaceUp: true, configMatches: true, publicKey: "public-fixture", listenPort: 51821,
addresses: ["10.255.254.1/24"], peers: [{ publicKey: "router-fixture", allowedIps: ["10.255.254.2/32"], lastHandshakeAt: null, receivedBytes: 0, sentBytes: 0 }],
privateKey: "must-not-be-exposed",
}))
})
})
await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(socket, resolve) })
try {
const status = await callFlowAgent("/check", snapshot, socket)
assert.equal(status.reachable, true); assert.equal(status.configMatches, true)
assert.equal(JSON.stringify(status).includes("must-not-be-exposed"), false)
assert.equal(status.peers[0].lastHandshakeAt, null)
mode = "conflict"; await assert.rejects(callFlowAgent("/check", snapshot, socket), /Конфликт адресов/)
mode = "invalid"; await assert.rejects(callFlowAgent("/check", snapshot, socket), /некорректный ответ/)
mode = "oversize"; await assert.rejects(callFlowAgent("/check", snapshot, socket), /некорректный ответ/)
await assert.rejects(callFlowAgent("/status", undefined, ""), /не настроена/)
assert.equal(emptyHostStatus(true, "offline").configMatches, null)
} finally { await new Promise<void>(resolve => server.close(() => resolve())) }
console.log("NetFlow agent IPC: request, validated status, secret stripping, conflict, invalid/oversize response, manual mode")
@@ -0,0 +1,45 @@
import { request } from "node:http"
import { trafficFlowHostStatusSchema, type TrafficFlowHostStatus } from "@mmapp/contracts/traffic-flow"
export type FlowAgentSnapshot = {
collectorIp: string; prefix: string; flowListenPort: number; wgListenPort: number
hostPublicKey: string; hostPrivateKey: string
peers: { serverId: number; publicKey: string; allowedIps: string[]; endpoint?: string }[]
}
export const agentSocket = () => process.env.FLOW_AGENT_SOCKET?.trim() || ""
export const emptyHostStatus = (configured: boolean, error: string | null = null): TrafficFlowHostStatus => ({
configured, reachable: false, interfaceUp: false, configMatches: null,
publicKey: "", listenPort: 0, addresses: [], peers: [], error,
})
export function callFlowAgent(path: "/status" | "/check" | "/apply", snapshot?: FlowAgentSnapshot, socketPath = agentSocket()): Promise<TrafficFlowHostStatus> {
if (!socketPath) return Promise.reject(new Error("Автоматизация хоста NetFlow не настроена"))
const body = snapshot ? JSON.stringify(snapshot) : undefined
return new Promise((resolve, reject) => {
const fail = () => reject(new Error("Агент NetFlow недоступен или вернул некорректный ответ; проверьте контейнер mmapp-netflow-agent"))
const req = request({ socketPath, path, method: body ? "POST" : "GET", headers: body ? { "content-type": "application/json", "content-length": Buffer.byteLength(body) } : {} }, res => {
let text = ""
res.setEncoding("utf8")
res.on("data", (chunk: string) => { text += chunk; if (text.length > 262144) req.destroy() })
res.on("error", fail)
res.on("end", () => {
try {
const raw: unknown = JSON.parse(text)
if (res.statusCode !== 200) {
// The local controller deliberately emits sanitized errors only.
const error = raw as { error?: unknown }
reject(new Error(typeof error.error === "string" ? error.error.slice(0, 500) : "Агент NetFlow отклонил настройки"))
return
}
resolve(trafficFlowHostStatusSchema.parse({ ...(raw as object), configured: true, reachable: true, error: null }))
} catch { fail() }
})
})
// An absolute deadline also covers a peer that keeps sending small chunks.
const timer = setTimeout(() => req.destroy(), path === "/apply" ? 90_000 : 30_000)
req.on("close", () => clearTimeout(timer))
req.on("error", fail)
req.end(body)
})
}
@@ -0,0 +1,18 @@
import assert from "node:assert/strict"
import { readFile } from "node:fs/promises"
import { buildFlowAgentInstaller } from "./traffic-flow-agent-install.js"
import { buildTrafficFlowHostFiles } from "./traffic-flow-host-file-builders.js"
const files = buildTrafficFlowHostFiles({ collectorIp: "10.255.254.1", prefix: "10.255.254.0/24", flowListenPort: 4740,
wgListenPort: 51821, hostPrivateKey: Buffer.alloc(32, 1).toString("base64"), peers: [] })
const conf = files.find(file => file.id === "wg-quick")!.code
const source = await readFile(new URL("../../../deploy/netflow-agent/install.sh", import.meta.url), "utf8")
const script = buildFlowAgentInstaller(conf, 4740, source)
assert.ok(script.startsWith("#!/usr/bin/env bash\n"))
assert.ok(script.includes(conf.trim() + "\nMM_CONFIG_EOF\n"))
assert.ok(script.includes('"${1:-/opt/cdn-mm}" 4740'))
assert.ok(script.includes("wg-quick@wg-flow.service"))
assert.ok(script.includes("docker-compose.netflow-agent.yml"))
assert.ok(!script.includes("\r"))
assert.throws(() => buildFlowAgentInstaller("MM_CONFIG_EOF\n", 4739, source))
assert.throws(() => buildFlowAgentInstaller(conf, 0, source))
console.log("NetFlow agent installer export: validated snapshot, custom port/path, embedded installer and LF-only output")
@@ -0,0 +1,8 @@
/** Call only with a configuration already validated by buildTrafficFlowHostFiles. */
export function buildFlowAgentInstaller(conf: string, flowPort: number, installer: string): string {
if (!Number.isInteger(flowPort) || flowPort < 1 || flowPort > 65535 || conf.split(/\r?\n/).includes("MM_CONFIG_EOF")) {
throw new Error("Некорректная конфигурация установщика NetFlow")
}
const clean = (text: string) => text.replaceAll("\r\n", "\n").replace(/\s*$/, "") + "\n"
return "#!/usr/bin/env bash\nset -euo pipefail\numask 077\nEXPORT_DIR=$(mktemp -d)\ntrap 'rm -rf -- \"$EXPORT_DIR\"' EXIT\ncat > \"$EXPORT_DIR/wg-flow.conf\" <<'MM_CONFIG_EOF'\n" + clean(conf) + "MM_CONFIG_EOF\ncat > \"$EXPORT_DIR/install.sh\" <<'MM_INSTALL_EOF'\n" + clean(installer) + "MM_INSTALL_EOF\nbash \"$EXPORT_DIR/install.sh\" \"$EXPORT_DIR/wg-flow.conf\" \"${1:-/opt/cdn-mm}\" " + flowPort + "\n"
}
@@ -0,0 +1,46 @@
import { withAdvisoryLock } from "../db/index.js"
import { parseJsonArray } from "../db/json.js"
import { getTrafficFlowSettingsRow, invalidateTrafficFlowSettingsCache } from "./traffic-flow-settings.js"
import { agentSocket, callFlowAgent, emptyHostStatus, type FlowAgentSnapshot } from "./traffic-flow-agent-client.js"
import { FlowConfigurationQueue } from "./traffic-flow-operation-queue.js"
// Session lock: RouterOS/network operations use other pooled connections. Never lock the settings row here.
const queue = new FlowConfigurationQueue()
export const withFlowConfigurationLock = <T>(fn: () => Promise<T>) => queue.run(() => withAdvisoryLock(7340291, async () => {
invalidateTrafficFlowSettingsCache()
return fn()
}))
async function snapshot(): Promise<FlowAgentSnapshot> {
const row = await getTrafficFlowSettingsRow()
return {
collectorIp: row.collectorIp, prefix: row.prefix, flowListenPort: row.flowListenPort, wgListenPort: row.wgListenPort,
hostPublicKey: row.hostPublicKey, hostPrivateKey: row.hostPrivateKey,
peers: (parseJsonArray(row.peersJson) as FlowAgentSnapshot["peers"]).map(p => ({
serverId: p.serverId, publicKey: p.publicKey, allowedIps: p.allowedIps, ...(p.endpoint ? { endpoint: p.endpoint } : {}),
})),
}
}
export async function getFlowHostStatus() {
if (!agentSocket()) return emptyHostStatus(false)
try { return await callFlowAgent("/check", await snapshot()) }
catch (error) {
const message = error instanceof Error ? error.message : "Проверка агента не выполнена"
// Preserve live measurements on a settings conflict; never claim configuration agreement.
try { return { ...await callFlowAgent("/status"), configMatches: false, error: message } }
catch { return emptyHostStatus(true, message) }
}
}
export async function preflightFlowHost() {
if (!agentSocket()) return
try { await callFlowAgent("/check", await snapshot()) }
catch (error) { throw Object.assign(new Error(error instanceof Error ? error.message : "Агент NetFlow не готов"), { statusCode: 409 }) }
}
// Caller holds withFlowConfigurationLock. This is also used after saving a newly discovered router key.
export async function reconcileFlowHost() {
if (!agentSocket()) return emptyHostStatus(false)
return callFlowAgent("/apply", await snapshot())
}
@@ -1,8 +1,15 @@
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
import { parseJsonArray } from "../db/json.js"
import { buildTrafficFlowHostFiles } from "./traffic-flow-host-file-builders.js"
import { readFile } from "node:fs/promises"
import { buildFlowAgentInstaller } from "./traffic-flow-agent-install.js"
export async function listTrafficFlowHostFiles() {
const row = await getTrafficFlowSettingsRow()
return buildTrafficFlowHostFiles({ ...row, peers: parseJsonArray(row.peersJson) })
const files = buildTrafficFlowHostFiles({ ...row, peers: parseJsonArray(row.peersJson) })
// The same source is used by a repository install and by the downloadable self-contained installer.
const installer = await readFile(new URL("../../../deploy/netflow-agent/install.sh", import.meta.url), "utf8")
const conf = files.find(file => file.id === "wg-quick")!.code
const code = buildFlowAgentInstaller(conf, row.flowListenPort, installer)
return [{ id: "agent", label: "Агент Docker", filename: "install-netflow-agent.sh", code }, ...files]
}
@@ -0,0 +1,21 @@
import assert from "node:assert/strict"
import { FlowConfigurationQueue } from "./traffic-flow-operation-queue.js"
const queue = new FlowConfigurationQueue()
let release!: () => void, active = 0, maximum = 0
const hold = new Promise<void>(resolve => { release = resolve })
const events: number[] = []
const tasks = Array.from({ length: 32 }, (_, i) => queue.run(async () => {
active++; maximum = Math.max(active, maximum); events.push(i)
try { if (i === 0) await hold; if (i === 1) throw Error("fixture failure"); return i }
finally { active-- }
}))
const done = Promise.allSettled(tasks)
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(events, [0]); assert.equal(active, 1)
release()
const results = await done
assert.equal(maximum, 1); assert.equal(results[1].status, "rejected")
assert.equal(results.filter(result => result.status === "fulfilled").length, 31)
assert.deepEqual(events, Array.from({ length: 32 }, (_, i) => i))
assert.equal(await queue.run(async () => "recovered"), "recovered")
console.log("NetFlow queue: 32 concurrent operations use one lock waiter, failures release the queue")
@@ -0,0 +1,12 @@
/** Only one local operation may wait for the PostgreSQL advisory lock.
* Otherwise queued requests could exhaust the pool needed by its current owner.
*/
export class FlowConfigurationQueue {
private tail: Promise<void> = Promise.resolve()
run<T>(operation: () => Promise<T>): Promise<T> {
const result = this.tail.then(operation)
this.tail = result.then(() => undefined, () => undefined)
return result
}
}
+29 -3
View File
@@ -22,6 +22,8 @@ import {
import { refreshFlowExporterMap, startTrafficFlowListener } from "./traffic-flow-ingest.js"
import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js"
import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js"
import { agentSocket } from "./traffic-flow-agent-client.js"
import { getFlowHostStatus, preflightFlowHost, reconcileFlowHost, withFlowConfigurationLock } from "./traffic-flow-host-agent.js"
const IFACE_NAME = "wg-flow"
const JH_LISTEN_PORT = 13232
@@ -202,8 +204,16 @@ export function usablePublicHost(raw: string | undefined): string {
export async function applyFlowOverlay(
serverIdRaw: string | number,
opts?: { publicEndpoint?: string; requestHost?: string; disableGreFastPath?: boolean },
): Promise<TrafficFlowOverlayResult> {
return withFlowConfigurationLock(() => applyFlowOverlayLocked(serverIdRaw, opts))
}
async function applyFlowOverlayLocked(
serverIdRaw: string | number,
opts?: { publicEndpoint?: string; requestHost?: string; disableGreFastPath?: boolean },
): Promise<TrafficFlowOverlayResult> {
const steps: string[] = []
await preflightFlowHost()
const keys = await ensureHostKeys()
const settings = await getTrafficFlowSettingsRow()
const hostPublicKey = settings.hostPublicKey || keys.publicKey
@@ -338,8 +348,21 @@ export async function applyFlowOverlay(
await refreshFlowExporterMap()
steps.push("Коллектор IPFIX на MM включён")
let hostStatus = undefined
let hostError: string | undefined
if (agentSocket()) {
try {
hostStatus = await reconcileFlowHost()
steps.push("Пир применён на Linux-хосте без перезапуска WireGuard")
} catch (error) {
hostError = error instanceof Error ? error.message : "Настройки хоста не применены"
hostStatus = await getFlowHostStatus()
steps.push("MikroTik настроен; требуется повторное применение настроек хоста")
}
}
return {
ok: true,
ok: !hostError,
serverId: server.id,
interfaceName: IFACE_NAME,
address,
@@ -347,11 +370,14 @@ export async function applyFlowOverlay(
linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host, endpointHost),
trafficFlow: true,
steps,
hostFiles: await listTrafficFlowHostFiles(),
hostFiles: agentSocket() ? [] : await listTrafficFlowHostFiles(),
stage: hostError ? "host-pending" : agentSocket() ? "applied" : "manual",
...(hostStatus ? { hostStatus } : {}),
...(hostError ? { hostError } : {}),
}
} catch (e) {
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
const err = Object.assign(new Error(`RouterOS: ${msg}`), { statusCode: 502 })
const err = Object.assign(new Error(`Настройка MikroTik могла быть выполнена частично: ${msg}. Проверьте состояние и повторите подключение.`), { statusCode: 502 })
throw err
}
}
+63
View File
@@ -0,0 +1,63 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { can } from "@/lib/auth"
import { Button } from "@/components/ui/button"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { AlertCircleIcon } from "lucide-react"
import { getTrafficFlowHostStatus, reconcileTrafficFlowHost } from "@/shared/api/traffic-flow"
import type { TrafficFlowHostStatus } from "@mmapp/contracts/traffic-flow"
export function FlowHostPanel({ backendUrl, enabled = true, onConfigured }: {
backendUrl: string; enabled?: boolean; onConfigured?: (configured: boolean) => void
}) {
const [status, setStatus] = useState<TrafficFlowHostStatus | null>(null)
const [receivedAt, setReceivedAt] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
const [operationError, setOperationError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const operation = useRef<AbortController | null>(null)
const [revision, setRevision] = useState(0)
const accept = useCallback((value: TrafficFlowHostStatus) => {
setStatus(value); setReceivedAt(Date.now()); setError(null); onConfigured?.(value.configured)
}, [onConfigured])
useEffect(() => () => { operation.current?.abort() }, [])
useEffect(() => {
if (!enabled || !can("mm:traffic:read") || busy) return
const controller = new AbortController()
let timer: ReturnType<typeof setTimeout> | undefined
async function refresh() {
try { const value = await getTrafficFlowHostStatus(backendUrl, { signal: controller.signal }); if (!controller.signal.aborted) accept(value) }
catch (reason) { if (!controller.signal.aborted) setError(reason instanceof Error ? reason.message : "Статус хоста не получен") }
finally { if (!controller.signal.aborted) timer = setTimeout(() => { void refresh() }, 10_000) }
}
void refresh()
return () => { controller.abort(); if (timer) clearTimeout(timer) }
}, [accept, backendUrl, enabled, busy, revision])
async function apply() {
if (operation.current) return
const controller = new AbortController(); operation.current = controller
setBusy(true); setOperationError(null)
try { const value = await reconcileTrafficFlowHost(backendUrl, { signal: controller.signal }); if (!controller.signal.aborted) accept(value) }
catch (reason) { if (!controller.signal.aborted) setOperationError(reason instanceof Error ? reason.message : "Применение не подтверждено; обновите состояние хоста") }
finally { operation.current = null; if (!controller.signal.aborted) setBusy(false) }
}
if (!enabled || !can("mm:traffic:read")) return null
return <div className="flex flex-col gap-3 rounded-md border px-4 py-3" aria-label="Автоматизация WireGuard на хосте">
<p className="text-sm font-medium">WireGuard на сервере MM</p>
<p role="status" className="text-sm">{!status ? "Получение состояния агента…" : !status.configured ? "Ручной режим. Установите агент из раздела «Файлы хоста»." : !status.reachable ? "Агент недоступен" : status.configMatches ? "Настройки применены" : "Настройки требуют проверки или применения"}</p>
{error && receivedAt ? <p className="text-xs text-muted-foreground">Показано последнее состояние: {new Date(receivedAt).toLocaleString("ru-RU")}</p> : null}
{status?.configured && status.reachable ? <>
<p className="text-xs text-muted-foreground">Интерфейс: {status.interfaceUp ? status.addresses.join(", ") : "не поднят"} · UDP {status.listenPort}</p>
<ul className="flex flex-col gap-1 text-xs text-muted-foreground" aria-label="Handshake пиров">
{status.peers.map(peer => <li key={peer.publicKey}>{peer.allowedIps.join(", ") || "Без адреса"} · {peer.lastHandshakeAt ? "Handshake: " + new Date(peer.lastHandshakeAt * 1000).toLocaleString("ru-RU") + ((receivedAt ?? 0) / 1000 - peer.lastHandshakeAt > 180 ? " (давно)" : "") : "Ожидание handshake"}</li>)}
</ul>
</> : null}
{operationError || error || status?.error ? <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Состояние хоста требует внимания</AlertTitle><AlertDescription>{operationError || error || status?.error}</AlertDescription></Alert> : null}
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => setRevision(value => value + 1)}>Обновить состояние хоста</Button>
{status?.configured && can("mm:traffic:write") ? <Button type="button" size="sm" disabled={busy || !status.reachable} onClick={() => { void apply() }}>{busy ? "Применение…" : "Применить настройки хоста"}</Button> : null}
</div>
</div>
}
+8 -5
View File
@@ -22,6 +22,7 @@ import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { FlowHostPanel } from "./flow-host-panel"
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
import type { ServerRead } from "@mmapp/contracts/servers"
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
@@ -95,7 +96,8 @@ function FlowOverlaySheet({
})
setResult(res)
setTab(res.hostFiles[0]?.id ?? "linux")
toast.success(`wg-flow на ${res.address}`)
if (res.ok) toast.success(`wg-flow на ${res.address}`)
else toast.warning(res.hostError || "MikroTik настроен; требуется применение на хосте")
onDone?.(res)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось подключить JH")
@@ -125,7 +127,7 @@ function FlowOverlaySheet({
Подключить jump-host
</SheetTitle>
<SheetDescription>
Создаст wg-flow на MikroTik (сервер, listen 13232) и выдаст готовый bash для Linux-хоста Docker MM (клиент).
Настроит wg-flow и IPFIX на MikroTik. Если агент Docker установлен, автоматически применит пир на Linux-хосте MM.
</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
@@ -170,13 +172,14 @@ function FlowOverlaySheet({
</FormField>
{result ? (
<div className="flex min-h-0 flex-col gap-4">
<Alert variant="success">
<Alert variant={result.stage === "host-pending" ? "warning" : "success"}>
<InfoIcon />
<AlertTitle>Linux-хост /opt/cdn-mm</AlertTitle>
<AlertTitle>{result.stage === "applied" ? "Настройки применены" : result.stage === "host-pending" ? "MikroTik настроен, хост ожидает применения" : "Установка агента на Linux-хосте"}</AlertTitle>
<AlertDescription>
Скопируйте вкладку Linux и выполните от root. WG — клиент к JH:13232; контейнер слушает только WG-IP:4739, не 0.0.0.0. Ключ не кладите в git.
{result.hostError || (result.stage === "applied" ? "Пир добавлен без перезапуска WireGuard. Ожидайте handshake и поступления IPFIX в панели NetFlow." : "Выполните установщик из вкладки «Агент Docker» от root на сервере MM. Он однократно пересоздаст backend. Экспорт содержит приватный ключ — храните его как секрет.")}
</AlertDescription>
</Alert>
<FlowHostPanel backendUrl={backendUrl} />
<ul className="flex flex-col gap-1.5">
{result.steps.map((s) => (
<li key={s} className="flex items-start gap-2 text-sm">
+13 -10
View File
@@ -13,6 +13,7 @@ import { Input } from "@/components/ui/input"
import { CodeExportSheet, type CodeExportFormat } from "@/components/reui-kit/code-export-sheet"
import type { TrafficFlowSettingsDto } from "@mmapp/contracts/traffic-flow"
import { GeoipSettingsSection } from "./geoip-settings-section"
import { FlowHostPanel } from "./flow-host-panel"
import {
generateTrafficFlowKeys,
getTrafficFlowHostFiles,
@@ -24,11 +25,11 @@ import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic
import { KeyRoundIcon, DownloadIcon, InfoIcon, AlertCircleIcon } from "lucide-react"
const HOST_STEPS = [
"На хосте Docker (не в контейнере mmapp-backend): apt install wireguard (или эквивалент).",
"Скачайте wg-flow.conf и положите в /etc/wireguard/wg-flow.conf.",
"wg-quick up wg-flow (или systemctl enable --now wg-quick@wg-flow).",
"Создайте ключи хоста, откройте «Файлы хоста» и скачайте вкладку «Агент Docker».",
"Выполните sudo bash install-netflow-agent.sh /opt/cdn-mm на хосте Docker. При установке backend будет пересоздан.",
"Обновите статус хоста. Дальнейшие подключения автоматически применяются агентом.",
"Firewall: разрешите UDP listen WireGuard. UDP 4739 наружу не открывайте.",
"В docker-compose у backend раскомментируйте bind IPFIX только на адресе wg-flow.",
"Установщик публикует IPFIX только на адресе wg-flow. Ручные файлы доступны в остальных вкладках экспорта.",
"Проверка: wg show · ss -ulnp | grep 4739 · в этой панели — last datagram.",
]
@@ -43,6 +44,7 @@ function NetflowSettingsContent({
enabled: boolean
}) {
const [settings,setSettings] = useState<TrafficFlowSettingsDto|null>(null)
const [automatic,setAutomatic] = useState(false)
const [draft,setDraft] = useState<NetflowDraft>(emptyNetflowDraft)
const dirty = useRef(new Set<keyof NetflowDraft>())
const [dirtyCount,setDirtyCount] = useState(0)
@@ -147,16 +149,16 @@ function NetflowSettingsContent({
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField label="Collector IP" hint="Адрес в туннеле, куда JH шлёт flow">
<Input className="font-mono" aria-label="Collector IP" disabled={disabled} value={draft.collectorIp} onChange={(e) => change("collectorIp", e.target.value)} />
<Input className="font-mono" aria-label="Collector IP" disabled={disabled || automatic} value={draft.collectorIp} onChange={(e) => change("collectorIp", e.target.value)} />
</FormField>
<FormField label="Префикс overlay">
<Input className="font-mono" aria-label="Префикс overlay" disabled={disabled} value={draft.prefix} onChange={(e) => change("prefix", e.target.value)} />
<Input className="font-mono" aria-label="Префикс overlay" disabled={disabled || automatic} value={draft.prefix} onChange={(e) => change("prefix", e.target.value)} />
</FormField>
<FormField label="UDP IPFIX">
<Input className="font-mono" aria-label="UDP IPFIX" disabled={disabled} value={draft.flowListenPort} onChange={(e) => change("flowListenPort", e.target.value)} />
<Input className="font-mono" aria-label="UDP IPFIX" disabled={disabled || automatic} value={draft.flowListenPort} onChange={(e) => change("flowListenPort", e.target.value)} />
</FormField>
<FormField label="WG listen">
<Input className="font-mono" aria-label="WG listen" disabled={disabled} value={draft.wgListenPort} onChange={(e) => change("wgListenPort", e.target.value)} />
<Input className="font-mono" aria-label="WG listen" disabled={disabled || automatic} value={draft.wgListenPort} onChange={(e) => change("wgListenPort", e.target.value)} />
</FormField>
<FormField label="Публичный endpoint хоста MM" hint="IP или DNS, который видят JH">
<Input className="font-mono" aria-label="Публичный endpoint хоста MM" disabled={disabled} value={draft.publicEndpoint} onChange={(e) => change("publicEndpoint", e.target.value)} placeholder="203.0.113.10" />
@@ -201,14 +203,15 @@ function NetflowSettingsContent({
{settings?.lastError ? ` · ${settings.lastError}` : ""}
</p>
<div className="rounded-md border px-4 py-3 flex flex-col gap-2">
<FlowHostPanel backendUrl={backendUrl} enabled={enabled} onConfigured={setAutomatic} />
{automatic ? <p className="text-xs text-muted-foreground">Адрес, префикс и порты закреплены установкой агента. Для их изменения повторите установщик.</p> : <div className="rounded-md border px-4 py-3 flex flex-col gap-2">
<p className="text-sm font-medium">Туннель на сервере Docker MM</p>
<ol className="text-xs text-muted-foreground flex flex-col gap-1.5 list-decimal pl-4">
{HOST_STEPS.map((s) => (
<li key={s}>{s.replaceAll("4739", settings ? String(settings.flowListenPort) : "IPFIX")}</li>
))}
</ol>
</div>
</div>}
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={disabled || !dirtyCount} onClick={() => { void handleSave() }}>
+27
View File
@@ -0,0 +1,27 @@
# Optional addition to docker-compose.cdn-mm.yml. Run the exported installer first.
# Keep this file in every subsequent docker compose -f invocation.
services:
netflow-agent:
image: git.shx.one/denozord/mikrotikmanager-netflow-agent:${MM_NETFLOW_AGENT_IMAGE_TAG:-latest}
container_name: mmapp-netflow-agent
restart: unless-stopped
network_mode: host
cap_drop: [ALL]
cap_add: [NET_ADMIN]
security_opt: ["no-new-privileges:true"]
read_only: true
tmpfs: ["/tmp:rw,noexec,nosuid,size=8m"]
volumes:
- ./data/netflow-wireguard:/etc/wireguard
- ./data/netflow-agent-socket:/run/mm-netflow-host
logging:
driver: json-file
options: {max-size: "5m", max-file: "2"}
backend:
environment:
FLOW_AGENT_SOCKET: /run/mm-netflow-host/agent.sock
volumes:
- ./data/netflow-agent-socket:/run/mm-netflow-host:ro
depends_on:
netflow-agent:
condition: service_healthy
+11
View File
@@ -0,0 +1,11 @@
FROM alpine:3.22 AS base
RUN apk add --no-cache python3 wireguard-tools iproute2
COPY agent.py /app/agent.py
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
HEALTHCHECK --interval=15s --timeout=5s --retries=3 CMD ["python3", "/app/agent.py", "--healthcheck"]
ENTRYPOINT ["python3", "/app/agent.py"]
FROM base AS test
COPY test_agent.py /app/test_agent.py
FROM base AS runtime
+367
View File
@@ -0,0 +1,367 @@
"""Local, single-interface WireGuard controller. Never executes wg-quick/hooks."""
import base64
import binascii
import http.client
import http.server
import ipaddress
import json
import os
from pathlib import Path
import re
import socket
import socketserver
import subprocess
import sys
import tempfile
import threading
IFACE = "wg-flow"
CONFIG = Path(os.environ.get("FLOW_AGENT_CONFIG_DIR", "/etc/wireguard"))
SOCKET = os.environ.get("FLOW_AGENT_SOCKET", "/run/mm-netflow-host/agent.sock")
LOCK = threading.RLock()
MAX_BODY = 262144
class AgentError(Exception):
pass
def run(*args, input=None):
try:
result = subprocess.run(args, input=input, text=True, capture_output=True, timeout=10)
except (OSError, subprocess.TimeoutExpired):
raise AgentError("Команда WireGuard/IP недоступна или превысила время ожидания") from None
if result.returncode:
# stderr/stdout can contain keys and must never reach an HTTP response/log.
raise AgentError("Операция WireGuard/IP не выполнена")
return result.stdout.strip()
def key(value):
try:
if not isinstance(value, str) or len(value) != 44 or len(base64.b64decode(value, validate=True)) != 32:
raise ValueError()
except (ValueError, binascii.Error):
raise AgentError("Некорректный ключ WireGuard") from None
return value
def port(value):
if type(value) is not int or not 1 <= value <= 65535:
raise AgentError("Некорректный UDP-порт")
return value
def endpoint(value):
if not isinstance(value, str) or not re.fullmatch(r"(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):[0-9]{1,5}", value):
raise AgentError("Некорректный endpoint")
port(int(value.rsplit(":", 1)[1]))
return value
def parse_conf(text):
interface, peers, section = {}, [], None
allowed = {"interface": {"PrivateKey", "Address", "ListenPort", "MTU"},
"peer": {"PublicKey", "PresharedKey", "AllowedIPs", "Endpoint", "PersistentKeepalive"}}
for raw in text.splitlines():
line = raw.split("#", 1)[0].strip()
if not line:
continue
if line == "[Interface]" and section is None:
section, current = "interface", interface
elif line == "[Peer]" and section is not None:
section, current = "peer", {}
peers.append(current)
else:
name, sep, value = line.partition("=")
name, value = name.strip(), value.strip()
if not sep or section is None or name not in allowed[section] or name in current or not value:
raise AgentError("Конфигурация wg-flow содержит неподдерживаемые или повторные параметры; требуется ручная проверка")
current[name] = value
if not interface or not {"PrivateKey", "Address", "ListenPort"} <= interface.keys():
raise AgentError("Неполная конфигурация wg-flow")
key(interface["PrivateKey"])
try:
ipaddress.IPv4Interface(interface["Address"])
port(int(interface["ListenPort"]))
if "MTU" in interface and not 576 <= int(interface["MTU"]) <= 9000:
raise ValueError()
for peer in peers:
key(peer.get("PublicKey"))
if "PresharedKey" in peer:
key(peer["PresharedKey"])
if "Endpoint" in peer:
endpoint(peer["Endpoint"])
if peer.get("AllowedIPs"):
networks(peer["AllowedIPs"].split(","))
if "PersistentKeepalive" in peer and not 0 <= int(peer["PersistentKeepalive"]) <= 65535:
raise ValueError()
except (ValueError, TypeError):
raise AgentError("Некорректные параметры wg-flow") from None
if len({p["PublicKey"] for p in peers}) != len(peers):
raise AgentError("Повторный публичный ключ пира")
return interface, peers
def networks(values):
if not isinstance(values, list) or not values:
raise AgentError("AllowedIPs должны содержать адреса")
try:
return [ipaddress.ip_network(v.strip(), strict=False) for v in values]
except (ValueError, AttributeError):
raise AgentError("Некорректные AllowedIPs") from None
def validate(raw):
fields = {"collectorIp", "prefix", "flowListenPort", "wgListenPort", "hostPublicKey", "hostPrivateKey", "peers"}
if not isinstance(raw, dict) or set(raw) != fields:
raise AgentError("Некорректный снимок NetFlow")
try:
collector = ipaddress.IPv4Address(raw["collectorIp"])
prefix = ipaddress.IPv4Network(raw["prefix"], strict=False)
if prefix.prefixlen != 24 or collector not in prefix or collector in (prefix.network_address, prefix.broadcast_address):
raise ValueError()
except (ValueError, TypeError):
raise AgentError("Требуется IPv4 overlay /24 и адрес коллектора внутри него") from None
port(raw["flowListenPort"])
port(raw["wgListenPort"])
key(raw["hostPublicKey"])
key(raw["hostPrivateKey"])
if run("wg", "pubkey", input=raw["hostPrivateKey"] + "\n") != raw["hostPublicKey"]:
raise AgentError("Публичный и приватный ключи MM не соответствуют друг другу")
binding = json.loads((CONFIG / "binding.json").read_text())
for name in ("collectorIp", "prefix", "flowListenPort", "wgListenPort"):
if binding.get(name) != raw[name]:
raise AgentError("Адрес, префикс или порт отличаются от установки агента; повторите установщик")
if not isinstance(raw["peers"], list) or len(raw["peers"]) > 253:
raise AgentError("Некорректный список пиров")
peers, ids, keys = [], set(), set()
for peer in raw["peers"]:
if not isinstance(peer, dict) or not {"serverId", "publicKey", "allowedIps"} <= peer.keys() or set(peer) - {"serverId", "publicKey", "allowedIps", "endpoint"}:
raise AgentError("Некорректный пир")
peer_key = key(peer["publicKey"])
if type(peer["serverId"]) is not int or peer["serverId"] <= 0 or peer["serverId"] in ids or peer_key in keys or peer_key == raw["hostPublicKey"]:
raise AgentError("Повторный или некорректный пир")
ids.add(peer["serverId"])
keys.add(peer_key)
nets = networks(peer["allowedIps"])
if len(nets) != 1 or nets[0].version != 4 or nets[0].prefixlen != 32 or nets[0].network_address not in prefix or nets[0].network_address in (collector, prefix.network_address, prefix.broadcast_address):
raise AgentError("Пиру разрешён только собственный IPv4 /32 внутри overlay")
data = {"PublicKey": peer_key, "AllowedIPs": str(nets[0])}
if peer.get("endpoint"):
data.update(Endpoint=endpoint(peer["endpoint"]), PersistentKeepalive="25")
peers.append(data)
return peers
def merge_peers(existing, desired):
merged = {peer["PublicKey"]: dict(peer) for peer in existing}
for peer in desired:
public = peer["PublicKey"]
# Replacing a PSK would silently break a previously working tunnel.
if merged.get(public, {}).get("PresharedKey", "A" * 43 + "=") != "A" * 43 + "=":
raise AgentError("Управляемый пир использует preshared key; требуется ручная проверка")
merged[public] = dict(peer)
rows = list(merged.values())
wanted = {p["PublicKey"] for p in desired}
for i, left in enumerate(rows):
for right in rows[i + 1:]:
if left["PublicKey"] not in wanted and right["PublicKey"] not in wanted:
continue
if not left.get("AllowedIPs") or not right.get("AllowedIPs"):
continue
if any(a.version == b.version and a.overlaps(b) for a in networks(left["AllowedIPs"].split(",")) for b in networks(right["AllowedIPs"].split(","))):
raise AgentError("AllowedIPs управляемого пира пересекаются с другим пиром")
return rows
def render(interface, peers, native=False):
rows = ["[Interface]"]
rows += [f"{k} = {v}" for k, v in interface.items() if not native or k not in ("Address", "MTU")]
for peer in peers:
rows += ["", "[Peer]"] + [f"{k} = {v}" for k, v in peer.items() if k != "AllowedIPs" or v]
return "\n".join(rows) + "\n"
def atomic_write(path, text):
fd, name = tempfile.mkstemp(prefix=".flow-", dir=path.parent)
try:
with os.fdopen(fd, "w") as stream:
stream.write(text)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, path)
if os.name == "posix":
directory = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
if os.path.exists(name):
os.unlink(name)
def sync(text):
# wg requires a file. mkstemp creates it with mode 0600.
fd, name = tempfile.mkstemp(prefix="flow-sync-")
try:
with os.fdopen(fd, "w") as stream:
stream.write(text)
run("wg", "syncconf", IFACE, name)
finally:
os.unlink(name)
def live():
dump = run("wg", "show", IFACE, "dump").splitlines()
if not dump or len(dump[0].split("\t")) != 4:
raise AgentError("Интерфейс wg-flow недоступен")
interface = dump[0].split("\t")
peers, raw_peers = [], []
for line in dump[1:]:
cols = line.split("\t")
if len(cols) != 8:
raise AgentError("Некорректный ответ WireGuard")
public, psk, ep, ips, handshake, rx, tx, keepalive = cols
peers.append({"publicKey": public, "allowedIps": ips.split(",") if ips != "(none)" else [],
"lastHandshakeAt": int(handshake) or None, "receivedBytes": int(rx), "sentBytes": int(tx)})
item = {"PublicKey": public, "AllowedIPs": ips if ips != "(none)" else ""}
if psk != "(none)":
item["PresharedKey"] = psk
if ep != "(none)":
item["Endpoint"] = ep
item["PersistentKeepalive"] = keepalive
raw_peers.append(item)
addresses = json.loads(run("ip", "-j", "-4", "address", "show", "dev", IFACE))
cidrs = [f"{a['local']}/{a['prefixlen']}" for row in addresses for a in row.get("addr_info", []) if a.get("family") == "inet"]
return {"interfaceUp": any("UP" in row.get("flags", []) for row in addresses), "publicKey": interface[1], "listenPort": int(interface[2]), "addresses": cidrs, "peers": peers}, raw_peers
def check(raw, apply=False):
desired = validate(raw)
path = CONFIG / "wg-flow.conf"
previous = path.read_text()
interface, stored = parse_conf(previous)
expected_address = f"{raw['collectorIp']}/{raw['prefix'].split('/')[1]}"
if run("wg", "pubkey", input=interface["PrivateKey"] + "\n") != raw["hostPublicKey"] or interface["Address"] != expected_address or int(interface["ListenPort"]) != raw["wgListenPort"]:
raise AgentError("Ключ, адрес или порт существующего wg-flow отличаются от MM; требуется ручная проверка")
status, runtime = live()
if not status["interfaceUp"]:
raise AgentError("Интерфейс wg-flow выключен; проверьте wg-quick на хосте")
if status["publicKey"] != raw["hostPublicKey"] or expected_address not in status["addresses"] or status["listenPort"] != raw["wgListenPort"]:
raise AgentError("Работающий wg-flow отличается от конфигурации; требуется ручная проверка")
# Preserve both persistent and runtime-only third-party peers. Detect conflicts before writing.
combined = {p["PublicKey"]: p for p in runtime}
combined.update({p["PublicKey"]: p for p in stored})
merged = merge_peers(list(combined.values()), desired)
runtime_merged = merge_peers(runtime, desired)
# Do not reset a third-party peer's runtime settings to an older persistent file.
# Preserve roaming endpoints of managed peers unless the persisted endpoint was edited.
stored_by_key = {p["PublicKey"]: p for p in stored}
runtime_by_key = {p["PublicKey"]: p for p in runtime}
desired_by_key = {p["PublicKey"]: p for p in desired}
for peer in runtime_merged:
public = peer["PublicKey"]
if public in desired_by_key and stored_by_key.get(public, {}).get("Endpoint") == desired_by_key[public].get("Endpoint"):
if runtime_by_key.get(public, {}).get("Endpoint"):
peer["Endpoint"] = runtime_by_key[public]["Endpoint"]
def matches(actual, persistent=False):
by_key = {p["PublicKey"]: p for p in actual}
return all(p["PublicKey"] in by_key and all(by_key[p["PublicKey"]].get(k) == v for k, v in p.items() if persistent or k != "Endpoint") for p in desired)
matched = matches(stored, persistent=True) and matches(runtime)
if apply:
rollback = run("wg", "showconf", IFACE)
try:
atomic_write(path, render(interface, merged))
# Keep the running interface header verbatim (including a possible fwmark).
header = rollback.split("[Peer]", 1)[0].rstrip()
peer_config = render({}, runtime_merged, native=True).removeprefix("[Interface]\n")
sync(header + "\n" + peer_config)
status, after = live()
if not matches(after):
raise AgentError("Применение пиров не подтверждено")
except Exception:
failures = []
for action in (lambda: atomic_write(path, previous), lambda: sync(rollback)):
try:
action()
except Exception:
failures.append(True)
raise AgentError("Применение не удалось; " + ("восстановление требует ручной проверки" if failures else "предыдущая конфигурация восстановлена")) from None
matched = True
return {**status, "configMatches": matched}
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *_):
pass
def handle_request(self):
try:
with LOCK:
if self.command == "GET" and self.path == "/status":
result, _ = live()
result["configMatches"] = None
elif self.command == "POST" and self.path in ("/check", "/apply"):
size = int(self.headers.get("Content-Length", "0"))
if not 0 < size <= MAX_BODY or self.headers.get("Transfer-Encoding"):
raise AgentError("Некорректный размер запроса")
raw = json.loads(self.rfile.read(size))
result = check(raw, apply=self.path == "/apply")
else:
self.respond(404, {"error": "Операция не поддерживается"})
return
self.respond(200, result)
except AgentError as error:
self.respond(409, {"error": str(error)})
except Exception:
self.respond(503, {"error": "Агент NetFlow не готов; проверьте конфигурацию и интерфейс хоста"})
def setup(self):
super().setup()
self.connection.settimeout(15)
def respond(self, code, body):
data = json.dumps(body, ensure_ascii=False).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
do_GET = handle_request
do_POST = handle_request
class Server(socketserver.ThreadingMixIn, getattr(socketserver, "UnixStreamServer", socketserver.TCPServer)):
daemon_threads = True
class UnixConnection(http.client.HTTPConnection):
def connect(self):
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.settimeout(3)
self.sock.connect(SOCKET)
def main():
if not hasattr(socketserver, "UnixStreamServer"):
raise SystemExit("NetFlow agent requires Linux Unix sockets")
if "--healthcheck" in sys.argv:
client = UnixConnection("localhost")
client.request("GET", "/status")
response = client.getresponse()
sys.exit(0 if response.status == 200 and json.loads(response.read()).get("interfaceUp") else 1)
os.umask(0o077)
Path(SOCKET).parent.mkdir(parents=True, exist_ok=True)
if os.path.lexists(SOCKET):
Path(SOCKET).unlink()
with Server(SOCKET, Handler) as server:
os.chmod(SOCKET, 0o660)
print("NetFlow agent: Unix API ready", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# Usage: sudo bash install.sh /absolute/path/wg-flow.conf /opt/cdn-mm 4739
set -euo pipefail
umask 077
[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "Запустите от root" >&2; exit 1; }
SOURCE=$(realpath "${1:?Укажите скачанный wg-flow.conf}")
COMPOSE_DIR=$(realpath "${2:-/opt/cdn-mm}")
FLOW_PORT=${3:-4739}
[[ "$FLOW_PORT" =~ ^[0-9]+$ ]] && (( FLOW_PORT >= 1 && FLOW_PORT <= 65535 )) || exit 1
command -v docker >/dev/null
docker compose version >/dev/null
systemctl show docker.service --property=LoadState --value | grep -qx loaded
if ! command -v wg >/dev/null || ! command -v python3 >/dev/null || ! command -v modprobe >/dev/null; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard-tools python3 iproute2 kmod
fi
modprobe wireguard
[[ -f "$COMPOSE_DIR/docker-compose.yml" ]] || { echo "Не найден docker-compose.yml" >&2; exit 1; }
AGENT_IMAGE=${MM_NETFLOW_AGENT_IMAGE:-git.shx.one/denozord/mikrotikmanager-netflow-agent:latest}
[[ "$AGENT_IMAGE" =~ ^[A-Za-z0-9][A-Za-z0-9._:/@-]+$ ]] || { echo "Некорректное имя образа" >&2; exit 1; }
# The image must be available before changing the host configuration.
docker pull "$AGENT_IMAGE"
TASK_TEMP=$(mktemp -d)
trap 'rm -rf -- "$TASK_TEMP"' EXIT
python3 - "$SOURCE" "$COMPOSE_DIR" "$FLOW_PORT" "$TASK_TEMP" <<'PY'
import base64, ipaddress, json, os, pathlib, re, subprocess, sys
source, directory, port, temp = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), int(sys.argv[3]), pathlib.Path(sys.argv[4])
def read(text):
section, fields, peers = None, {}, []
allowed = {'Interface': {'PrivateKey','Address','ListenPort','MTU'}, 'Peer': {'PublicKey','AllowedIPs','Endpoint','PersistentKeepalive','PresharedKey'}}
for raw in text.splitlines():
line = raw.split('#',1)[0].strip()
if not line: continue
if line == '[Interface]' and section is None: section, current = 'Interface', fields
elif line == '[Peer]' and section is not None:
section, current = 'Peer', {}; peers.append(current)
else:
key, sep, value = line.partition('='); key, value = key.strip(), value.strip()
if not sep or section not in allowed or key not in allowed[section] or key in current or not value:
raise SystemExit('Конфигурация содержит неподдерживаемые параметры; требуется ручная проверка')
current[key] = value
try:
addr = ipaddress.IPv4Interface(fields['Address'])
if addr.network.prefixlen != 24 or addr.ip in (addr.network.network_address, addr.network.broadcast_address): raise ValueError()
if len(base64.b64decode(fields['PrivateKey'], validate=True)) != 32 or not 1 <= int(fields['ListenPort']) <= 65535: raise ValueError()
if 'MTU' in fields and not 576 <= int(fields['MTU']) <= 9000: raise ValueError()
for peer in peers:
if len(base64.b64decode(peer['PublicKey'], validate=True)) != 32: raise ValueError()
for network in peer['AllowedIPs'].split(','): ipaddress.ip_network(network.strip(), strict=False)
if 'Endpoint' in peer and not re.fullmatch(r'(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):[0-9]{1,5}', peer['Endpoint']): raise ValueError()
if 'PersistentKeepalive' in peer and not 0 <= int(peer['PersistentKeepalive']) <= 65535: raise ValueError()
except (ValueError, KeyError): raise SystemExit('Некорректная конфигурация WireGuard; ключи не изменены')
return fields, addr
expected, addr = read(source.read_text())
active = pathlib.Path('/etc/wireguard/wg-flow.conf')
candidate = active.read_text() if active.exists() else source.read_text()
actual, actual_addr = read(candidate)
for key in ('PrivateKey','Address','ListenPort'):
if actual[key] != expected[key]: raise SystemExit('Ключ, адрес или порт существующего туннеля отличаются от MM; установка остановлена')
public = subprocess.run(['wg','pubkey'], input=actual['PrivateKey']+'\n',text=True,capture_output=True,check=True).stdout.strip()
if subprocess.run(['ip','link','show','dev','wg-flow'],capture_output=True).returncode == 0:
dump = subprocess.run(['wg','show','wg-flow','dump'],capture_output=True,text=True,check=True).stdout.splitlines()
head = dump[0].split('\t')
addresses = json.loads(subprocess.run(['ip','-j','-4','address','show','dev','wg-flow'],capture_output=True,text=True,check=True).stdout)
if head[1] != public or int(head[2]) != int(actual['ListenPort']) or not any(a.get('local') == str(addr.ip) and a.get('prefixlen') == addr.network.prefixlen for row in addresses for a in row.get('addr_info',[])):
raise SystemExit('Работающий wg-flow отличается от MM; установка остановлена')
binding = dict(collectorIp=str(addr.ip), prefix=str(addr.network), flowListenPort=port, wgListenPort=int(actual['ListenPort']))
(temp/'binding.json').write_text(json.dumps(binding))
(temp/'wg-flow.conf').write_text(candidate)
(temp/'collector').write_text(str(addr.ip))
PY
COLLECTOR_IP=$(cat "$TASK_TEMP/collector")
# Keep a dedicated override; never replace docker-compose.override.yml.
cat > "$TASK_TEMP/compose.yml" <<EOF
services:
netflow-agent:
image: "$AGENT_IMAGE"
container_name: mmapp-netflow-agent
restart: unless-stopped
network_mode: host
cap_drop: [ALL]
cap_add: [NET_ADMIN]
security_opt: ["no-new-privileges:true"]
read_only: true
tmpfs: ["/tmp:rw,noexec,nosuid,size=8m"]
volumes:
- ./data/netflow-wireguard:/etc/wireguard
- ./data/netflow-agent-socket:/run/mm-netflow-host
logging:
driver: json-file
options: {max-size: "5m", max-file: "2"}
backend:
environment:
FLOW_AGENT_SOCKET: /run/mm-netflow-host/agent.sock
FLOW_LISTEN_HOST: "0.0.0.0"
volumes:
- ./data/netflow-agent-socket:/run/mm-netflow-host:ro
ports:
- "$COLLECTOR_IP:$FLOW_PORT:$FLOW_PORT/udp"
depends_on:
netflow-agent:
condition: service_healthy
EOF
COMPOSE_ARGS=(-f "$COMPOSE_DIR/docker-compose.yml")
if [[ -f "$COMPOSE_DIR/docker-compose.override.yml" ]]; then COMPOSE_ARGS+=(-f "$COMPOSE_DIR/docker-compose.override.yml"); fi
# Render first, and reject an existing public/incorrect IPFIX binding before touching WireGuard.
docker compose --project-directory "$COMPOSE_DIR" "${COMPOSE_ARGS[@]}" -f "$TASK_TEMP/compose.yml" config --format json > "$TASK_TEMP/merged.json"
python3 - "$TASK_TEMP/merged.json" "$COLLECTOR_IP" "$FLOW_PORT" <<'PY'
import json,sys
config=json.load(open(sys.argv[1])); ip=sys.argv[2]; port=int(sys.argv[3])
for bind in config['services']['backend'].get('ports',[]):
if bind.get('protocol','tcp') == 'udp' and (bind['target'] in (4739,port) or str(bind.get('published')) == str(port)):
if bind.get('host_ip') != ip or bind['target'] != port or str(bind.get('published')) != str(port):
raise SystemExit('В Compose уже есть другой IPFIX bind. Согласуйте его с адресом и портом MM и повторите установку.')
PY
if docker container inspect mmapp-netflow-agent >/dev/null 2>&1; then docker stop mmapp-netflow-agent; fi
install -d -m 700 /etc/wireguard "$COMPOSE_DIR/data/netflow-wireguard" "$COMPOSE_DIR/data/netflow-agent-socket"
if [[ -e /etc/wireguard/wg-flow.conf ]]; then
cp -L /etc/wireguard/wg-flow.conf "$COMPOSE_DIR/data/netflow-wireguard/wg-flow.conf.backup.$(date +%s)"
fi
install -m 600 "$TASK_TEMP/wg-flow.conf" "$COMPOSE_DIR/data/netflow-wireguard/wg-flow.conf"
install -m 600 "$TASK_TEMP/binding.json" "$COMPOSE_DIR/data/netflow-wireguard/binding.json"
ln -sfn "$COMPOSE_DIR/data/netflow-wireguard/wg-flow.conf" /etc/wireguard/wg-flow.conf
install -m 600 "$TASK_TEMP/compose.yml" "$COMPOSE_DIR/docker-compose.netflow-agent.yml"
install -d /etc/systemd/system/docker.service.d
cat > /etc/systemd/system/docker.service.d/mm-netflow.conf <<'EOF'
[Unit]
Wants=wg-quick@wg-flow.service
After=wg-quick@wg-flow.service
EOF
systemctl daemon-reload
systemctl enable wg-quick@wg-flow.service
if ! ip link show dev wg-flow >/dev/null 2>&1; then
systemctl start wg-quick@wg-flow.service
else
ip link set dev wg-flow up
fi
cd "$COMPOSE_DIR"
docker compose "${COMPOSE_ARGS[@]}" -f docker-compose.netflow-agent.yml up -d netflow-agent
AGENT_READY=false
for attempt in $(seq 1 60); do
if [[ "$(docker inspect --format '{{.State.Health.Status}}' mmapp-netflow-agent)" == healthy ]]; then
AGENT_READY=true
break
fi
sleep 1
done
[[ "$AGENT_READY" == true ]] || { echo "Агент не готов. Проверьте docker logs mmapp-netflow-agent; backend не пересоздан." >&2; exit 1; }
docker compose "${COMPOSE_ARGS[@]}" -f docker-compose.netflow-agent.yml up -d --no-deps --force-recreate backend
echo "Агент установлен. Используйте те же -f файлы при дальнейших операциях Compose:"
printf 'docker compose '; printf '%q ' "${COMPOSE_ARGS[@]}" -f "$COMPOSE_DIR/docker-compose.netflow-agent.yml"; echo 'up -d'
echo "В MM обновите статус хоста и нажмите «Применить настройки хоста»."
+201
View File
@@ -0,0 +1,201 @@
import base64
import copy
import json
import os
from pathlib import Path
import tempfile
import unittest
import shutil
import uuid
from unittest.mock import patch
import agent
def key(n):
return base64.b64encode(bytes([n]) * 32).decode()
class ControllerTest(unittest.TestCase):
def setUp(self):
# Windows Python 3.13's TemporaryDirectory mode 0700 excludes the restricted sandbox token.
base = Path.cwd() / "tmp" if os.name == "nt" else Path(tempfile.gettempdir())
base.mkdir(exist_ok=True)
self.root = base / ("flow-agent-test-" + uuid.uuid4().hex)
self.root.mkdir()
def cleanup():
if self.root.resolve().parent != base.resolve():
raise RuntimeError("Unexpected test directory")
shutil.rmtree(self.root)
self.addCleanup(cleanup)
self.addCleanup(patch.stopall)
patch.object(agent, "CONFIG", self.root).start()
self.interface = dict(PrivateKey=key(1), Address="10.255.254.1/24", ListenPort="51821", MTU="1420")
self.third = dict(PublicKey=key(3), AllowedIPs="192.0.2.5/32", PresharedKey=key(9), PersistentKeepalive="0")
self.desired = dict(collectorIp="10.255.254.1", prefix="10.255.254.0/24", flowListenPort=4739,
wgListenPort=51821, hostPublicKey=key(2), hostPrivateKey=key(1),
peers=[dict(serverId=1, publicKey=key(4), allowedIps=["10.255.254.2/32"], endpoint="router.test:13232")])
(self.root / "binding.json").write_text(json.dumps({k: self.desired[k] for k in ("collectorIp", "prefix", "flowListenPort", "wgListenPort")}))
self.previous = agent.render(self.interface, [self.third])
(self.root / "wg-flow.conf").write_text(self.previous)
self.runtime = [copy.deepcopy(self.third)]
self.calls = []
def run(*args, input=None):
self.calls.append(args)
if args == ("wg", "pubkey"):
return key(2) if input == key(1) + "\n" else key(8)
if args == ("wg", "showconf", "wg-flow"):
return agent.render(self.interface, self.runtime, native=True)
raise AssertionError(args)
patch.object(agent, "run", run).start()
patch.object(agent, "live", self.live).start()
patch.object(agent, "sync", self.sync).start()
self.synced = []
def live(self):
return dict(interfaceUp=True, publicKey=key(2), listenPort=51821, addresses=["10.255.254.1/24"], peers=[]), copy.deepcopy(self.runtime)
def sync(self, text):
self.synced.append(text)
# Native configs don't include wg-quick's Address; simulate applying their peers.
_, self.runtime = agent.parse_conf(text.replace("ListenPort = 51821", "ListenPort = 51821\nAddress = 10.255.254.1/24"))
def test_apply_preserves_third_party_psk_and_is_idempotent(self):
self.assertFalse(agent.check(self.desired)["configMatches"])
self.assertTrue(agent.check(self.desired, apply=True)["configMatches"])
text = (self.root / "wg-flow.conf").read_text()
self.assertIn(key(3), text)
self.assertIn(key(9), text)
self.assertIn(key(4), text)
self.assertNotIn("Address", self.synced[0])
self.assertTrue(agent.check(self.desired)["configMatches"])
agent.check(self.desired, apply=True)
self.assertEqual((self.root / "wg-flow.conf").read_text(), text)
if os.name != "nt":
self.assertEqual((self.root / "wg-flow.conf").stat().st_mode & 0o777, 0o600)
def test_runtime_only_peer_preserved(self):
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="198.51.100.7/32", PersistentKeepalive="0"))
agent.check(self.desired, apply=True)
self.assertIn(key(6), (self.root / "wg-flow.conf").read_text())
def test_runtime_peer_without_allowed_ips_preserved(self):
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="", PersistentKeepalive="0"))
agent.check(self.desired, apply=True)
self.assertTrue(agent.check(self.desired)["configMatches"])
def test_third_party_runtime_settings_are_not_reset(self):
# Runtime may contain a roaming endpoint and intentionally unsaved settings.
self.runtime[0].update(Endpoint="198.51.100.2:50000", PersistentKeepalive="10")
agent.check(self.desired, apply=True)
third = next(p for p in self.runtime if p["PublicKey"] == key(3))
self.assertEqual(third["Endpoint"], "198.51.100.2:50000")
self.assertEqual(third["PersistentKeepalive"], "10")
_, stored = agent.parse_conf((self.root / "wg-flow.conf").read_text())
self.assertEqual(next(p for p in stored if p["PublicKey"] == key(3))["PersistentKeepalive"], "0")
def test_managed_roaming_endpoint_preserved_without_changing_desired_dns(self):
agent.check(self.desired, apply=True)
next(p for p in self.runtime if p["PublicKey"] == key(4))["Endpoint"] = "198.51.100.2:50000"
agent.check(self.desired, apply=True)
_, stored = agent.parse_conf((self.root / "wg-flow.conf").read_text())
self.assertEqual(next(p for p in stored if p["PublicKey"] == key(4))["Endpoint"], "router.test:13232")
self.assertEqual(next(p for p in self.runtime if p["PublicKey"] == key(4))["Endpoint"], "198.51.100.2:50000")
self.assertTrue(agent.check(self.desired)["configMatches"])
def test_down_interface_cannot_report_applied(self):
status, peers = self.live()
with patch.object(agent, "live", return_value=({**status, "interfaceUp": False}, peers)):
with self.assertRaisesRegex(agent.AgentError, "выключен"):
agent.check(self.desired, apply=True)
self.assertEqual(self.synced, [])
def test_overlap_rejected_before_any_write(self):
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="10.255.254.0/24"))
with self.assertRaisesRegex(agent.AgentError, "пересекаются"):
agent.check(self.desired, apply=True)
self.assertEqual((self.root / "wg-flow.conf").read_text(), self.previous)
self.assertEqual(self.synced, [])
def test_different_interface_identity_rejected(self):
self.desired["hostPrivateKey"], self.desired["hostPublicKey"] = key(7), key(8)
with self.assertRaisesRegex(agent.AgentError, "существующего"):
agent.check(self.desired, apply=True)
self.assertEqual(self.synced, [])
def test_failed_apply_restores_file_and_runtime(self):
rollback = agent.render(self.interface, self.runtime, native=True)
with patch.object(agent, "sync", side_effect=[agent.AgentError("secret error"), None]) as sync:
with self.assertRaisesRegex(agent.AgentError, "предыдущая конфигурация восстановлена"):
agent.check(self.desired, apply=True)
self.assertEqual(sync.call_args_list[-1].args, (rollback,))
self.assertEqual((self.root / "wg-flow.conf").read_text(), self.previous)
def test_failed_rollback_is_explicit(self):
with patch.object(agent, "sync", side_effect=agent.AgentError("secret error")):
with self.assertRaisesRegex(agent.AgentError, "восстановление требует ручной проверки"):
agent.check(self.desired, apply=True)
def test_invalid_or_shell_input_rejected(self):
for change in [dict(flowListenPort=4740), dict(prefix="10.255.254.0/28"), dict(hostPrivateKey="$(id)"), dict(command="id"), dict(peers=[dict(serverId=1, publicKey=key(4), allowedIps=["0.0.0.0/0"])]), dict(peers=[dict(serverId=1, publicKey=key(4), allowedIps=["10.255.254.2/32"], endpoint="router.test:13232\nPostUp = id")])]:
with self.subTest(change=list(change)):
with self.assertRaises(agent.AgentError):
agent.check({**self.desired, **change}, apply=True)
self.assertEqual(self.synced, [])
def test_hooks_and_duplicate_fields_rejected(self):
for suffix in ("\nPostUp = id", "\nListenPort = 3", "\n[Interface]\nPrivateKey = secret"):
with self.assertRaises(agent.AgentError):
agent.parse_conf(agent.render(self.interface, []) + suffix)
def test_endpoint_change_requires_apply(self):
agent.check(self.desired, apply=True)
self.desired["peers"][0]["endpoint"] = "new-router.test:13232"
self.assertFalse(agent.check(self.desired)["configMatches"])
def test_command_errors_do_not_expose_keys(self):
with patch.object(agent.subprocess, "run", return_value=type("Result", (), {"returncode": 1, "stdout": key(1), "stderr": key(9)})()):
# Use the original function, not the fixture runner.
with self.assertRaisesRegex(agent.AgentError, "не выполнена") as error:
ORIGINAL_RUN("wg", "show")
self.assertNotIn(key(1), str(error.exception))
self.assertNotIn(key(9), str(error.exception))
ORIGINAL_RUN = agent.run
@unittest.skipUnless(os.environ.get("FLOW_AGENT_KERNEL_TEST") == "1", "Requires Linux kernel WireGuard and NET_ADMIN in an isolated container")
class KernelTest(unittest.TestCase):
def test_real_interface_apply_and_preservation(self):
with tempfile.TemporaryDirectory() as folder, patch.object(agent, "CONFIG", Path(folder)):
private = agent.run("wg", "genkey")
public = agent.run("wg", "pubkey", input=private + "\n")
other = agent.run("wg", "pubkey", input=agent.run("wg", "genkey") + "\n")
desired_key = agent.run("wg", "pubkey", input=agent.run("wg", "genkey") + "\n")
interface = dict(PrivateKey=private, Address="10.255.254.1/24", ListenPort="51821")
third = dict(PublicKey=other, AllowedIPs="192.0.2.3/32")
path = Path(folder)
(path / "wg-flow.conf").write_text(agent.render(interface, [third]))
snapshot = dict(collectorIp="10.255.254.1", prefix="10.255.254.0/24", flowListenPort=4739, wgListenPort=51821,
hostPublicKey=public, hostPrivateKey=private, peers=[dict(serverId=1, publicKey=desired_key, allowedIps=["10.255.254.2/32"], endpoint="127.0.0.1:13232")])
(path / "binding.json").write_text(json.dumps({k: snapshot[k] for k in ("collectorIp", "prefix", "flowListenPort", "wgListenPort")}))
agent.run("ip", "link", "add", "dev", agent.IFACE, "type", "wireguard")
try:
agent.run("ip", "address", "add", "10.255.254.1/24", "dev", agent.IFACE)
agent.sync(agent.render(interface, [third], native=True))
agent.run("ip", "link", "set", "dev", agent.IFACE, "up")
before = json.loads(agent.run("ip", "-j", "link", "show", "dev", agent.IFACE))[0]["ifindex"]
self.assertTrue(agent.check(snapshot, apply=True)["configMatches"])
self.assertTrue(agent.check(snapshot)["configMatches"])
status, _ = agent.live()
self.assertEqual({p["publicKey"] for p in status["peers"]}, {other, desired_key})
self.assertNotIn(private, json.dumps(status))
after = json.loads(agent.run("ip", "-j", "link", "show", "dev", agent.IFACE))[0]["ifindex"]
self.assertEqual(before, after)
finally:
agent.run("ip", "link", "delete", "dev", agent.IFACE)
if __name__ == "__main__":
unittest.main()
+156
View File
@@ -0,0 +1,156 @@
# Автоматическое подключение NetFlow
`mmapp-netflow-agent` управляет только `wg-flow` на том же Debian/Ubuntu-хосте,
где работает Docker Engine. Используются WireGuard ядра Linux и обычный Docker с
root-правами. Rootless Docker и Docker Desktop не поддерживаются этим режимом.
## Production-состав
С Compose `deploy/docker-compose.cdn-mm.yml` и дополнением агента работают семь контейнеров:
| Контейнер | Назначение |
| --- | --- |
| cdn-mm-traefik | HTTPS и маршрутизация |
| cdnmanager | CDN Manager |
| mmapp-frontend | Интерфейс MM |
| mmapp-backend | API, настройка MikroTik и приём IPFIX |
| mmapp-postgres | Настройки и потоки |
| mmapp-updater | Существующее обновление контейнеров MM |
| mmapp-netflow-agent | Применение пиров WireGuard на хосте |
Собственный systemd-сервис не нужен. Стандартный `wg-quick@wg-flow` поднимает
интерфейс до Docker: backend публикует IPFIX только на его адресе.
```mermaid
flowchart TB
User[Пользователь] -->|HTTPS| Proxy[Traefik]
subgraph Host[Production Linux]
subgraph Docker[Docker Compose]
Proxy --> Frontend[frontend]
Proxy --> CDN[CDN Manager]
Frontend -->|API| Backend[backend + IPFIX collector]
Backend --> PG[PostgreSQL]
Backend <-->|HTTP через Unix socket bind mount| Agent[netflow-agent: host network + NET_ADMIN]
Updater[updater] -.-> Backend
Updater -.-> Frontend
end
Agent -->|atomic write| Conf[wg-flow.conf]
Agent -->|wg syncconf| WG[wg-flow: интерфейс хоста]
Conf --> Boot[wg-quick при загрузке]
Boot --> WG
WG -->|Docker UDP bind на WG-IP| Backend
end
Backend -->|RouterOS API| Router[MikroTik]
Router <-->|WireGuard: внутри IPFIX| WG
```
Агент не обрабатывает потоки: при остановке/пересоздании контейнера интерфейс
и приём IPFIX продолжают работать. `NET_ADMIN` вместе с host network позволяет
управлять сетью хоста; API ограничено фиксированным интерфейсом и структурированными
настройками. Агент не получает Docker socket, `privileged`, корневую ФС хоста,
право загрузки модулей или TCP API.
## Однократная установка
1. Обновить MM до версии с агентом. Образ агента должен быть опубликован CI
в том же реестре; production-хост должен быть авторизован через `docker login`.
2. В «Сбор данных → NetFlow» создать ключи хоста, открыть «Файлы хоста» и скачать
`install-netflow-agent.sh` из вкладки «Агент Docker».
3. Выполнить на Linux-хосте:
```bash
sudo bash install-netflow-agent.sh /opt/cdn-mm
```
Файл содержит приватный ключ. Хранить его как секрет, не коммитить.
При необходимости образ можно задать явно:
```bash
sudo MM_NETFLOW_AGENT_IMAGE=git.shx.one/denozord/mikrotikmanager-netflow-agent:VERSION \
bash install-netflow-agent.sh /opt/cdn-mm
```
4. В MM обновить состояние хоста и применить настройки. Затем подключать
jump-host из раздела трафика: пиры применяются автоматически без перезапуска WG.
Альтернатива из checkout: скачать `wg-flow.conf` из MM и выполнить
`sudo bash deploy/netflow-agent/install.sh /absolute/path/wg-flow.conf /opt/cdn-mm 4739`.
Третий аргумент — фактический IPFIX-порт из настроек.
Установщик сначала проверяет конфигурацию и Compose. Он сохраняет существующие
пиры и делает резервную копию конфигурации; при несовпадении ключа, адреса или порта
останавливается. Hooks, SaveConfig и нестандартные параметры wg-quick требуют
ручной проверки и не принимаются. Интерфейс должен иметь IPv4 /24;
пиры MM получают собственные /32 в этой сети.
Создаются:
- `data/netflow-wireguard/wg-flow.conf` и `binding.json` с правами `0600`;
- ссылка `/etc/wireguard/wg-flow.conf` на постоянную конфигурацию;
- `docker-compose.netflow-agent.yml`, не заменяющий пользовательский override;
- drop-in `/etc/systemd/system/docker.service.d/mm-netflow.conf` для порядка
запуска `wg-quick → Docker`;
- общий каталог `data/netflow-agent-socket` для Unix-сокета, подключённый в backend только для чтения. Bind mount сохраняет этот режим при пересоздании backend существующим updater.
Установщик однократно пересоздаёт backend и не перезапускает Docker или Traefik.
Он не изменяет firewall хоста. Существующий публичный или несовместимый IPFIX bind
в Compose нужно исправить перед повторным запуском. Не публиковать IPFIX на `0.0.0.0`.
## Эксплуатация
При следующих операциях Compose сохранять все файлы дополнений:
```bash
cd /opt/cdn-mm
docker compose -f docker-compose.yml -f docker-compose.override.yml \
-f docker-compose.netflow-agent.yml up -d
```
Если `docker-compose.override.yml` отсутствует, убрать соответствующий `-f`.
Установщик печатает команду для фактического набора файлов.
Образ агента обновляется явно через Compose; существующий updater не включает
его в автоматические цели. При пересоздании backend updater сохраняет его mounts.
Просмотр: `GET /api/traffic/flow/host-status`, право `mm:traffic:read`.
Повторное применение: `POST /api/traffic/flow/host-reconcile`, право `mm:traffic:write`.
Статус не содержит приватных/preshared ключей. `lastHandshakeAt` — Unix seconds
или `null`; факт применения настроек не означает handshake или получение IPFIX.
Время последнего IPFIX-пакета в панели относится ко всему коллектору.
Подключения и изменения настроек сериализуются PostgreSQL advisory lock.
После рестарта backend повторно применяет сохранённые пиры. При конфликте или
недоступности агента приложение и текущий приём продолжают работать.
Если MikroTik уже настроен, но хост не применён, результат подключения содержит
`ok: false`, `stage: host-pending`, `hostError`: нажать «Применить настройки хоста».
При ошибке во время настройки роутера повторить подключение; его изменения
не откатываются автоматически.
Агент сохраняет сторонние persistent и runtime-only пиры, проверяет пересечения
AllowedIPs, атомарно записывает конфигурацию и применяет её через `wg syncconf`.
При сбое восстанавливает предыдущий файл и runtime; сбой восстановления сообщается
отдельно. Добавление/обновление пиров не меняет адреса, маршруты и интерфейс.
Первая версия не удаляет туннели. Адрес коллектора, префикс и порты закреплены
установкой: изменение требует согласования настроек MM, WG и Compose вручную и
повторного запуска установщика. Автоматическая смена адреса или ротация ключа
не выполняется. Другие установки могут продолжать пользоваться ручным экспортом
без `FLOW_AGENT_SOCKET`.
## Проверки
```bash
npm --prefix backend run test:netflow-settings
npx playwright test -c playwright.netflow-agent.config.ts
python3 -m unittest discover -s deploy/netflow-agent -p 'test_*.py' -v
bash -n deploy/netflow-agent/install.sh
docker build --target test -t mm-netflow-agent-test deploy/netflow-agent
docker run --rm --cap-drop ALL --cap-add NET_ADMIN --read-only --tmpfs /tmp \
-e FLOW_AGENT_KERNEL_TEST=1 --entrypoint python3 mm-netflow-agent-test \
-m unittest discover -s /app -p test_agent.py -v
```
Последняя проверка использует отдельное сетевое пространство контейнера, проверяет
настоящий kernel WireGuard, сохранение стороннего пира и неизменность интерфейса.
Она не заменяет приёмку на стенде с двумя MikroTik: handshake/IPFIX от каждого,
сохранение первого соединения при добавлении второго, перезапуск агента/backend
и холодная загрузка Linux-хоста с проверкой UDP bind.
+20
View File
@@ -9,6 +9,23 @@ export const flowHostPeerSchema = z.object({
endpoint: z.string().optional(),
})
export const trafficFlowHostStatusSchema = z.object({
configured: z.boolean(),
reachable: z.boolean(),
interfaceUp: z.boolean(),
configMatches: z.boolean().nullable(),
publicKey: z.string(),
listenPort: z.number().int().nonnegative(),
addresses: z.array(z.string()),
peers: z.array(z.object({
publicKey: z.string(), allowedIps: z.array(z.string()),
lastHandshakeAt: z.number().int().nonnegative().nullable(),
receivedBytes: z.number().nonnegative(), sentBytes: z.number().nonnegative(),
})),
error: z.string().nullable(),
})
export type TrafficFlowHostStatus = z.infer<typeof trafficFlowHostStatusSchema>
export const trafficFlowSettingsDtoSchema = z.object({
enabled: z.boolean(),
collectorIp: z.string().min(1),
@@ -77,6 +94,9 @@ export const trafficFlowOverlayResultSchema = z.object({
trafficFlow: z.boolean(),
steps: z.array(z.string()),
hostFiles: z.array(trafficFlowHostFileSchema),
hostStatus: trafficFlowHostStatusSchema.optional(),
stage: z.enum(["manual", "host-pending", "applied"]).optional(),
hostError: z.string().optional(),
})
export const flowTalkerDtoSchema = z.object({
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "@playwright/test"
import base from "./playwright.config"
const server = base.webServer
if (!server || Array.isArray(server)) throw new Error("Ожидается один локальный сервер")
export default defineConfig({
...base,
use: { ...base.use, baseURL: "http://127.0.0.1:3102" },
testIgnore: [],
testMatch: ["netflow-agent.spec.ts", "netflow-settings.spec.ts"],
webServer: {
...server,
command: "node node_modules/next/dist/bin/next dev --webpack --hostname 127.0.0.1 --port 3102",
url: "http://127.0.0.1:3102",
},
})
+14
View File
@@ -10,13 +10,27 @@ import type {
TrafficFlowOverlayResult,
TrafficFlowSettingsDto,
TrafficFlowSettingsPatch,
TrafficFlowHostStatus,
} from "@mmapp/contracts/traffic-flow"
import { z } from "zod"
import { trafficFlowSettingsDtoSchema, trafficFlowSettingsPatchSchema, trafficFlowHostFileSchema, flowPurgeDtoSchema, flowAnalyticsDtoSchema, flowMonthlyDtoSchema } from "@mmapp/contracts/traffic-flow"
import { requestJson } from "@/shared/api/http-client"
import { trafficFlowHostStatusSchema } from "@mmapp/contracts/traffic-flow"
export type { TrafficFlowHostFile }
export async function getTrafficFlowHostStatus(baseUrl: string, init?: RequestInit): Promise<TrafficFlowHostStatus> {
return flowConfirmed(trafficFlowHostStatusSchema, await requestJson(baseUrl, "/api/traffic/flow/host-status", {
...init, signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(65_000)]),
}))
}
export async function reconcileTrafficFlowHost(baseUrl: string, init?: RequestInit): Promise<TrafficFlowHostStatus> {
const response = flowConfirmed(z.object({ ok: z.literal(true), hostStatus: trafficFlowHostStatusSchema }),
await requestJson(baseUrl, "/api/traffic/flow/host-reconcile", { ...init, method: "POST", signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(120_000)]) }))
return response.hostStatus
}
const flowSettingsSchema = trafficFlowSettingsDtoSchema.extend({ flowListenPort: z.number().int().min(1).max(65535), wgListenPort: z.number().int().min(1).max(65535) }).refine(data => (!data.hasHostPrivateKey || data.hostPublicKey.length > 0) && (!data.listenerBound || !!data.listenerAddress) && (data.lastDatagramAt === null || Number.isFinite(Date.parse(data.lastDatagramAt))) && new Set(data.peers.map(peer => peer.serverId)).size === data.peers.length, "Несогласованные настройки NetFlow")
const flowPatchSchema = trafficFlowSettingsPatchSchema.strict()
// Optional overlay/wire measurements may be unavailable independently of the
+58
View File
@@ -0,0 +1,58 @@
import { test, expect, type Page } from "@playwright/test"
import { keyedNetflowSettings } from "./fixtures/netflow-settings"
const installed = {
configured: true, reachable: true, interfaceUp: true, configMatches: false,
publicKey: "public-fixture", listenPort: 51821, addresses: ["10.255.254.1/24"],
peers: [{ publicKey: "router-fixture", allowedIps: ["10.255.254.2/32"], lastHandshakeAt: null, receivedBytes: 0, sentBytes: 0 }], error: null,
}
async function setup(page: Page) {
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
if (path === "/api/auth/config") return route.fulfill({ json: { required: false } })
if (path === "/api/traffic/flow/settings") return route.fulfill({ json: keyedNetflowSettings })
if (path === "/api/traffic/flow/host-status") return route.fulfill({ json: installed })
return route.fulfill({ status: 503, json: { error: "outside agent fixture" } })
})
}
test("NetFlow agent: apply, handshake waiting and frozen installation fields", async ({ page }, testInfo) => {
await setup(page)
let applied = false, posts = 0
await page.route("**/api/traffic/flow/host-status", route => route.fulfill({ json: { ...installed, configMatches: applied } }))
await page.route("**/api/traffic/flow/host-reconcile", route => { applied = true; posts++; return route.fulfill({ json: { ok: true, hostStatus: { ...installed, configMatches: true } } }) })
await page.goto("/data-collection")
await expect(page.getByRole("textbox", { name: "UDP IPFIX", exact: true })).toBeDisabled()
await expect(page.getByRole("textbox", { name: "Collector IP", exact: true })).toBeDisabled()
await expect(page.getByText("Ожидание handshake", { exact: false })).toBeVisible()
await page.getByRole("button", { name: "Применить настройки хоста", exact: true }).click()
await expect(page.getByText("Настройки применены", { exact: true })).toBeVisible()
expect(posts).toBe(1)
const panel = page.getByRole("region", { name: "Настройки NetFlow", exact: true })
await expect(panel.getByText(/Последний пакет:.*Не получен/)).toBeVisible()
await panel.screenshot({ path: testInfo.outputPath("netflow-agent.png") })
})
test("NetFlow agent: apply conflict stays visible after status refresh", async ({ page }) => {
await setup(page)
let posts = 0
await page.route("**/api/traffic/flow/host-reconcile", route => { posts++; return route.fulfill({ status: 502, json: { error: "AllowedIPs пересекаются с другим пиром" } }) })
await page.goto("/data-collection")
await page.getByRole("button", { name: "Применить настройки хоста", exact: true }).click()
await expect(page.getByText("AllowedIPs пересекаются с другим пиром", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Обновить состояние хоста", exact: true }).click()
await expect(page.getByText("AllowedIPs пересекаются с другим пиром", { exact: true })).toBeVisible()
expect(posts).toBe(1)
})
test("NetFlow agent: unavailable status does not invent success or enable apply", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 900 })
await setup(page)
await page.route("**/api/traffic/flow/host-status", route => route.fulfill({ json: { ...installed, reachable: false, interfaceUp: false, configMatches: null, peers: [], error: "Агент недоступен" } }))
await page.goto("/data-collection")
await expect(page.getByRole("button", { name: "Применить настройки хоста", exact: true })).toBeDisabled()
await expect(page.getByText("Настройки применены", { exact: true })).toHaveCount(0)
await expect(page.getByRole("textbox", { name: "UDP IPFIX", exact: true })).toHaveValue("4739")
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
})