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