feat(netflow): автоматизировать настройку туннеля коллектора
This commit is contained in:
1 parent
fcca92706b
commit
db81cf0f97
33 files changed
+1503
-22
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
deploy/netflow-agent/*.sh text eol=lf
|
||||||
|
deploy/netflow-agent/*.py text eol=lf
|
||||||
@@ -155,6 +155,8 @@ jobs:
|
|||||||
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
||||||
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
|
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
|
||||||
cp -R backend/src "$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
|
if [ -d backend/drizzle ]; then
|
||||||
cp -R backend/drizzle "$STAGING/backend/"
|
cp -R backend/drizzle "$STAGING/backend/"
|
||||||
fi
|
fi
|
||||||
@@ -329,12 +331,69 @@ jobs:
|
|||||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||||
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
|
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:
|
publish-release:
|
||||||
needs:
|
needs:
|
||||||
- prepare-release
|
- prepare-release
|
||||||
- backend-image
|
- backend-image
|
||||||
- frontend-image
|
- frontend-image
|
||||||
- updater-image
|
- updater-image
|
||||||
|
- netflow-agent-image
|
||||||
if: needs.prepare-release.outputs.should_release == 'true'
|
if: needs.prepare-release.outputs.should_release == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -420,6 +479,7 @@ jobs:
|
|||||||
- prepare-release
|
- prepare-release
|
||||||
- backend-image
|
- backend-image
|
||||||
- frontend-image
|
- frontend-image
|
||||||
|
- netflow-agent-image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
|
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
|
||||||
|
|||||||
@@ -62,3 +62,11 @@ opencode.json
|
|||||||
|
|
||||||
# Local codegraph process
|
# Local codegraph process
|
||||||
.codegraph/daemon.pid
|
.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/
|
||||||
@@ -568,3 +568,7 @@ docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
|
|||||||
10. Проверить `curl` на `:8000/health` и `:3000/`.
|
10. Проверить `curl` на `:8000/health` и `:3000/`.
|
||||||
11. В UI: режим live и URL бэкенда; сверить с `CORS_ORIGIN`.
|
11. В UI: режим live и URL бэкенда; сверить с `CORS_ORIGIN`.
|
||||||
12. Просмотреть `docker logs mmapp-updater`; настроить регулярный backup тома `backend-data`.
|
12. Просмотреть `docker logs mmapp-updater`; настроить регулярный backup тома `backend-data`.
|
||||||
|
|
||||||
|
### Автоматизация NetFlow через Docker-агент
|
||||||
|
|
||||||
|
[Установка, схема взаимодействия и production-состав](docs/netflow-agent.md).
|
||||||
@@ -17,3 +17,5 @@ AUTH_REQUIRED=false
|
|||||||
AUTH_JWT_SECRET=dev-secret-change-me
|
AUTH_JWT_SECRET=dev-secret-change-me
|
||||||
AUTH_ISSUER=https://auth.shnt.top
|
AUTH_ISSUER=https://auth.shnt.top
|
||||||
AUTH_PORTAL_URL=http://localhost:5175
|
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
|
||||||
@@ -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/dist ./backend/dist
|
||||||
COPY --from=build /app/backend/package.json ./backend/package.json
|
COPY --from=build /app/backend/package.json ./backend/package.json
|
||||||
COPY --from=build /app/backend/drizzle ./backend/drizzle
|
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
|
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
|
||||||
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"test": "node scripts/test.mjs",
|
"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: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-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: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: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",
|
"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",
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import usersRoutes from "./routes/users.js"
|
|||||||
import statisticsRoutes from "./routes/statistics.js"
|
import statisticsRoutes from "./routes/statistics.js"
|
||||||
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||||
import { getFlowWorkerHealth, startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.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"
|
import { initGeoip } from "./services/traffic-flow-geoip.js"
|
||||||
|
|
||||||
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 })
|
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 })
|
||||||
@@ -147,6 +149,10 @@ export async function buildApp(opts?: {
|
|||||||
await refreshScheduler()
|
await refreshScheduler()
|
||||||
await initGeoip()
|
await initGeoip()
|
||||||
await startTrafficFlowListener()
|
await startTrafficFlowListener()
|
||||||
|
if (agentSocket()) {
|
||||||
|
try { await withFlowConfigurationLock(reconcileFlowHost) }
|
||||||
|
catch { app.log.warn("NetFlow: агент хоста не синхронизирован; проверьте состояние в настройках NetFlow") }
|
||||||
|
}
|
||||||
app.addHook("onClose", async () => {
|
app.addHook("onClose", async () => {
|
||||||
stopScheduler()
|
stopScheduler()
|
||||||
stopTrafficFlowListener()
|
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")
|
||||||
@@ -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 : "Настройки хоста не применены" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
} from "@mmapp/contracts/traffic-flow"
|
} from "@mmapp/contracts/traffic-flow"
|
||||||
import { createTrafficFlowPurgeRoutes } from "./traffic-flow-purge.js"
|
import { createTrafficFlowPurgeRoutes } from "./traffic-flow-purge.js"
|
||||||
import { createTrafficFlowSettingsRoutes } from "./traffic-flow-settings.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 {
|
import {
|
||||||
ensureHostKeys,
|
ensureHostKeys,
|
||||||
getTrafficFlowSettingsRow,
|
getTrafficFlowSettingsRow,
|
||||||
@@ -150,11 +153,21 @@ function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
|
await app.register(createTrafficFlowHostRoutes({
|
||||||
|
status: getFlowHostStatus,
|
||||||
|
reconcile: () => withFlowConfigurationLock(reconcileFlowHost),
|
||||||
|
}))
|
||||||
await app.register(createTrafficFlowSettingsRoutes({
|
await app.register(createTrafficFlowSettingsRoutes({
|
||||||
read: () => toTrafficFlowSettingsDto(getFlowListenerState()),
|
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,
|
startListener: startTrafficFlowListener,
|
||||||
ensureKeys: ensureHostKeys,
|
ensureKeys: () => withFlowConfigurationLock(ensureHostKeys),
|
||||||
hasPrivateKey: async () => Boolean((await getTrafficFlowSettingsRow()).hostPrivateKey),
|
hasPrivateKey: async () => Boolean((await getTrafficFlowSettingsRow()).hostPrivateKey),
|
||||||
hostFiles: listTrafficFlowHostFiles,
|
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("[email protected]"))
|
||||||
|
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 { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
|
||||||
import { parseJsonArray } from "../db/json.js"
|
import { parseJsonArray } from "../db/json.js"
|
||||||
import { buildTrafficFlowHostFiles } from "./traffic-flow-host-file-builders.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() {
|
export async function listTrafficFlowHostFiles() {
|
||||||
const row = await getTrafficFlowSettingsRow()
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
import { refreshFlowExporterMap, startTrafficFlowListener } from "./traffic-flow-ingest.js"
|
import { refreshFlowExporterMap, startTrafficFlowListener } from "./traffic-flow-ingest.js"
|
||||||
import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js"
|
import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js"
|
||||||
import { invalidateFlowCatalogCache } from "./traffic-flow-topology.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 IFACE_NAME = "wg-flow"
|
||||||
const JH_LISTEN_PORT = 13232
|
const JH_LISTEN_PORT = 13232
|
||||||
@@ -202,8 +204,16 @@ export function usablePublicHost(raw: string | undefined): string {
|
|||||||
export async function applyFlowOverlay(
|
export async function applyFlowOverlay(
|
||||||
serverIdRaw: string | number,
|
serverIdRaw: string | number,
|
||||||
opts?: { publicEndpoint?: string; requestHost?: string; disableGreFastPath?: boolean },
|
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> {
|
): Promise<TrafficFlowOverlayResult> {
|
||||||
const steps: string[] = []
|
const steps: string[] = []
|
||||||
|
await preflightFlowHost()
|
||||||
const keys = await ensureHostKeys()
|
const keys = await ensureHostKeys()
|
||||||
const settings = await getTrafficFlowSettingsRow()
|
const settings = await getTrafficFlowSettingsRow()
|
||||||
const hostPublicKey = settings.hostPublicKey || keys.publicKey
|
const hostPublicKey = settings.hostPublicKey || keys.publicKey
|
||||||
@@ -338,8 +348,21 @@ export async function applyFlowOverlay(
|
|||||||
await refreshFlowExporterMap()
|
await refreshFlowExporterMap()
|
||||||
steps.push("Коллектор IPFIX на MM включён")
|
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 {
|
return {
|
||||||
ok: true,
|
ok: !hostError,
|
||||||
serverId: server.id,
|
serverId: server.id,
|
||||||
interfaceName: IFACE_NAME,
|
interfaceName: IFACE_NAME,
|
||||||
address,
|
address,
|
||||||
@@ -347,11 +370,14 @@ export async function applyFlowOverlay(
|
|||||||
linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host, endpointHost),
|
linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host, endpointHost),
|
||||||
trafficFlow: true,
|
trafficFlow: true,
|
||||||
steps,
|
steps,
|
||||||
hostFiles: await listTrafficFlowHostFiles(),
|
hostFiles: agentSocket() ? [] : await listTrafficFlowHostFiles(),
|
||||||
|
stage: hostError ? "host-pending" : agentSocket() ? "applied" : "manual",
|
||||||
|
...(hostStatus ? { hostStatus } : {}),
|
||||||
|
...(hostError ? { hostError } : {}),
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(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
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||||
SheetDescription, SheetFooter, SheetClose,
|
SheetDescription, SheetFooter, SheetClose,
|
||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
|
import { FlowHostPanel } from "./flow-host-panel"
|
||||||
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
|
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
|
||||||
import type { ServerRead } from "@mmapp/contracts/servers"
|
import type { ServerRead } from "@mmapp/contracts/servers"
|
||||||
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
||||||
@@ -95,7 +96,8 @@ function FlowOverlaySheet({
|
|||||||
})
|
})
|
||||||
setResult(res)
|
setResult(res)
|
||||||
setTab(res.hostFiles[0]?.id ?? "linux")
|
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)
|
onDone?.(res)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Не удалось подключить JH")
|
toast.error(e instanceof Error ? e.message : "Не удалось подключить JH")
|
||||||
@@ -125,7 +127,7 @@ function FlowOverlaySheet({
|
|||||||
Подключить jump-host
|
Подключить jump-host
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Создаст wg-flow на MikroTik (сервер, listen 13232) и выдаст готовый bash для Linux-хоста Docker MM (клиент).
|
Настроит wg-flow и IPFIX на MikroTik. Если агент Docker установлен, автоматически применит пир на Linux-хосте MM.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
|
<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>
|
</FormField>
|
||||||
{result ? (
|
{result ? (
|
||||||
<div className="flex min-h-0 flex-col gap-4">
|
<div className="flex min-h-0 flex-col gap-4">
|
||||||
<Alert variant="success">
|
<Alert variant={result.stage === "host-pending" ? "warning" : "success"}>
|
||||||
<InfoIcon />
|
<InfoIcon />
|
||||||
<AlertTitle>Linux-хост /opt/cdn-mm</AlertTitle>
|
<AlertTitle>{result.stage === "applied" ? "Настройки применены" : result.stage === "host-pending" ? "MikroTik настроен, хост ожидает применения" : "Установка агента на Linux-хосте"}</AlertTitle>
|
||||||
<AlertDescription>
|
<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>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
<FlowHostPanel backendUrl={backendUrl} />
|
||||||
<ul className="flex flex-col gap-1.5">
|
<ul className="flex flex-col gap-1.5">
|
||||||
{result.steps.map((s) => (
|
{result.steps.map((s) => (
|
||||||
<li key={s} className="flex items-start gap-2 text-sm">
|
<li key={s} className="flex items-start gap-2 text-sm">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { CodeExportSheet, type CodeExportFormat } from "@/components/reui-kit/code-export-sheet"
|
import { CodeExportSheet, type CodeExportFormat } from "@/components/reui-kit/code-export-sheet"
|
||||||
import type { TrafficFlowSettingsDto } from "@mmapp/contracts/traffic-flow"
|
import type { TrafficFlowSettingsDto } from "@mmapp/contracts/traffic-flow"
|
||||||
import { GeoipSettingsSection } from "./geoip-settings-section"
|
import { GeoipSettingsSection } from "./geoip-settings-section"
|
||||||
|
import { FlowHostPanel } from "./flow-host-panel"
|
||||||
import {
|
import {
|
||||||
generateTrafficFlowKeys,
|
generateTrafficFlowKeys,
|
||||||
getTrafficFlowHostFiles,
|
getTrafficFlowHostFiles,
|
||||||
@@ -24,11 +25,11 @@ import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic
|
|||||||
import { KeyRoundIcon, DownloadIcon, InfoIcon, AlertCircleIcon } from "lucide-react"
|
import { KeyRoundIcon, DownloadIcon, InfoIcon, AlertCircleIcon } from "lucide-react"
|
||||||
|
|
||||||
const HOST_STEPS = [
|
const HOST_STEPS = [
|
||||||
"На хосте Docker (не в контейнере mmapp-backend): apt install wireguard (или эквивалент).",
|
"Создайте ключи хоста, откройте «Файлы хоста» и скачайте вкладку «Агент Docker».",
|
||||||
"Скачайте wg-flow.conf и положите в /etc/wireguard/wg-flow.conf.",
|
"Выполните sudo bash install-netflow-agent.sh /opt/cdn-mm на хосте Docker. При установке backend будет пересоздан.",
|
||||||
"wg-quick up wg-flow (или systemctl enable --now wg-quick@wg-flow).",
|
"Обновите статус хоста. Дальнейшие подключения автоматически применяются агентом.",
|
||||||
"Firewall: разрешите UDP listen WireGuard. UDP 4739 наружу не открывайте.",
|
"Firewall: разрешите UDP listen WireGuard. UDP 4739 наружу не открывайте.",
|
||||||
"В docker-compose у backend раскомментируйте bind IPFIX только на адресе wg-flow.",
|
"Установщик публикует IPFIX только на адресе wg-flow. Ручные файлы доступны в остальных вкладках экспорта.",
|
||||||
"Проверка: wg show · ss -ulnp | grep 4739 · в этой панели — last datagram.",
|
"Проверка: wg show · ss -ulnp | grep 4739 · в этой панели — last datagram.",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ function NetflowSettingsContent({
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
}) {
|
}) {
|
||||||
const [settings,setSettings] = useState<TrafficFlowSettingsDto|null>(null)
|
const [settings,setSettings] = useState<TrafficFlowSettingsDto|null>(null)
|
||||||
|
const [automatic,setAutomatic] = useState(false)
|
||||||
const [draft,setDraft] = useState<NetflowDraft>(emptyNetflowDraft)
|
const [draft,setDraft] = useState<NetflowDraft>(emptyNetflowDraft)
|
||||||
const dirty = useRef(new Set<keyof NetflowDraft>())
|
const dirty = useRef(new Set<keyof NetflowDraft>())
|
||||||
const [dirtyCount,setDirtyCount] = useState(0)
|
const [dirtyCount,setDirtyCount] = useState(0)
|
||||||
@@ -147,16 +149,16 @@ function NetflowSettingsContent({
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<FormField label="Collector IP" hint="Адрес в туннеле, куда JH шлёт flow">
|
<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>
|
||||||
<FormField label="Префикс overlay">
|
<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>
|
||||||
<FormField label="UDP IPFIX">
|
<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>
|
||||||
<FormField label="WG listen">
|
<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>
|
||||||
<FormField label="Публичный endpoint хоста MM" hint="IP или DNS, который видят JH">
|
<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" />
|
<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}` : ""}
|
{settings?.lastError ? ` · ${settings.lastError}` : ""}
|
||||||
</p>
|
</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>
|
<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">
|
<ol className="text-xs text-muted-foreground flex flex-col gap-1.5 list-decimal pl-4">
|
||||||
{HOST_STEPS.map((s) => (
|
{HOST_STEPS.map((s) => (
|
||||||
<li key={s}>{s.replaceAll("4739", settings ? String(settings.flowListenPort) : "IPFIX")}</li>
|
<li key={s}>{s.replaceAll("4739", settings ? String(settings.flowListenPort) : "IPFIX")}</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button size="sm" disabled={disabled || !dirtyCount} onClick={() => { void handleSave() }}>
|
<Button size="sm" disabled={disabled || !dirtyCount} onClick={() => { void handleSave() }}>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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]
|
||||||
|
[email protected]
|
||||||
|
[email protected]
|
||||||
|
EOF
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable [email protected]
|
||||||
|
if ! ip link show dev wg-flow >/dev/null 2>&1; then
|
||||||
|
systemctl start [email protected]
|
||||||
|
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 обновите статус хоста и нажмите «Применить настройки хоста»."
|
||||||
@@ -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()
|
||||||
@@ -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.
|
||||||
@@ -9,6 +9,23 @@ export const flowHostPeerSchema = z.object({
|
|||||||
endpoint: z.string().optional(),
|
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({
|
export const trafficFlowSettingsDtoSchema = z.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
collectorIp: z.string().min(1),
|
collectorIp: z.string().min(1),
|
||||||
@@ -77,6 +94,9 @@ export const trafficFlowOverlayResultSchema = z.object({
|
|||||||
trafficFlow: z.boolean(),
|
trafficFlow: z.boolean(),
|
||||||
steps: z.array(z.string()),
|
steps: z.array(z.string()),
|
||||||
hostFiles: z.array(trafficFlowHostFileSchema),
|
hostFiles: z.array(trafficFlowHostFileSchema),
|
||||||
|
hostStatus: trafficFlowHostStatusSchema.optional(),
|
||||||
|
stage: z.enum(["manual", "host-pending", "applied"]).optional(),
|
||||||
|
hostError: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const flowTalkerDtoSchema = z.object({
|
export const flowTalkerDtoSchema = z.object({
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -10,13 +10,27 @@ import type {
|
|||||||
TrafficFlowOverlayResult,
|
TrafficFlowOverlayResult,
|
||||||
TrafficFlowSettingsDto,
|
TrafficFlowSettingsDto,
|
||||||
TrafficFlowSettingsPatch,
|
TrafficFlowSettingsPatch,
|
||||||
|
TrafficFlowHostStatus,
|
||||||
} from "@mmapp/contracts/traffic-flow"
|
} from "@mmapp/contracts/traffic-flow"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { trafficFlowSettingsDtoSchema, trafficFlowSettingsPatchSchema, trafficFlowHostFileSchema, flowPurgeDtoSchema, flowAnalyticsDtoSchema, flowMonthlyDtoSchema } from "@mmapp/contracts/traffic-flow"
|
import { trafficFlowSettingsDtoSchema, trafficFlowSettingsPatchSchema, trafficFlowHostFileSchema, flowPurgeDtoSchema, flowAnalyticsDtoSchema, flowMonthlyDtoSchema } from "@mmapp/contracts/traffic-flow"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { trafficFlowHostStatusSchema } from "@mmapp/contracts/traffic-flow"
|
||||||
|
|
||||||
export type { TrafficFlowHostFile }
|
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 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()
|
const flowPatchSchema = trafficFlowSettingsPatchSchema.strict()
|
||||||
// Optional overlay/wire measurements may be unavailable independently of the
|
// Optional overlay/wire measurements may be unavailable independently of the
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user