Compare commits

..
9 Commits
Author SHA1 Message Date
Denozordec e461dc21bd fix(traffic): строить непрерывную историю коротких периодов
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 4m3s
Docker images / frontend-image (push) Successful in 2m13s
Docker images / updater-image (push) Successful in 39s
Docker images / netflow-agent-image (push) Successful in 31s
Docker images / backend-image (push) Successful in 1m37s
Docker images / publish-release (push) Successful in 13s
Docker images / notify-webhook (push) Successful in 2s
2026-10-16 19:27:30 +07:00
Denozordec 2852b11234 test(netflow): зафиксировать приёмку на двух маршрутизаторах
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 4m27s
Docker images / frontend-image (push) Successful in 2m19s
Docker images / updater-image (push) Successful in 39s
Docker images / netflow-agent-image (push) Successful in 1m6s
Docker images / backend-image (push) Successful in 1m40s
Docker images / publish-release (push) Successful in 10s
Docker images / notify-webhook (push) Successful in 2s
2026-10-16 15:47:50 +07:00
Denozordec 0abdac82de fix(netflow): показывать текущие счётчики приёмника 2026-10-16 15:45:10 +07:00
Denozordec db81cf0f97 feat(netflow): автоматизировать настройку туннеля коллектора 2026-10-16 15:03:23 +07:00
Denozordec fcca92706b chore(docs): сохранить проверку защиты от переполнения очереди подключений 2026-10-16 09:58:16 +07:00
Denozordec 4881306f0b chore(docs): сохранить диагностику задержек подключения к серверу 2026-10-16 09:53:53 +07:00
Denozordec ef229ca9c4 chore(docs): сохранить результат отключения небезопасных служб 2026-10-16 01:56:22 +07:00
Denozordec 25ae76dc3f fix(api): уточнить причины отказа входа на маршрутизатор 2026-10-16 01:32:29 +07:00
Denozordec 552d5f831f chore(docs): зафиксировать приёмку обновления в рабочем окружении 2026-10-16 00:53:30 +07:00
51 changed files with 2543 additions and 41 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
deploy/netflow-agent/*.sh text eol=lf
deploy/netflow-agent/*.py text eol=lf
+60
View File
@@ -155,6 +155,8 @@ jobs:
cp -R packages/contracts/src "$STAGING/packages/contracts/"
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
cp -R backend/src "$STAGING/backend/"
mkdir -p "$STAGING/deploy/netflow-agent"
cp deploy/netflow-agent/install.sh "$STAGING/deploy/netflow-agent/"
if [ -d backend/drizzle ]; then
cp -R backend/drizzle "$STAGING/backend/"
fi
@@ -329,12 +331,69 @@ jobs:
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
netflow-agent-image:
needs: prepare-release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure netflow-agent image name
shell: bash
run: |
set -euo pipefail
owner=$(echo "${{ gitea.repository }}" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
name=$(echo "${{ gitea.repository }}" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
echo "IMAGE_NAME=${{ env.REGISTRY }}/${owner}/${stem}-netflow-agent" >> "${GITHUB_ENV}"
- name: Test controller and installer
run: |
python3 -m unittest discover -s deploy/netflow-agent -p "test_*.py" -v
bash -n deploy/netflow-agent/install.sh
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
- name: Verify image with real WireGuard
run: |
docker build --target test -t mm-netflow-agent-test deploy/netflow-agent
docker run --rm --cap-drop ALL --cap-add NET_ADMIN --read-only --tmpfs /tmp -e FLOW_AGENT_KERNEL_TEST=1 --entrypoint python3 mm-netflow-agent-test -m unittest discover -s /app -p "test_agent.py" -v
- name: Build and push netflow-agent image
uses: docker/build-push-action@v5
with:
context: deploy/netflow-agent
file: deploy/netflow-agent/Dockerfile
platforms: linux/amd64
push: true
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ gitea.sha }}
${{ env.IMAGE_NAME }}:${{ needs.prepare-release.outputs.version }}
cache-from: type=gha,scope=netflow-agent
cache-to: type=gha,mode=max,scope=netflow-agent
labels: |
org.opencontainers.image.title=mmapp-netflow-agent
org.opencontainers.image.description=MikroTik Manager local WireGuard controller
org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }}
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
publish-release:
needs:
- prepare-release
- backend-image
- frontend-image
- updater-image
- netflow-agent-image
if: needs.prepare-release.outputs.should_release == 'true'
runs-on: ubuntu-latest
steps:
@@ -420,6 +479,7 @@ jobs:
- prepare-release
- backend-image
- frontend-image
- netflow-agent-image
runs-on: ubuntu-latest
env:
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
+8
View File
@@ -62,3 +62,11 @@ opencode.json
# Local codegraph process
.codegraph/daemon.pid
# Python controller tests
__pycache__/
*.pyc
# NetFlow agent host state (when Compose is installed in a checkout)
/data/netflow-wireguard/
/data/netflow-agent-socket/
+4
View File
@@ -568,3 +568,7 @@ docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
10. Проверить `curl` на `:8000/health` и `:3000/`.
11. В UI: режим live и URL бэкенда; сверить с `CORS_ORIGIN`.
12. Просмотреть `docker logs mmapp-updater`; настроить регулярный backup тома `backend-data`.
### Автоматизация NetFlow через Docker-агент
[Установка, схема взаимодействия и production-состав](docs/netflow-agent.md).
+2
View File
@@ -17,3 +17,5 @@ AUTH_REQUIRED=false
AUTH_JWT_SECRET=dev-secret-change-me
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=http://localhost:5175
# Optional: enabled by the NetFlow agent Compose installer. Linux Unix socket only.
# FLOW_AGENT_SOCKET=/run/mm-netflow-host/agent.sock
+1
View File
@@ -57,6 +57,7 @@ COPY --from=build /app/packages/contracts ./packages/contracts
COPY --from=build /app/backend/dist ./backend/dist
COPY --from=build /app/backend/package.json ./backend/package.json
COPY --from=build /app/backend/drizzle ./backend/drizzle
COPY deploy/netflow-agent/install.sh ./deploy/netflow-agent/install.sh
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
COPY --from=build /app/backend/node_modules ./backend/node_modules
EXPOSE 8000
+1 -1
View File
@@ -33,7 +33,7 @@
"test": "node scripts/test.mjs",
"test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts",
"test:netflow-purge": "tsx src/db/operation-lock.test.ts && tsx src/services/traffic-flow-purge-execution.test.ts && tsx src/routes/traffic-flow-purge.test.ts",
"test:netflow-settings": "tsx src/services/traffic-flow-host-file-builders.test.ts && tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts",
"test:netflow-settings": "tsx src/services/traffic-flow-host-file-builders.test.ts && tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts && tsx src/services/traffic-flow-agent-install.test.ts && tsx src/services/traffic-flow-agent-client.test.ts && tsx src/services/traffic-flow-operation-queue.test.ts && tsx src/routes/traffic-flow-host.test.ts",
"test:geoip": "tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/plugins/auth.geoip.test.ts",
"test:ospf-optimizer": "tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/services/ospf-effective-disabled.test.ts && tsx src/routes/ospf-optimizer.test.ts",
"test:containers": "tsx src/services/containers-live.test.ts && tsx src/services/containers-state.test.ts && tsx src/services/container-removal.test.ts && tsx src/routes/containers-state.test.ts",
+6
View File
@@ -39,6 +39,8 @@ import usersRoutes from "./routes/users.js"
import statisticsRoutes from "./routes/statistics.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
import { getFlowWorkerHealth, startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
import { agentSocket } from "./services/traffic-flow-agent-client.js"
import { reconcileFlowHost, withFlowConfigurationLock } from "./services/traffic-flow-host-agent.js"
import { initGeoip } from "./services/traffic-flow-geoip.js"
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 })
@@ -147,6 +149,10 @@ export async function buildApp(opts?: {
await refreshScheduler()
await initGeoip()
await startTrafficFlowListener()
if (agentSocket()) {
try { await withFlowConfigurationLock(reconcileFlowHost) }
catch { app.log.warn("NetFlow: агент хоста не синхронизирован; проверьте состояние в настройках NetFlow") }
}
app.addHook("onClose", async () => {
stopScheduler()
stopTrafficFlowListener()
+2 -4
View File
@@ -12,7 +12,7 @@ import {
type TestConnectionRequest,
} from "../types/server.js"
import { pollServer } from "../services/poller.js"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import { MikrotikClient, MikrotikError, routerOsAccessHint } from "../services/mikrotik.js"
import { resolveRosSrcIpv4 } from "../utils/ros-src-address.js"
import {
createServer,
@@ -56,9 +56,7 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
let message = "Не удалось подключиться"
if (err instanceof MikrotikError) {
if (err.statusCode === 401) message = "Неверный логин или пароль (401 Unauthorized)"
else if (err.statusCode === 403) message = "Нет прав доступа к REST API (403 Forbidden)"
else message = `Ошибка RouterOS API: ${err.statusCode}`
message = routerOsAccessHint(err.statusCode, err.body) ?? `Ошибка RouterOS API: ${err.statusCode}`
} else if (err instanceof Error) {
const msg = err.message.toLowerCase()
if (msg.includes("timeout") || msg.includes("abort"))
@@ -0,0 +1,46 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import { SignJWT } from "jose"
import { createTrafficFlowHostRoutes } from "./traffic-flow-host.js"
import { emptyHostStatus } from "../services/traffic-flow-agent-client.js"
process.env.AUTH_REQUIRED = "true"
process.env.AUTH_JWT_SECRET = "flow-host-test-secret"
process.env.AUTH_ISSUER = "https://auth.test.local"
process.env.NODE_ENV = "test"
const { default: authPlugin, requireAuth } = await import("../plugins/auth.js")
const app = Fastify()
await app.register(authPlugin)
app.addHook("preHandler", requireAuth)
let applied = 0, fail = false
let status = emptyHostStatus(false)
await app.register(createTrafficFlowHostRoutes({
status: async () => status,
reconcile: async () => { applied++; if (fail) throw Error("Туннель не изменён: конфликт адресов"); return status },
}), { prefix: "/api" })
async function token(permission: string) {
return new SignJWT({ sub: "fixture", apps: ["mm"], permissions: [permission] }).setProtectedHeader({ alg: "HS256" })
.setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("flow-host-test-secret"))
}
for (const permission of ["mm:dashboard:read", "mm:traffic:read", "mm:traffic:write", "mm:traffic:admin"]) {
const headers = { authorization: "Bearer " + await token(permission) }
assert.equal((await app.inject({ method: "GET", url: "/api/traffic/flow/host-status", headers })).statusCode, permission === "mm:dashboard:read" ? 403 : 200)
const before = applied
const response = await app.inject({ method: "POST", url: "/api/traffic/flow/host-reconcile", headers })
const allowed = permission.endsWith(":write") || permission.endsWith(":admin")
assert.equal(response.statusCode, allowed ? 409 : 403)
assert.equal(applied, before + Number(allowed))
}
assert.equal((await app.inject({ method: "GET", url: "/api/traffic/flow/host-status" })).statusCode, 401)
const headers = { authorization: "Bearer " + await token("mm:traffic:write") }
status = { ...emptyHostStatus(true), reachable: true, interfaceUp: true, configMatches: true }
let response = await app.inject({ method: "POST", url: "/api/traffic/flow/host-reconcile", headers })
assert.equal(response.statusCode, 200); assert.equal(response.json().hostStatus.configMatches, true)
fail = true
response = await app.inject({ method: "POST", url: "/api/traffic/flow/host-reconcile", headers })
assert.equal(response.statusCode, 502); assert.match(response.json().error, /конфликт адресов/)
status = emptyHostStatus(true, "offline")
response = await app.inject({ method: "GET", url: "/api/traffic/flow/host-status", headers })
assert.equal(response.json().configMatches, null); assert.equal(response.json().reachable, false)
await app.close()
console.log("NetFlow host HTTP/JWT: read/write permissions, manual mode, reconcile success/conflict and offline status")
+20
View File
@@ -0,0 +1,20 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import type { TrafficFlowHostStatus } from "@mmapp/contracts/traffic-flow"
export function createTrafficFlowHostRoutes(deps: {
status: () => Promise<TrafficFlowHostStatus>
reconcile: () => Promise<TrafficFlowHostStatus>
}): FastifyPluginAsyncZod {
return async app => {
app.get("/traffic/flow/host-status", async () => deps.status())
app.post("/traffic/flow/host-reconcile", async (_req, reply) => {
try {
const hostStatus = await deps.reconcile()
if (!hostStatus.configured) return reply.status(409).send({ error: "Автоматизация хоста NetFlow не настроена" })
return { ok: true, hostStatus }
} catch (error) {
return reply.status(502).send({ error: error instanceof Error ? error.message : "Настройки хоста не применены" })
}
})
}
}
+21 -3
View File
@@ -8,6 +8,9 @@ import {
} from "@mmapp/contracts/traffic-flow"
import { createTrafficFlowPurgeRoutes } from "./traffic-flow-purge.js"
import { createTrafficFlowSettingsRoutes } from "./traffic-flow-settings.js"
import { createTrafficFlowHostRoutes } from "./traffic-flow-host.js"
import { getFlowHostStatus, reconcileFlowHost, withFlowConfigurationLock } from "../services/traffic-flow-host-agent.js"
import { agentSocket } from "../services/traffic-flow-agent-client.js"
import {
ensureHostKeys,
getTrafficFlowSettingsRow,
@@ -16,6 +19,7 @@ import {
} from "../services/traffic-flow-settings.js"
import {
getFlowListenerState,
getFlowRuntimeCounters,
purgeTrafficFlowStore,
startTrafficFlowListener,
listFlowTalkers,
@@ -150,11 +154,25 @@ function sleep(ms: number, signal: AbortSignal): Promise<void> {
}
const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
await app.register(createTrafficFlowHostRoutes({
status: getFlowHostStatus,
reconcile: () => withFlowConfigurationLock(reconcileFlowHost),
}))
await app.register(createTrafficFlowSettingsRoutes({
read: () => toTrafficFlowSettingsDto(getFlowListenerState()),
update: async patch => { await updateTrafficFlowSettings(patch) },
read: async () => {
const settings = await toTrafficFlowSettingsDto(getFlowListenerState())
const { packetsReceived, lastExporterIp, lastError, lastDatagramAt } = await getFlowRuntimeCounters()
return { ...settings, packetsReceived, lastExporterIp, lastError, lastDatagramAt }
},
update: async patch => { await withFlowConfigurationLock(async () => {
const current = await getTrafficFlowSettingsRow()
if (agentSocket() && (["collectorIp", "prefix", "flowListenPort", "wgListenPort"] as const).some(key => patch[key] !== undefined && patch[key] !== current[key])) {
throw Object.assign(new Error("Адрес, префикс и порты закреплены установкой агента; повторите установщик хоста"), { statusCode: 409 })
}
await updateTrafficFlowSettings(patch)
}) },
startListener: startTrafficFlowListener,
ensureKeys: ensureHostKeys,
ensureKeys: () => withFlowConfigurationLock(ensureHostKeys),
hasPrivateKey: async () => Boolean((await getTrafficFlowSettingsRow()).hostPrivateKey),
hostFiles: listTrafficFlowHostFiles,
}))
+29 -1
View File
@@ -1,7 +1,35 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import { createServer } from "node:http"
import { MikrotikClient } from "./mikrotik.js"
import { MikrotikClient, MikrotikError, routerOsAccessHint } from "./mikrotik.js"
test("RouterOS access diagnostics distinguish login saturation without assuming a wrong password or retrying", async () => {
let requests = 0
const body = '{"detail":"too many concurrent logins (6)","error":500,"message":"Internal Server Error"}'
const server = createServer((req, res) => { requests++; req.resume(); res.writeHead(500); res.end(body) })
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
const address = server.address(); assert(address && typeof address !== "string")
const client = new MikrotikClient({ host: "127.0.0.1", port: address.port, useSsl: false, verifySsl: false, username: "fixture", password: "fixture" })
try {
for (const operation of [() => client.get("/interface"), () => client.put("/own", {})]) {
await assert.rejects(operation, (error: unknown) => {
assert(error instanceof MikrotikError)
assert.equal(error.statusCode, 500); assert.equal(error.body, body)
assert.match(error.message, /превышен лимит одновременных входов/)
return true
})
}
assert.equal(requests, 2, "Neither reads nor writes may silently retry a refused login")
assert.match(routerOsAccessHint(401, '{"message":"Unauthorized"}')!, /ограничения входа/)
assert.doesNotMatch(routerOsAccessHint(401, "")!, /Неверный логин или пароль/)
for (const unknown of ["not JSON", "null", '{"detail":6}', '{"detail":"disk failure"}']) {
assert.equal(routerOsAccessHint(500, unknown), undefined)
}
assert.equal(routerOsAccessHint(400, body), undefined)
} finally {
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}
})
test("RouterOS print preserves native query arrays and property selection", async () => {
let received: unknown, target = "", method = ""
+17 -1
View File
@@ -1110,13 +1110,29 @@ export class MikrotikClient {
// ── Error type ─────────────────────────────────────────────────────────────────
/** HTTP 401 alone does not distinguish wrong credentials from a refused login. */
export function routerOsAccessHint(statusCode: number, body: string): string | undefined {
if (statusCode === 401) return "RouterOS отклонил авторизацию: проверьте учётные данные, права API/REST и ограничения входа"
if (statusCode === 403) return "RouterOS запретил доступ к REST API"
if (statusCode === 500) {
try {
const detail: unknown = JSON.parse(body)?.detail
if (typeof detail === "string" && /^too many concurrent logins(?:\s*\(\d+\))?$/i.test(detail.trim())) {
return "RouterOS занят: превышен лимит одновременных входов"
}
} catch { /* Keep an unrecognized response unchanged. */ }
}
return undefined
}
export class MikrotikError extends Error {
constructor(
public readonly statusCode: number,
public readonly path: string,
public readonly body: string,
) {
super(`RouterOS API error ${statusCode} on ${path}: ${body}`)
const hint = routerOsAccessHint(statusCode, body)
super(`${hint ? `${hint}. ` : ""}RouterOS API error ${statusCode} on ${path}: ${body}`)
this.name = "MikrotikError"
}
}
@@ -0,0 +1,42 @@
import assert from "node:assert/strict"
import { createServer } from "node:http"
import { randomUUID } from "node:crypto"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { callFlowAgent, emptyHostStatus, type FlowAgentSnapshot } from "./traffic-flow-agent-client.js"
const socket = process.platform === "win32" ? `\\\\.\\pipe\\flow-agent-${randomUUID()}` : join(tmpdir(), `flow-agent-${randomUUID()}.sock`)
const snapshot: FlowAgentSnapshot = {
collectorIp: "10.255.254.1", prefix: "10.255.254.0/24", flowListenPort: 4739, wgListenPort: 51821,
hostPublicKey: "public-fixture", hostPrivateKey: "private-fixture", peers: [],
}
let mode = "ok"
const server = createServer((req, res) => {
let body = ""
req.on("data", chunk => { body += chunk })
req.on("end", () => {
assert.equal(req.url, "/check"); assert.equal(req.method, "POST")
assert.deepEqual(JSON.parse(body), snapshot)
if (mode === "conflict") { res.writeHead(409); res.end(JSON.stringify({ error: "Конфликт адресов" })); return }
if (mode === "invalid") { res.end("not-json"); return }
if (mode === "oversize") { res.end("x".repeat(300_000)); return }
res.end(JSON.stringify({
interfaceUp: true, configMatches: true, publicKey: "public-fixture", listenPort: 51821,
addresses: ["10.255.254.1/24"], peers: [{ publicKey: "router-fixture", allowedIps: ["10.255.254.2/32"], lastHandshakeAt: null, receivedBytes: 0, sentBytes: 0 }],
privateKey: "must-not-be-exposed",
}))
})
})
await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(socket, resolve) })
try {
const status = await callFlowAgent("/check", snapshot, socket)
assert.equal(status.reachable, true); assert.equal(status.configMatches, true)
assert.equal(JSON.stringify(status).includes("must-not-be-exposed"), false)
assert.equal(status.peers[0].lastHandshakeAt, null)
mode = "conflict"; await assert.rejects(callFlowAgent("/check", snapshot, socket), /Конфликт адресов/)
mode = "invalid"; await assert.rejects(callFlowAgent("/check", snapshot, socket), /некорректный ответ/)
mode = "oversize"; await assert.rejects(callFlowAgent("/check", snapshot, socket), /некорректный ответ/)
await assert.rejects(callFlowAgent("/status", undefined, ""), /не настроена/)
assert.equal(emptyHostStatus(true, "offline").configMatches, null)
} finally { await new Promise<void>(resolve => server.close(() => resolve())) }
console.log("NetFlow agent IPC: request, validated status, secret stripping, conflict, invalid/oversize response, manual mode")
@@ -0,0 +1,45 @@
import { request } from "node:http"
import { trafficFlowHostStatusSchema, type TrafficFlowHostStatus } from "@mmapp/contracts/traffic-flow"
export type FlowAgentSnapshot = {
collectorIp: string; prefix: string; flowListenPort: number; wgListenPort: number
hostPublicKey: string; hostPrivateKey: string
peers: { serverId: number; publicKey: string; allowedIps: string[]; endpoint?: string }[]
}
export const agentSocket = () => process.env.FLOW_AGENT_SOCKET?.trim() || ""
export const emptyHostStatus = (configured: boolean, error: string | null = null): TrafficFlowHostStatus => ({
configured, reachable: false, interfaceUp: false, configMatches: null,
publicKey: "", listenPort: 0, addresses: [], peers: [], error,
})
export function callFlowAgent(path: "/status" | "/check" | "/apply", snapshot?: FlowAgentSnapshot, socketPath = agentSocket()): Promise<TrafficFlowHostStatus> {
if (!socketPath) return Promise.reject(new Error("Автоматизация хоста NetFlow не настроена"))
const body = snapshot ? JSON.stringify(snapshot) : undefined
return new Promise((resolve, reject) => {
const fail = () => reject(new Error("Агент NetFlow недоступен или вернул некорректный ответ; проверьте контейнер mmapp-netflow-agent"))
const req = request({ socketPath, path, method: body ? "POST" : "GET", headers: body ? { "content-type": "application/json", "content-length": Buffer.byteLength(body) } : {} }, res => {
let text = ""
res.setEncoding("utf8")
res.on("data", (chunk: string) => { text += chunk; if (text.length > 262144) req.destroy() })
res.on("error", fail)
res.on("end", () => {
try {
const raw: unknown = JSON.parse(text)
if (res.statusCode !== 200) {
// The local controller deliberately emits sanitized errors only.
const error = raw as { error?: unknown }
reject(new Error(typeof error.error === "string" ? error.error.slice(0, 500) : "Агент NetFlow отклонил настройки"))
return
}
resolve(trafficFlowHostStatusSchema.parse({ ...(raw as object), configured: true, reachable: true, error: null }))
} catch { fail() }
})
})
// An absolute deadline also covers a peer that keeps sending small chunks.
const timer = setTimeout(() => req.destroy(), path === "/apply" ? 90_000 : 30_000)
req.on("close", () => clearTimeout(timer))
req.on("error", fail)
req.end(body)
})
}
@@ -0,0 +1,18 @@
import assert from "node:assert/strict"
import { readFile } from "node:fs/promises"
import { buildFlowAgentInstaller } from "./traffic-flow-agent-install.js"
import { buildTrafficFlowHostFiles } from "./traffic-flow-host-file-builders.js"
const files = buildTrafficFlowHostFiles({ collectorIp: "10.255.254.1", prefix: "10.255.254.0/24", flowListenPort: 4740,
wgListenPort: 51821, hostPrivateKey: Buffer.alloc(32, 1).toString("base64"), peers: [] })
const conf = files.find(file => file.id === "wg-quick")!.code
const source = await readFile(new URL("../../../deploy/netflow-agent/install.sh", import.meta.url), "utf8")
const script = buildFlowAgentInstaller(conf, 4740, source)
assert.ok(script.startsWith("#!/usr/bin/env bash\n"))
assert.ok(script.includes(conf.trim() + "\nMM_CONFIG_EOF\n"))
assert.ok(script.includes('"${1:-/opt/cdn-mm}" 4740'))
assert.ok(script.includes("wg-quick@wg-flow.service"))
assert.ok(script.includes("docker-compose.netflow-agent.yml"))
assert.ok(!script.includes("\r"))
assert.throws(() => buildFlowAgentInstaller("MM_CONFIG_EOF\n", 4739, source))
assert.throws(() => buildFlowAgentInstaller(conf, 0, source))
console.log("NetFlow agent installer export: validated snapshot, custom port/path, embedded installer and LF-only output")
@@ -0,0 +1,8 @@
/** Call only with a configuration already validated by buildTrafficFlowHostFiles. */
export function buildFlowAgentInstaller(conf: string, flowPort: number, installer: string): string {
if (!Number.isInteger(flowPort) || flowPort < 1 || flowPort > 65535 || conf.split(/\r?\n/).includes("MM_CONFIG_EOF")) {
throw new Error("Некорректная конфигурация установщика NetFlow")
}
const clean = (text: string) => text.replaceAll("\r\n", "\n").replace(/\s*$/, "") + "\n"
return "#!/usr/bin/env bash\nset -euo pipefail\numask 077\nEXPORT_DIR=$(mktemp -d)\ntrap 'rm -rf -- \"$EXPORT_DIR\"' EXIT\ncat > \"$EXPORT_DIR/wg-flow.conf\" <<'MM_CONFIG_EOF'\n" + clean(conf) + "MM_CONFIG_EOF\ncat > \"$EXPORT_DIR/install.sh\" <<'MM_INSTALL_EOF'\n" + clean(installer) + "MM_INSTALL_EOF\nbash \"$EXPORT_DIR/install.sh\" \"$EXPORT_DIR/wg-flow.conf\" \"${1:-/opt/cdn-mm}\" " + flowPort + "\n"
}
@@ -4,7 +4,7 @@ import {
ingestParsedFlowsForServerForTests,
resetFlowRingsForTests,
} from "./traffic-flow-ingest.js"
import { buildFlowAnalytics, formatLiveSseFromBuilder, getFlowMonthly, listFlowClients, listFlowExporters } from "./traffic-flow-analytics.js"
import { buildFlowAnalytics, formatLiveSseFromBuilder, getFlowMonthly, listFlowClients, listFlowExporters, resetFlowAnalyticsCacheForTests } from "./traffic-flow-analytics.js"
import { withPgOrSkip } from "../test/pg.js"
import { dbQuery } from "../db/index.js"
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
@@ -556,6 +556,22 @@ try {
}
}
// Historical 5m/15m charts survive an empty live ring (e.g. immediately after restart).
resetFlowRingsForTests()
await dbQuery("DELETE FROM flow_buckets WHERE server_id=$1", [serverId])
for (const minutesAgo of [2, 3, 4, 10]) {
await dbQuery(`INSERT INTO flow_buckets (server_id,bucket_at,src,dst,in_iface,bytes,packets)
VALUES ($1,date_trunc('minute',now())-($2::int*interval '1 minute'),'10.1.1.8','8.8.8.8','2',60000,10)`, [serverId, minutesAgo])
}
for (const minutes of [5, 15]) for (const dedup of [true, false]) {
resetFlowAnalyticsCacheForTests()
const history = await buildFlowAnalytics({ minutes, serverId, dedup, excludeMesh:false, excludeOverlay:false })
assert.equal(history.rxSeries.length, minutes)
assert.equal(history.rxSeries.filter(v => v > 0).length, minutes === 5 ? 3 : 4)
const missing = await buildFlowAnalytics({ minutes, serverId, iface:"not-an-interface" })
assert(missing.rxSeries.every(v => v === 0), "Historical chart respects interface filter")
}
} finally {
for (const table of ["flow_daily_dims", "flow_hour_facts", "flow_daily_facts", "flow_buckets", "traffic_samples", "user_interface_bindings", "server_snapshots"]) {
await dbQuery("DELETE FROM " + table + " WHERE server_id = $1", [serverId])
@@ -423,9 +423,10 @@ async function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): Promise<FlowAn
? getRingMbps(ringServer, ifaceFilter === "" ? "__all__" : (ifacesMap.get(ifaceFilter)?.index || ifaceFilter))
: { rx: Array(60).fill(0) as number[], tx: Array(60).fill(0) as number[], rxNow: 0, txNow: 0 }
// The live ring covers about one minute; historical charts must use the requested window.
const fromBuckets = seriesFromRows(matched, q.minutes)
const rxSeries = q.minutes <= 15 ? ring.rx : fromBuckets.rx
const txSeries = q.minutes <= 15 ? ring.tx : fromBuckets.tx
const rxSeries = fromBuckets.rx
const txSeries = fromBuckets.tx
const ifaceRows = [...ifacesMap.entries()]
.sort((a, b) => b[1].bytes - a[1].bytes)
@@ -0,0 +1,46 @@
import { withAdvisoryLock } from "../db/index.js"
import { parseJsonArray } from "../db/json.js"
import { getTrafficFlowSettingsRow, invalidateTrafficFlowSettingsCache } from "./traffic-flow-settings.js"
import { agentSocket, callFlowAgent, emptyHostStatus, type FlowAgentSnapshot } from "./traffic-flow-agent-client.js"
import { FlowConfigurationQueue } from "./traffic-flow-operation-queue.js"
// Session lock: RouterOS/network operations use other pooled connections. Never lock the settings row here.
const queue = new FlowConfigurationQueue()
export const withFlowConfigurationLock = <T>(fn: () => Promise<T>) => queue.run(() => withAdvisoryLock(7340291, async () => {
invalidateTrafficFlowSettingsCache()
return fn()
}))
async function snapshot(): Promise<FlowAgentSnapshot> {
const row = await getTrafficFlowSettingsRow()
return {
collectorIp: row.collectorIp, prefix: row.prefix, flowListenPort: row.flowListenPort, wgListenPort: row.wgListenPort,
hostPublicKey: row.hostPublicKey, hostPrivateKey: row.hostPrivateKey,
peers: (parseJsonArray(row.peersJson) as FlowAgentSnapshot["peers"]).map(p => ({
serverId: p.serverId, publicKey: p.publicKey, allowedIps: p.allowedIps, ...(p.endpoint ? { endpoint: p.endpoint } : {}),
})),
}
}
export async function getFlowHostStatus() {
if (!agentSocket()) return emptyHostStatus(false)
try { return await callFlowAgent("/check", await snapshot()) }
catch (error) {
const message = error instanceof Error ? error.message : "Проверка агента не выполнена"
// Preserve live measurements on a settings conflict; never claim configuration agreement.
try { return { ...await callFlowAgent("/status"), configMatches: false, error: message } }
catch { return emptyHostStatus(true, message) }
}
}
export async function preflightFlowHost() {
if (!agentSocket()) return
try { await callFlowAgent("/check", await snapshot()) }
catch (error) { throw Object.assign(new Error(error instanceof Error ? error.message : "Агент NetFlow не готов"), { statusCode: 409 }) }
}
// Caller holds withFlowConfigurationLock. This is also used after saving a newly discovered router key.
export async function reconcileFlowHost() {
if (!agentSocket()) return emptyHostStatus(false)
return callFlowAgent("/apply", await snapshot())
}
@@ -1,8 +1,15 @@
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
import { parseJsonArray } from "../db/json.js"
import { buildTrafficFlowHostFiles } from "./traffic-flow-host-file-builders.js"
import { readFile } from "node:fs/promises"
import { buildFlowAgentInstaller } from "./traffic-flow-agent-install.js"
export async function listTrafficFlowHostFiles() {
const row = await getTrafficFlowSettingsRow()
return buildTrafficFlowHostFiles({ ...row, peers: parseJsonArray(row.peersJson) })
const files = buildTrafficFlowHostFiles({ ...row, peers: parseJsonArray(row.peersJson) })
// The same source is used by a repository install and by the downloadable self-contained installer.
const installer = await readFile(new URL("../../../deploy/netflow-agent/install.sh", import.meta.url), "utf8")
const conf = files.find(file => file.id === "wg-quick")!.code
const code = buildFlowAgentInstaller(conf, row.flowListenPort, installer)
return [{ id: "agent", label: "Агент Docker", filename: "install-netflow-agent.sh", code }, ...files]
}
+1 -1
View File
@@ -439,7 +439,7 @@ export async function listStoredFlowRows(sinceIso: string): Promise<PendingFlowR
export async function listFlowRowsForWindow(minutes: number): Promise<PendingFlowRow[]> {
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
if (minutes <= 15 && !worker) return await listLiveFlowRows(sinceIso)
// History remains available while the collector starts or is stopped; pending rows are merged below.
return await listStoredFlowRows(sinceIso)
}
@@ -0,0 +1,21 @@
import assert from "node:assert/strict"
import { FlowConfigurationQueue } from "./traffic-flow-operation-queue.js"
const queue = new FlowConfigurationQueue()
let release!: () => void, active = 0, maximum = 0
const hold = new Promise<void>(resolve => { release = resolve })
const events: number[] = []
const tasks = Array.from({ length: 32 }, (_, i) => queue.run(async () => {
active++; maximum = Math.max(active, maximum); events.push(i)
try { if (i === 0) await hold; if (i === 1) throw Error("fixture failure"); return i }
finally { active-- }
}))
const done = Promise.allSettled(tasks)
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(events, [0]); assert.equal(active, 1)
release()
const results = await done
assert.equal(maximum, 1); assert.equal(results[1].status, "rejected")
assert.equal(results.filter(result => result.status === "fulfilled").length, 31)
assert.deepEqual(events, Array.from({ length: 32 }, (_, i) => i))
assert.equal(await queue.run(async () => "recovered"), "recovered")
console.log("NetFlow queue: 32 concurrent operations use one lock waiter, failures release the queue")
@@ -0,0 +1,12 @@
/** Only one local operation may wait for the PostgreSQL advisory lock.
* Otherwise queued requests could exhaust the pool needed by its current owner.
*/
export class FlowConfigurationQueue {
private tail: Promise<void> = Promise.resolve()
run<T>(operation: () => Promise<T>): Promise<T> {
const result = this.tail.then(operation)
this.tail = result.then(() => undefined, () => undefined)
return result
}
}
+29 -3
View File
@@ -22,6 +22,8 @@ import {
import { refreshFlowExporterMap, startTrafficFlowListener } from "./traffic-flow-ingest.js"
import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js"
import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js"
import { agentSocket } from "./traffic-flow-agent-client.js"
import { getFlowHostStatus, preflightFlowHost, reconcileFlowHost, withFlowConfigurationLock } from "./traffic-flow-host-agent.js"
const IFACE_NAME = "wg-flow"
const JH_LISTEN_PORT = 13232
@@ -202,8 +204,16 @@ export function usablePublicHost(raw: string | undefined): string {
export async function applyFlowOverlay(
serverIdRaw: string | number,
opts?: { publicEndpoint?: string; requestHost?: string; disableGreFastPath?: boolean },
): Promise<TrafficFlowOverlayResult> {
return withFlowConfigurationLock(() => applyFlowOverlayLocked(serverIdRaw, opts))
}
async function applyFlowOverlayLocked(
serverIdRaw: string | number,
opts?: { publicEndpoint?: string; requestHost?: string; disableGreFastPath?: boolean },
): Promise<TrafficFlowOverlayResult> {
const steps: string[] = []
await preflightFlowHost()
const keys = await ensureHostKeys()
const settings = await getTrafficFlowSettingsRow()
const hostPublicKey = settings.hostPublicKey || keys.publicKey
@@ -338,8 +348,21 @@ export async function applyFlowOverlay(
await refreshFlowExporterMap()
steps.push("Коллектор IPFIX на MM включён")
let hostStatus = undefined
let hostError: string | undefined
if (agentSocket()) {
try {
hostStatus = await reconcileFlowHost()
steps.push("Пир применён на Linux-хосте без перезапуска WireGuard")
} catch (error) {
hostError = error instanceof Error ? error.message : "Настройки хоста не применены"
hostStatus = await getFlowHostStatus()
steps.push("MikroTik настроен; требуется повторное применение настроек хоста")
}
}
return {
ok: true,
ok: !hostError,
serverId: server.id,
interfaceName: IFACE_NAME,
address,
@@ -347,11 +370,14 @@ export async function applyFlowOverlay(
linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host, endpointHost),
trafficFlow: true,
steps,
hostFiles: await listTrafficFlowHostFiles(),
hostFiles: agentSocket() ? [] : await listTrafficFlowHostFiles(),
stage: hostError ? "host-pending" : agentSocket() ? "applied" : "manual",
...(hostStatus ? { hostStatus } : {}),
...(hostError ? { hostError } : {}),
}
} catch (e) {
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
const err = Object.assign(new Error(`RouterOS: ${msg}`), { statusCode: 502 })
const err = Object.assign(new Error(`Настройка MikroTik могла быть выполнена частично: ${msg}. Проверьте состояние и повторите подключение.`), { statusCode: 502 })
throw err
}
}
+26 -1
View File
@@ -132,7 +132,7 @@ const observed=(rows:ReturnType<typeof observationSample>[])=>buildTrafficFromSa
assert.ok(observed([]).observations?.rxSeries.every(v=>!v));assert.equal(observed([]).observations?.rxNow,false)
assert.equal(observed([observationSample(0,10,10)]).observations?.rxNow,false)
const observedZero=observed([observationSample(0,10,10),observationSample(30_000,10,10)])
assert.equal(observedZero.rxNow,0);assert.equal(observedZero.observations?.rxNow,true);assert.equal(observedZero.observations?.rxSeries.filter(Boolean).length,1)
assert.equal(observedZero.rxNow,0);assert.equal(observedZero.observations?.rxNow,true);assert.equal(observedZero.observations?.rxSeries.filter(Boolean).length,30)
const partialReset=observed([observationSample(0,10,10),observationSample(30_000,1,20)])
assert.equal(partialReset.observations?.rxNow,false);assert.equal(partialReset.observations?.txNow,true)
const fullReset=observed([observationSample(0,10,10),observationSample(20_000,20,20),observationSample(40_000,1,1)])
@@ -147,3 +147,28 @@ console.log("traffic observation metadata tests ok")
const complete=observed([observationSample(0,10,10),observationSample(30_000,20,20),observationSample(0,10,10,"ether2"),observationSample(30_000,20,20,"ether2")]);assert.equal(complete.observations?.rxNow,true)
const badTime=observed([{...observationSample(0,10,10),sampledAt:"invalid"},observationSample(30_000,20,20)]);assert.equal(badTime.observations?.rxNow,false)
assert.equal(rateBpsFromDelta(0,Number.MAX_VALUE,0,1000),null)
// A 30-second collector must produce a line across measured intervals on short ranges.
for (const minutes of [5, 15]) {
const span = minutes * 60_000
const rows = Array.from({ length: span / 30_000 + 1 }, (_, i) => ({
...observationSample(i * 30_000, i * 3_750_000, i * 1_875_000),
continuityKnown: true, nativeCounterIdentity: "ether1-v1",
}))
const history = buildTrafficFromSamples(rows, observationStart, observationStart + span, undefined, undefined, true)
assert(history.observations!.rxSeries.every(Boolean), `${minutes}m continuous measured RX`)
assert(history.observations!.txSeries.every(Boolean), `${minutes}m continuous measured TX`)
assert(history.rxSeries.every(v => v === 1)); assert(history.txSeries.every(v => v === 0.5))
assert.equal(history.rxTotalGiB, (span / 30_000) * 3_750_000 / 1024 ** 3)
// Explicit loss of continuity stays unknown; it must not be bridged by chart rendering.
rows[2].continuityKnown = false
const gap = buildTrafficFromSamples(rows, observationStart, observationStart + span, undefined, undefined, true)
assert.equal(gap.observations!.rxSeries.filter(v => !v).length, 30_000 / (span / 60))
assert(gap.observations!.rxSeries.slice(0, 30_000 / (span / 60)).every(Boolean))
const partial = buildTrafficFromSamples(rows.slice(4), observationStart, observationStart + span, undefined, undefined, true)
assert(partial.observations!.rxSeries.slice(0, 120_000 / (span / 60)).every(v => !v), "No history invented before first sample")
}
// Unequal poll intervals contribute by covered duration when sharing a chart bucket.
const weighted = observed([observationSample(0,0,0),observationSample(500,62_500,62_500),observationSample(1500,312_500,312_500)])
assert.equal(weighted.rxSeries[0], 1.5)
assert.equal(weighted.rxSeries[1], 2)
+34 -6
View File
@@ -114,6 +114,34 @@ function parseIsoMs(iso: string): number {
return Date.parse(iso)
}
/** A counter delta measures its entire interval, not just the next poll's timestamp. */
function intervalSeries(
waves: Map<number, PeakWave>, start: number, end: number, side: "rx" | "tx",
): { values: number[]; observed: boolean[] } {
const sums = Array(SERIES_POINTS).fill(0) as number[]
const durations = Array(SERIES_POINTS).fill(0) as number[]
const unknown = Array(SERIES_POINTS).fill(false) as boolean[]
const width = (end - start) / SERIES_POINTS
if (!(width > 0)) return { values: sums, observed: unknown }
for (const [at, wave] of waves) {
const from = Math.max(start, wave.start), to = Math.min(end, at)
if (!(to > from)) continue
const first = Math.max(0, Math.floor((from - start) / width))
const last = Math.min(SERIES_POINTS - 1, Math.ceil((to - start) / width) - 1)
for (let i = first; i <= last; i++) {
const overlap = Math.min(to, start + (i + 1) * width) - Math.max(from, start + i * width)
if (!(overlap > 0)) continue
sums[i] += wave[side] * overlap
durations[i] += overlap
unknown[i] ||= !wave[side === "rx" ? "rxKnown" : "txKnown"]
}
}
return {
values: sums.map((sum, i) => durations[i] > 0 ? sum / durations[i] : 0),
observed: durations.map((duration, i) => duration > 0 && !unknown[i]),
}
}
export function sampleSeriesKey(interfaceName: string, peerPublicKey = ""): string {
return `${interfaceName}\0${peerPublicKey}`
}
@@ -232,8 +260,6 @@ export function buildTrafficFromSamples(
const latestWave = membership.get(latestMembershipAt)
if (latestWave?.declared) sessions = latestWave.active
const rxSeries = bucketAvg(rxPoints, rangeStartMs, rangeEndMs)
const txSeries = bucketAvg(txPoints, rangeStartMs, rangeEndMs)
const lastTs = [...byTs.keys()].sort((a, b) => a - b).at(-1)
const last = lastTs != null ? byTs.get(lastTs) : undefined
const peak = (points: Array<{t:number;v:number}>, side: "rxObserved"|"txObserved") => points.reduce((m,p) => {
@@ -249,6 +275,8 @@ export function buildTrafficFromSamples(
const complete = wave.sameStart && wave.count === expectedAt(t)
waves.set(t,{start:wave.start,rx:wave.rx,tx:wave.tx,rxKnown:complete&&wave.rxObserved,txKnown:complete&&wave.txObserved})
}
const rxHistory = intervalSeries(waves, rangeStartMs, rangeEndMs, "rx")
const txHistory = intervalSeries(waves, rangeStartMs, rangeEndMs, "tx")
return retainPeakWaves({
rxNow: last?.rx ?? 0,
txNow: last?.tx ?? 0,
@@ -257,13 +285,13 @@ export function buildTrafficFromSamples(
rxTotalGiB: rxBytesDelta / (1024 ** 3),
txTotalGiB: txBytesDelta / (1024 ** 3),
sessions,
rxSeries,
txSeries,
rxSeries: rxHistory.values,
txSeries: txHistory.values,
...(includeObservations?{observations:{
rxNow:lastAttemptAt!==null&&lastTs===lastAttemptAt&&!!last?.rxObserved&&last.count===expectedAt(lastTs!),
txNow:lastAttemptAt!==null&&lastTs===lastAttemptAt&&!!last?.txObserved&&last.count===expectedAt(lastTs!),
rxSeries:bucketAvg([...byTs].map(([t,v])=>({t,v:Number(v.rxObserved&&v.count===expectedAt(t)&&t>=rangeStartMs&&t<=rangeEndMs)})),rangeStartMs,rangeEndMs).map(v=>v===1),
txSeries:bucketAvg([...byTs].map(([t,v])=>({t,v:Number(v.txObserved&&v.count===expectedAt(t)&&t>=rangeStartMs&&t<=rangeEndMs)})),rangeStartMs,rangeEndMs).map(v=>v===1),
rxSeries:rxHistory.observed,
txSeries:txHistory.observed,
sampledAt:lastAttemptAt===null?null:new Date(lastAttemptAt).toISOString(),
measurementIntervalMs:lastTs===lastAttemptAt?last?.intervalMs??null:null,
rxPeak:[...waves.values()].some(w=>w.rxKnown),
+63
View File
@@ -0,0 +1,63 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { can } from "@/lib/auth"
import { Button } from "@/components/ui/button"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { AlertCircleIcon } from "lucide-react"
import { getTrafficFlowHostStatus, reconcileTrafficFlowHost } from "@/shared/api/traffic-flow"
import type { TrafficFlowHostStatus } from "@mmapp/contracts/traffic-flow"
export function FlowHostPanel({ backendUrl, enabled = true, onConfigured }: {
backendUrl: string; enabled?: boolean; onConfigured?: (configured: boolean) => void
}) {
const [status, setStatus] = useState<TrafficFlowHostStatus | null>(null)
const [receivedAt, setReceivedAt] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
const [operationError, setOperationError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const operation = useRef<AbortController | null>(null)
const [revision, setRevision] = useState(0)
const accept = useCallback((value: TrafficFlowHostStatus) => {
setStatus(value); setReceivedAt(Date.now()); setError(null); onConfigured?.(value.configured)
}, [onConfigured])
useEffect(() => () => { operation.current?.abort() }, [])
useEffect(() => {
if (!enabled || !can("mm:traffic:read") || busy) return
const controller = new AbortController()
let timer: ReturnType<typeof setTimeout> | undefined
async function refresh() {
try { const value = await getTrafficFlowHostStatus(backendUrl, { signal: controller.signal }); if (!controller.signal.aborted) accept(value) }
catch (reason) { if (!controller.signal.aborted) setError(reason instanceof Error ? reason.message : "Статус хоста не получен") }
finally { if (!controller.signal.aborted) timer = setTimeout(() => { void refresh() }, 10_000) }
}
void refresh()
return () => { controller.abort(); if (timer) clearTimeout(timer) }
}, [accept, backendUrl, enabled, busy, revision])
async function apply() {
if (operation.current) return
const controller = new AbortController(); operation.current = controller
setBusy(true); setOperationError(null)
try { const value = await reconcileTrafficFlowHost(backendUrl, { signal: controller.signal }); if (!controller.signal.aborted) accept(value) }
catch (reason) { if (!controller.signal.aborted) setOperationError(reason instanceof Error ? reason.message : "Применение не подтверждено; обновите состояние хоста") }
finally { operation.current = null; if (!controller.signal.aborted) setBusy(false) }
}
if (!enabled || !can("mm:traffic:read")) return null
return <div className="flex flex-col gap-3 rounded-md border px-4 py-3" aria-label="Автоматизация WireGuard на хосте">
<p className="text-sm font-medium">WireGuard на сервере MM</p>
<p role="status" className="text-sm">{!status ? "Получение состояния агента…" : !status.configured ? "Ручной режим. Установите агент из раздела «Файлы хоста»." : !status.reachable ? "Агент недоступен" : status.configMatches ? "Настройки применены" : "Настройки требуют проверки или применения"}</p>
{error && receivedAt ? <p className="text-xs text-muted-foreground">Показано последнее состояние: {new Date(receivedAt).toLocaleString("ru-RU")}</p> : null}
{status?.configured && status.reachable ? <>
<p className="text-xs text-muted-foreground">Интерфейс: {status.interfaceUp ? status.addresses.join(", ") : "не поднят"} · UDP {status.listenPort}</p>
<ul className="flex flex-col gap-1 text-xs text-muted-foreground" aria-label="Handshake пиров">
{status.peers.map(peer => <li key={peer.publicKey}>{peer.allowedIps.join(", ") || "Без адреса"} · {peer.lastHandshakeAt ? "Handshake: " + new Date(peer.lastHandshakeAt * 1000).toLocaleString("ru-RU") + ((receivedAt ?? 0) / 1000 - peer.lastHandshakeAt > 180 ? " (давно)" : "") : "Ожидание handshake"}</li>)}
</ul>
</> : null}
{operationError || error || status?.error ? <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Состояние хоста требует внимания</AlertTitle><AlertDescription>{operationError || error || status?.error}</AlertDescription></Alert> : null}
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => setRevision(value => value + 1)}>Обновить состояние хоста</Button>
{status?.configured && can("mm:traffic:write") ? <Button type="button" size="sm" disabled={busy || !status.reachable} onClick={() => { void apply() }}>{busy ? "Применение…" : "Применить настройки хоста"}</Button> : null}
</div>
</div>
}
+8 -5
View File
@@ -22,6 +22,7 @@ import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { FlowHostPanel } from "./flow-host-panel"
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
import type { ServerRead } from "@mmapp/contracts/servers"
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
@@ -95,7 +96,8 @@ function FlowOverlaySheet({
})
setResult(res)
setTab(res.hostFiles[0]?.id ?? "linux")
toast.success(`wg-flow на ${res.address}`)
if (res.ok) toast.success(`wg-flow на ${res.address}`)
else toast.warning(res.hostError || "MikroTik настроен; требуется применение на хосте")
onDone?.(res)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось подключить JH")
@@ -125,7 +127,7 @@ function FlowOverlaySheet({
Подключить jump-host
</SheetTitle>
<SheetDescription>
Создаст wg-flow на MikroTik (сервер, listen 13232) и выдаст готовый bash для Linux-хоста Docker MM (клиент).
Настроит wg-flow и IPFIX на MikroTik. Если агент Docker установлен, автоматически применит пир на Linux-хосте MM.
</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
@@ -170,13 +172,14 @@ function FlowOverlaySheet({
</FormField>
{result ? (
<div className="flex min-h-0 flex-col gap-4">
<Alert variant="success">
<Alert variant={result.stage === "host-pending" ? "warning" : "success"}>
<InfoIcon />
<AlertTitle>Linux-хост /opt/cdn-mm</AlertTitle>
<AlertTitle>{result.stage === "applied" ? "Настройки применены" : result.stage === "host-pending" ? "MikroTik настроен, хост ожидает применения" : "Установка агента на Linux-хосте"}</AlertTitle>
<AlertDescription>
Скопируйте вкладку Linux и выполните от root. WG — клиент к JH:13232; контейнер слушает только WG-IP:4739, не 0.0.0.0. Ключ не кладите в git.
{result.hostError || (result.stage === "applied" ? "Пир добавлен без перезапуска WireGuard. Ожидайте handshake и поступления IPFIX в панели NetFlow." : "Выполните установщик из вкладки «Агент Docker» от root на сервере MM. Он однократно пересоздаст backend. Экспорт содержит приватный ключ — храните его как секрет.")}
</AlertDescription>
</Alert>
<FlowHostPanel backendUrl={backendUrl} />
<ul className="flex flex-col gap-1.5">
{result.steps.map((s) => (
<li key={s} className="flex items-start gap-2 text-sm">
+13 -10
View File
@@ -13,6 +13,7 @@ import { Input } from "@/components/ui/input"
import { CodeExportSheet, type CodeExportFormat } from "@/components/reui-kit/code-export-sheet"
import type { TrafficFlowSettingsDto } from "@mmapp/contracts/traffic-flow"
import { GeoipSettingsSection } from "./geoip-settings-section"
import { FlowHostPanel } from "./flow-host-panel"
import {
generateTrafficFlowKeys,
getTrafficFlowHostFiles,
@@ -24,11 +25,11 @@ import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic
import { KeyRoundIcon, DownloadIcon, InfoIcon, AlertCircleIcon } from "lucide-react"
const HOST_STEPS = [
"На хосте Docker (не в контейнере mmapp-backend): apt install wireguard (или эквивалент).",
"Скачайте wg-flow.conf и положите в /etc/wireguard/wg-flow.conf.",
"wg-quick up wg-flow (или systemctl enable --now wg-quick@wg-flow).",
"Создайте ключи хоста, откройте «Файлы хоста» и скачайте вкладку «Агент Docker».",
"Выполните sudo bash install-netflow-agent.sh /opt/cdn-mm на хосте Docker. При установке backend будет пересоздан.",
"Обновите статус хоста. Дальнейшие подключения автоматически применяются агентом.",
"Firewall: разрешите UDP listen WireGuard. UDP 4739 наружу не открывайте.",
"В docker-compose у backend раскомментируйте bind IPFIX только на адресе wg-flow.",
"Установщик публикует IPFIX только на адресе wg-flow. Ручные файлы доступны в остальных вкладках экспорта.",
"Проверка: wg show · ss -ulnp | grep 4739 · в этой панели — last datagram.",
]
@@ -43,6 +44,7 @@ function NetflowSettingsContent({
enabled: boolean
}) {
const [settings,setSettings] = useState<TrafficFlowSettingsDto|null>(null)
const [automatic,setAutomatic] = useState(false)
const [draft,setDraft] = useState<NetflowDraft>(emptyNetflowDraft)
const dirty = useRef(new Set<keyof NetflowDraft>())
const [dirtyCount,setDirtyCount] = useState(0)
@@ -147,16 +149,16 @@ function NetflowSettingsContent({
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField label="Collector IP" hint="Адрес в туннеле, куда JH шлёт flow">
<Input className="font-mono" aria-label="Collector IP" disabled={disabled} value={draft.collectorIp} onChange={(e) => change("collectorIp", e.target.value)} />
<Input className="font-mono" aria-label="Collector IP" disabled={disabled || automatic} value={draft.collectorIp} onChange={(e) => change("collectorIp", e.target.value)} />
</FormField>
<FormField label="Префикс overlay">
<Input className="font-mono" aria-label="Префикс overlay" disabled={disabled} value={draft.prefix} onChange={(e) => change("prefix", e.target.value)} />
<Input className="font-mono" aria-label="Префикс overlay" disabled={disabled || automatic} value={draft.prefix} onChange={(e) => change("prefix", e.target.value)} />
</FormField>
<FormField label="UDP IPFIX">
<Input className="font-mono" aria-label="UDP IPFIX" disabled={disabled} value={draft.flowListenPort} onChange={(e) => change("flowListenPort", e.target.value)} />
<Input className="font-mono" aria-label="UDP IPFIX" disabled={disabled || automatic} value={draft.flowListenPort} onChange={(e) => change("flowListenPort", e.target.value)} />
</FormField>
<FormField label="WG listen">
<Input className="font-mono" aria-label="WG listen" disabled={disabled} value={draft.wgListenPort} onChange={(e) => change("wgListenPort", e.target.value)} />
<Input className="font-mono" aria-label="WG listen" disabled={disabled || automatic} value={draft.wgListenPort} onChange={(e) => change("wgListenPort", e.target.value)} />
</FormField>
<FormField label="Публичный endpoint хоста MM" hint="IP или DNS, который видят JH">
<Input className="font-mono" aria-label="Публичный endpoint хоста MM" disabled={disabled} value={draft.publicEndpoint} onChange={(e) => change("publicEndpoint", e.target.value)} placeholder="203.0.113.10" />
@@ -201,14 +203,15 @@ function NetflowSettingsContent({
{settings?.lastError ? ` · ${settings.lastError}` : ""}
</p>
<div className="rounded-md border px-4 py-3 flex flex-col gap-2">
<FlowHostPanel backendUrl={backendUrl} enabled={enabled} onConfigured={setAutomatic} />
{automatic ? <p className="text-xs text-muted-foreground">Адрес, префикс и порты закреплены установкой агента. Для их изменения повторите установщик.</p> : <div className="rounded-md border px-4 py-3 flex flex-col gap-2">
<p className="text-sm font-medium">Туннель на сервере Docker MM</p>
<ol className="text-xs text-muted-foreground flex flex-col gap-1.5 list-decimal pl-4">
{HOST_STEPS.map((s) => (
<li key={s}>{s.replaceAll("4739", settings ? String(settings.flowListenPort) : "IPFIX")}</li>
))}
</ol>
</div>
</div>}
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={disabled || !dirtyCount} onClick={() => { void handleSave() }}>
+83
View File
@@ -0,0 +1,83 @@
# Production275 — SYN и задержки API arn-gw01 (2026-10-16)
Поручение: исследовать предупреждения SYN flooding на80/443 и периодически
медленный HTTPS REST на `arn-gw01.rtnt.top`. Выполнены только чтение и короткие
диагностические подключения. Конфигурация production/RouterOS не менялась.
## Результаты
Устройство ID1, DNS IPv4 `62.182.194.146`, REST HTTPS443 с проверкой сертификата.
RouterOS7.24.5 stable,1 CPU. В разовом resource: CPU6%, свободно933036032 байт
из1207959552, uptime5m41s. Перезапуск выполнен до диагностики, не агентом.
1. Первый диагностический GET system/resource завершился тайм-аутом10с.
Следующие чтения: system/clock2307мс, ip/settings638мс, ip/service145мс,
log160мс; отдельный полный resource1170мс. Неверная авторизация не наблюдалась.
2. В первоначальном журнале41 событие: первая отметка02:43:13, оба SYN warning
на80/443 —02:43:20. Совпадение с uptime указывает на события первых секунд
запуска. Позднее часы используют Europe/Stockholm/+02:00, в журнале есть
скачок02:43→04:44; ранние отметки нельзя механически переводить как уже
синхронизированное местное время. Непрерывную SYN-атаку из этих двух записей
вывести нельзя. Последующее кольцо1000 записей уже не содержало предупреждений.
3. Четыре последовательных GET system/identity с новым TCP/TLS, один за раз:
| Замер | DNS | TCP установлен | TLS завершён | Заголовки ответа | Всего |
| --- | --- | --- | --- | --- | --- |
| 1 | 6 | 1042 | 1141 | 1161 | 1162 |
| 2 | 2 | 3071 | 3189 | 3208 | 3208 |
| 3 | 3 | 3088 | 3233 | 3254 | 3254 |
| 4 | 2 | 5136 | 5254 | 5273 | 5274 |
Значения — накопленные миллисекунды от начала запроса. Все HTTP200;
после TLS до ответа19–21мс. Основная задержка находится до TCP connect.
4. Захват только SYN/SYN-ACK заголовков на eth0 production-хоста,20с,
фильтр строго target IPv4/TCP443. Полезная нагрузка не сохранялась.
Два наших контрольных GET прошли155/149мс; SYN-ACK через18–21мс.
Затем в обычном опросе приложения зафиксированы повторения исходящих SYN:
один исходный порт — первое SYN1792119069.651615, SYN-ACK1792119073.773299
(4.122с, несколько повторов); другой —1792119069.713556→1792119071.757179
(2.044с). При полученном ответе на SYN RTT около18мс. Захват16 пакетов,
dropped by kernel0, завершён автоматически через timeout20s.
5. В02:52:33 UTC (09:52:33 Новосибирск) среди239 TCP conntrack записей4 к
локальным80/443: time-wait1/syn-sent2/syn-recv1. Две от приложения,
две с других внешних адресов — по одной80 и443. Массовый текущий SYN flood
этим одиночным снимком не подтверждён. В этой версии адрес и порт — отдельные
поля; для итогового подсчёта использованы dst-address **и** dst-port.
6. tcp-syncookies=false. www80/www-ssl443/reverse-proxy443 включены,
max-sessions20; FTP/Telnet/SSH уже выключены. В input есть блокировка EVOFW_DENY,
drop UDP53 и accept WireGuard; отдельных ограничений80/443 доверенными
источниками и завершающего drop input в прочитанных правилах нет. RAW пустой.
Правила не менялись; обратный прокси потенциально использует те же порты,
поэтому закрывать их без проверки потребителей нельзя.
7. Scheduler20минут на02:49 UTC: traffic4 error/36 ok, gre_bgp4 error/36 ok.
Выборка последних ошибок называет arn и тайм-аут10с; последние в02:43:49–50 UTC,
рядом с запуском устройства. Успешные задания иногда занимают9–11с суммарно.
Это не доказательство медленного SQL или обработки REST на RouterOS.
## Вывод
Воспроизведены задержки TCP-handshake и повторные SYN **до** TLS/REST.
Разовые низкие CPU и conntrack не показывают постоянного истощения ресурсов.
Причину нужно различать между сетью/фильтрацией по пути и обработкой SYN на CHR;
захват только на отправителе не определяет точное место потери. Историческое
предупреждение при запуске само по себе не доказывает текущую DDoS-атаку.
Приложение сейчас создаёт новые соединения (keepAlive=false, maxSockets2 на origin).
Это увеличивает количество TCP/TLS-handshake, но не доказывает, что приложение
создаёт SYN flood. Менять поведение пула вслепую нельзя: прежние проверки273 касались
также отказов входа. Повышение API timeout лишь скроет часть проявлений.
## Подготовленный следующий шаг
После отдельного разрешения владельца проверить ID1/host и исходный
tcp-syncookies=false, изменить **только** `/ip/settings tcp-syncookies=true`
через REST PATCH. Без перезагрузки, изменения паролей, служб, правил firewall
и маршрутов. Повторно прочитать значение, повторить небольшой последовательный
замер TCP/TLS/HTTP и сверить штатные опросы. Откат — вернуть исходное false после
решения владельца; не объявлять положительный краткий замер доказательством
устранения причины. Это пока предложение, изменение не применялось.
[IP Settings MikroTik](https://help.mikrotik.com/docs/spaces/ROS/pages/103841817/IP%2BSettings)
описывает SYN cookies при переполнении SYN backlog и ограничения TCP extensions.
Это защита от переполнения очереди, она не устраняет потери в сети провайдера.
Точный scope изменения и проверка должны быть согласованы до записи в production.
+80
View File
@@ -0,0 +1,80 @@
# Production276 — включение SYN cookies на arn-gw01 (2026-10-16)
Владелец явно разрешил: «Включить tcp-syncookies и проверить».
Исходная диагностика: [Production275](PRODUCTION-ARN-275.md).
## Применение
Перед записью проверены server ID1/host `arn-gw01.rtnt.top` и исходное
`tcp-syncookies=false`. Первая попытка PATCH `/ip/settings` была отклонена400
`missing or invalid resource identifier`; значение после отказа осталосьfalse.
Этот singleton-раздел изменяется командой POST `/ip/settings/set`.
Выполнена ровно одна успешная команда с единственным параметром
`{"tcp-syncookies":"true"}`. Подтверждение в02:55:01.749 UTC,
повторное чтение02:55:01.893 UTC —true и корректная identity.
Местное время: **2026-10-16 09:55:01 по Новосибирску**.
Пароли, пользователи, firewall, порты, маршруты, приложение, БД и контейнеры
не менялись; перезапуск не выполнялся. Уточнение протокола PATCH→POST не расширяет
согласованный scope и не меняет дополнительные параметры.
## Метод проверки
С тем же production-хостом, адресом устройства, учётной записью и проверкой TLS
повторяются GET system/identity с новым TCP/TLS, последовательно, без повторов
при ошибке. Один запрос каждые4 секунды,8 измерений. Отдельно захват45 секунд
только SYN/SYN-ACK на eth0, фильтр строго target IPv4/TCP443, без сохранения payload.
Проверяются результат чтения настройки, uptime и штатные задания scheduler.
## Контрольные запросы после изменения
С02:55:24 по02:55:53 UTC выполнены8 измерений. Все HTTP200, без повторов.
| Замер | DNS | TCP установлен | TLS завершён | Заголовки ответа | Всего |
| --- | --- | --- | --- | --- | --- |
| 1 | 6 | 25 | 138 | 158 | 159 |
| 2 | 9 | 29 | 137 | 158 | 158 |
| 3 | 3 | 22 | 125 | 144 | 145 |
| 4 | 3 | 22 | 123 | 143 | 143 |
| 5 | 3 | 25 | 130 | 152 | 152 |
| 6 | 2 | 22 | 126 | 168 | 169 |
| 7 | 2 | 21 | 126 | 145 | 145 |
| 8 | 2 | 45 | 313 | 334 | 334 |
Все значения — накопленные миллисекунды от начала запроса. Диапазон полного
запроса143–334мс против1162–5274мс в четырёх исходных измерениях; TCP21–45мс
против1042–5136мс. Настройка повторноtrue, uptime12m42s/CPU14% в замере02:55:54.
В повторном45-секундном захвате42 SYN/SYN-ACK пакета (44 received by filter,
0 dropped by kernel), повторов SYN с тем же исходным портом/sequence в полученном
выводе не обнаружено. Запись payload не выполнялась. Захват завершился по timeout,
фонового процесса захвата после него нет. Это короткая выборка, а не гарантия
отсутствия сетевых потерь при любой нагрузке.
На02:57:30 UTC /09:57:30 Новосибирск, через2мин29с после применения:
| Штатное задание | Успешно | Ошибки | Максимальная длительность |
| --- | --- | --- | --- |
| traffic | 5 | 0 | 1091мс |
| gre_bgp | 5 | 0 | 426мс |
| uptime_resources | 10 | 0 | 854мс |
| internet_path | 1 | 0 | 284мс |
Считались запуски с started_at после02:55:01.893 UTC. Финальный readbacktrue,
CPU6%, uptime14m19s, RouterOS7.24.5 stable — перезапуска не было. Доступное кольцо
логов04:55:53–04:57:16 по часам устройства не содержит новых SYN warning;
оно не покрывает весь интервал после изменения. Старые события не удалялись.
В коротком сравнении после включения SYN cookies многосекундная задержка TCP
не воспроизвелась, результаты существенно лучше. Это согласуется с гипотезой
проблемы обработки входящих SYN на CHR, но не устанавливает источник всех прежних
потерь и не доказывает результат под другой нагрузкой. Дополнительные изменения
не применялись. Наблюдение и захват завершены; новых фоновых циклов нет.
## Откат
При необходимости по решению владельца повторно проверить устройство и текущее
значение, выполнить POST `/ip/settings/set` с `tcp-syncookies=false`, затем чтение
`/ip/settings?.proplist=tcp-syncookies`. Автоматического отката после успешной
проверки не предусмотрено: включение разрешено явно. Другие настройки не трогать.
+36
View File
@@ -0,0 +1,36 @@
# Обновление production до v2.0.2 (2026-10-16)
Владелец разрешил публикацию готовых Filters268, карты270 и recursive271 в main и обновление backend/frontend после успешного CI. Четыре коммита0f0d3aa0..e6a2e400 отправлены без перезаписи истории. Все7 jobs [CI2468](https://git.shx.one/denozord/MikrotikManager/actions/runs/2468) завершились success, опубликован v2.0.2. Версия/тег вычислены CI, package.json вручную не менялся.
Оба образа скачаны по digest, OCI version2.0.2/revision e6a2e4009d8af9e0e2f98c645791981c2b6d6ca8 проверены до перезапуска:
```text
backend: git.shx.one/denozord/mikrotikmanager-backend@sha256:1a02d517b16d9d475b478e7fae1a2749bad88a156f0e49183e8cb57e2d1a7f10
frontend: git.shx.one/denozord/mikrotikmanager-frontend@sha256:fbd0c3743613a60d18dc6a13120cf1d755f89bc574c490e363d235301a267765
```
Проверенные digest локально помечены соответствующими frontend:latest/backend:latest. Compose-файл не редактировался. Выполнено последовательно:
```bash
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never backend
# Проверка backend healthy; затем:
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never frontend
```
По Новосибирску backend запущен16.10 в00:41:33, frontend в00:42:11. Оба healthy. PostgreSQL сохранил container ID и StartedAt; конфигурация RouterOS и права агентом не менялись. SHA256 Compose до/после одинаков: e675c00d1cbdf6558f9cad601c2674007d899d1b47c92acd0d866ff51bb61bd31. Краткое время Compose recreate не используется как измерение пользовательского простоя.
До обновления ошибка JH воспроизведена в браузере. После: HTTP200/live=true/stale=false для3 JH,4/3/5 endpoints; соответствующие UI-срезы загружены без прежней ошибки. Группировка нескольких endpoints одного dst/table в одну строку сохранена. Filters выбранного московского JH:10 правил,0 ложных missing-route warnings. Карта:4 узла/новая легенда/25GRE в данных; после клика computed outline=none, keyboard ring opacity=0. Read-only DNS по-прежнему запрещён mm:network:write, нарисованы3/25 GRE; все25 связей подтверждёнными не объявляются.
[Структурированный протокол](../docs/ux-ui/acceptance/prod272-release.json). Скриншоты production в игнорируемом tmp, в Git не включены. Изменение маршрутов, формы записи и удаление в production не выполнялись.
Старые образы оставлены для отката, prune не запускался. Для восстановления прежних сервисов при необходимости:
```bash
docker tag sha256:6f344eecb5dd0ec820c23b8d71f89a16ebccff3ac898d954eadd159f2611b6a4 git.shx.one/denozord/mikrotikmanager-backend:latest
docker tag sha256:73ac48979084c0c93b77e75308a9e2f002481586704d0c03fd9a7b5e39511647 git.shx.one/denozord/mikrotikmanager-frontend:latest
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never backend
# Дождаться healthy, затем:
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never frontend
```
Откат вернёт исправленные дефекты. Он не понадобился и не выполнялся. Настоящий протокол сохранён отдельным локальным коммитом без повторного push, чтобы документирование приёмки само не запускало следующий релиз.
+72
View File
@@ -0,0 +1,72 @@
# Production273 — отказы входа RouterOS (2026-10-16)
Диагностика production выполнена только чтением. Пароли, пользователи, службы,
firewall, задания RouterOS и контейнеры не изменялись. Секреты не включены.
## Наблюдения
- `nov-gw01.rtnt.top`, server ID3: RouterOS **7.24.4 stable**.
- Рядом зафиксированы `500 / too many concurrent logins (6)` и `401 Unauthorized`
на `/interface`, `/interface/gre`, `/routing/bgp/session`. Последние отказы серии:
**2026-10-16 01:11:40–44 по Новосибирску** (2026-10-15 18:11:40–44 UTC).
- В последних15 минутах на01:27: traffic30/30 ok, gre_bgp30/30 ok,
internet_path3/3 ok, ошибок этих заданий нет.
- Тот же сохранённый пароль успешно использован для system/resource, user/active,
ip/service, interface/gre (3 записи), routing/bgp/session (3 записи).
Пользователь приложения: full, disabled=false, address пустой, api/rest-api
разрешены. Это подтверждает текущий доступ, но не объясняет каждый старый401.
- FTP/Telnet включены. В кольце журнала01:26:00–01:26:47:10 отказов входа
с посторонних адресов —9 FTP и1 Telnet; отказов с адреса приложения в этой
выборке нет. Следующий снимок показывает **7 внешних Telnet-соединений**.
Подбор учётных данных наблюдается; успешный посторонний вход не установлен.
- У www/www-ssl/api/api-ssl max-sessions=20. Число6 в ошибке нельзя автоматически
отождествлять с этим параметром или количеством TCP-соединений приложения.
- user/active содержит много старых rest-api записей с адреса приложения;
api запись в момент чтения одна. По одному списку нельзя доказывать утечку
живых TCP-соединений или принудительно удалять записи.
- RouterOS scheduler `evofw-sync`: enabled, interval1m, run-count625.
В доступном кольце01:26–01:30:866 изменений от scheduler, из них860 добавлений
address-list и6 изменений filter rule. Два script/job в снимке, включая начатый
01:22:08. Скрипты не выгружались и не менялись; связь с отказами не доказана.
- Общий HTTP/HTTPS Agent backend ограничивает сокеты до2 **на origin**.
Одно задание планировщика защищено от повторного запуска; разные задания
работают одновременно. Шесть одновременных REST-запросов из основного процесса
приложения не установлены.
## Вывод и границы
Неверный пароль как причина текущего доступа не подтвердился. RouterOS явно
сообщил о насыщении механизма входа;401 возникали рядом, затем без изменения
пароля прекратились. Связь с внешним подбором, продлением REST-сессии или локальным
scheduler необходимо различить дальнейшим наблюдением.15 минут успеха не доказывают
устранение причины.
[Официальная документация REST](https://manual.mikrotik.com/docs/developer-guides/rest-api/)
описывает внутренний API login, повторное использование логина10 минут и политики
api/rest-api. Страница относится к7.26; поведение7.24.4 не считать идентичным
без отдельного подтверждения.
## Локальная правка
- 500 с точным JSON detail `too many concurrent logins (...)` получает подсказку
«RouterOS занят: превышен лимит одновременных входов».
- 401 больше не обозначается безусловно как неверный пароль: предлагается проверить
учётные данные, права API/REST и ограничения входа.
- Исходные HTTP status/path/body сохранены; повторов чтения/записи не добавлено.
Другие500 не классифицируются как лимит входов.
- 12 HTTP-проверок и профиль ros-file, backend production build, scoped ESLint,
diff check прошли. Локально, без push/deploy.
## Следующий шаг, требующий разрешения
Временно отключить **только** ftp (`*1`,21) и telnet (`*0`,23) на ID3.
Перед записью повторно проверить имя/ID и исходный disabled каждой службы.
Это отключит FTP/Telnet подключения; Winbox, HTTPS, API и маршрутизация сохраняются.
Для этой диагностики production доступ предоставлен на чтение: запись требует
отдельного разрешения владельца. Если эти службы нужны, согласовать доверенные адреса.
После разрешения: сохранить исходные disabled, отключить две службы, проверить
применение и REST-доступ; наблюдать обычный опрос не менее двух периодов продления
REST-логина, сравнить ошибки и login failures. Не повышать max-sessions, не менять
пароль, не удалять user/active, не перезапускать маршрутизатор. Результат записать
отдельно: отключение ещё не выполнялось и причинная связь не доказана.
+78
View File
@@ -0,0 +1,78 @@
# Production274 — отключение FTP/Telnet и наблюдение (2026-10-16)
Владелец явно разрешил отключить FTP и Telnet на `nov-gw01.rtnt.top`.
Предыдущая диагностика: [Production273](PRODUCTION-ROUTEROS-273.md).
## Изменение
Перед записью проверены server ID3/host, единственность статических служб,
их ID, порты21/23 и исходный disabled=false. Поочерёдно применено только
`disabled=true`:
| Служба | ID | До | После | Время UTC 2026-10-15 |
| --- | --- | --- | --- | --- |
| ftp | *1 | false | true | 18:33:45.013 |
| telnet | *0 | false | true | 18:33:45.322 |
Повторное чтение18:33:45.582 UTC подтвердило обе службы выключенными.
Это **2026-10-16 01:33:45 по Новосибирску**.
www80/www-ssl443/winbox8291/api8728/api-ssl8729 остались disabled=false,
как до операции. REST identity подтвердил имя устройства; resource —7.24.4 stable,
uptime10h28m24s, без перезапуска.
Пароль, пользователи, firewall, маршруты, локальные задания RouterOS, контейнеры
и приложение не изменялись. Локальная правка подсказок273 не развёртывалась.
## Метод наблюдения
Штатные задания продолжают работать с прежними интервалами. Диагностический
процесс раз в минуту только читает scheduler_runs и RouterOS log; дополнительных
запусков заданий/нагрузочных тестов нет. Подсчитываются ok/error после18:33:45.582 UTC
для traffic, gre_bgp, internet_path, uptime_resources. Журнал логинов проверяется
с продвижением по ID и контролем перекрытия кольцевого буфера, без вывода секретов.
## Итог наблюдения
Окончание:2026-10-15 18:54:44 UTC /2026-10-16 01:54:44 по Новосибирску,
почти21 минута после применения. После изменения в scheduler_runs:
| Задание | Успешно | Ошибки | Максимальная длительность |
| --- | --- | --- | --- |
| traffic | 42 | 0 | 2278мс |
| gre_bgp | 42 | 0 | 910мс |
| internet_path | 4 | 0 | 809мс |
| uptime_resources | 84 | 0 | 1937мс |
Итоговое чтение: статические ftp/telnet disabled=true; www/www-ssl/winbox/api/api-ssl
остались включены. Identity/REST работает, RouterOS7.24.4 stable,
uptime10h49m22s — перезапуска не было. В списке остаются6 динамических строк Telnet;
отключение службы не доказывает завершение всех прежних соединений. Другие службы,
соединения и firewall принудительно не менялись.
В18:55:47 UTC проверено новое TCP-подключение с production backend к имени
устройства:21 и23 вернули ECONNREFUSED. Новые подключения к этим портам отклоняются.
В наблюдавшихся новых логах нет отказов FTP/Telnet, но есть2 отказа Winbox.
Их источники не были сохранены: не объявлять эти2 события доказанным внешним
подбором. Кольцо RouterOS переполнялось scheduler-изменениями; обнаружено4 пропуска
перекрытия, поэтому отсутствие FTP/Telnet failures не доказано для всего интервала.
Статистика scheduler_runs этим ограничением не затронута.
Изменение служб само по себе не доказывает причину предыдущих401/500: они
прекратились ещё до операции. Зафиксировано отсутствие повторения после отключения,
а не гарантированное устранение первопричины. Автоматический сбор наблюдений завершён.
## Восстановление исходного состояния
Владелец отдельно подтвердил: **«Оставить FTP и Telnet выключенными»**.
После наблюдения службы не включать обратно. При необходимости отдельного отката:
повторно проверить host, имена/ID, вернуть disabled=false только ftp/telnet и
сверить чтением. Других изменений конфигурации этим блоком не выполнять.
## Дополнительный вопрос о PostgreSQL
По вопросу владельца выполнена только читающая проверка production PostgreSQL:
pg_is_in_recovery=false, wal_level=replica, pg_stat_replication count=0,
pg_replication_slots count=0, pg_subscription count=0, synchronous_standby_names
пустой. В конфигурации проекта один PostgreSQL и один DATABASE_URL backend.
Активная физическая/логическая репликация не обнаружена; параметры БД не менялись.
+27
View File
@@ -0,0 +1,27 @@
# Optional addition to docker-compose.cdn-mm.yml. Run the exported installer first.
# Keep this file in every subsequent docker compose -f invocation.
services:
netflow-agent:
image: git.shx.one/denozord/mikrotikmanager-netflow-agent:${MM_NETFLOW_AGENT_IMAGE_TAG:-latest}
container_name: mmapp-netflow-agent
restart: unless-stopped
network_mode: host
cap_drop: [ALL]
cap_add: [NET_ADMIN]
security_opt: ["no-new-privileges:true"]
read_only: true
tmpfs: ["/tmp:rw,noexec,nosuid,size=8m"]
volumes:
- ./data/netflow-wireguard:/etc/wireguard
- ./data/netflow-agent-socket:/run/mm-netflow-host
logging:
driver: json-file
options: {max-size: "5m", max-file: "2"}
backend:
environment:
FLOW_AGENT_SOCKET: /run/mm-netflow-host/agent.sock
volumes:
- ./data/netflow-agent-socket:/run/mm-netflow-host:ro
depends_on:
netflow-agent:
condition: service_healthy
+11
View File
@@ -0,0 +1,11 @@
FROM alpine:3.22 AS base
RUN apk add --no-cache python3 wireguard-tools iproute2
COPY agent.py /app/agent.py
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
HEALTHCHECK --interval=15s --timeout=5s --retries=3 CMD ["python3", "/app/agent.py", "--healthcheck"]
ENTRYPOINT ["python3", "/app/agent.py"]
FROM base AS test
COPY test_agent.py /app/test_agent.py
FROM base AS runtime
+367
View File
@@ -0,0 +1,367 @@
"""Local, single-interface WireGuard controller. Never executes wg-quick/hooks."""
import base64
import binascii
import http.client
import http.server
import ipaddress
import json
import os
from pathlib import Path
import re
import socket
import socketserver
import subprocess
import sys
import tempfile
import threading
IFACE = "wg-flow"
CONFIG = Path(os.environ.get("FLOW_AGENT_CONFIG_DIR", "/etc/wireguard"))
SOCKET = os.environ.get("FLOW_AGENT_SOCKET", "/run/mm-netflow-host/agent.sock")
LOCK = threading.RLock()
MAX_BODY = 262144
class AgentError(Exception):
pass
def run(*args, input=None):
try:
result = subprocess.run(args, input=input, text=True, capture_output=True, timeout=10)
except (OSError, subprocess.TimeoutExpired):
raise AgentError("Команда WireGuard/IP недоступна или превысила время ожидания") from None
if result.returncode:
# stderr/stdout can contain keys and must never reach an HTTP response/log.
raise AgentError("Операция WireGuard/IP не выполнена")
return result.stdout.strip()
def key(value):
try:
if not isinstance(value, str) or len(value) != 44 or len(base64.b64decode(value, validate=True)) != 32:
raise ValueError()
except (ValueError, binascii.Error):
raise AgentError("Некорректный ключ WireGuard") from None
return value
def port(value):
if type(value) is not int or not 1 <= value <= 65535:
raise AgentError("Некорректный UDP-порт")
return value
def endpoint(value):
if not isinstance(value, str) or not re.fullmatch(r"(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):[0-9]{1,5}", value):
raise AgentError("Некорректный endpoint")
port(int(value.rsplit(":", 1)[1]))
return value
def parse_conf(text):
interface, peers, section = {}, [], None
allowed = {"interface": {"PrivateKey", "Address", "ListenPort", "MTU"},
"peer": {"PublicKey", "PresharedKey", "AllowedIPs", "Endpoint", "PersistentKeepalive"}}
for raw in text.splitlines():
line = raw.split("#", 1)[0].strip()
if not line:
continue
if line == "[Interface]" and section is None:
section, current = "interface", interface
elif line == "[Peer]" and section is not None:
section, current = "peer", {}
peers.append(current)
else:
name, sep, value = line.partition("=")
name, value = name.strip(), value.strip()
if not sep or section is None or name not in allowed[section] or name in current or not value:
raise AgentError("Конфигурация wg-flow содержит неподдерживаемые или повторные параметры; требуется ручная проверка")
current[name] = value
if not interface or not {"PrivateKey", "Address", "ListenPort"} <= interface.keys():
raise AgentError("Неполная конфигурация wg-flow")
key(interface["PrivateKey"])
try:
ipaddress.IPv4Interface(interface["Address"])
port(int(interface["ListenPort"]))
if "MTU" in interface and not 576 <= int(interface["MTU"]) <= 9000:
raise ValueError()
for peer in peers:
key(peer.get("PublicKey"))
if "PresharedKey" in peer:
key(peer["PresharedKey"])
if "Endpoint" in peer:
endpoint(peer["Endpoint"])
if peer.get("AllowedIPs"):
networks(peer["AllowedIPs"].split(","))
if "PersistentKeepalive" in peer and not 0 <= int(peer["PersistentKeepalive"]) <= 65535:
raise ValueError()
except (ValueError, TypeError):
raise AgentError("Некорректные параметры wg-flow") from None
if len({p["PublicKey"] for p in peers}) != len(peers):
raise AgentError("Повторный публичный ключ пира")
return interface, peers
def networks(values):
if not isinstance(values, list) or not values:
raise AgentError("AllowedIPs должны содержать адреса")
try:
return [ipaddress.ip_network(v.strip(), strict=False) for v in values]
except (ValueError, AttributeError):
raise AgentError("Некорректные AllowedIPs") from None
def validate(raw):
fields = {"collectorIp", "prefix", "flowListenPort", "wgListenPort", "hostPublicKey", "hostPrivateKey", "peers"}
if not isinstance(raw, dict) or set(raw) != fields:
raise AgentError("Некорректный снимок NetFlow")
try:
collector = ipaddress.IPv4Address(raw["collectorIp"])
prefix = ipaddress.IPv4Network(raw["prefix"], strict=False)
if prefix.prefixlen != 24 or collector not in prefix or collector in (prefix.network_address, prefix.broadcast_address):
raise ValueError()
except (ValueError, TypeError):
raise AgentError("Требуется IPv4 overlay /24 и адрес коллектора внутри него") from None
port(raw["flowListenPort"])
port(raw["wgListenPort"])
key(raw["hostPublicKey"])
key(raw["hostPrivateKey"])
if run("wg", "pubkey", input=raw["hostPrivateKey"] + "\n") != raw["hostPublicKey"]:
raise AgentError("Публичный и приватный ключи MM не соответствуют друг другу")
binding = json.loads((CONFIG / "binding.json").read_text())
for name in ("collectorIp", "prefix", "flowListenPort", "wgListenPort"):
if binding.get(name) != raw[name]:
raise AgentError("Адрес, префикс или порт отличаются от установки агента; повторите установщик")
if not isinstance(raw["peers"], list) or len(raw["peers"]) > 253:
raise AgentError("Некорректный список пиров")
peers, ids, keys = [], set(), set()
for peer in raw["peers"]:
if not isinstance(peer, dict) or not {"serverId", "publicKey", "allowedIps"} <= peer.keys() or set(peer) - {"serverId", "publicKey", "allowedIps", "endpoint"}:
raise AgentError("Некорректный пир")
peer_key = key(peer["publicKey"])
if type(peer["serverId"]) is not int or peer["serverId"] <= 0 or peer["serverId"] in ids or peer_key in keys or peer_key == raw["hostPublicKey"]:
raise AgentError("Повторный или некорректный пир")
ids.add(peer["serverId"])
keys.add(peer_key)
nets = networks(peer["allowedIps"])
if len(nets) != 1 or nets[0].version != 4 or nets[0].prefixlen != 32 or nets[0].network_address not in prefix or nets[0].network_address in (collector, prefix.network_address, prefix.broadcast_address):
raise AgentError("Пиру разрешён только собственный IPv4 /32 внутри overlay")
data = {"PublicKey": peer_key, "AllowedIPs": str(nets[0])}
if peer.get("endpoint"):
data.update(Endpoint=endpoint(peer["endpoint"]), PersistentKeepalive="25")
peers.append(data)
return peers
def merge_peers(existing, desired):
merged = {peer["PublicKey"]: dict(peer) for peer in existing}
for peer in desired:
public = peer["PublicKey"]
# Replacing a PSK would silently break a previously working tunnel.
if merged.get(public, {}).get("PresharedKey", "A" * 43 + "=") != "A" * 43 + "=":
raise AgentError("Управляемый пир использует preshared key; требуется ручная проверка")
merged[public] = dict(peer)
rows = list(merged.values())
wanted = {p["PublicKey"] for p in desired}
for i, left in enumerate(rows):
for right in rows[i + 1:]:
if left["PublicKey"] not in wanted and right["PublicKey"] not in wanted:
continue
if not left.get("AllowedIPs") or not right.get("AllowedIPs"):
continue
if any(a.version == b.version and a.overlaps(b) for a in networks(left["AllowedIPs"].split(",")) for b in networks(right["AllowedIPs"].split(","))):
raise AgentError("AllowedIPs управляемого пира пересекаются с другим пиром")
return rows
def render(interface, peers, native=False):
rows = ["[Interface]"]
rows += [f"{k} = {v}" for k, v in interface.items() if not native or k not in ("Address", "MTU")]
for peer in peers:
rows += ["", "[Peer]"] + [f"{k} = {v}" for k, v in peer.items() if k != "AllowedIPs" or v]
return "\n".join(rows) + "\n"
def atomic_write(path, text):
fd, name = tempfile.mkstemp(prefix=".flow-", dir=path.parent)
try:
with os.fdopen(fd, "w") as stream:
stream.write(text)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, path)
if os.name == "posix":
directory = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
if os.path.exists(name):
os.unlink(name)
def sync(text):
# wg requires a file. mkstemp creates it with mode 0600.
fd, name = tempfile.mkstemp(prefix="flow-sync-")
try:
with os.fdopen(fd, "w") as stream:
stream.write(text)
run("wg", "syncconf", IFACE, name)
finally:
os.unlink(name)
def live():
dump = run("wg", "show", IFACE, "dump").splitlines()
if not dump or len(dump[0].split("\t")) != 4:
raise AgentError("Интерфейс wg-flow недоступен")
interface = dump[0].split("\t")
peers, raw_peers = [], []
for line in dump[1:]:
cols = line.split("\t")
if len(cols) != 8:
raise AgentError("Некорректный ответ WireGuard")
public, psk, ep, ips, handshake, rx, tx, keepalive = cols
peers.append({"publicKey": public, "allowedIps": ips.split(",") if ips != "(none)" else [],
"lastHandshakeAt": int(handshake) or None, "receivedBytes": int(rx), "sentBytes": int(tx)})
item = {"PublicKey": public, "AllowedIPs": ips if ips != "(none)" else ""}
if psk != "(none)":
item["PresharedKey"] = psk
if ep != "(none)":
item["Endpoint"] = ep
item["PersistentKeepalive"] = keepalive
raw_peers.append(item)
addresses = json.loads(run("ip", "-j", "-4", "address", "show", "dev", IFACE))
cidrs = [f"{a['local']}/{a['prefixlen']}" for row in addresses for a in row.get("addr_info", []) if a.get("family") == "inet"]
return {"interfaceUp": any("UP" in row.get("flags", []) for row in addresses), "publicKey": interface[1], "listenPort": int(interface[2]), "addresses": cidrs, "peers": peers}, raw_peers
def check(raw, apply=False):
desired = validate(raw)
path = CONFIG / "wg-flow.conf"
previous = path.read_text()
interface, stored = parse_conf(previous)
expected_address = f"{raw['collectorIp']}/{raw['prefix'].split('/')[1]}"
if run("wg", "pubkey", input=interface["PrivateKey"] + "\n") != raw["hostPublicKey"] or interface["Address"] != expected_address or int(interface["ListenPort"]) != raw["wgListenPort"]:
raise AgentError("Ключ, адрес или порт существующего wg-flow отличаются от MM; требуется ручная проверка")
status, runtime = live()
if not status["interfaceUp"]:
raise AgentError("Интерфейс wg-flow выключен; проверьте wg-quick на хосте")
if status["publicKey"] != raw["hostPublicKey"] or expected_address not in status["addresses"] or status["listenPort"] != raw["wgListenPort"]:
raise AgentError("Работающий wg-flow отличается от конфигурации; требуется ручная проверка")
# Preserve both persistent and runtime-only third-party peers. Detect conflicts before writing.
combined = {p["PublicKey"]: p for p in runtime}
combined.update({p["PublicKey"]: p for p in stored})
merged = merge_peers(list(combined.values()), desired)
runtime_merged = merge_peers(runtime, desired)
# Do not reset a third-party peer's runtime settings to an older persistent file.
# Preserve roaming endpoints of managed peers unless the persisted endpoint was edited.
stored_by_key = {p["PublicKey"]: p for p in stored}
runtime_by_key = {p["PublicKey"]: p for p in runtime}
desired_by_key = {p["PublicKey"]: p for p in desired}
for peer in runtime_merged:
public = peer["PublicKey"]
if public in desired_by_key and stored_by_key.get(public, {}).get("Endpoint") == desired_by_key[public].get("Endpoint"):
if runtime_by_key.get(public, {}).get("Endpoint"):
peer["Endpoint"] = runtime_by_key[public]["Endpoint"]
def matches(actual, persistent=False):
by_key = {p["PublicKey"]: p for p in actual}
return all(p["PublicKey"] in by_key and all(by_key[p["PublicKey"]].get(k) == v for k, v in p.items() if persistent or k != "Endpoint") for p in desired)
matched = matches(stored, persistent=True) and matches(runtime)
if apply:
rollback = run("wg", "showconf", IFACE)
try:
atomic_write(path, render(interface, merged))
# Keep the running interface header verbatim (including a possible fwmark).
header = rollback.split("[Peer]", 1)[0].rstrip()
peer_config = render({}, runtime_merged, native=True).removeprefix("[Interface]\n")
sync(header + "\n" + peer_config)
status, after = live()
if not matches(after):
raise AgentError("Применение пиров не подтверждено")
except Exception:
failures = []
for action in (lambda: atomic_write(path, previous), lambda: sync(rollback)):
try:
action()
except Exception:
failures.append(True)
raise AgentError("Применение не удалось; " + ("восстановление требует ручной проверки" if failures else "предыдущая конфигурация восстановлена")) from None
matched = True
return {**status, "configMatches": matched}
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *_):
pass
def handle_request(self):
try:
with LOCK:
if self.command == "GET" and self.path == "/status":
result, _ = live()
result["configMatches"] = None
elif self.command == "POST" and self.path in ("/check", "/apply"):
size = int(self.headers.get("Content-Length", "0"))
if not 0 < size <= MAX_BODY or self.headers.get("Transfer-Encoding"):
raise AgentError("Некорректный размер запроса")
raw = json.loads(self.rfile.read(size))
result = check(raw, apply=self.path == "/apply")
else:
self.respond(404, {"error": "Операция не поддерживается"})
return
self.respond(200, result)
except AgentError as error:
self.respond(409, {"error": str(error)})
except Exception:
self.respond(503, {"error": "Агент NetFlow не готов; проверьте конфигурацию и интерфейс хоста"})
def setup(self):
super().setup()
self.connection.settimeout(15)
def respond(self, code, body):
data = json.dumps(body, ensure_ascii=False).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
do_GET = handle_request
do_POST = handle_request
class Server(socketserver.ThreadingMixIn, getattr(socketserver, "UnixStreamServer", socketserver.TCPServer)):
daemon_threads = True
class UnixConnection(http.client.HTTPConnection):
def connect(self):
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.settimeout(3)
self.sock.connect(SOCKET)
def main():
if not hasattr(socketserver, "UnixStreamServer"):
raise SystemExit("NetFlow agent requires Linux Unix sockets")
if "--healthcheck" in sys.argv:
client = UnixConnection("localhost")
client.request("GET", "/status")
response = client.getresponse()
sys.exit(0 if response.status == 200 and json.loads(response.read()).get("interfaceUp") else 1)
os.umask(0o077)
Path(SOCKET).parent.mkdir(parents=True, exist_ok=True)
if os.path.lexists(SOCKET):
Path(SOCKET).unlink()
with Server(SOCKET, Handler) as server:
os.chmod(SOCKET, 0o660)
print("NetFlow agent: Unix API ready", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# Usage: sudo bash install.sh /absolute/path/wg-flow.conf /opt/cdn-mm 4739
set -euo pipefail
umask 077
[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "Запустите от root" >&2; exit 1; }
SOURCE=$(realpath "${1:?Укажите скачанный wg-flow.conf}")
COMPOSE_DIR=$(realpath "${2:-/opt/cdn-mm}")
FLOW_PORT=${3:-4739}
[[ "$FLOW_PORT" =~ ^[0-9]+$ ]] && (( FLOW_PORT >= 1 && FLOW_PORT <= 65535 )) || exit 1
command -v docker >/dev/null
docker compose version >/dev/null
systemctl show docker.service --property=LoadState --value | grep -qx loaded
if ! command -v wg >/dev/null || ! command -v python3 >/dev/null || ! command -v modprobe >/dev/null; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard-tools python3 iproute2 kmod
fi
modprobe wireguard
[[ -f "$COMPOSE_DIR/docker-compose.yml" ]] || { echo "Не найден docker-compose.yml" >&2; exit 1; }
AGENT_IMAGE=${MM_NETFLOW_AGENT_IMAGE:-git.shx.one/denozord/mikrotikmanager-netflow-agent:latest}
[[ "$AGENT_IMAGE" =~ ^[A-Za-z0-9][A-Za-z0-9._:/@-]+$ ]] || { echo "Некорректное имя образа" >&2; exit 1; }
# The image must be available before changing the host configuration.
docker pull "$AGENT_IMAGE"
TASK_TEMP=$(mktemp -d)
trap 'rm -rf -- "$TASK_TEMP"' EXIT
python3 - "$SOURCE" "$COMPOSE_DIR" "$FLOW_PORT" "$TASK_TEMP" <<'PY'
import base64, ipaddress, json, os, pathlib, re, subprocess, sys
source, directory, port, temp = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), int(sys.argv[3]), pathlib.Path(sys.argv[4])
def read(text):
section, fields, peers = None, {}, []
allowed = {'Interface': {'PrivateKey','Address','ListenPort','MTU'}, 'Peer': {'PublicKey','AllowedIPs','Endpoint','PersistentKeepalive','PresharedKey'}}
for raw in text.splitlines():
line = raw.split('#',1)[0].strip()
if not line: continue
if line == '[Interface]' and section is None: section, current = 'Interface', fields
elif line == '[Peer]' and section is not None:
section, current = 'Peer', {}; peers.append(current)
else:
key, sep, value = line.partition('='); key, value = key.strip(), value.strip()
if not sep or section not in allowed or key not in allowed[section] or key in current or not value:
raise SystemExit('Конфигурация содержит неподдерживаемые параметры; требуется ручная проверка')
current[key] = value
try:
addr = ipaddress.IPv4Interface(fields['Address'])
if addr.network.prefixlen != 24 or addr.ip in (addr.network.network_address, addr.network.broadcast_address): raise ValueError()
if len(base64.b64decode(fields['PrivateKey'], validate=True)) != 32 or not 1 <= int(fields['ListenPort']) <= 65535: raise ValueError()
if 'MTU' in fields and not 576 <= int(fields['MTU']) <= 9000: raise ValueError()
for peer in peers:
if len(base64.b64decode(peer['PublicKey'], validate=True)) != 32: raise ValueError()
for network in peer['AllowedIPs'].split(','): ipaddress.ip_network(network.strip(), strict=False)
if 'Endpoint' in peer and not re.fullmatch(r'(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):[0-9]{1,5}', peer['Endpoint']): raise ValueError()
if 'PersistentKeepalive' in peer and not 0 <= int(peer['PersistentKeepalive']) <= 65535: raise ValueError()
except (ValueError, KeyError): raise SystemExit('Некорректная конфигурация WireGuard; ключи не изменены')
return fields, addr
expected, addr = read(source.read_text())
active = pathlib.Path('/etc/wireguard/wg-flow.conf')
candidate = active.read_text() if active.exists() else source.read_text()
actual, actual_addr = read(candidate)
for key in ('PrivateKey','Address','ListenPort'):
if actual[key] != expected[key]: raise SystemExit('Ключ, адрес или порт существующего туннеля отличаются от MM; установка остановлена')
public = subprocess.run(['wg','pubkey'], input=actual['PrivateKey']+'\n',text=True,capture_output=True,check=True).stdout.strip()
if subprocess.run(['ip','link','show','dev','wg-flow'],capture_output=True).returncode == 0:
dump = subprocess.run(['wg','show','wg-flow','dump'],capture_output=True,text=True,check=True).stdout.splitlines()
head = dump[0].split('\t')
addresses = json.loads(subprocess.run(['ip','-j','-4','address','show','dev','wg-flow'],capture_output=True,text=True,check=True).stdout)
if head[1] != public or int(head[2]) != int(actual['ListenPort']) or not any(a.get('local') == str(addr.ip) and a.get('prefixlen') == addr.network.prefixlen for row in addresses for a in row.get('addr_info',[])):
raise SystemExit('Работающий wg-flow отличается от MM; установка остановлена')
binding = dict(collectorIp=str(addr.ip), prefix=str(addr.network), flowListenPort=port, wgListenPort=int(actual['ListenPort']))
(temp/'binding.json').write_text(json.dumps(binding))
(temp/'wg-flow.conf').write_text(candidate)
(temp/'collector').write_text(str(addr.ip))
PY
COLLECTOR_IP=$(cat "$TASK_TEMP/collector")
# Keep a dedicated override; never replace docker-compose.override.yml.
cat > "$TASK_TEMP/compose.yml" <<EOF
services:
netflow-agent:
image: "$AGENT_IMAGE"
container_name: mmapp-netflow-agent
restart: unless-stopped
network_mode: host
cap_drop: [ALL]
cap_add: [NET_ADMIN]
security_opt: ["no-new-privileges:true"]
read_only: true
tmpfs: ["/tmp:rw,noexec,nosuid,size=8m"]
volumes:
- ./data/netflow-wireguard:/etc/wireguard
- ./data/netflow-agent-socket:/run/mm-netflow-host
logging:
driver: json-file
options: {max-size: "5m", max-file: "2"}
backend:
environment:
FLOW_AGENT_SOCKET: /run/mm-netflow-host/agent.sock
FLOW_LISTEN_HOST: "0.0.0.0"
volumes:
- ./data/netflow-agent-socket:/run/mm-netflow-host:ro
ports:
- "$COLLECTOR_IP:$FLOW_PORT:$FLOW_PORT/udp"
depends_on:
netflow-agent:
condition: service_healthy
EOF
COMPOSE_ARGS=(-f "$COMPOSE_DIR/docker-compose.yml")
if [[ -f "$COMPOSE_DIR/docker-compose.override.yml" ]]; then COMPOSE_ARGS+=(-f "$COMPOSE_DIR/docker-compose.override.yml"); fi
# Render first, and reject an existing public/incorrect IPFIX binding before touching WireGuard.
docker compose --project-directory "$COMPOSE_DIR" "${COMPOSE_ARGS[@]}" -f "$TASK_TEMP/compose.yml" config --format json > "$TASK_TEMP/merged.json"
python3 - "$TASK_TEMP/merged.json" "$COLLECTOR_IP" "$FLOW_PORT" <<'PY'
import json,sys
config=json.load(open(sys.argv[1])); ip=sys.argv[2]; port=int(sys.argv[3])
for bind in config['services']['backend'].get('ports',[]):
if bind.get('protocol','tcp') == 'udp' and (bind['target'] in (4739,port) or str(bind.get('published')) == str(port)):
if bind.get('host_ip') != ip or bind['target'] != port or str(bind.get('published')) != str(port):
raise SystemExit('В Compose уже есть другой IPFIX bind. Согласуйте его с адресом и портом MM и повторите установку.')
PY
if docker container inspect mmapp-netflow-agent >/dev/null 2>&1; then docker stop mmapp-netflow-agent; fi
install -d -m 700 /etc/wireguard "$COMPOSE_DIR/data/netflow-wireguard" "$COMPOSE_DIR/data/netflow-agent-socket"
if [[ -e /etc/wireguard/wg-flow.conf ]]; then
cp -L /etc/wireguard/wg-flow.conf "$COMPOSE_DIR/data/netflow-wireguard/wg-flow.conf.backup.$(date +%s)"
fi
install -m 600 "$TASK_TEMP/wg-flow.conf" "$COMPOSE_DIR/data/netflow-wireguard/wg-flow.conf"
install -m 600 "$TASK_TEMP/binding.json" "$COMPOSE_DIR/data/netflow-wireguard/binding.json"
ln -sfn "$COMPOSE_DIR/data/netflow-wireguard/wg-flow.conf" /etc/wireguard/wg-flow.conf
install -m 600 "$TASK_TEMP/compose.yml" "$COMPOSE_DIR/docker-compose.netflow-agent.yml"
install -d /etc/systemd/system/docker.service.d
cat > /etc/systemd/system/docker.service.d/mm-netflow.conf <<'EOF'
[Unit]
Wants=wg-quick@wg-flow.service
After=wg-quick@wg-flow.service
EOF
systemctl daemon-reload
systemctl enable wg-quick@wg-flow.service
if ! ip link show dev wg-flow >/dev/null 2>&1; then
systemctl start wg-quick@wg-flow.service
else
ip link set dev wg-flow up
fi
cd "$COMPOSE_DIR"
docker compose "${COMPOSE_ARGS[@]}" -f docker-compose.netflow-agent.yml up -d netflow-agent
AGENT_READY=false
for attempt in $(seq 1 60); do
if [[ "$(docker inspect --format '{{.State.Health.Status}}' mmapp-netflow-agent)" == healthy ]]; then
AGENT_READY=true
break
fi
sleep 1
done
[[ "$AGENT_READY" == true ]] || { echo "Агент не готов. Проверьте docker logs mmapp-netflow-agent; backend не пересоздан." >&2; exit 1; }
docker compose "${COMPOSE_ARGS[@]}" -f docker-compose.netflow-agent.yml up -d --no-deps --force-recreate backend
echo "Агент установлен. Используйте те же -f файлы при дальнейших операциях Compose:"
printf 'docker compose '; printf '%q ' "${COMPOSE_ARGS[@]}" -f "$COMPOSE_DIR/docker-compose.netflow-agent.yml"; echo 'up -d'
echo "В MM обновите статус хоста и нажмите «Применить настройки хоста»."
+201
View File
@@ -0,0 +1,201 @@
import base64
import copy
import json
import os
from pathlib import Path
import tempfile
import unittest
import shutil
import uuid
from unittest.mock import patch
import agent
def key(n):
return base64.b64encode(bytes([n]) * 32).decode()
class ControllerTest(unittest.TestCase):
def setUp(self):
# Windows Python 3.13's TemporaryDirectory mode 0700 excludes the restricted sandbox token.
base = Path.cwd() / "tmp" if os.name == "nt" else Path(tempfile.gettempdir())
base.mkdir(exist_ok=True)
self.root = base / ("flow-agent-test-" + uuid.uuid4().hex)
self.root.mkdir()
def cleanup():
if self.root.resolve().parent != base.resolve():
raise RuntimeError("Unexpected test directory")
shutil.rmtree(self.root)
self.addCleanup(cleanup)
self.addCleanup(patch.stopall)
patch.object(agent, "CONFIG", self.root).start()
self.interface = dict(PrivateKey=key(1), Address="10.255.254.1/24", ListenPort="51821", MTU="1420")
self.third = dict(PublicKey=key(3), AllowedIPs="192.0.2.5/32", PresharedKey=key(9), PersistentKeepalive="0")
self.desired = dict(collectorIp="10.255.254.1", prefix="10.255.254.0/24", flowListenPort=4739,
wgListenPort=51821, hostPublicKey=key(2), hostPrivateKey=key(1),
peers=[dict(serverId=1, publicKey=key(4), allowedIps=["10.255.254.2/32"], endpoint="router.test:13232")])
(self.root / "binding.json").write_text(json.dumps({k: self.desired[k] for k in ("collectorIp", "prefix", "flowListenPort", "wgListenPort")}))
self.previous = agent.render(self.interface, [self.third])
(self.root / "wg-flow.conf").write_text(self.previous)
self.runtime = [copy.deepcopy(self.third)]
self.calls = []
def run(*args, input=None):
self.calls.append(args)
if args == ("wg", "pubkey"):
return key(2) if input == key(1) + "\n" else key(8)
if args == ("wg", "showconf", "wg-flow"):
return agent.render(self.interface, self.runtime, native=True)
raise AssertionError(args)
patch.object(agent, "run", run).start()
patch.object(agent, "live", self.live).start()
patch.object(agent, "sync", self.sync).start()
self.synced = []
def live(self):
return dict(interfaceUp=True, publicKey=key(2), listenPort=51821, addresses=["10.255.254.1/24"], peers=[]), copy.deepcopy(self.runtime)
def sync(self, text):
self.synced.append(text)
# Native configs don't include wg-quick's Address; simulate applying their peers.
_, self.runtime = agent.parse_conf(text.replace("ListenPort = 51821", "ListenPort = 51821\nAddress = 10.255.254.1/24"))
def test_apply_preserves_third_party_psk_and_is_idempotent(self):
self.assertFalse(agent.check(self.desired)["configMatches"])
self.assertTrue(agent.check(self.desired, apply=True)["configMatches"])
text = (self.root / "wg-flow.conf").read_text()
self.assertIn(key(3), text)
self.assertIn(key(9), text)
self.assertIn(key(4), text)
self.assertNotIn("Address", self.synced[0])
self.assertTrue(agent.check(self.desired)["configMatches"])
agent.check(self.desired, apply=True)
self.assertEqual((self.root / "wg-flow.conf").read_text(), text)
if os.name != "nt":
self.assertEqual((self.root / "wg-flow.conf").stat().st_mode & 0o777, 0o600)
def test_runtime_only_peer_preserved(self):
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="198.51.100.7/32", PersistentKeepalive="0"))
agent.check(self.desired, apply=True)
self.assertIn(key(6), (self.root / "wg-flow.conf").read_text())
def test_runtime_peer_without_allowed_ips_preserved(self):
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="", PersistentKeepalive="0"))
agent.check(self.desired, apply=True)
self.assertTrue(agent.check(self.desired)["configMatches"])
def test_third_party_runtime_settings_are_not_reset(self):
# Runtime may contain a roaming endpoint and intentionally unsaved settings.
self.runtime[0].update(Endpoint="198.51.100.2:50000", PersistentKeepalive="10")
agent.check(self.desired, apply=True)
third = next(p for p in self.runtime if p["PublicKey"] == key(3))
self.assertEqual(third["Endpoint"], "198.51.100.2:50000")
self.assertEqual(third["PersistentKeepalive"], "10")
_, stored = agent.parse_conf((self.root / "wg-flow.conf").read_text())
self.assertEqual(next(p for p in stored if p["PublicKey"] == key(3))["PersistentKeepalive"], "0")
def test_managed_roaming_endpoint_preserved_without_changing_desired_dns(self):
agent.check(self.desired, apply=True)
next(p for p in self.runtime if p["PublicKey"] == key(4))["Endpoint"] = "198.51.100.2:50000"
agent.check(self.desired, apply=True)
_, stored = agent.parse_conf((self.root / "wg-flow.conf").read_text())
self.assertEqual(next(p for p in stored if p["PublicKey"] == key(4))["Endpoint"], "router.test:13232")
self.assertEqual(next(p for p in self.runtime if p["PublicKey"] == key(4))["Endpoint"], "198.51.100.2:50000")
self.assertTrue(agent.check(self.desired)["configMatches"])
def test_down_interface_cannot_report_applied(self):
status, peers = self.live()
with patch.object(agent, "live", return_value=({**status, "interfaceUp": False}, peers)):
with self.assertRaisesRegex(agent.AgentError, "выключен"):
agent.check(self.desired, apply=True)
self.assertEqual(self.synced, [])
def test_overlap_rejected_before_any_write(self):
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="10.255.254.0/24"))
with self.assertRaisesRegex(agent.AgentError, "пересекаются"):
agent.check(self.desired, apply=True)
self.assertEqual((self.root / "wg-flow.conf").read_text(), self.previous)
self.assertEqual(self.synced, [])
def test_different_interface_identity_rejected(self):
self.desired["hostPrivateKey"], self.desired["hostPublicKey"] = key(7), key(8)
with self.assertRaisesRegex(agent.AgentError, "существующего"):
agent.check(self.desired, apply=True)
self.assertEqual(self.synced, [])
def test_failed_apply_restores_file_and_runtime(self):
rollback = agent.render(self.interface, self.runtime, native=True)
with patch.object(agent, "sync", side_effect=[agent.AgentError("secret error"), None]) as sync:
with self.assertRaisesRegex(agent.AgentError, "предыдущая конфигурация восстановлена"):
agent.check(self.desired, apply=True)
self.assertEqual(sync.call_args_list[-1].args, (rollback,))
self.assertEqual((self.root / "wg-flow.conf").read_text(), self.previous)
def test_failed_rollback_is_explicit(self):
with patch.object(agent, "sync", side_effect=agent.AgentError("secret error")):
with self.assertRaisesRegex(agent.AgentError, "восстановление требует ручной проверки"):
agent.check(self.desired, apply=True)
def test_invalid_or_shell_input_rejected(self):
for change in [dict(flowListenPort=4740), dict(prefix="10.255.254.0/28"), dict(hostPrivateKey="$(id)"), dict(command="id"), dict(peers=[dict(serverId=1, publicKey=key(4), allowedIps=["0.0.0.0/0"])]), dict(peers=[dict(serverId=1, publicKey=key(4), allowedIps=["10.255.254.2/32"], endpoint="router.test:13232\nPostUp = id")])]:
with self.subTest(change=list(change)):
with self.assertRaises(agent.AgentError):
agent.check({**self.desired, **change}, apply=True)
self.assertEqual(self.synced, [])
def test_hooks_and_duplicate_fields_rejected(self):
for suffix in ("\nPostUp = id", "\nListenPort = 3", "\n[Interface]\nPrivateKey = secret"):
with self.assertRaises(agent.AgentError):
agent.parse_conf(agent.render(self.interface, []) + suffix)
def test_endpoint_change_requires_apply(self):
agent.check(self.desired, apply=True)
self.desired["peers"][0]["endpoint"] = "new-router.test:13232"
self.assertFalse(agent.check(self.desired)["configMatches"])
def test_command_errors_do_not_expose_keys(self):
with patch.object(agent.subprocess, "run", return_value=type("Result", (), {"returncode": 1, "stdout": key(1), "stderr": key(9)})()):
# Use the original function, not the fixture runner.
with self.assertRaisesRegex(agent.AgentError, "не выполнена") as error:
ORIGINAL_RUN("wg", "show")
self.assertNotIn(key(1), str(error.exception))
self.assertNotIn(key(9), str(error.exception))
ORIGINAL_RUN = agent.run
@unittest.skipUnless(os.environ.get("FLOW_AGENT_KERNEL_TEST") == "1", "Requires Linux kernel WireGuard and NET_ADMIN in an isolated container")
class KernelTest(unittest.TestCase):
def test_real_interface_apply_and_preservation(self):
with tempfile.TemporaryDirectory() as folder, patch.object(agent, "CONFIG", Path(folder)):
private = agent.run("wg", "genkey")
public = agent.run("wg", "pubkey", input=private + "\n")
other = agent.run("wg", "pubkey", input=agent.run("wg", "genkey") + "\n")
desired_key = agent.run("wg", "pubkey", input=agent.run("wg", "genkey") + "\n")
interface = dict(PrivateKey=private, Address="10.255.254.1/24", ListenPort="51821")
third = dict(PublicKey=other, AllowedIPs="192.0.2.3/32")
path = Path(folder)
(path / "wg-flow.conf").write_text(agent.render(interface, [third]))
snapshot = dict(collectorIp="10.255.254.1", prefix="10.255.254.0/24", flowListenPort=4739, wgListenPort=51821,
hostPublicKey=public, hostPrivateKey=private, peers=[dict(serverId=1, publicKey=desired_key, allowedIps=["10.255.254.2/32"], endpoint="127.0.0.1:13232")])
(path / "binding.json").write_text(json.dumps({k: snapshot[k] for k in ("collectorIp", "prefix", "flowListenPort", "wgListenPort")}))
agent.run("ip", "link", "add", "dev", agent.IFACE, "type", "wireguard")
try:
agent.run("ip", "address", "add", "10.255.254.1/24", "dev", agent.IFACE)
agent.sync(agent.render(interface, [third], native=True))
agent.run("ip", "link", "set", "dev", agent.IFACE, "up")
before = json.loads(agent.run("ip", "-j", "link", "show", "dev", agent.IFACE))[0]["ifindex"]
self.assertTrue(agent.check(snapshot, apply=True)["configMatches"])
self.assertTrue(agent.check(snapshot)["configMatches"])
status, _ = agent.live()
self.assertEqual({p["publicKey"] for p in status["peers"]}, {other, desired_key})
self.assertNotIn(private, json.dumps(status))
after = json.loads(agent.run("ip", "-j", "link", "show", "dev", agent.IFACE))[0]["ifindex"]
self.assertEqual(before, after)
finally:
agent.run("ip", "link", "delete", "dev", agent.IFACE)
if __name__ == "__main__":
unittest.main()
+178
View File
@@ -0,0 +1,178 @@
# Автоматическое подключение 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.
### Приёмка на стенде — 2026-10-16
На `161.104.46.222` проверены временные контейнеры новой ветки с отдельной БД.
15 тестов агента прошли с настоящим kernel WireGuard; 32 профильных скрипта
и две отдельные SQL-проверки (monthly и IPv6 UDP/worker) завершились успешно.
Оба MikroTik (`194.226.121.150`, `104.165.244.7`) подключены через API: подтверждены
handshake, сохранение первого туннеля при добавлении второго и повторное подключение
без дубликатов. В БД записаны IPFIX-потоки обоих MikroTik и синтетического Linux
экспортёра через WireGuard. Реальные перезапуски контейнеров агента и backend
сохранили соединения, приём пакетов продолжился.
При проверке исправлен API настроек: счётчики и время последнего пакета теперь
читаются из работающего коллектора. После исправления полный живой сценарий повторён.
Настройки обоих маршрутизаторов восстановлены; временные контейнеры, интерфейс,
БД/роли и приватные файлы удалены. Основные сервисы остались на прежних образах.
Установщик с интеграцией `wg-quick`/systemd и холодная перезагрузка хоста на этом
стенде не выполнялись. Образы собраны поверх существующего runtime backend;
полная release-сборка frontend и публикация образов в эту проверку не входили.
Результаты, исходные неудачные попытки и границы —
[отчёт приёмки](ux-ui/acceptance/netflow-agent-2026-10-16.json).
+43 -1
View File
@@ -1,6 +1,10 @@
# UX/UI: журнал реализации
Актуальное состояние 2026-10-16: Production271 — подготовлено исправление чтения рекурсивных маршрутов JH, подтверждено native чтением всех3 JH. Локальные Filters268, визуальная карта270 и backend271 ещё не опубликованы; production frontend2.0.1 из269. Подробности и проверки ниже.
Текущее продолжение 2026-10-16: Production276 — по разрешению владельца включён tcp-syncookies на arn-gw01; повторное чтениеtrue.8/8 HTTP200, TCP21–45мс против1–5с до изменения, полный запрос143–334мс. За2мин29с штатные5 traffic/5 gre_bgp/10 resources/1 internet_path без ошибок. Других изменений и перезапуска не было; краткая проверка не доказывает отсутствие проблем при любой нагрузке.
Продолжение 2026-10-16: Production274 — по разрешению владельца на nov-gw01 выключены FTP/Telnet и оставлены выключенными по отдельному подтверждению. За21 минуту штатного опроса42 traffic/42 gre_bgp/4 internet_path/84 uptime_resources успешны, ошибок нет; новые подключения21/23 отклоняются. Причинная связь прошлых401/500 с подбором пока не доказана. Приложение/пароли/firewall не менялись; подсказки273 остаются локальными.
Актуальное состояние 2026-10-16: Production272 — исправления268/270/271 опубликованы в main, CI2468 success, production backend/frontend обновлены до2.0.2 и healthy. В HTTP/UI подтверждены рекурсивные маршруты всех3 JH; Filters без ложного missing-route warning;4 узла карты и новая легенда. PostgreSQL не перезапускался. Read-only DNS и дальнейшая проверка трафика остаются отдельными задачами.
Предыдущее состояние 2026-10-15: Production269 — по отдельному разрешению обновлён production frontend до2.0.1; в браузере подтверждены3JH/1EN и25GRE в данных. Filters268 остаётся локальным;6 профильных E2E/TypeScript passed. Карта и миграция retention опубликованы в main; исправлен CI PostgreSQL, backend-test2467/8417 success. Карта принята в production с ограничением read-only DNS; Filters ещё не развёрнут. Online-индекс266 применён отдельно с разрешения владельца; нагрузка БД снизилась в двух повторных замерах. Проверка трафика и причин остальных тайм-аутов продолжается отдельно. Лабораторная приёмка264 завершена в документированных границах стенда:1396 основных UI и221 context-сценарий;120 live default states30 экранов. Runtime стенда backend258/frontend261. Непроверенные внешние группы сохранены в итоговом акте. Production v2.0.0 подтверждён через UI; [описание релиза](releases/2.0.0.md).
@@ -40,6 +44,44 @@
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Завершён2026-10-15 в границах предоставленного стенда:1396 UI/221 context,120 live states30 экранов, production builds/ReUI checklist и реальные интеграции. Ограничения — в [акте](ux-ui-acceptance-candidate.md); production-выпуск не выполнялся. |
## Production276 — SYN cookies arn-gw01 и повторная проверка (2026-10-16)
После прямого разрешения владельца проверены ID1/host и false;16 октября09:55:01 по Новосибирску выполнен только POST /ip/settings/set с tcp-syncookies=true. Предварительный PATCH singleton-раздела отклонён400 без применения; использована штатная команда set с тем же единственным параметром. Повторные чтения и identity подтвердили результат. Пароли, службы, firewall, маршруты, приложение, БД и контейнеры не менялись; перезапуска не было.
8 последовательных контрольных HTTP200: TCP21–45мс против1042–5136мс, полный HTTPS143–334мс против1162–5274мс в исходных4 замерах.45-секундный захват только target443 SYN/SYN-ACK:42 пакета, повторов SYN в полученном выводе нет, kernel dropped0, захват завершён. К09:57:30 штатные задания после применения:5 traffic/5 gre_bgp/10 resources/1 internet_path успешны, ошибок0, максимальные длительности1091/426/854/284мс. Финальный readbacktrue, CPU6%, uptime14m19s. В доступном неполном кольце логов новых SYN warning нет; старые события не удалялись.
В кратком сравнении улучшение подтверждено, но источник всех потерь и поведение при другой нагрузке не доказаны. [Измерения и откат](../deploy/PRODUCTION-ARN-276.md). Диагностика завершена, фоновых циклов/захвата нет. Документация сохраняется локальным коммитом без push; код не менялся, повторная сборка не требовалась.
## Production275 — диагностика SYN и задержек arn-gw01 (2026-10-16)
На ID1/arn-gw01 воспроизведён REST timeout10с. Четыре последовательных HTTP200 показали DNS2–6мс, TCP1.042–5.136с, TLS ещё99–145мс, ответ после TLS19–21мс.20-секундный захват SYN/SYN-ACK только target443 на production-хосте подтвердил повторные SYN штатного приложения и ожидание ответа2.044/4.122с; нормальный SYN-ACK18–21мс. Место потери между хостом и обработкой CHR не установлено.
RouterOS7.24.5, uptime5m41s/CPU6% в одном замере; оба warning80/443 через7с после первой записи запуска, позднее есть смена часов Europe/Stockholm. Массовую текущую атаку это не доказывает. tcp-syncookies=false; ограничения input80/443 доверенными источниками в прочитанных правилах нет, RAW пустой. Итоговый conntrack-снимок:4 локальных80/443 TCP,2 от приложения/2 других, не свидетельство большого потока. Порты и адреса этой версии считались отдельными полями.
[Протокол и подготовленный следующий шаг](../deploy/PRODUCTION-ARN-275.md). Предложено отдельно согласовать изменение только tcp-syncookies с чтением результата и коротким повторным замером; сеть провайдера оно не исправляет. Пароли, RouterOS, firewall, приложение и контейнеры не менялись. Диагностика завершена, tcpdump остановился по20с timeout; код не менялся, сборка/тесты повторно не запускались. Локальный коммит без push.
## Production274 — отключение FTP/Telnet и наблюдение (2026-10-16)
После явного разрешения владельца на ID3/nov-gw01 повторно проверены службы ftp*1/21 и telnet*0/23, затем16 октября01:33:45 по Новосибирску применено только disabled=true. Повторное чтение подтвердило результат; www/www-ssl/winbox/api/api-ssl сохранены включёнными. Владелец отдельно подтвердил оставить FTP/Telnet выключенными. Пароли, firewall, маршрутизация, задания RouterOS и контейнеры не менялись.
До01:54:44 наблюдался обычный опрос:42 traffic/42 gre_bgp/4 internet_path/84 uptime_resources успешны, ошибок0; максимальные длительности2278/910/809/1937мс. Финальный REST и uptime подтверждают доступность без перезапуска. На01:55:47 новые TCP-подключения21/23 получили ECONNREFUSED. Остаются6 старых динамических Telnet строк: принудительное завершение соединений не выполнялось. В выборках логов2 отказа Winbox с несохранёнными источниками;4 пропуска кольца из-за scheduler-изменений, поэтому непрерывное отсутствие атак не утверждается. Ошибки401/500 прекратились ещё до отключения; причинная связь не доказана. Наблюдение завершено, фоновых диагностических циклов не осталось.
По дополнительному вопросу владельца проверена production PostgreSQL только чтением: standby=false, wal_level=replica, подключённых реплик/slots/subscriptions0, synchronous_standby_names пустой. Репликация в конфигурации проекта и активном состоянии не обнаружена. [Процедура, результат и откат](../deploy/PRODUCTION-ROUTEROS-274.md). Документация сохраняется локальным коммитом, без push/deploy; исправление подсказок273 не развёрнуто.
## Production273 — диагностика отказов входа RouterOS (2026-10-16)
На nov-gw01 подтверждены соседние500 `too many concurrent logins (6)` и401; последние в01:11:40–44 по Новосибирску. Тот же сохранённый пароль работает, full/api/rest-api разрешены. На01:27 последние15 минут: traffic30/30, gre_bgp30/30, internet_path3/3 успешны. Наблюдаются внешние попытки входа через открытые FTP/Telnet и7 внешних Telnet-соединений, а также частые изменения от локального evofw-sync. Их причинная связь с отказами пока не доказана; насыщение входа нельзя приравнивать к max-sessions20 или старым user/active записям. Production, RouterOS и пароли не менялись.
Локально исправлены подсказки:401 не объявляет пароль неверным; точный500/detail распознаётся как лимит одновременных входов. Исходные status/path/body сохранены, повторов операций нет.12 HTTP-проверок, профиль ros-file, backend build, scoped ESLint и diff check прошли. [Протокол и процедура](../deploy/PRODUCTION-ROUTEROS-273.md). Без push/deploy. Следующий шаг — после отдельного разрешения временно отключить только FTP/Telnet на ID3 и сравнить обычный опрос минимум за два периода продления REST-логина. Это ещё не выполненное исправление первопричины.
## Production272 — публикация и приёмка v2.0.2 (2026-10-16)
По прямому разрешению владельца подготовленные коммиты268/269/270/271 отправлены в main до e6a2e400. Все7 jobs CI2468 success; v2.0.2 опубликован. Проверены OCI version/revision и digest обоих образов, затем обновлены только backend и frontend через Compose --no-deps --pull never. Backend healthy с00:41:33, frontend healthy с00:42:11 по Новосибирску; PostgreSQL container ID/StartedAt и SHA256 Compose сохранены. RouterOS-конфигурация и права не менялись; автоматического prune не было.
Обычный HTTP после обновления:3 JH,4/3/5 маршрутов,200/live=true/stale=false, разовые задержки1048/2575/736мс. Каталоги шлюзов200,9/7/8 вариантов за1167/3800/993мс. В браузере до обновления воспроизведена ошибка, после последовательно выбраны все3 JH и подтверждены4/3/5 endpoints без ошибки; несколько endpoints могут группироваться в одну строку. Filters выбранного московского JH:10 правил,0 ложных «нет строки в списке», /32 подписи видны. Карта:3JH/1EN, новая графическая легенда; клик даёт aria-pressed=true, outline=none и focus-ring opacity=0. DNS read-only403 mm:network:write остаётся;25GRE загружены,3 линии отрисованы, полная FQDN-связность не принята.
[Процедура и откат](../deploy/PRODUCTION-RELEASE-272.md), [анонимизированный протокол](ux-ui/acceptance/prod272-release.json); приватные скриншоты tmp/prod272-*.jpg вне Git. Формы записи/удаление в production не выполнялись. Протокол сохраняется локально без нового push, чтобы не запускать дополнительный релиз только документацией. Следующие отдельные задачи: read-only DNS, оценка поведения легенды рядом с открытыми деталями на узком холсте, независимая проверка трафика и остальных тайм-аутов; общая production-готовность всех внешних сценариев этим блоком не объявляется.
## Production271 — чтение рекурсивных маршрутов JH (2026-10-16)
Production GET /recursive-routes/catalog на двух московских JH возвращал503 «Не подтверждены параметры управляемого маршрута», на новосибирском наблюдался отдельный10с timeout. Проверка native RouterOS чтением: список и GET конкретной строки не содержат disabled/check-gateway; /ip/route/get подтверждает disabled=false, незаданный check-gateway возвращает[]. Строгий parser сохранён; добавлено дочитывание отсутствующих полей только для собственных маршрутов, максимум2 запроса одновременно. Только успешный пустой get check-gateway означает none; null/неверный envelope/ошибка не становятся defaults. Explicit invalid значения не заменяются.
+11
View File
@@ -2758,3 +2758,14 @@ Runtime backend ux-regression222/frontend ux-routing218. Latest backend25/UI52
2 roundtrip objects,12 unique API scenarios, synthetic native export/CHR contacts0. Own DB/roles/catalog/localfiles/currentobjects cleaned. Versioning Enabled:8versions+8markers removed,3 own prefixes remaining0; bucket configuration unchanged. Native backup/restore/live UI/смена storage origin/retention semantics остаются230. Private env удалены, secrets не в published files. [API](ux-ui/acceptance/s3-application229.json), [version cleanup](ux-ui/acceptance/s3-version-cleanup229.json).
Локальные .rsc после235: `/opt/mmapp-test/data/backups` → `/app/storage/backups`; см. [порядок переноса](../deploy/BACKUP-STORAGE.md). Две новые native copies после force-recreate проверены и удалены; десять старых catalog rows сохранены с зафиксированным отсутствием files.
## NetFlow host agent — 2026-10-16
Ветка `codex/netflow-host-agent`, исходники `db81cf0f` + исправление `0abdac82`.
Временные backend/agent и отдельные БД: kernel WG 15/15, профиль32 и отдельные
monthly/IPv6 UDP SQL passed. Оба CHR установили handshake и передали IPFIX в БД;
повторное подключение, сохранение стороннего пира и рестарты agent/backend прошли.
Найденные устаревшие счётчики API исправлены, живой сценарий повторён полностью.
Исходные Traffic Flow настройки восстановлены, own WG/containers/DB/roles/keys
удалены; четыре основных сервиса и их образы сохранены. Installer/systemd/cold reboot
не выполнялись. [Отчёт и границы](ux-ui/acceptance/netflow-agent-2026-10-16.json).
@@ -0,0 +1,402 @@
{
"date": "2026-10-16",
"stand": "161.104.46.222",
"branch": "codex/netflow-host-agent",
"baseCommit": "db81cf0f",
"testedSourceCommit": "0abdac82de3fd53fa0eeaae672a3681918191a9a",
"passed": true,
"scope": "Temporary backend/agent containers, separate PostgreSQL databases and host wg-flow; both authorized test MikroTik configured and restored.",
"build": {
"strategy": "Current compiled backend/contracts over existing backend runtime with matching application dependencies; test-only jose and transpiled tests added. Agent built from its Dockerfile.",
"uploadedFilesVerified": 1976,
"baseProductionCompiledFilesVerified": 1396,
"patchedCompiledRouteSha256": "99a5b78f0586cbc53c35ed4bcefadd4fdd6ecea6da075f936b7768c634b03dd1",
"patchedCompiledRouteMatchesLocal": true
},
"agentKernelTests": {
"passed": 15,
"failed": 0,
"skipped": 0,
"network": "Isolated container namespace",
"coverage": [
"Real kernel WireGuard apply",
"Foreign persistent/runtime peers",
"Unchanged interface identity",
"Validation/conflicts",
"Configuration and runtime rollback"
]
},
"sql": {
"primary": {
"database": "mm_accept_166_1792139159368",
"passed": true,
"cases": [
{
"path": "services/traffic-flow-host-file-builders.test.js",
"durationMs": 196,
"skips": []
},
{
"path": "services/traffic-flow-key-execution.test.js",
"durationMs": 54,
"skips": []
},
{
"path": "routes/traffic-flow-settings.test.js",
"durationMs": 586,
"skips": []
},
{
"path": "plugins/auth.netflow-settings.test.js",
"durationMs": 665,
"skips": []
},
{
"path": "services/traffic-flow-agent-install.test.js",
"durationMs": 174,
"skips": []
},
{
"path": "services/traffic-flow-agent-client.test.js",
"durationMs": 260,
"skips": []
},
{
"path": "services/traffic-flow-operation-queue.test.js",
"durationMs": 54,
"skips": []
},
{
"path": "routes/traffic-flow-host.test.js",
"durationMs": 830,
"skips": []
},
{
"path": "routes/traffic-flow-query.test.js",
"durationMs": 1816,
"skips": []
},
{
"path": "services/traffic-flow-month.test.js",
"durationMs": 1146,
"skips": [
"Monthly SQL: skip (requires an owned isolated database)"
]
},
{
"path": "services/traffic-flow-parse.test.js",
"durationMs": 1174,
"skips": []
},
{
"path": "services/traffic-flow-ipv6-pipeline.test.js",
"durationMs": 1203,
"skips": [
"IPv6 UDP/worker/SQL: skip (requires an owned isolated database)"
]
},
{
"path": "services/traffic-flow-map-exporter.test.js",
"durationMs": 55,
"skips": []
},
{
"path": "services/traffic-flow-ifaces.test.js",
"durationMs": 64,
"skips": []
},
{
"path": "services/traffic-flow-ifindex.test.js",
"durationMs": 59,
"skips": []
},
{
"path": "services/traffic-flow-dedup.test.js",
"durationMs": 55,
"skips": []
},
{
"path": "services/traffic-flow-planes.test.js",
"durationMs": 51,
"skips": []
},
{
"path": "services/traffic-flow-ip.test.js",
"durationMs": 75,
"skips": []
},
{
"path": "services/traffic-flow-dest.test.js",
"durationMs": 1836,
"skips": []
},
{
"path": "services/traffic-flow-classify.test.js",
"durationMs": 903,
"skips": []
},
{
"path": "services/traffic-flow-ripe.test.js",
"durationMs": 1440,
"skips": []
},
{
"path": "services/traffic-flow-brands.test.js",
"durationMs": 93,
"skips": []
},
{
"path": "services/traffic-flow-ingest.test.js",
"durationMs": 5340,
"skips": []
},
{
"path": "services/traffic-flow-analytics.test.js",
"durationMs": 2172,
"skips": []
},
{
"path": "services/traffic-flow-map-hops.test.js",
"durationMs": 1661,
"skips": []
},
{
"path": "services/traffic-flow-purge.test.js",
"durationMs": 2451,
"skips": []
},
{
"path": "services/geoip-download.test.js",
"durationMs": 667,
"skips": []
},
{
"path": "services/traffic-flow-geoip.test.js",
"durationMs": 1930,
"skips": []
},
{
"path": "services/traffic-flow-facts.test.js",
"durationMs": 964,
"skips": []
},
{
"path": "services/traffic-flow-facts-filter.test.js",
"durationMs": 1362,
"skips": []
},
{
"path": "services/traffic-flow-facts-rebuild.test.js",
"durationMs": 1833,
"skips": []
},
{
"path": "services/statistics-aggregate.test.js",
"durationMs": 1915,
"skips": []
}
]
},
"dedicatedIpv6": {
"database": "mm_accept_144_1792139159368",
"passed": true,
"cases": [
{
"path": "services/traffic-flow-ipv6-pipeline.test.js",
"durationMs": 7879,
"skips": []
}
]
},
"dedicatedMonthly": {
"database": "mm_accept_149_1792139159368",
"passed": true,
"cases": [
{
"path": "services/traffic-flow-month.test.js",
"durationMs": 3800,
"skips": []
}
]
},
"note": "The two explicitly guarded SQL cases skipped in primary were then executed without skips in separate owned databases. This profile predates only the runtime-counter route fix."
},
"live": {
"sourceCommit": "db81cf0f",
"passed": true,
"checks": [
{
"name": "native-baselines-and-own-database-prepared",
"routers": 2
},
{
"name": "real-mikrotik-connected-1",
"address": "198.18.254.2"
},
{
"name": "real-mikrotik-connected-2",
"address": "198.18.254.3"
},
{
"name": "first-router-preserved-after-second",
"peerCount": 3
},
{
"name": "repeat-connect-without-duplicate"
},
{
"name": "synthetic-ipfix-through-real-wireguard-to-postgresql",
"packetsReceived": 4,
"lastExporterIp": "198.18.254.200"
},
{
"name": "agent-restart-preserves-tunnels-and-ipfix"
},
{
"name": "backend-real-restart-reconciled",
"peerCount": 4
},
{
"name": "ipfix-after-backend-restart"
},
{
"name": "persisted-exporter-summary",
"exporters": [
{
"server_id": 1,
"buckets": 44,
"bytes": "18451"
},
{
"server_id": 2,
"buckets": 27,
"bytes": "10281"
},
{
"server_id": 3,
"buckets": 1,
"bytes": "432075"
}
]
},
{
"name": "router-baseline-restored-2"
},
{
"name": "router-baseline-restored-1"
}
],
"nativeCleanup": true
},
"cleanup": {
"nativeAndHostCleanup": true,
"ownedDatabaseRemoved": true,
"exitCode": 0
},
"finalVerification": {
"passed": true,
"services": [
{
"name": "mmapp-test-backend",
"image": "mmapp-test/backend:ux-certificates258",
"imageId": "sha256:984684709e619972bae8657208e3150d12f1c4f2b5642fecf5286a5d87e9fe71",
"running": true,
"health": "healthy",
"imageUnchanged": true
},
{
"name": "mmapp-test-frontend",
"image": "mmapp-test/frontend:ux-accessibility261",
"imageId": "sha256:eec4410f1109a46e864d695cfb4311fdb9a224247549c8a9e78e44c6c0e455cb",
"running": true,
"health": null,
"imageUnchanged": true
},
{
"name": "mmapp-test-portal",
"image": "git.shx.one/denozord/auth-portal@sha256:2bdf42400a1b35caa43303b9d01078b054b2a9041eba7019c1fffde71b7e4489",
"imageId": "sha256:2bdf42400a1b35caa43303b9d01078b054b2a9041eba7019c1fffde71b7e4489",
"running": true,
"health": "healthy",
"imageUnchanged": true
},
{
"name": "mmapp-test-postgres",
"image": "postgres:18-alpine",
"imageId": "sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873",
"running": true,
"health": "healthy",
"imageUnchanged": true
}
],
"temporaryContainersRemaining": 0,
"hostOwnedWireguardRemaining": false,
"privateFixturesRemoved": true,
"mainFlowSettings": {
"enabled": false,
"collector_ip": "10.255.254.1",
"prefix": "10.255.254.0/24",
"flow_listen_port": 4739,
"wg_listen_port": 51821,
"peerCount": 0
},
"ownedDatabasesAndRolesRemaining": 0,
"routers": [
{
"id": "1",
"host": "194.226.121.150",
"reachable": true,
"ownedWireguardInterfaces": 0,
"ownedFlowTargets": 0,
"ownedCollectorRoutes": 0,
"trafficFlowEnabled": "false"
},
{
"id": "2",
"host": "104.165.244.7",
"reachable": true,
"ownedWireguardInterfaces": 0,
"ownedFlowTargets": 0,
"ownedCollectorRoutes": 0,
"trafficFlowEnabled": "false"
}
]
},
"correction": {
"commit": "0abdac82",
"problem": "Settings API returned stale persisted counters despite successful worker ingestion.",
"change": "Read packet count, last exporter, error and packet time from collector runtime counters.",
"regressionProof": "Initial live run persisted flows but timed out waiting for the settings packet counter after agent restart. Final run observed packetsReceived=4 and exporter 198.18.254.200, then passed both actual container restarts and continued reception.",
"localValidation": [
"Backend/contracts build",
"8 netflow-settings test scripts",
"ESLint for modified route"
]
},
"earlierAttempts": [
{
"stage": "SQL runner",
"result": "Harness FLOW_OPTIONAL truthiness corrected; primary profile rerun passed."
},
{
"stage": "Live runner bootstrap",
"result": "Production image lacks development pino-pretty; harness disabled pretty logging; resources cleaned."
},
{
"stage": "Live counter verification",
"result": "Product stale-counter defect detected and fixed; both routers and host restored before final full live rerun.",
"failure": {
"phase": "exercise",
"name": "Error",
"message": "Timed out: IPFIX continues during agent restart"
}
}
],
"nativeExportEvidence": "Own DB recorded server_id 1 and 2 through their distinct WG source addresses: 44 buckets/18451 bytes and 27 buckets/10281 bytes. Server 3 is the separate synthetic Linux IPFIX exporter (1 bucket/432075 bytes). These are exported flow-byte totals, not captured UDP wire bytes.",
"limits": [
"One-time installer was not applied to the primary stand deployment; its compose file is named compose.yaml. Installer integration with wg-quick/systemd and cold host reboot remain unverified on this stand.",
"No production deployment, registry publication, or primary frontend/backend replacement was performed.",
"Docker images and source/log artifacts are retained under /opt/mmapp-test/artifacts/netflow-agent-db81cf0f-20261016; temporary containers, owned DBs/roles and private fixture files were removed.",
"This is a functional acceptance run, not a load or long-duration stability test."
]
}
@@ -0,0 +1,40 @@
{
"date": "2026-10-16",
"authorization": "Publish prepared Filters/map/recursive fixes to main; after successful CI update production backend and frontend only",
"version": "2.0.2",
"revision": "e6a2e4009d8af9e0e2f98c645791981c2b6d6ca8",
"ci": { "url": "https://git.shx.one/denozord/MikrotikManager/actions/runs/2468", "allSevenJobs": "success" },
"images": {
"backend": "sha256:1a02d517b16d9d475b478e7fae1a2749bad88a156f0e49183e8cb57e2d1a7f10",
"frontend": "sha256:fbd0c3743613a60d18dc6a13120cf1d755f89bc574c490e363d235301a267765",
"ociVersionAndRevision": "verified before restart"
},
"deployment": {
"backendStartedAt": "2026-10-16T00:41:33+07:00",
"frontendStartedAt": "2026-10-16T00:42:11+07:00",
"backendHealth": "healthy",
"frontendHealth": "healthy",
"postgresContainerAndStartedAtUnchanged": true,
"composeFileUnchanged": true,
"composeSha256": "e675c00d1cbdf6558f9cad601c2674007d899d1b47c92acd0d866ff51bb61bd31",
"routerOsConfigurationWriteActionsByAgent": 0,
"permissionsChanged": false
},
"httpAcceptance": [
{ "serverId": 2, "routesHttp": 200, "routes": 4, "routeReadMs": 1048, "live": true, "stale": false, "gatewaysHttp": 200, "gateways": 9, "gatewayReadMs": 1167 },
{ "serverId": 3, "routesHttp": 200, "routes": 3, "routeReadMs": 2575, "live": true, "stale": false, "gatewaysHttp": 200, "gateways": 7, "gatewayReadMs": 3800 },
{ "serverId": 4, "routesHttp": 200, "routes": 5, "routeReadMs": 736, "live": true, "stale": false, "gatewaysHttp": 200, "gateways": 8, "gatewayReadMs": 993 }
],
"uiAcceptance": {
"recursiveRoutes": "All three JH selected; 4/3/5 route endpoints displayed without the previous error; grouped rows may contain multiple endpoints",
"filters": "Selected server 2: 10 rendered rules, zero missing-route warnings, recursive /32 addresses visible",
"map": "4 devices (3 JH/1 EN), expanded graphical legend, 25 GRE loaded / 3 rendered links",
"pointerSelection": { "selected": true, "computedOutlineStyle": "none", "keyboardRingOpacity": "0" },
"editingAndDeletion": "not executed; read-only account"
},
"limitations": [
"Read-only DNS still rejected by mm:network:write; unresolved FQDN links are not confirmed",
"One-off API timings, not a capacity benchmark or proof that every network timeout is eliminated"
],
"privateScreenshotsOutsideGit": ["tmp/prod272-recursive.jpg", "tmp/prod272-filters.jpg", "tmp/prod272-map-overview.jpg"]
}
+20
View File
@@ -9,6 +9,23 @@ export const flowHostPeerSchema = z.object({
endpoint: z.string().optional(),
})
export const trafficFlowHostStatusSchema = z.object({
configured: z.boolean(),
reachable: z.boolean(),
interfaceUp: z.boolean(),
configMatches: z.boolean().nullable(),
publicKey: z.string(),
listenPort: z.number().int().nonnegative(),
addresses: z.array(z.string()),
peers: z.array(z.object({
publicKey: z.string(), allowedIps: z.array(z.string()),
lastHandshakeAt: z.number().int().nonnegative().nullable(),
receivedBytes: z.number().nonnegative(), sentBytes: z.number().nonnegative(),
})),
error: z.string().nullable(),
})
export type TrafficFlowHostStatus = z.infer<typeof trafficFlowHostStatusSchema>
export const trafficFlowSettingsDtoSchema = z.object({
enabled: z.boolean(),
collectorIp: z.string().min(1),
@@ -77,6 +94,9 @@ export const trafficFlowOverlayResultSchema = z.object({
trafficFlow: z.boolean(),
steps: z.array(z.string()),
hostFiles: z.array(trafficFlowHostFileSchema),
hostStatus: trafficFlowHostStatusSchema.optional(),
stage: z.enum(["manual", "host-pending", "applied"]).optional(),
hostError: z.string().optional(),
})
export const flowTalkerDtoSchema = z.object({
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "@playwright/test"
import base from "./playwright.config"
const server = base.webServer
if (!server || Array.isArray(server)) throw new Error("Ожидается один локальный сервер")
export default defineConfig({
...base,
use: { ...base.use, baseURL: "http://127.0.0.1:3102" },
testIgnore: [],
testMatch: ["netflow-agent.spec.ts", "netflow-settings.spec.ts"],
webServer: {
...server,
command: "node node_modules/next/dist/bin/next dev --webpack --hostname 127.0.0.1 --port 3102",
url: "http://127.0.0.1:3102",
},
})
+14
View File
@@ -10,13 +10,27 @@ import type {
TrafficFlowOverlayResult,
TrafficFlowSettingsDto,
TrafficFlowSettingsPatch,
TrafficFlowHostStatus,
} from "@mmapp/contracts/traffic-flow"
import { z } from "zod"
import { trafficFlowSettingsDtoSchema, trafficFlowSettingsPatchSchema, trafficFlowHostFileSchema, flowPurgeDtoSchema, flowAnalyticsDtoSchema, flowMonthlyDtoSchema } from "@mmapp/contracts/traffic-flow"
import { requestJson } from "@/shared/api/http-client"
import { trafficFlowHostStatusSchema } from "@mmapp/contracts/traffic-flow"
export type { TrafficFlowHostFile }
export async function getTrafficFlowHostStatus(baseUrl: string, init?: RequestInit): Promise<TrafficFlowHostStatus> {
return flowConfirmed(trafficFlowHostStatusSchema, await requestJson(baseUrl, "/api/traffic/flow/host-status", {
...init, signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(65_000)]),
}))
}
export async function reconcileTrafficFlowHost(baseUrl: string, init?: RequestInit): Promise<TrafficFlowHostStatus> {
const response = flowConfirmed(z.object({ ok: z.literal(true), hostStatus: trafficFlowHostStatusSchema }),
await requestJson(baseUrl, "/api/traffic/flow/host-reconcile", { ...init, method: "POST", signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(120_000)]) }))
return response.hostStatus
}
const flowSettingsSchema = trafficFlowSettingsDtoSchema.extend({ flowListenPort: z.number().int().min(1).max(65535), wgListenPort: z.number().int().min(1).max(65535) }).refine(data => (!data.hasHostPrivateKey || data.hostPublicKey.length > 0) && (!data.listenerBound || !!data.listenerAddress) && (data.lastDatagramAt === null || Number.isFinite(Date.parse(data.lastDatagramAt))) && new Set(data.peers.map(peer => peer.serverId)).size === data.peers.length, "Несогласованные настройки NetFlow")
const flowPatchSchema = trafficFlowSettingsPatchSchema.strict()
// Optional overlay/wire measurements may be unavailable independently of the
+58
View File
@@ -0,0 +1,58 @@
import { test, expect, type Page } from "@playwright/test"
import { keyedNetflowSettings } from "./fixtures/netflow-settings"
const installed = {
configured: true, reachable: true, interfaceUp: true, configMatches: false,
publicKey: "public-fixture", listenPort: 51821, addresses: ["10.255.254.1/24"],
peers: [{ publicKey: "router-fixture", allowedIps: ["10.255.254.2/32"], lastHandshakeAt: null, receivedBytes: 0, sentBytes: 0 }], error: null,
}
async function setup(page: Page) {
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
if (path === "/api/auth/config") return route.fulfill({ json: { required: false } })
if (path === "/api/traffic/flow/settings") return route.fulfill({ json: keyedNetflowSettings })
if (path === "/api/traffic/flow/host-status") return route.fulfill({ json: installed })
return route.fulfill({ status: 503, json: { error: "outside agent fixture" } })
})
}
test("NetFlow agent: apply, handshake waiting and frozen installation fields", async ({ page }, testInfo) => {
await setup(page)
let applied = false, posts = 0
await page.route("**/api/traffic/flow/host-status", route => route.fulfill({ json: { ...installed, configMatches: applied } }))
await page.route("**/api/traffic/flow/host-reconcile", route => { applied = true; posts++; return route.fulfill({ json: { ok: true, hostStatus: { ...installed, configMatches: true } } }) })
await page.goto("/data-collection")
await expect(page.getByRole("textbox", { name: "UDP IPFIX", exact: true })).toBeDisabled()
await expect(page.getByRole("textbox", { name: "Collector IP", exact: true })).toBeDisabled()
await expect(page.getByText("Ожидание handshake", { exact: false })).toBeVisible()
await page.getByRole("button", { name: "Применить настройки хоста", exact: true }).click()
await expect(page.getByText("Настройки применены", { exact: true })).toBeVisible()
expect(posts).toBe(1)
const panel = page.getByRole("region", { name: "Настройки NetFlow", exact: true })
await expect(panel.getByText(/Последний пакет:.*Не получен/)).toBeVisible()
await panel.screenshot({ path: testInfo.outputPath("netflow-agent.png") })
})
test("NetFlow agent: apply conflict stays visible after status refresh", async ({ page }) => {
await setup(page)
let posts = 0
await page.route("**/api/traffic/flow/host-reconcile", route => { posts++; return route.fulfill({ status: 502, json: { error: "AllowedIPs пересекаются с другим пиром" } }) })
await page.goto("/data-collection")
await page.getByRole("button", { name: "Применить настройки хоста", exact: true }).click()
await expect(page.getByText("AllowedIPs пересекаются с другим пиром", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Обновить состояние хоста", exact: true }).click()
await expect(page.getByText("AllowedIPs пересекаются с другим пиром", { exact: true })).toBeVisible()
expect(posts).toBe(1)
})
test("NetFlow agent: unavailable status does not invent success or enable apply", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 900 })
await setup(page)
await page.route("**/api/traffic/flow/host-status", route => route.fulfill({ json: { ...installed, reachable: false, interfaceUp: false, configMatches: null, peers: [], error: "Агент недоступен" } }))
await page.goto("/data-collection")
await expect(page.getByRole("button", { name: "Применить настройки хоста", exact: true })).toBeDisabled()
await expect(page.getByText("Настройки применены", { exact: true })).toHaveCount(0)
await expect(page.getByRole("textbox", { name: "UDP IPFIX", exact: true })).toHaveValue("4739")
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
})
+38
View File
@@ -0,0 +1,38 @@
import {test, expect} from '@playwright/test'
import {buildTrafficFromSamples} from '../../backend/src/services/traffic-rate'
for (const mode of ['servers','flows'] as const) for (const minutes of [5,15]) {
test(`${mode}: ${minutes} минут рисуются линией`, async ({page}) => {
const end=Date.now(), name='Короткая история'
const history=(span:number)=>buildTrafficFromSamples(Array.from({length:span*2+1},(_,i)=>({
interfaceName:'ether1',sampledAt:new Date(end-span*60000+i*30000).toISOString(),
rxBytes:i*3750000,txBytes:i*1875000,rxBps:0,txBps:0,running:true,disabled:false,
})),end-span*60000,end,undefined,undefined,true)
await page.route('**/health',r=>r.fulfill({json:{ok:true}}))
await page.route('**/api/**',r=>{
const url=new URL(r.request().url()),path=url.pathname,span=url.searchParams.get('range')==='5m'?5:url.searchParams.get('range')==='15m'?15:60
const built=history(span),server={id:'1',name,site:'TEST',country:'RU',status:'online',...built,rxTotal:1,txTotal:1,volumeUnit:'GiB'}
const card={id:'1',name,subtitle:'',site:'TEST',country:'RU',status:'online',rxNow:1,txNow:0,sessions:1,bytes:60000,rxSeries:[1,2,1],txSeries:[0,0,0]}
const analytics={bpsNow:1000000,bytes:60000,packets:10,conversations:1,uniqueSrc:1,uniqueDst:1,topProto:'TCP',rxSeries:Array.from({length:span},(_,i)=>1+i%3),txSeries:Array(span).fill(0),applications:[],protocols:[],sources:[],destinations:[],interfaces:[],conversationsList:[],ifaces:[],live:false}
if(path.endsWith('/live'))return r.fulfill({status:503,json:{error:'Live выключен в проверке истории'}})
const json=path==='/api/auth/config'?{required:false}:path==='/api/traffic/servers'?{servers:[server]}:
path==='/api/traffic/servers/1'?{server}:path==='/api/traffic/flow/exporters'?{exporters:[card]}:
path==='/api/traffic/flow/analytics'?analytics:path==='/api/traffic/flow/clients'?{clients:[]}:
path==='/api/traffic/flows'?{exportersOnline:1,bytesPerMin:1,uniqueSrc:1,uniqueDst:1,topProto:'TCP',talkers:[],listenerBound:true}:
path==='/api/traffic/users'?{users:[]}:path.endsWith('/interfaces')||path.endsWith('/bound-interfaces')?{interfaces:[]}:
path==='/api/servers'?[]:{}
return r.fulfill({json})
})
await page.goto('/traffic')
if(mode==='flows')await page.getByRole('group',{name:'Разделы трафика'}).getByRole('button',{name:'Потоки',exact:true}).click()
await page.getByRole('button',{name:`${minutes}м`,exact:true}).click()
const chart=page.getByRole('region',{name:'История RX/TX'})
await expect(chart).toBeVisible()
const curves=chart.locator('path.recharts-line-curve')
await expect(curves).toHaveCount(2)
await expect.poll(async()=>((await curves.first().getAttribute('d'))?.match(/[CL]/g)||[]).length).toBeGreaterThan(2)
await expect.poll(()=>curves.first().evaluate(el=>(el as SVGGraphicsElement).getBBox().width/el.closest('[role="region"]')!.clientWidth)).toBeGreaterThan(0.7)
await expect(chart.locator('[data-isolated-direction]')).toHaveCount(0)
await page.screenshot({path:`tmp/netflow-agent-stand/traffic-short-${mode}-${minutes}.png`,fullPage:true})
})
}