Compare commits

...
15 Commits
Author SHA1 Message Date
Denozordec 2852b11234 test(netflow): зафиксировать приёмку на двух маршрутизаторах
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 4m27s
Docker images / frontend-image (push) Successful in 2m19s
Docker images / updater-image (push) Successful in 39s
Docker images / netflow-agent-image (push) Successful in 1m6s
Docker images / backend-image (push) Successful in 1m40s
Docker images / publish-release (push) Successful in 10s
Docker images / notify-webhook (push) Successful in 2s
2026-10-16 15:47:50 +07:00
Denozordec 0abdac82de fix(netflow): показывать текущие счётчики приёмника 2026-10-16 15:45:10 +07:00
Denozordec db81cf0f97 feat(netflow): автоматизировать настройку туннеля коллектора 2026-10-16 15:03:23 +07:00
Denozordec fcca92706b chore(docs): сохранить проверку защиты от переполнения очереди подключений 2026-10-16 09:58:16 +07:00
Denozordec 4881306f0b chore(docs): сохранить диагностику задержек подключения к серверу 2026-10-16 09:53:53 +07:00
Denozordec ef229ca9c4 chore(docs): сохранить результат отключения небезопасных служб 2026-10-16 01:56:22 +07:00
Denozordec 25ae76dc3f fix(api): уточнить причины отказа входа на маршрутизатор 2026-10-16 01:32:29 +07:00
Denozordec 552d5f831f chore(docs): зафиксировать приёмку обновления в рабочем окружении 2026-10-16 00:53:30 +07:00
Denozordec e6a2e4009d fix(routes): читать рекурсивные маршруты с незаданными свойствами
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 5m2s
Docker images / frontend-image (push) Successful in 2m46s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 1m54s
Docker images / publish-release (push) Successful in 15s
Docker images / notify-webhook (push) Successful in 2s
2026-10-16 00:28:45 +07:00
Denozordec eb24a08981 fix(map): восстановить легенду и убрать лишние рамки фокуса 2026-10-15 23:36:19 +07:00
Denozordec 1c11a133f3 chore(docs): зафиксировать обновление карты в рабочем окружении 2026-10-15 23:24:54 +07:00
Denozordec 0f0d3aa060 fix(filters): восстановить связь рекурсивного шлюза после опроса 2026-10-15 23:17:42 +07:00
Denozordec 042407f03e fix(ci): подключить тесты к базе в сети задания
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 5m11s
Docker images / frontend-image (push) Successful in 2m15s
Docker images / updater-image (push) Successful in 41s
Docker images / backend-image (push) Successful in 1m41s
Docker images / publish-release (push) Successful in 10s
Docker images / notify-webhook (push) Successful in 2s
2026-10-15 22:14:14 +07:00
Denozordec b2cf71fcc7 fix(db): индексировать историю для очистки планировщика
Docker images / prepare-release (push) Successful in 14s
Docker images / backend-test (push) Failing after 2m49s
Docker images / backend-image (push) Skipped
Docker images / frontend-image (push) Successful in 2m39s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 42s
Docker images / publish-release (push) Skipped
2026-10-15 22:04:38 +07:00
Denozordec fda7f353ad fix(map): сохранить узлы при датах опроса из базы данных 2026-10-15 21:51:43 +07:00
68 changed files with 3162 additions and 57 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
deploy/netflow-agent/*.sh text eol=lf
deploy/netflow-agent/*.py text eol=lf
+92 -5
View File
@@ -57,16 +57,31 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
NAME="mm-pg-${{ gitea.run_id }}-${{ gitea.run_attempt }}" NAME="mm-pg-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
docker rm -f "$NAME" mm-pg 2>/dev/null || true docker rm -f "$NAME" 2>/dev/null || true
HOST_PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()') # Docker-backed Gitea runners execute this step in a separate container.
# Join its network; a published host loopback port is not reachable here.
NETWORKS=$(docker inspect --format '{{range $name, $network := .NetworkSettings.Networks}}{{println $name}}{{end}}' "$(cat /etc/hostname)" 2>/dev/null || true)
if [ -n "$NETWORKS" ]; then
test "$(printf '%s\n' "$NETWORKS" | wc -l)" -eq 1
NETWORK_ARGS=(--network "$NETWORKS" --network-alias "$NAME")
PG_HOST="$NAME"
PG_PORT=5432
else
# Host-based runners can use a Docker-assigned ephemeral host port.
NETWORK_ARGS=(-p 127.0.0.1::5432)
PG_HOST=127.0.0.1
fi
docker run -d --name "$NAME" --rm \ docker run -d --name "$NAME" --rm \
-e POSTGRES_USER=mmapp \ -e POSTGRES_USER=mmapp \
-e POSTGRES_PASSWORD=mmapp \ -e POSTGRES_PASSWORD=mmapp \
-e POSTGRES_DB=mmapp \ -e POSTGRES_DB=mmapp \
-p "127.0.0.1:${HOST_PORT}:5432" \ "${NETWORK_ARGS[@]}" \
postgres:18-alpine postgres:18-alpine
echo "PG_CONTAINER=$NAME" >> "${GITHUB_ENV}" echo "PG_CONTAINER=$NAME" >> "${GITHUB_ENV}"
echo "DATABASE_URL=postgres://mmapp:mmapp@127.0.0.1:${HOST_PORT}/mmapp" >> "${GITHUB_ENV}" if [ "$PG_HOST" = 127.0.0.1 ]; then
PG_PORT=$(docker port "$NAME" 5432/tcp | awk -F: '{print $NF}')
fi
echo "DATABASE_URL=postgres://mmapp:mmapp@${PG_HOST}:${PG_PORT}/mmapp" >> "${GITHUB_ENV}"
for i in $(seq 1 40); do for i in $(seq 1 40); do
if docker exec "$NAME" pg_isready -U mmapp -d mmapp; then if docker exec "$NAME" pg_isready -U mmapp -d mmapp; then
exit 0 exit 0
@@ -79,15 +94,27 @@ jobs:
- name: Install and test backend - name: Install and test backend
shell: bash shell: bash
env:
REQUIRE_TEST_POSTGRES: "1"
run: | run: |
set -euo pipefail set -euo pipefail
npm ci npm ci
node --input-type=module <<'NODE'
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, connectionTimeoutMillis: 8000 });
try {
await pool.query('SELECT 1');
console.log('PostgreSQL reachable from the test process');
} finally {
await pool.end();
}
NODE
npm run test --prefix backend npm run test --prefix backend
- name: Stop PostgreSQL - name: Stop PostgreSQL
if: always() if: always()
shell: bash shell: bash
run: docker rm -f "${PG_CONTAINER:-}" mm-pg 2>/dev/null || true run: docker rm -f "${PG_CONTAINER:-}" 2>/dev/null || true
backend-image: backend-image:
needs: needs:
@@ -128,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
@@ -302,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:
@@ -393,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 }}
+8
View File
@@ -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/
+4
View File
@@ -568,3 +568,7 @@ docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
10. Проверить `curl` на `:8000/health` и `:3000/`. 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).
+7 -1
View File
@@ -1676,11 +1676,17 @@ function FiltersPage() {
} }
const openEdit = (rule: FilterRule) => { const openEdit = (rule: FilterRule) => {
if (mutationsLocked || applyLock.current) return if (mutationsLocked || applyLock.current) return
// The observed catalog can have new DB IDs after refresh. Recover only an
// explicit recursive reference, never reinterpret a named GRE interface.
const routes = recRoutesByServer[selectedServerId] ?? []
const recursive = isRecursiveGatewayRef(rule.gatewayTunnelId)
? routes.find(r => r.id === rule.gatewayTunnelId.slice(4)) ?? pickRecursiveRouteByGatewayHop(routes, rule.gateway)
: undefined
setSheetInitial({ setSheetInitial({
action: rule.action ?? "route", action: rule.action ?? "route",
community: rule.community, community: rule.community,
gateway: rule.gateway, gateway: rule.gateway,
gatewayTunnelId: rule.gatewayTunnelId, gatewayTunnelId: recursive ? `rec:${recursive.id}` : rule.gatewayTunnelId,
gatewayKind: isRecursiveGatewayRef(rule.gatewayTunnelId) ? "recursive" : "gre", gatewayKind: isRecursiveGatewayRef(rule.gatewayTunnelId) ? "recursive" : "gre",
description: rule.description, description: rule.description,
}) })
+14 -10
View File
@@ -11,6 +11,7 @@ import {Dialog,DialogContent,DialogHeader,DialogTitle,DialogDescription} from "@
import {usePrefersReducedMotion} from "@/hooks/use-prefers-reduced-motion" import {usePrefersReducedMotion} from "@/hooks/use-prefers-reduced-motion"
import {toast} from "sonner" import {toast} from "sonner"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { NetworkMapLegend } from "@/components/network-map-legend"
import { import {
servers as mockServers, servers as mockServers,
greTunnels as mockGreTunnels, greTunnels as mockGreTunnels,
@@ -556,7 +557,7 @@ function GreEdgeMetricBadge({
"Клик — панель справа." "Клик — панель справа."
return ( return (
<g <g
role="button" aria-label={"Метрики "+(outerSummary??"связи")} tabIndex={0} className="focus-visible:outline-2 focus-visible:outline-ring" role="button" aria-label={"Метрики "+(outerSummary??"связи")} tabIndex={0} className="network-map-focusable"
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onOpen(e)}}} onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onOpen(e)}}}
transform={`translate(${mx},${my})`} transform={`translate(${mx},${my})`}
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
@@ -564,6 +565,7 @@ function GreEdgeMetricBadge({
onClick={(e) => { e.stopPropagation(); onOpen(e) }} onClick={(e) => { e.stopPropagation(); onOpen(e) }}
> >
<title>{hint}</title> <title>{hint}</title>
<rect className="network-map-focus-ring" x={-bw / 2 - 4} y={-bh / 2 - 4} width={bw + 8} height={bh + 8} rx="9" fill="none" stroke="var(--ring)" strokeWidth="2" />
<rect <rect
x={-bw / 2} x={-bw / 2}
y={-bh / 2} y={-bh / 2}
@@ -607,7 +609,7 @@ function NetflowRateBadge({
const bh = showDir ? 32 : 20 const bh = showDir ? 32 : 20
return ( return (
<g <g
role={onOpen?"button":undefined} aria-label={onOpen?"Поток NetFlow между узлами":undefined} tabIndex={onOpen?0:undefined} className="focus-visible:outline-2 focus-visible:outline-ring" role={onOpen?"button":undefined} aria-label={onOpen?"Поток NetFlow между узлами":undefined} tabIndex={onOpen?0:undefined} className="network-map-focusable"
onKeyDown={e=>{if(onOpen&&(e.key==="Enter"||e.key===" ")){e.preventDefault();e.stopPropagation();onOpen(e)}}} onKeyDown={e=>{if(onOpen&&(e.key==="Enter"||e.key===" ")){e.preventDefault();e.stopPropagation();onOpen(e)}}}
transform={`translate(${mx},${my})`} transform={`translate(${mx},${my})`}
style={{ cursor: onOpen ? "pointer" : "default" }} style={{ cursor: onOpen ? "pointer" : "default" }}
@@ -617,6 +619,7 @@ function NetflowRateBadge({
<title> <title>
Поток NetFlow между узлами (как в «Трафик»: 5 мин, без overlay/mesh). Скорость канала — отдельно. Поток NetFlow между узлами (как в «Трафик»: 5 мин, без overlay/mesh). Скорость канала — отдельно.
</title> </title>
<rect className="network-map-focus-ring" x={-bw / 2 - 4} y={-bh / 2 - 4} width={bw + 8} height={bh + 8} rx="9" fill="none" stroke="var(--ring)" strokeWidth="2" />
<rect <rect
x={-bw / 2} x={-bw / 2}
y={-bh / 2} y={-bh / 2}
@@ -687,7 +690,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat
const ts = TYPE_STYLE[n.type] const ts = TYPE_STYLE[n.type]
return ( return (
<g className="focus-visible:outline-2 focus-visible:outline-ring" role="button" aria-label={"Устройство "+n.name} aria-pressed={isSel} tabIndex={isVis?0:-1} <g className="network-map-focusable" role="button" aria-label={"Устройство "+n.name} aria-pressed={isSel} tabIndex={isVis?0:-1}
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}} onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}}
transform={`translate(${n.x},${n.y})`} transform={`translate(${n.x},${n.y})`}
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }} style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
@@ -699,6 +702,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat
onMouseEnter={() => onHoverChange(true)} onMouseEnter={() => onHoverChange(true)}
onMouseLeave={() => onHoverChange(false)} onMouseLeave={() => onHoverChange(false)}
> >
<circle className="network-map-focus-ring" r={ts.r + 6} fill="none" stroke="var(--ring)" strokeWidth="2" />
{n.status === "online" && isVis && ( {n.status === "online" && isVis && (
<circle r={ts.r + 14} fill={ss.glow} opacity="0.7"> <circle r={ts.r + 14} fill={ss.glow} opacity="0.7">
</circle> </circle>
@@ -707,7 +711,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat
<circle r={ts.r + 12} fill={ss.glow} /> <circle r={ts.r + 12} fill={ss.glow} />
)} )}
{isSel && ( {isSel && (
<circle r={ts.r + 10} fill="none" stroke="var(--foreground)" strokeWidth="1.5" strokeDasharray="4 3" /> <circle r={ts.r + 10} fill="none" stroke="var(--foreground)" strokeWidth="1.5" strokeDasharray="4 3" opacity="0.45" />
)} )}
<circle r={ts.r} fill={ss.fill} stroke={ss.stroke} strokeWidth={isSel ? 2.5 : 1.8} /> <circle r={ts.r} fill={ss.fill} stroke={ss.stroke} strokeWidth={isSel ? 2.5 : 1.8} />
<g transform={`translate(${ts.r - 10},-${ts.r - 10})`}> <g transform={`translate(${ts.r - 10},-${ts.r - 10})`}>
@@ -807,7 +811,7 @@ function ServiceNode({
const bh = MAP_SERVICE_NODE_H const bh = MAP_SERVICE_NODE_H
const flagIso = destMode === "countries" && iso && iso !== "Прочее" ? iso : "" const flagIso = destMode === "countries" && iso && iso !== "Прочее" ? iso : ""
return ( return (
<g role="button" aria-label={(destMode==="countries"?"Страна ":"Сервис ")+label} aria-pressed={isSel} tabIndex={isVis?0:-1} className="focus-visible:outline-2 focus-visible:outline-ring" <g role="button" aria-label={(destMode==="countries"?"Страна ":"Сервис ")+label} aria-pressed={isSel} tabIndex={isVis?0:-1} className="network-map-focusable"
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}} onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}}
transform={`translate(${x},${y})`} transform={`translate(${x},${y})`}
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }} style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
@@ -816,6 +820,7 @@ function ServiceNode({
onClick={(e) => { e.stopPropagation(); onClick() }} onClick={(e) => { e.stopPropagation(); onClick() }}
> >
<title>{`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`}</title> <title>{`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`}</title>
<rect className="network-map-focus-ring" x={-bw / 2 - 3} y={-bh / 2 - 3} width={bw + 6} height={bh + 6} rx="9" fill="none" stroke="var(--ring)" strokeWidth="2" />
{isSel && ( {isSel && (
<rect <rect
x={-bw / 2 - 6} x={-bw / 2 - 6}
@@ -925,12 +930,13 @@ function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onPo
}) { }) {
const r = 18 const r = 18
return ( return (
<g role="button" aria-label={"Аплинк "+wan.name} aria-pressed={isSel} tabIndex={0} className="focus-visible:outline-2 focus-visible:outline-ring" <g role="button" aria-label={"Аплинк "+wan.name} aria-pressed={isSel} tabIndex={0} className="network-map-focusable"
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onSelect()}}} onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onSelect()}}}
transform={`translate(${x},${y})`} transform={`translate(${x},${y})`}
style={{ cursor: isDragged ? "grabbing" : "grab" }} style={{ cursor: isDragged ? "grabbing" : "grab" }}
onPointerDown={e => { e.stopPropagation(); onPointerDown(e) }} onPointerDown={e => { e.stopPropagation(); onPointerDown(e) }}
onClick={e => { e.stopPropagation(); onSelect() }}> onClick={e => { e.stopPropagation(); onSelect() }}>
<circle className="network-map-focus-ring" r={r + 4} fill="none" stroke="var(--ring)" strokeWidth="2" />
{active && ( {active && (
<circle r={r + 6} fill={color} opacity="0.12"> <circle r={r + 6} fill={color} opacity="0.12">
</circle> </circle>
@@ -1718,6 +1724,7 @@ function NetworkMapContent() {
if (!svg) return if (!svg) return
// Clone with full viewBox (reset pan/zoom) // Clone with full viewBox (reset pan/zoom)
const clone = svg.cloneNode(true) as SVGSVGElement const clone = svg.cloneNode(true) as SVGSVGElement
clone.querySelectorAll(".network-map-focus-ring").forEach(ring => ring.remove())
clone.setAttribute("viewBox", `0 0 ${W} ${H}`) clone.setAttribute("viewBox", `0 0 ${W} ${H}`)
clone.setAttribute("width", String(W)) clone.setAttribute("width", String(W))
clone.setAttribute("height", String(H)) clone.setAttribute("height", String(H))
@@ -2252,10 +2259,7 @@ function NetworkMapContent() {
{/* ── Canvas ── */} {/* ── Canvas ── */}
<div ref={canvasRef} className={cn("flex-1 min-h-0 min-w-0 relative overflow-hidden bg-background select-none", (selected || selectedGreEdge || selectedService) && "hidden lg:block")}> <div ref={canvasRef} className={cn("flex-1 min-h-0 min-w-0 relative overflow-hidden bg-background select-none", (selected || selectedGreEdge || selectedService) && "hidden lg:block")}>
<details className="absolute top-3 left-3 z-20 rounded-md border bg-popover p-2 text-xs shadow-sm"> <NetworkMapLegend />
<summary className="cursor-pointer focus-visible:outline-2 focus-visible:outline-ring">Обозначения карты</summary>
<div className="mt-2 space-y-1"><p>HR · домашний роутер</p><p>JH · промежуточный узел</p><p>EN · выходной узел</p><p>Круг · устройство / WAN</p><p>Прямоугольник · сервис / страна</p><p>Пунктир WAN → JH · модель связности</p><p>Цвет · статус; неизвестно ≠ оффлайн</p></div>
</details>
<p id="network-map-help" className="sr-only">Стрелки перемещают карту, плюс и минус меняют масштаб, F вписывает объекты. Tab выбирает объект, Enter открывает детали.</p> <p id="network-map-help" className="sr-only">Стрелки перемещают карту, плюс и минус меняют масштаб, F вписывает объекты. Tab выбирает объект, Enter открывает детали.</p>
<svg <svg
ref={svgRef} role="group" aria-label="Интерактивная карта сети" tabIndex={0} aria-describedby="network-map-help" ref={svgRef} role="group" aria-label="Интерактивная карта сети" tabIndex={0} aria-describedby="network-map-help"
+12
View File
@@ -2,6 +2,18 @@
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
/* SVG focus follows the object shape, never the text/foreignObject bounding box. */
.network-map-focusable {
outline: none;
}
.network-map-focus-ring {
opacity: 0;
pointer-events: none;
}
.network-map-focusable:focus-visible > .network-map-focus-ring {
opacity: 1;
}
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
+2
View File
@@ -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
+1
View File
@@ -57,6 +57,7 @@ COPY --from=build /app/packages/contracts ./packages/contracts
COPY --from=build /app/backend/dist ./backend/dist COPY --from=build /app/backend/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
@@ -0,0 +1,4 @@
-- Retention runs after each scheduler receipt; avoid scanning the complete history.
-- On a busy existing installation, pre-create this same index CONCURRENTLY
-- outside a transaction before deploying (deploy/PRODUCTION-RETENTION-INDEX.md).
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_finished_at ON scheduler_runs (finished_at);
+3 -3
View File
@@ -22,8 +22,8 @@
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-live.test.ts && tsx src/services/traffic-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts && tsx src/services/traffic-live-sql.test.ts && tsx src/services/traffic-volume-sql.test.ts && tsx src/services/traffic-binding-status.test.ts && tsx src/services/traffic-status-sql.test.ts && tsx src/services/traffic-gap.test.ts && tsx src/services/traffic-identity.test.ts && tsx src/services/traffic-membership.test.ts && tsx src/services/traffic-aggregate-peak.test.ts && tsx src/services/traffic-users-cache.test.ts && tsx src/services/traffic-collection-lock.test.ts && tsx src/services/traffic-concurrency-sql.test.ts", "test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-live.test.ts && tsx src/services/traffic-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts && tsx src/services/traffic-live-sql.test.ts && tsx src/services/traffic-volume-sql.test.ts && tsx src/services/traffic-binding-status.test.ts && tsx src/services/traffic-status-sql.test.ts && tsx src/services/traffic-gap.test.ts && tsx src/services/traffic-identity.test.ts && tsx src/services/traffic-membership.test.ts && tsx src/services/traffic-aggregate-peak.test.ts && tsx src/services/traffic-users-cache.test.ts && tsx src/services/traffic-collection-lock.test.ts && tsx src/services/traffic-concurrency-sql.test.ts",
"test:traffic-flow": "tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts", "test:traffic-flow": "tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts", "test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
"test:pg": "tsx src/db/pg-pool.test.ts && tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/services/config-revisions.test.ts", "test:pg": "tsx src/db/pg-pool.test.ts && tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/db/scheduler-retention-index.test.ts && tsx src/services/config-revisions.test.ts",
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts && tsx src/services/recursive-apply.test.ts", "test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts && tsx src/services/recursive-effective-properties.test.ts && tsx src/services/recursive-apply.test.ts",
"test:config-sync": "tsx src/utils/server-id.test.ts && tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts && tsx src/services/gre-native-identity.test.ts && tsx src/services/gre-ipv6.test.ts && tsx src/routes/gre-ipv6-validation.test.ts && tsx src/services/network-disabled-server.test.ts", "test:config-sync": "tsx src/utils/server-id.test.ts && tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts && tsx src/services/gre-native-identity.test.ts && tsx src/services/gre-ipv6.test.ts && tsx src/routes/gre-ipv6-validation.test.ts && tsx src/services/network-disabled-server.test.ts",
"test:ros-file": "tsx src/services/ros-file-contents.test.ts && tsx src/services/mikrotik-response.test.ts", "test:ros-file": "tsx src/services/ros-file-contents.test.ts && tsx src/services/mikrotik-response.test.ts",
"test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts", "test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts",
@@ -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",
@@ -0,0 +1,33 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import pg from "pg"
import { env } from "../config.js"
// No bootstrap/migrations against public tables: this test needs only a connection.
const pool = new pg.Pool({ connectionString: env.DATABASE_URL, connectionTimeoutMillis: 8000, statement_timeout: 10000 })
try {
const client = await pool.connect()
try {
await client.query("BEGIN")
// Temporary shadow table: public scheduler history is never changed.
await client.query("CREATE TEMP TABLE scheduler_runs(id integer PRIMARY KEY, finished_at timestamptz NOT NULL) ON COMMIT DROP")
await client.query(`INSERT INTO scheduler_runs SELECT i, now() - CASE WHEN i=1 THEN interval '60 days' ELSE i * interval '1 second' END FROM generate_series(1,20000) AS i`)
await client.query("ANALYZE scheduler_runs")
const cutoff = new Date(Date.now() - 30 * 86400_000).toISOString()
const plan = async () => JSON.stringify((await client.query("EXPLAIN (FORMAT JSON) SELECT id FROM scheduler_runs WHERE finished_at < $1", [cutoff])).rows)
assert.match(await plan(), /Seq Scan/)
const migration = readFileSync(new URL("../../drizzle/0013_scheduler_retention_index.sql", import.meta.url), "utf8")
await client.query(migration)
await client.query(migration)
assert.match(await plan(), /Index Scan|Bitmap Index Scan/)
assert.deepEqual((await client.query("SELECT id FROM scheduler_runs WHERE finished_at < $1", [cutoff])).rows, [{ id: 1 }])
const index = (await client.query("SELECT indisvalid FROM pg_index WHERE indrelid = 'pg_temp.scheduler_runs'::regclass AND indexrelid::regclass::text LIKE '%idx_scheduler_runs_finished_at'")).rows
assert.deepEqual(index, [{ indisvalid: true }])
console.log("Scheduler retention: actual migration/idempotence/valid index/selective plan/result preservation passed on 20,000 temporary rows")
} finally {
await client.query("ROLLBACK")
client.release()
}
} finally {
await pool.end()
}
+1
View File
@@ -649,6 +649,7 @@ export const schedulerRuns = pgTable("scheduler_runs", {
resultJson: jsonb("result_json"), resultJson: jsonb("result_json"),
}, (t) => [ }, (t) => [
index("idx_scheduler_runs_job_time").on(t.jobKey, t.startedAt), index("idx_scheduler_runs_job_time").on(t.jobKey, t.startedAt),
index("idx_scheduler_runs_finished_at").on(t.finishedAt),
]) ])
export const events = pgTable("events", { export const events = pgTable("events", {
+6
View File
@@ -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()
@@ -9,6 +9,7 @@ let rawReads = 0, lateReadFailure = false, applyError: Error | undefined
let lookup = 0, writes = 0, revisions = 0, fail = false, stored = [row], payload: unknown = [row], revisionServer = 1, section = "recursive-routes" let lookup = 0, writes = 0, revisions = 0, fail = false, stored = [row], payload: unknown = [row], revisionServer = 1, section = "recursive-routes"
const observed = { ".id": "*1", static: "true", "dst-address": row.dstAddress, gateway: row.gateway, distance: "0", scope: "0", "target-scope": "0", "routing-table": "main", "check-gateway": "ping", disabled: "false", comment: "MikrotikManager:recursive Тест" } const observed = { ".id": "*1", static: "true", "dst-address": row.dstAddress, gateway: row.gateway, distance: "0", scope: "0", "target-scope": "0", "routing-table": "main", "check-gateway": "ping", disabled: "false", comment: "MikrotikManager:recursive Тест" }
let rawPayload: unknown = [observed], rawFailure = false, savedSnapshots = 0 let rawPayload: unknown = [observed], rawFailure = false, savedSnapshots = 0
let propertyFailure = false, propertyReads = 0
const applied: Array<{ rows: typeof row[]; source: string }> = [] const applied: Array<{ rows: typeof row[]; source: string }> = []
const app = Fastify() const app = Fastify()
app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler) app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler)
@@ -16,6 +17,7 @@ await app.register(createRecursiveRoutes({
getServer: async id => { lookup++; return id === 1 ? server : undefined }, getServer: async id => { lookup++; return id === 1 ? server : undefined },
getRoutes: async () => { if (lateReadFailure) throw Error("Сбой чтения после применения"); return stored }, getRoutes: async () => { if (lateReadFailure) throw Error("Сбой чтения после применения"); return stored },
readRaw: async () => { rawReads++; if (rawFailure) throw new Error("Тест опроса"); return rawPayload }, readRaw: async () => { rawReads++; if (rawFailure) throw new Error("Тест опроса"); return rawPayload },
readProperty: async (_server, _id, property) => { propertyReads++; if (propertyFailure) throw Error("Свойство недоступно"); return property === "disabled" ? { ret: "false" } : [] },
saveObserved: async (_id, rows) => { savedSnapshots++; stored = rows as typeof row[] }, saveObserved: async (_id, rows) => { savedSnapshots++; stored = rows as typeof row[] },
apply: async (device, rows, source) => { apply: async (device, rows, source) => {
assert.equal(device.id, 1); writes++; applied.push({ rows: rows as typeof row[], source }) assert.equal(device.id, 1); writes++; applied.push({ rows: rows as typeof row[], source })
@@ -85,6 +87,14 @@ rawFailure=true
assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})).statusCode,503) assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})).statusCode,503)
rawFailure=false;rawPayload=[] rawFailure=false;rawPayload=[]
assert.deepEqual((await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})).json(),{serverId:1,routes:[],live:true,stale:false}) assert.deepEqual((await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})).json(),{serverId:1,routes:[],live:true,stale:false})
rawPayload=[{...observed,disabled:undefined,"check-gateway":undefined}]
const recovered=await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})
assert.equal(recovered.statusCode,200);assert.equal(recovered.json().routes[0].disabled,false);assert.equal(recovered.json().routes[0].checkGateway,"none");assert.equal(propertyReads,2)
propertyFailure=true
const beforePropertyFailure=savedSnapshots
const failedProperty=await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})
assert.equal(failedProperty.statusCode,503);assert.equal(failedProperty.json().routes,undefined);assert.equal(savedSnapshots,beforePropertyFailure)
propertyFailure=false
rawPayload=[{".id":"one",gateway:"192.0.2.1"}] rawPayload=[{".id":"one",gateway:"192.0.2.1"}]
assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/gateways/catalog?serverId=1"})).json().gateways[0].status,"unknown") assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/gateways/catalog?serverId=1"})).json().gateways[0].status,"unknown")
for (const type of ["home-router", "exit-node"] as const) { for (const type of ["home-router", "exit-node"] as const) {
+15 -5
View File
@@ -1,5 +1,5 @@
import { recursiveApplyBodySchema, recursiveTargetSchema, recursiveInputRowsSchema } from '../services/recursive-route-validation.js' import { recursiveApplyBodySchema, recursiveTargetSchema, recursiveInputRowsSchema } from '../services/recursive-route-validation.js'
import { parseRecursiveCatalog, parseRecursiveGatewayCatalog } from "../services/recursive-route-catalog.js" import { parseRecursiveCatalog, parseRecursiveGatewayCatalog, readRecursiveEffectiveProperties, type RecursiveObservedProperty } from "../services/recursive-route-catalog.js"
import { executeRecursiveApply, RecursiveApplyError } from "../services/recursive-apply.js" import { executeRecursiveApply, RecursiveApplyError } from "../services/recursive-apply.js"
import { asc, eq } from "drizzle-orm" import { asc, eq } from "drizzle-orm"
import { z } from "zod" import { z } from "zod"
@@ -176,7 +176,8 @@ function recursiveFailureReceipt(error: unknown, result?: { pushed: number; dele
export interface RecursiveRouteDependencies { export interface RecursiveRouteDependencies {
getServer: (id: number) => Promise<ServerRow | undefined> getServer: (id: number) => Promise<ServerRow | undefined>
readRaw: (server: ServerRow) => Promise<unknown> readRaw: (server: ServerRow, purpose?: "routes" | "gateways") => Promise<unknown>
readProperty: (server: ServerRow, id: string, property: RecursiveObservedProperty) => Promise<unknown>
saveObserved: (serverId: number, routes: RecursiveRouteDto[]) => Promise<void> saveObserved: (serverId: number, routes: RecursiveRouteDto[]) => Promise<void>
getRoutes: typeof mapDbRoutes getRoutes: typeof mapDbRoutes
apply: typeof applyRecursiveToServer apply: typeof applyRecursiveToServer
@@ -187,7 +188,15 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
const deps: RecursiveRouteDependencies = { const deps: RecursiveRouteDependencies = {
getServer: async id => (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0], getServer: async id => (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0],
getRoutes: mapDbRoutes, apply: applyRecursiveToServer, getRevision: getRevisionById, getRoutes: mapDbRoutes, apply: applyRecursiveToServer, getRevision: getRevisionById,
readRaw: server => MikrotikClient.fromServer(server).get<unknown>("/ip/route"), readRaw: (server, purpose) => {
const client = MikrotikClient.fromServer(server)
// Every ownership marker is a comment. Keep all commented rows, including
// unsupported ones that must block an unsafe replacement of the managed set.
return purpose === "gateways"
? client.get<unknown>("/ip/route?.proplist=.id,gateway,active")
: client.post<unknown>("/ip/route/print", { ".query": ["comment"] })
},
readProperty: (server, id, property) => MikrotikClient.fromServer(server).post("/ip/route/get", { ".id": id, "value-name": property }),
saveObserved: async (serverId, routes) => { saveObserved: async (serverId, routes) => {
await replaceDbRoutes(serverId, routes) await replaceDbRoutes(serverId, routes)
await appendRevisionIfChanged({ serverId, section: "recursive-routes", source: "observed", payload: canonicalRecursiveRoutes(routes) }) await appendRevisionIfChanged({ serverId, section: "recursive-routes", source: "observed", payload: canonicalRecursiveRoutes(routes) })
@@ -203,7 +212,8 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" }) if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
try { try {
const routes = mergeCachedCountry(parseRecursiveCatalog(await deps.readRaw(server)), await deps.getRoutes(serverId)) const observed = await readRecursiveEffectiveProperties(await deps.readRaw(server), (id, property) => deps.readProperty(server, id, property))
const routes = mergeCachedCountry(parseRecursiveCatalog(observed), await deps.getRoutes(serverId))
await deps.saveObserved(serverId, routes) await deps.saveObserved(serverId, routes)
return reply.send({ serverId, routes, live: true, stale: false }) return reply.send({ serverId, routes, live: true, stale: false })
} catch (error) { } catch (error) {
@@ -217,7 +227,7 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" }) if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
try { try {
return reply.send({ serverId, gateways: parseRecursiveGatewayCatalog(await deps.readRaw(server)) }) return reply.send({ serverId, gateways: parseRecursiveGatewayCatalog(await deps.readRaw(server, "gateways")) })
} catch (error) { } catch (error) {
return reply.status(503).send({ error: error instanceof Error ? error.message : "Не удалось прочитать шлюзы" }) return reply.status(503).send({ error: error instanceof Error ? error.message : "Не удалось прочитать шлюзы" })
} }
+2 -4
View File
@@ -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")
+20
View File
@@ -0,0 +1,20 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import type { TrafficFlowHostStatus } from "@mmapp/contracts/traffic-flow"
export function createTrafficFlowHostRoutes(deps: {
status: () => Promise<TrafficFlowHostStatus>
reconcile: () => Promise<TrafficFlowHostStatus>
}): FastifyPluginAsyncZod {
return async app => {
app.get("/traffic/flow/host-status", async () => deps.status())
app.post("/traffic/flow/host-reconcile", async (_req, reply) => {
try {
const hostStatus = await deps.reconcile()
if (!hostStatus.configured) return reply.status(409).send({ error: "Автоматизация хоста NetFlow не настроена" })
return { ok: true, hostStatus }
} catch (error) {
return reply.status(502).send({ error: error instanceof Error ? error.message : "Настройки хоста не применены" })
}
})
}
}
+21 -3
View File
@@ -8,6 +8,9 @@ import {
} from "@mmapp/contracts/traffic-flow" } 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,
})) }))
+50 -1
View File
@@ -1,7 +1,56 @@
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 () => {
let received: unknown, target = "", method = ""
const server = createServer(async (req, res) => {
target = req.url ?? ""; method = req.method ?? ""
let body = ""; for await (const chunk of req) body += chunk.toString()
received = JSON.parse(body)
res.setHeader("content-type", "application/json"); res.end(JSON.stringify([{ ".id": "*1" }]))
})
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 {
const result = await client.post("/ip/route/print", { ".query": ["comment"], ".proplist": [".id", "gateway"] })
assert.deepEqual(received, { ".query": ["comment"], ".proplist": [".id", "gateway"] })
assert.equal(target, "/rest/ip/route/print"); assert.equal(method, "POST")
assert.deepEqual(result, [{ ".id": "*1" }])
} finally {
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}
})
test("RouterOS shared REST pool bounds logins across client instances and closes idle sockets", async () => { test("RouterOS shared REST pool bounds logins across client instances and closes idle sockets", async () => {
let active=0,peak=0,requests=0 let active=0,peak=0,requests=0
+19 -3
View File
@@ -104,7 +104,7 @@ function rosRequest(
function rosPost( function rosPost(
params: MikrotikConnectParams, params: MikrotikConnectParams,
path: string, path: string,
body: Record<string, string>, body: Record<string, string | string[]>,
timeoutMs: number, timeoutMs: number,
externalSignal?: AbortSignal, externalSignal?: AbortSignal,
): Promise<unknown> { ): Promise<unknown> {
@@ -595,7 +595,7 @@ export class MikrotikClient {
return rosRequest(this.params, path, timeoutMs) as Promise<T> return rosRequest(this.params, path, timeoutMs) as Promise<T>
} }
async post<T>(path: string, body: Record<string, string>, timeoutMs = 15_000, signal?: AbortSignal): Promise<T> { async post<T>(path: string, body: Record<string, string | string[]>, timeoutMs = 15_000, signal?: AbortSignal): Promise<T> {
return rosPost(this.params, path, body, timeoutMs, signal) as Promise<T> return rosPost(this.params, path, body, timeoutMs, signal) as Promise<T>
} }
@@ -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,44 @@
import assert from "node:assert/strict"
import { readRecursiveEffectiveProperties, parseRecursiveCatalog } from "./recursive-route-catalog.js"
const row = { ".id": "*1", static: "true", "dst-address": "10.9.9.2/32", gateway: "192.0.2.1%gre-test", distance: "1", scope: "30", "target-scope": "10", "routing-table": "main", comment: "MikrotikManager:recursive Тест" }
const calls: string[] = []
const result = await readRecursiveEffectiveProperties([row], async (id, property) => {
calls.push(id + ":" + property)
return property === "disabled" ? { ret: "false" } : []
})
assert.equal(parseRecursiveCatalog(result)[0].disabled, false)
assert.equal(parseRecursiveCatalog(result)[0].checkGateway, "none")
assert.deepEqual(calls.sort(), ["*1:check-gateway", "*1:disabled"])
assert.equal("disabled" in row, false)
let reads = 0
const complete = { ...row, disabled: "true", "check-gateway": "ping" }
assert.deepEqual(await readRecursiveEffectiveProperties([complete, { ".id": "*other", comment: "user-owned" }], async () => { reads++; return null }), [complete, { ".id": "*other", comment: "user-owned" }])
assert.equal(reads, 0)
for (const payload of [null, {}, [null], [{ ...row, ".id": "" }], [row, row]]) {
await assert.rejects(readRecursiveEffectiveProperties(payload, async () => { reads++; return null }))
assert.equal(reads, 0)
}
for (const property of ["disabled", "check-gateway"] as const) {
for (const response of [null, {}, { ret: false }, { ret: "" }, { ret: "unknown" }, [{}], ...(property === "disabled" ? [[]] : [])]) {
const input = { ...complete }; delete (input as Record<string, unknown>)[property]
await assert.rejects(readRecursiveEffectiveProperties([input], async () => response), /Не подтверждён/)
}
}
await assert.rejects(readRecursiveEffectiveProperties([row], async () => { throw Error("RouterOS недоступен") }), /недоступен/)
const explicit = await readRecursiveEffectiveProperties([row], async (_id, property) => ({ ret: property === "disabled" ? "true" : "bfd" }))
assert.equal(parseRecursiveCatalog(explicit)[0].disabled, true)
assert.equal(parseRecursiveCatalog(explicit)[0].checkGateway, "bfd")
// Explicit invalid values are not replaced with defaults or re-read.
const invalid = { ...complete, disabled: "unknown" }
const untouched = await readRecursiveEffectiveProperties([invalid], async () => { throw Error("unexpected read") })
assert.throws(() => parseRecursiveCatalog(untouched))
let active = 0, maximum = 0
await readRecursiveEffectiveProperties(Array.from({ length: 12 }, (_, i) => ({ ...row, ".id": "*" + i })), async (_id, property) => {
maximum = Math.max(maximum, ++active)
await new Promise(resolve => setTimeout(resolve, 1)); active--
return { ret: property === "disabled" ? "false" : "arp" }
})
assert.equal(maximum, 2)
console.log("Recursive effective properties: omitted flags/unset check, strict envelopes, ownership, no mutation, errors and bounded reads passed")
@@ -13,6 +13,44 @@ function rawRows(payload: unknown): Array<Record<string, unknown>> {
} }
function rosText(value: unknown): string | undefined { return typeof value === "string" ? value : undefined } function rosText(value: unknown): string | undefined { return typeof value === "string" ? value : undefined }
function text(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null } function text(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null }
export type RecursiveObservedProperty = "disabled" | "check-gateway"
/** RouterOS print can omit unset properties. Ask get for the effective value,
* rather than interpreting an incomplete snapshot as enabled/no-check. */
export async function readRecursiveEffectiveProperties(
payload: unknown,
readProperty: (id: string, property: RecursiveObservedProperty) => Promise<unknown>,
): Promise<Array<Record<string, unknown>>> {
const rows = rawRows(payload).map(row => ({ ...row }))
const tasks: Array<{ row: Record<string, unknown>; property: RecursiveObservedProperty }> = []
for (const row of rows) {
if (typeof row.comment !== "string" || !isOwnedRecursiveComment(row.comment)) continue
for (const property of ["disabled", "check-gateway"] as const) {
if (row[property] === undefined) tasks.push({ row, property })
}
}
let cursor = 0, failed = false
await Promise.all(Array.from({ length: Math.min(2, tasks.length) }, async () => {
while (!failed && cursor < tasks.length) {
const { row, property } = tasks[cursor++]!
try {
const response = await readProperty(String(row[".id"]), property)
// An empty successful /get reply means the optional check is unset.
// Do not treat null, malformed envelopes or failed reads as defaults.
const unset = property === "check-gateway" && Array.isArray(response) && response.length === 0
const value = response && typeof response === "object" && !Array.isArray(response) && "ret" in response ? response.ret : undefined
if (property === "disabled") {
if (typeof value !== "string" || observedBoolean(value) === null) throw new Error("Не подтверждён disabled маршрута " + row[".id"])
row.disabled = observedBoolean(value) ? "true" : "false"
} else {
if (!unset && (typeof value !== "string" || !["none", "arp", "ping", "bfd"].includes(value))) throw new Error("Не подтверждён check-gateway маршрута " + row[".id"])
row[property] = unset ? "none" : value
}
} catch (error) { failed = true; throw error }
}
}))
return rows
}
export function parseRecursiveCatalog(payload: unknown): MappedRecursiveRoute[] { export function parseRecursiveCatalog(payload: unknown): MappedRecursiveRoute[] {
return rawRows(payload).filter(row => typeof row.comment === "string" && isOwnedRecursiveComment(row.comment)).map(row => { return rawRows(payload).filter(row => typeof row.comment === "string" && isOwnedRecursiveComment(row.comment)).map(row => {
const id = String(row[".id"]) const id = String(row[".id"])
@@ -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
}
}
+29 -3
View File
@@ -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
} }
} }
+1
View File
@@ -10,6 +10,7 @@ export function withPgOrSkip(): Promise<boolean> {
await initDatabase() await initDatabase()
return true return true
} catch (err) { } catch (err) {
if (process.env.REQUIRE_TEST_POSTGRES === "1") throw err
console.warn( console.warn(
"skip: PostgreSQL недоступен", "skip: PostgreSQL недоступен",
err instanceof Error ? err.message : err, err instanceof Error ? err.message : err,
+1 -1
View File
@@ -77,7 +77,7 @@ function FilterGatewayCell({
? recursiveRoutes.find((r) => r.id === rule.gatewayTunnelId.slice(4)) ? recursiveRoutes.find((r) => r.id === rule.gatewayTunnelId.slice(4))
: undefined : undefined
const recRowByHop = const recRowByHop =
!isBlackhole && !(rule.gatewayTunnelId ?? "").trim() && rule.gateway.trim() !isBlackhole && !recRowByRef && (isRecRef || !(rule.gatewayTunnelId ?? "").trim()) && rule.gateway.trim()
? pickRecursiveRouteByGatewayHop(recursiveRoutes, rule.gateway) ? pickRecursiveRouteByGatewayHop(recursiveRoutes, rule.gateway)
: undefined : undefined
const recRow = recRowByRef ?? recRowByHop const recRow = recRowByRef ?? recRowByHop
+47
View File
@@ -0,0 +1,47 @@
"use client"
import { useEffect, useRef } from "react"
import { Badge } from "@/components/ui/badge"
export function NetworkMapLegend() {
const details = useRef<HTMLDetailsElement>(null)
useEffect(() => {
if (window.matchMedia("(max-width: 767px)").matches && details.current) details.current.open = false
}, [])
return (
<details ref={details} open data-map-legend className="absolute left-3 top-3 z-20 max-w-[calc(100%-1.5rem)] rounded-lg border bg-popover text-popover-foreground shadow-sm">
<summary className="cursor-pointer px-3 py-2 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-ring">Легенда карты</summary>
<div role="region" aria-label="Легенда карты" className="flex max-h-[42vh] w-44 max-w-full flex-col gap-2 overflow-y-auto border-t px-3 py-2 text-[11px]">
<div>
<p className="mb-1.5 font-medium text-muted-foreground">Статус устройства</p>
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5">
{[["var(--status-online)", "Онлайн"], ["var(--status-degraded)", "Внимание"], ["var(--status-offline)", "Оффлайн"], ["var(--muted-foreground)", "Неизвестно"]].map(([color, label]) => (
<span key={label} className="flex items-center gap-1.5"><span aria-hidden className="size-2 shrink-0 rounded-full" style={{ background: color }} />{label}</span>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<p className="font-medium text-muted-foreground">Тип узла</p>
{[["JH", "JumpHost", "var(--chart-1)"], ["EN", "Exit Node", "var(--chart-3)"], ["HR", "Home Router", "var(--chart-2)"]].map(([label, name, color]) => (
<span key={label} className="flex items-center gap-2"><Badge variant="secondary" style={{ color }} className="min-w-8 justify-center text-[10px]">{label}</Badge><span>{name}</span></span>
))}
<span className="flex items-center gap-2"><span aria-hidden className="mx-1 size-3 rounded-full border border-current" />WAN · аплинк</span>
<span className="flex items-center gap-2"><span aria-hidden className="mx-1 size-3 rounded border border-current" />Сервис / страна</span>
</div>
<div className="flex flex-col gap-1.5">
<p className="font-medium text-muted-foreground">Связи и измерения</p>
{[
["GRE", "var(--status-online)", undefined],
["GRE + IPsec", "var(--status-online)", "7 4"],
["Поток NetFlow", "var(--chart-4)", "6 5"],
["WAN → JH · модель", "var(--muted-foreground)", "5 4"],
].map(([label, color, dash]) => (
<span key={label} className="flex items-center gap-2"><svg aria-hidden width="28" height="12" className="shrink-0"><line x1="0" y1="6" x2="28" y2="6" stroke={color} strokeWidth="2" strokeDasharray={dash} /></svg>{label}</span>
))}
<p className="text-muted-foreground">RTT · задержка пробы. «—» · нет измерения.</p>
<p className="text-muted-foreground">NetFlow · трафик за 5 минут, не пропускная способность канала.</p>
</div>
</div>
</details>
)
}
+63
View File
@@ -0,0 +1,63 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { can } from "@/lib/auth"
import { Button } from "@/components/ui/button"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { AlertCircleIcon } from "lucide-react"
import { getTrafficFlowHostStatus, reconcileTrafficFlowHost } from "@/shared/api/traffic-flow"
import type { TrafficFlowHostStatus } from "@mmapp/contracts/traffic-flow"
export function FlowHostPanel({ backendUrl, enabled = true, onConfigured }: {
backendUrl: string; enabled?: boolean; onConfigured?: (configured: boolean) => void
}) {
const [status, setStatus] = useState<TrafficFlowHostStatus | null>(null)
const [receivedAt, setReceivedAt] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
const [operationError, setOperationError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const operation = useRef<AbortController | null>(null)
const [revision, setRevision] = useState(0)
const accept = useCallback((value: TrafficFlowHostStatus) => {
setStatus(value); setReceivedAt(Date.now()); setError(null); onConfigured?.(value.configured)
}, [onConfigured])
useEffect(() => () => { operation.current?.abort() }, [])
useEffect(() => {
if (!enabled || !can("mm:traffic:read") || busy) return
const controller = new AbortController()
let timer: ReturnType<typeof setTimeout> | undefined
async function refresh() {
try { const value = await getTrafficFlowHostStatus(backendUrl, { signal: controller.signal }); if (!controller.signal.aborted) accept(value) }
catch (reason) { if (!controller.signal.aborted) setError(reason instanceof Error ? reason.message : "Статус хоста не получен") }
finally { if (!controller.signal.aborted) timer = setTimeout(() => { void refresh() }, 10_000) }
}
void refresh()
return () => { controller.abort(); if (timer) clearTimeout(timer) }
}, [accept, backendUrl, enabled, busy, revision])
async function apply() {
if (operation.current) return
const controller = new AbortController(); operation.current = controller
setBusy(true); setOperationError(null)
try { const value = await reconcileTrafficFlowHost(backendUrl, { signal: controller.signal }); if (!controller.signal.aborted) accept(value) }
catch (reason) { if (!controller.signal.aborted) setOperationError(reason instanceof Error ? reason.message : "Применение не подтверждено; обновите состояние хоста") }
finally { operation.current = null; if (!controller.signal.aborted) setBusy(false) }
}
if (!enabled || !can("mm:traffic:read")) return null
return <div className="flex flex-col gap-3 rounded-md border px-4 py-3" aria-label="Автоматизация WireGuard на хосте">
<p className="text-sm font-medium">WireGuard на сервере MM</p>
<p role="status" className="text-sm">{!status ? "Получение состояния агента…" : !status.configured ? "Ручной режим. Установите агент из раздела «Файлы хоста»." : !status.reachable ? "Агент недоступен" : status.configMatches ? "Настройки применены" : "Настройки требуют проверки или применения"}</p>
{error && receivedAt ? <p className="text-xs text-muted-foreground">Показано последнее состояние: {new Date(receivedAt).toLocaleString("ru-RU")}</p> : null}
{status?.configured && status.reachable ? <>
<p className="text-xs text-muted-foreground">Интерфейс: {status.interfaceUp ? status.addresses.join(", ") : "не поднят"} · UDP {status.listenPort}</p>
<ul className="flex flex-col gap-1 text-xs text-muted-foreground" aria-label="Handshake пиров">
{status.peers.map(peer => <li key={peer.publicKey}>{peer.allowedIps.join(", ") || "Без адреса"} · {peer.lastHandshakeAt ? "Handshake: " + new Date(peer.lastHandshakeAt * 1000).toLocaleString("ru-RU") + ((receivedAt ?? 0) / 1000 - peer.lastHandshakeAt > 180 ? " (давно)" : "") : "Ожидание handshake"}</li>)}
</ul>
</> : null}
{operationError || error || status?.error ? <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Состояние хоста требует внимания</AlertTitle><AlertDescription>{operationError || error || status?.error}</AlertDescription></Alert> : null}
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => setRevision(value => value + 1)}>Обновить состояние хоста</Button>
{status?.configured && can("mm:traffic:write") ? <Button type="button" size="sm" disabled={busy || !status.reachable} onClick={() => { void apply() }}>{busy ? "Применение…" : "Применить настройки хоста"}</Button> : null}
</div>
</div>
}
+8 -5
View File
@@ -22,6 +22,7 @@ import {
Sheet, SheetContent, SheetHeader, SheetTitle, 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 -10
View File
@@ -13,6 +13,7 @@ import { Input } from "@/components/ui/input"
import { CodeExportSheet, type CodeExportFormat } from "@/components/reui-kit/code-export-sheet" import { 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() }}>
+83
View File
@@ -0,0 +1,83 @@
# Production275 — SYN и задержки API arn-gw01 (2026-10-16)
Поручение: исследовать предупреждения SYN flooding на80/443 и периодически
медленный HTTPS REST на `arn-gw01.rtnt.top`. Выполнены только чтение и короткие
диагностические подключения. Конфигурация production/RouterOS не менялась.
## Результаты
Устройство ID1, DNS IPv4 `62.182.194.146`, REST HTTPS443 с проверкой сертификата.
RouterOS7.24.5 stable,1 CPU. В разовом resource: CPU6%, свободно933036032 байт
из1207959552, uptime5m41s. Перезапуск выполнен до диагностики, не агентом.
1. Первый диагностический GET system/resource завершился тайм-аутом10с.
Следующие чтения: system/clock2307мс, ip/settings638мс, ip/service145мс,
log160мс; отдельный полный resource1170мс. Неверная авторизация не наблюдалась.
2. В первоначальном журнале41 событие: первая отметка02:43:13, оба SYN warning
на80/443 —02:43:20. Совпадение с uptime указывает на события первых секунд
запуска. Позднее часы используют Europe/Stockholm/+02:00, в журнале есть
скачок02:43→04:44; ранние отметки нельзя механически переводить как уже
синхронизированное местное время. Непрерывную SYN-атаку из этих двух записей
вывести нельзя. Последующее кольцо1000 записей уже не содержало предупреждений.
3. Четыре последовательных GET system/identity с новым TCP/TLS, один за раз:
| Замер | DNS | TCP установлен | TLS завершён | Заголовки ответа | Всего |
| --- | --- | --- | --- | --- | --- |
| 1 | 6 | 1042 | 1141 | 1161 | 1162 |
| 2 | 2 | 3071 | 3189 | 3208 | 3208 |
| 3 | 3 | 3088 | 3233 | 3254 | 3254 |
| 4 | 2 | 5136 | 5254 | 5273 | 5274 |
Значения — накопленные миллисекунды от начала запроса. Все HTTP200;
после TLS до ответа19–21мс. Основная задержка находится до TCP connect.
4. Захват только SYN/SYN-ACK заголовков на eth0 production-хоста,20с,
фильтр строго target IPv4/TCP443. Полезная нагрузка не сохранялась.
Два наших контрольных GET прошли155/149мс; SYN-ACK через18–21мс.
Затем в обычном опросе приложения зафиксированы повторения исходящих SYN:
один исходный порт — первое SYN1792119069.651615, SYN-ACK1792119073.773299
(4.122с, несколько повторов); другой —1792119069.713556→1792119071.757179
(2.044с). При полученном ответе на SYN RTT около18мс. Захват16 пакетов,
dropped by kernel0, завершён автоматически через timeout20s.
5. В02:52:33 UTC (09:52:33 Новосибирск) среди239 TCP conntrack записей4 к
локальным80/443: time-wait1/syn-sent2/syn-recv1. Две от приложения,
две с других внешних адресов — по одной80 и443. Массовый текущий SYN flood
этим одиночным снимком не подтверждён. В этой версии адрес и порт — отдельные
поля; для итогового подсчёта использованы dst-address **и** dst-port.
6. tcp-syncookies=false. www80/www-ssl443/reverse-proxy443 включены,
max-sessions20; FTP/Telnet/SSH уже выключены. В input есть блокировка EVOFW_DENY,
drop UDP53 и accept WireGuard; отдельных ограничений80/443 доверенными
источниками и завершающего drop input в прочитанных правилах нет. RAW пустой.
Правила не менялись; обратный прокси потенциально использует те же порты,
поэтому закрывать их без проверки потребителей нельзя.
7. Scheduler20минут на02:49 UTC: traffic4 error/36 ok, gre_bgp4 error/36 ok.
Выборка последних ошибок называет arn и тайм-аут10с; последние в02:43:49–50 UTC,
рядом с запуском устройства. Успешные задания иногда занимают9–11с суммарно.
Это не доказательство медленного SQL или обработки REST на RouterOS.
## Вывод
Воспроизведены задержки TCP-handshake и повторные SYN **до** TLS/REST.
Разовые низкие CPU и conntrack не показывают постоянного истощения ресурсов.
Причину нужно различать между сетью/фильтрацией по пути и обработкой SYN на CHR;
захват только на отправителе не определяет точное место потери. Историческое
предупреждение при запуске само по себе не доказывает текущую DDoS-атаку.
Приложение сейчас создаёт новые соединения (keepAlive=false, maxSockets2 на origin).
Это увеличивает количество TCP/TLS-handshake, но не доказывает, что приложение
создаёт SYN flood. Менять поведение пула вслепую нельзя: прежние проверки273 касались
также отказов входа. Повышение API timeout лишь скроет часть проявлений.
## Подготовленный следующий шаг
После отдельного разрешения владельца проверить ID1/host и исходный
tcp-syncookies=false, изменить **только** `/ip/settings tcp-syncookies=true`
через REST PATCH. Без перезагрузки, изменения паролей, служб, правил firewall
и маршрутов. Повторно прочитать значение, повторить небольшой последовательный
замер TCP/TLS/HTTP и сверить штатные опросы. Откат — вернуть исходное false после
решения владельца; не объявлять положительный краткий замер доказательством
устранения причины. Это пока предложение, изменение не применялось.
[IP Settings MikroTik](https://help.mikrotik.com/docs/spaces/ROS/pages/103841817/IP%2BSettings)
описывает SYN cookies при переполнении SYN backlog и ограничения TCP extensions.
Это защита от переполнения очереди, она не устраняет потери в сети провайдера.
Точный scope изменения и проверка должны быть согласованы до записи в production.
+80
View File
@@ -0,0 +1,80 @@
# Production276 — включение SYN cookies на arn-gw01 (2026-10-16)
Владелец явно разрешил: «Включить tcp-syncookies и проверить».
Исходная диагностика: [Production275](PRODUCTION-ARN-275.md).
## Применение
Перед записью проверены server ID1/host `arn-gw01.rtnt.top` и исходное
`tcp-syncookies=false`. Первая попытка PATCH `/ip/settings` была отклонена400
`missing or invalid resource identifier`; значение после отказа осталосьfalse.
Этот singleton-раздел изменяется командой POST `/ip/settings/set`.
Выполнена ровно одна успешная команда с единственным параметром
`{"tcp-syncookies":"true"}`. Подтверждение в02:55:01.749 UTC,
повторное чтение02:55:01.893 UTC —true и корректная identity.
Местное время: **2026-10-16 09:55:01 по Новосибирску**.
Пароли, пользователи, firewall, порты, маршруты, приложение, БД и контейнеры
не менялись; перезапуск не выполнялся. Уточнение протокола PATCH→POST не расширяет
согласованный scope и не меняет дополнительные параметры.
## Метод проверки
С тем же production-хостом, адресом устройства, учётной записью и проверкой TLS
повторяются GET system/identity с новым TCP/TLS, последовательно, без повторов
при ошибке. Один запрос каждые4 секунды,8 измерений. Отдельно захват45 секунд
только SYN/SYN-ACK на eth0, фильтр строго target IPv4/TCP443, без сохранения payload.
Проверяются результат чтения настройки, uptime и штатные задания scheduler.
## Контрольные запросы после изменения
С02:55:24 по02:55:53 UTC выполнены8 измерений. Все HTTP200, без повторов.
| Замер | DNS | TCP установлен | TLS завершён | Заголовки ответа | Всего |
| --- | --- | --- | --- | --- | --- |
| 1 | 6 | 25 | 138 | 158 | 159 |
| 2 | 9 | 29 | 137 | 158 | 158 |
| 3 | 3 | 22 | 125 | 144 | 145 |
| 4 | 3 | 22 | 123 | 143 | 143 |
| 5 | 3 | 25 | 130 | 152 | 152 |
| 6 | 2 | 22 | 126 | 168 | 169 |
| 7 | 2 | 21 | 126 | 145 | 145 |
| 8 | 2 | 45 | 313 | 334 | 334 |
Все значения — накопленные миллисекунды от начала запроса. Диапазон полного
запроса143–334мс против1162–5274мс в четырёх исходных измерениях; TCP21–45мс
против1042–5136мс. Настройка повторноtrue, uptime12m42s/CPU14% в замере02:55:54.
В повторном45-секундном захвате42 SYN/SYN-ACK пакета (44 received by filter,
0 dropped by kernel), повторов SYN с тем же исходным портом/sequence в полученном
выводе не обнаружено. Запись payload не выполнялась. Захват завершился по timeout,
фонового процесса захвата после него нет. Это короткая выборка, а не гарантия
отсутствия сетевых потерь при любой нагрузке.
На02:57:30 UTC /09:57:30 Новосибирск, через2мин29с после применения:
| Штатное задание | Успешно | Ошибки | Максимальная длительность |
| --- | --- | --- | --- |
| traffic | 5 | 0 | 1091мс |
| gre_bgp | 5 | 0 | 426мс |
| uptime_resources | 10 | 0 | 854мс |
| internet_path | 1 | 0 | 284мс |
Считались запуски с started_at после02:55:01.893 UTC. Финальный readbacktrue,
CPU6%, uptime14m19s, RouterOS7.24.5 stable — перезапуска не было. Доступное кольцо
логов04:55:53–04:57:16 по часам устройства не содержит новых SYN warning;
оно не покрывает весь интервал после изменения. Старые события не удалялись.
В коротком сравнении после включения SYN cookies многосекундная задержка TCP
не воспроизвелась, результаты существенно лучше. Это согласуется с гипотезой
проблемы обработки входящих SYN на CHR, но не устанавливает источник всех прежних
потерь и не доказывает результат под другой нагрузкой. Дополнительные изменения
не применялись. Наблюдение и захват завершены; новых фоновых циклов нет.
## Откат
При необходимости по решению владельца повторно проверить устройство и текущее
значение, выполнить POST `/ip/settings/set` с `tcp-syncookies=false`, затем чтение
`/ip/settings?.proplist=tcp-syncookies`. Автоматического отката после успешной
проверки не предусмотрено: включение разрешено явно. Другие настройки не трогать.
+32
View File
@@ -0,0 +1,32 @@
# Production frontend v2.0.1 — 2026-10-15
Владелец отдельно разрешил обновить только frontend и проверить карту. До обновления публичный release-manifest и OCI labels работающего контейнера подтверждали2.0.0/72bf648c. Релиз2.0.1/042407f0 опубликован CI2467 успешно; публикация образа не обновила работающий frontend автоматически. Причина отсутствия узлов в старой версии — отклонение PostgreSQL polledAt (блок265).
На production прочитаны настройки Compose только для сервиса frontend: проект cdn-mm, файл /opt/cdn-mm/docker-compose.yml, образ git.shx.one/denozord/mikrotikmanager-frontend:latest. Учётные данные/полная конфигурация не выводились.
Проверенный образ:
```text
git.shx.one/denozord/mikrotikmanager-frontend@sha256:73ac48979084c0c93b77e75308a9e2f002481586704d0c03fd9a7b5e39511647
version=2.0.1
revision=042407f03ed935d26576cbd31dbaaca0e24d8a84
```
После pull по digest и проверки OCI revision выполнен локальный tag этого образа в frontend:latest, затем:
```bash
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never frontend
```
Frontend started2026-10-15T16:22:46.626507761Z (23:22:46 Новосибирск), healthy. Публичный manifest200/version2.0.1. Backend/PostgreSQL сохранили container ID и StartedAt, не перезапускались; Compose-файл, БД, права и маршрутизаторы не менялись. Пересоздание frontend заняло около1,3с по Compose, это не измерение полной пользовательской загрузки.
В браузере после reload подтверждены4 узла (3JH/1EN),4online,25 загруженных GRE; нарисованы3/25 связи. Ошибка первоначальной валидации исчезла, выбор устройства и экспорт доступны. Осталось ограничение read-only: POST network/resolve-hosts запрещён mm:network:write; связи по неразрешённым FQDN не подтверждены. Права не расширялись, все25 туннелей не объявляются подтверждёнными линиями. API до обновления: servers171мс, GRE498мс, speed-probes56мс; исправленная схема принимает4/25/0. Это разовый замер, не гарантия всех задержек.
Старый образ оставлен локально для отката; автоматический prune не запускался. При отдельно согласованном откате:
```bash
docker tag sha256:59afb59c6b7322f339cc1b5cca9a0c9ae725fd04a45a135bf023ed81c6e528a2 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 frontend
```
Откат возвращает и дефект карты. Исправление Filters0f0d3aa0 в образ2.0.1 не входит и остаётся локальным. Скриншот с production-данными сохранён приватно в игнорируемом tmp/prod-map269.jpg, в Git не включён.
+36
View File
@@ -0,0 +1,36 @@
# Обновление production до v2.0.2 (2026-10-16)
Владелец разрешил публикацию готовых Filters268, карты270 и recursive271 в main и обновление backend/frontend после успешного CI. Четыре коммита0f0d3aa0..e6a2e400 отправлены без перезаписи истории. Все7 jobs [CI2468](https://git.shx.one/denozord/MikrotikManager/actions/runs/2468) завершились success, опубликован v2.0.2. Версия/тег вычислены CI, package.json вручную не менялся.
Оба образа скачаны по digest, OCI version2.0.2/revision e6a2e4009d8af9e0e2f98c645791981c2b6d6ca8 проверены до перезапуска:
```text
backend: git.shx.one/denozord/mikrotikmanager-backend@sha256:1a02d517b16d9d475b478e7fae1a2749bad88a156f0e49183e8cb57e2d1a7f10
frontend: git.shx.one/denozord/mikrotikmanager-frontend@sha256:fbd0c3743613a60d18dc6a13120cf1d755f89bc574c490e363d235301a267765
```
Проверенные digest локально помечены соответствующими frontend:latest/backend:latest. Compose-файл не редактировался. Выполнено последовательно:
```bash
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never backend
# Проверка backend healthy; затем:
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never frontend
```
По Новосибирску backend запущен16.10 в00:41:33, frontend в00:42:11. Оба healthy. PostgreSQL сохранил container ID и StartedAt; конфигурация RouterOS и права агентом не менялись. SHA256 Compose до/после одинаков: e675c00d1cbdf6558f9cad601c2674007d899d1b47c92acd0d866ff51bb61bd31. Краткое время Compose recreate не используется как измерение пользовательского простоя.
До обновления ошибка JH воспроизведена в браузере. После: HTTP200/live=true/stale=false для3 JH,4/3/5 endpoints; соответствующие UI-срезы загружены без прежней ошибки. Группировка нескольких endpoints одного dst/table в одну строку сохранена. Filters выбранного московского JH:10 правил,0 ложных missing-route warnings. Карта:4 узла/новая легенда/25GRE в данных; после клика computed outline=none, keyboard ring opacity=0. Read-only DNS по-прежнему запрещён mm:network:write, нарисованы3/25 GRE; все25 связей подтверждёнными не объявляются.
[Структурированный протокол](../docs/ux-ui/acceptance/prod272-release.json). Скриншоты production в игнорируемом tmp, в Git не включены. Изменение маршрутов, формы записи и удаление в production не выполнялись.
Старые образы оставлены для отката, prune не запускался. Для восстановления прежних сервисов при необходимости:
```bash
docker tag sha256:6f344eecb5dd0ec820c23b8d71f89a16ebccff3ac898d954eadd159f2611b6a4 git.shx.one/denozord/mikrotikmanager-backend:latest
docker tag sha256:73ac48979084c0c93b77e75308a9e2f002481586704d0c03fd9a7b5e39511647 git.shx.one/denozord/mikrotikmanager-frontend:latest
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never backend
# Дождаться healthy, затем:
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never frontend
```
Откат вернёт исправленные дефекты. Он не понадобился и не выполнялся. Настоящий протокол сохранён отдельным локальным коммитом без повторного push, чтобы документирование приёмки само не запускало следующий релиз.
+50
View File
@@ -0,0 +1,50 @@
# Индекс истории планировщика — 2026-10-15
Диагностика production: scheduler_runs около1.06M строк/853МБ, retention DELETE после каждого задания, индекса finished_at нет. Наблюдалось ожидание IO/AioIoCompletion; EXPLAIN эквивалентного SELECT показал полный скан. Это подтверждает лишнюю нагрузку БД, но не доказывает причину каждого тайм-аута RouterOS.
Миграция0013 создаёт индекс для новых установок и последующего старта backend. Для работающей установки ниже приведён отдельный online-вариант. **На production выполнен 2026-10-15, запуск в14:55:45 UTC, после разрешения владельца только на индекс и проверку нагрузки.** Создание индекса изменяет БД и временно добавляет нагрузку на CPU/диск.
Подключение psql к текущему mmapp-postgres, без вывода пароля:
```bash
docker exec -it mmapp-postgres sh -c 'exec psql -X -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
```
Сначала проверить, что имя не занято другим или невалидным индексом:
```sql
SELECT indexrelid::regclass, indisvalid, indisready, pg_get_indexdef(indexrelid)
FROM pg_index WHERE indexrelid = to_regclass('public.idx_scheduler_runs_finished_at');
```
Если индекс отсутствует, выполнить **вне BEGIN/транзакции**:
```sql
SET lock_timeout = '5s';
SET statement_timeout = '10min';
CREATE INDEX CONCURRENTLY idx_scheduler_runs_finished_at
ON public.scheduler_runs (finished_at);
RESET statement_timeout;
RESET lock_timeout;
```
При прерывании concurrent build не повторять вслепую с IF NOT EXISTS: сначала проверить indisvalid/indisready. Если остался невалидный индекс, отдельно согласовать его удаление и повторное построение.
После успеха повторить проверку определения и валидности индекса, снять EXPLAIN условия retention и сравнить CPU/IO в нескольких окнах. При очень большой доле устаревших строк PostgreSQL может обоснованно выбрать Seq Scan; не отключать его принудительно. Срок хранения30 дней и код удаления не меняются. Индекс не удаляет записи и не требует перезапуска контейнеров.
Удаление индекса, перезапуски и изменение ресурсов не входят в эту процедуру. Исправление карты — отдельный frontend-патч и само этим SQL не разворачивается.
## Результат применения (2026-10-15)
CREATE INDEX CONCURRENTLY завершён успешно; indisvalid/indisready=true. Повторная проверка в15:01:30 UTC:23МБ,697 использований индекса. EXPLAIN эквивалентного SELECT retention: Bitmap Heap Scan → Bitmap Index Scan idx_scheduler_runs_finished_at вместо Parallel Seq Scan. EXPLAIN ANALYZE DELETE не запускался.
| Измерение | До индекса | После,14:56 UTC | После,15:00 UTC |
| --- | --- | --- | --- |
| CPU контейнера PostgreSQL, docker stats | 66,51% | 0,16% | 0,13% |
| CPU idle сервера, секундные vmstat | 0% | 74–94% | 94–97% |
| CPU iowait сервера, секундные vmstat | 29–34% | 0–5% | 0–1% |
| IO pressure some avg10 | 63,67% | 1,70% | 0,48% |
Первая строка vmstat (среднее с загрузки ОС) исключена из сравнения. Замеры показывают устойчивое улучшение в пределах нескольких минут обычной работы, а не нагрузочную приёмку или гарантию отсутствия всех тайм-аутов. PostgreSQL/контейнеры не перезапускались; ручное удаление данных, изменение retention и параметров приложения не выполнялись. Действующий штатный retention продолжал работать.
Автоматическая проверка фактического SQL миграции выполнена на отдельном тестовом Debian в контейнере mmapp-test-backend:20тыс. временных строк, повторный запуск, валидность индекса, смена плана и сохранение результата выборки. Тест не запускает bootstrap и не изменяет public-таблицы. Backend build и scoped ESLint прошли. [Структурированный протокол](../docs/ux-ui/acceptance/prod266-retention.json).
+72
View File
@@ -0,0 +1,72 @@
# Production273 — отказы входа RouterOS (2026-10-16)
Диагностика production выполнена только чтением. Пароли, пользователи, службы,
firewall, задания RouterOS и контейнеры не изменялись. Секреты не включены.
## Наблюдения
- `nov-gw01.rtnt.top`, server ID3: RouterOS **7.24.4 stable**.
- Рядом зафиксированы `500 / too many concurrent logins (6)` и `401 Unauthorized`
на `/interface`, `/interface/gre`, `/routing/bgp/session`. Последние отказы серии:
**2026-10-16 01:11:40–44 по Новосибирску** (2026-10-15 18:11:40–44 UTC).
- В последних15 минутах на01:27: traffic30/30 ok, gre_bgp30/30 ok,
internet_path3/3 ok, ошибок этих заданий нет.
- Тот же сохранённый пароль успешно использован для system/resource, user/active,
ip/service, interface/gre (3 записи), routing/bgp/session (3 записи).
Пользователь приложения: full, disabled=false, address пустой, api/rest-api
разрешены. Это подтверждает текущий доступ, но не объясняет каждый старый401.
- FTP/Telnet включены. В кольце журнала01:26:00–01:26:47:10 отказов входа
с посторонних адресов —9 FTP и1 Telnet; отказов с адреса приложения в этой
выборке нет. Следующий снимок показывает **7 внешних Telnet-соединений**.
Подбор учётных данных наблюдается; успешный посторонний вход не установлен.
- У www/www-ssl/api/api-ssl max-sessions=20. Число6 в ошибке нельзя автоматически
отождествлять с этим параметром или количеством TCP-соединений приложения.
- user/active содержит много старых rest-api записей с адреса приложения;
api запись в момент чтения одна. По одному списку нельзя доказывать утечку
живых TCP-соединений или принудительно удалять записи.
- RouterOS scheduler `evofw-sync`: enabled, interval1m, run-count625.
В доступном кольце01:26–01:30:866 изменений от scheduler, из них860 добавлений
address-list и6 изменений filter rule. Два script/job в снимке, включая начатый
01:22:08. Скрипты не выгружались и не менялись; связь с отказами не доказана.
- Общий HTTP/HTTPS Agent backend ограничивает сокеты до2 **на origin**.
Одно задание планировщика защищено от повторного запуска; разные задания
работают одновременно. Шесть одновременных REST-запросов из основного процесса
приложения не установлены.
## Вывод и границы
Неверный пароль как причина текущего доступа не подтвердился. RouterOS явно
сообщил о насыщении механизма входа;401 возникали рядом, затем без изменения
пароля прекратились. Связь с внешним подбором, продлением REST-сессии или локальным
scheduler необходимо различить дальнейшим наблюдением.15 минут успеха не доказывают
устранение причины.
[Официальная документация REST](https://manual.mikrotik.com/docs/developer-guides/rest-api/)
описывает внутренний API login, повторное использование логина10 минут и политики
api/rest-api. Страница относится к7.26; поведение7.24.4 не считать идентичным
без отдельного подтверждения.
## Локальная правка
- 500 с точным JSON detail `too many concurrent logins (...)` получает подсказку
«RouterOS занят: превышен лимит одновременных входов».
- 401 больше не обозначается безусловно как неверный пароль: предлагается проверить
учётные данные, права API/REST и ограничения входа.
- Исходные HTTP status/path/body сохранены; повторов чтения/записи не добавлено.
Другие500 не классифицируются как лимит входов.
- 12 HTTP-проверок и профиль ros-file, backend production build, scoped ESLint,
diff check прошли. Локально, без push/deploy.
## Следующий шаг, требующий разрешения
Временно отключить **только** ftp (`*1`,21) и telnet (`*0`,23) на ID3.
Перед записью повторно проверить имя/ID и исходный disabled каждой службы.
Это отключит FTP/Telnet подключения; Winbox, HTTPS, API и маршрутизация сохраняются.
Для этой диагностики production доступ предоставлен на чтение: запись требует
отдельного разрешения владельца. Если эти службы нужны, согласовать доверенные адреса.
После разрешения: сохранить исходные disabled, отключить две службы, проверить
применение и REST-доступ; наблюдать обычный опрос не менее двух периодов продления
REST-логина, сравнить ошибки и login failures. Не повышать max-sessions, не менять
пароль, не удалять user/active, не перезапускать маршрутизатор. Результат записать
отдельно: отключение ещё не выполнялось и причинная связь не доказана.
+78
View File
@@ -0,0 +1,78 @@
# Production274 — отключение FTP/Telnet и наблюдение (2026-10-16)
Владелец явно разрешил отключить FTP и Telnet на `nov-gw01.rtnt.top`.
Предыдущая диагностика: [Production273](PRODUCTION-ROUTEROS-273.md).
## Изменение
Перед записью проверены server ID3/host, единственность статических служб,
их ID, порты21/23 и исходный disabled=false. Поочерёдно применено только
`disabled=true`:
| Служба | ID | До | После | Время UTC 2026-10-15 |
| --- | --- | --- | --- | --- |
| ftp | *1 | false | true | 18:33:45.013 |
| telnet | *0 | false | true | 18:33:45.322 |
Повторное чтение18:33:45.582 UTC подтвердило обе службы выключенными.
Это **2026-10-16 01:33:45 по Новосибирску**.
www80/www-ssl443/winbox8291/api8728/api-ssl8729 остались disabled=false,
как до операции. REST identity подтвердил имя устройства; resource —7.24.4 stable,
uptime10h28m24s, без перезапуска.
Пароль, пользователи, firewall, маршруты, локальные задания RouterOS, контейнеры
и приложение не изменялись. Локальная правка подсказок273 не развёртывалась.
## Метод наблюдения
Штатные задания продолжают работать с прежними интервалами. Диагностический
процесс раз в минуту только читает scheduler_runs и RouterOS log; дополнительных
запусков заданий/нагрузочных тестов нет. Подсчитываются ok/error после18:33:45.582 UTC
для traffic, gre_bgp, internet_path, uptime_resources. Журнал логинов проверяется
с продвижением по ID и контролем перекрытия кольцевого буфера, без вывода секретов.
## Итог наблюдения
Окончание:2026-10-15 18:54:44 UTC /2026-10-16 01:54:44 по Новосибирску,
почти21 минута после применения. После изменения в scheduler_runs:
| Задание | Успешно | Ошибки | Максимальная длительность |
| --- | --- | --- | --- |
| traffic | 42 | 0 | 2278мс |
| gre_bgp | 42 | 0 | 910мс |
| internet_path | 4 | 0 | 809мс |
| uptime_resources | 84 | 0 | 1937мс |
Итоговое чтение: статические ftp/telnet disabled=true; www/www-ssl/winbox/api/api-ssl
остались включены. Identity/REST работает, RouterOS7.24.4 stable,
uptime10h49m22s — перезапуска не было. В списке остаются6 динамических строк Telnet;
отключение службы не доказывает завершение всех прежних соединений. Другие службы,
соединения и firewall принудительно не менялись.
В18:55:47 UTC проверено новое TCP-подключение с production backend к имени
устройства:21 и23 вернули ECONNREFUSED. Новые подключения к этим портам отклоняются.
В наблюдавшихся новых логах нет отказов FTP/Telnet, но есть2 отказа Winbox.
Их источники не были сохранены: не объявлять эти2 события доказанным внешним
подбором. Кольцо RouterOS переполнялось scheduler-изменениями; обнаружено4 пропуска
перекрытия, поэтому отсутствие FTP/Telnet failures не доказано для всего интервала.
Статистика scheduler_runs этим ограничением не затронута.
Изменение служб само по себе не доказывает причину предыдущих401/500: они
прекратились ещё до операции. Зафиксировано отсутствие повторения после отключения,
а не гарантированное устранение первопричины. Автоматический сбор наблюдений завершён.
## Восстановление исходного состояния
Владелец отдельно подтвердил: **«Оставить FTP и Telnet выключенными»**.
После наблюдения службы не включать обратно. При необходимости отдельного отката:
повторно проверить host, имена/ID, вернуть disabled=false только ftp/telnet и
сверить чтением. Других изменений конфигурации этим блоком не выполнять.
## Дополнительный вопрос о PostgreSQL
По вопросу владельца выполнена только читающая проверка production PostgreSQL:
pg_is_in_recovery=false, wal_level=replica, pg_stat_replication count=0,
pg_replication_slots count=0, pg_subscription count=0, synchronous_standby_names
пустой. В конфигурации проекта один PostgreSQL и один DATABASE_URL backend.
Активная физическая/логическая репликация не обнаружена; параметры БД не менялись.
+27
View File
@@ -0,0 +1,27 @@
# Optional addition to docker-compose.cdn-mm.yml. Run the exported installer first.
# Keep this file in every subsequent docker compose -f invocation.
services:
netflow-agent:
image: git.shx.one/denozord/mikrotikmanager-netflow-agent:${MM_NETFLOW_AGENT_IMAGE_TAG:-latest}
container_name: mmapp-netflow-agent
restart: unless-stopped
network_mode: host
cap_drop: [ALL]
cap_add: [NET_ADMIN]
security_opt: ["no-new-privileges:true"]
read_only: true
tmpfs: ["/tmp:rw,noexec,nosuid,size=8m"]
volumes:
- ./data/netflow-wireguard:/etc/wireguard
- ./data/netflow-agent-socket:/run/mm-netflow-host
logging:
driver: json-file
options: {max-size: "5m", max-file: "2"}
backend:
environment:
FLOW_AGENT_SOCKET: /run/mm-netflow-host/agent.sock
volumes:
- ./data/netflow-agent-socket:/run/mm-netflow-host:ro
depends_on:
netflow-agent:
condition: service_healthy
+11
View File
@@ -0,0 +1,11 @@
FROM alpine:3.22 AS base
RUN apk add --no-cache python3 wireguard-tools iproute2
COPY agent.py /app/agent.py
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
HEALTHCHECK --interval=15s --timeout=5s --retries=3 CMD ["python3", "/app/agent.py", "--healthcheck"]
ENTRYPOINT ["python3", "/app/agent.py"]
FROM base AS test
COPY test_agent.py /app/test_agent.py
FROM base AS runtime
+367
View File
@@ -0,0 +1,367 @@
"""Local, single-interface WireGuard controller. Never executes wg-quick/hooks."""
import base64
import binascii
import http.client
import http.server
import ipaddress
import json
import os
from pathlib import Path
import re
import socket
import socketserver
import subprocess
import sys
import tempfile
import threading
IFACE = "wg-flow"
CONFIG = Path(os.environ.get("FLOW_AGENT_CONFIG_DIR", "/etc/wireguard"))
SOCKET = os.environ.get("FLOW_AGENT_SOCKET", "/run/mm-netflow-host/agent.sock")
LOCK = threading.RLock()
MAX_BODY = 262144
class AgentError(Exception):
pass
def run(*args, input=None):
try:
result = subprocess.run(args, input=input, text=True, capture_output=True, timeout=10)
except (OSError, subprocess.TimeoutExpired):
raise AgentError("Команда WireGuard/IP недоступна или превысила время ожидания") from None
if result.returncode:
# stderr/stdout can contain keys and must never reach an HTTP response/log.
raise AgentError("Операция WireGuard/IP не выполнена")
return result.stdout.strip()
def key(value):
try:
if not isinstance(value, str) or len(value) != 44 or len(base64.b64decode(value, validate=True)) != 32:
raise ValueError()
except (ValueError, binascii.Error):
raise AgentError("Некорректный ключ WireGuard") from None
return value
def port(value):
if type(value) is not int or not 1 <= value <= 65535:
raise AgentError("Некорректный UDP-порт")
return value
def endpoint(value):
if not isinstance(value, str) or not re.fullmatch(r"(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):[0-9]{1,5}", value):
raise AgentError("Некорректный endpoint")
port(int(value.rsplit(":", 1)[1]))
return value
def parse_conf(text):
interface, peers, section = {}, [], None
allowed = {"interface": {"PrivateKey", "Address", "ListenPort", "MTU"},
"peer": {"PublicKey", "PresharedKey", "AllowedIPs", "Endpoint", "PersistentKeepalive"}}
for raw in text.splitlines():
line = raw.split("#", 1)[0].strip()
if not line:
continue
if line == "[Interface]" and section is None:
section, current = "interface", interface
elif line == "[Peer]" and section is not None:
section, current = "peer", {}
peers.append(current)
else:
name, sep, value = line.partition("=")
name, value = name.strip(), value.strip()
if not sep or section is None or name not in allowed[section] or name in current or not value:
raise AgentError("Конфигурация wg-flow содержит неподдерживаемые или повторные параметры; требуется ручная проверка")
current[name] = value
if not interface or not {"PrivateKey", "Address", "ListenPort"} <= interface.keys():
raise AgentError("Неполная конфигурация wg-flow")
key(interface["PrivateKey"])
try:
ipaddress.IPv4Interface(interface["Address"])
port(int(interface["ListenPort"]))
if "MTU" in interface and not 576 <= int(interface["MTU"]) <= 9000:
raise ValueError()
for peer in peers:
key(peer.get("PublicKey"))
if "PresharedKey" in peer:
key(peer["PresharedKey"])
if "Endpoint" in peer:
endpoint(peer["Endpoint"])
if peer.get("AllowedIPs"):
networks(peer["AllowedIPs"].split(","))
if "PersistentKeepalive" in peer and not 0 <= int(peer["PersistentKeepalive"]) <= 65535:
raise ValueError()
except (ValueError, TypeError):
raise AgentError("Некорректные параметры wg-flow") from None
if len({p["PublicKey"] for p in peers}) != len(peers):
raise AgentError("Повторный публичный ключ пира")
return interface, peers
def networks(values):
if not isinstance(values, list) or not values:
raise AgentError("AllowedIPs должны содержать адреса")
try:
return [ipaddress.ip_network(v.strip(), strict=False) for v in values]
except (ValueError, AttributeError):
raise AgentError("Некорректные AllowedIPs") from None
def validate(raw):
fields = {"collectorIp", "prefix", "flowListenPort", "wgListenPort", "hostPublicKey", "hostPrivateKey", "peers"}
if not isinstance(raw, dict) or set(raw) != fields:
raise AgentError("Некорректный снимок NetFlow")
try:
collector = ipaddress.IPv4Address(raw["collectorIp"])
prefix = ipaddress.IPv4Network(raw["prefix"], strict=False)
if prefix.prefixlen != 24 or collector not in prefix or collector in (prefix.network_address, prefix.broadcast_address):
raise ValueError()
except (ValueError, TypeError):
raise AgentError("Требуется IPv4 overlay /24 и адрес коллектора внутри него") from None
port(raw["flowListenPort"])
port(raw["wgListenPort"])
key(raw["hostPublicKey"])
key(raw["hostPrivateKey"])
if run("wg", "pubkey", input=raw["hostPrivateKey"] + "\n") != raw["hostPublicKey"]:
raise AgentError("Публичный и приватный ключи MM не соответствуют друг другу")
binding = json.loads((CONFIG / "binding.json").read_text())
for name in ("collectorIp", "prefix", "flowListenPort", "wgListenPort"):
if binding.get(name) != raw[name]:
raise AgentError("Адрес, префикс или порт отличаются от установки агента; повторите установщик")
if not isinstance(raw["peers"], list) or len(raw["peers"]) > 253:
raise AgentError("Некорректный список пиров")
peers, ids, keys = [], set(), set()
for peer in raw["peers"]:
if not isinstance(peer, dict) or not {"serverId", "publicKey", "allowedIps"} <= peer.keys() or set(peer) - {"serverId", "publicKey", "allowedIps", "endpoint"}:
raise AgentError("Некорректный пир")
peer_key = key(peer["publicKey"])
if type(peer["serverId"]) is not int or peer["serverId"] <= 0 or peer["serverId"] in ids or peer_key in keys or peer_key == raw["hostPublicKey"]:
raise AgentError("Повторный или некорректный пир")
ids.add(peer["serverId"])
keys.add(peer_key)
nets = networks(peer["allowedIps"])
if len(nets) != 1 or nets[0].version != 4 or nets[0].prefixlen != 32 or nets[0].network_address not in prefix or nets[0].network_address in (collector, prefix.network_address, prefix.broadcast_address):
raise AgentError("Пиру разрешён только собственный IPv4 /32 внутри overlay")
data = {"PublicKey": peer_key, "AllowedIPs": str(nets[0])}
if peer.get("endpoint"):
data.update(Endpoint=endpoint(peer["endpoint"]), PersistentKeepalive="25")
peers.append(data)
return peers
def merge_peers(existing, desired):
merged = {peer["PublicKey"]: dict(peer) for peer in existing}
for peer in desired:
public = peer["PublicKey"]
# Replacing a PSK would silently break a previously working tunnel.
if merged.get(public, {}).get("PresharedKey", "A" * 43 + "=") != "A" * 43 + "=":
raise AgentError("Управляемый пир использует preshared key; требуется ручная проверка")
merged[public] = dict(peer)
rows = list(merged.values())
wanted = {p["PublicKey"] for p in desired}
for i, left in enumerate(rows):
for right in rows[i + 1:]:
if left["PublicKey"] not in wanted and right["PublicKey"] not in wanted:
continue
if not left.get("AllowedIPs") or not right.get("AllowedIPs"):
continue
if any(a.version == b.version and a.overlaps(b) for a in networks(left["AllowedIPs"].split(",")) for b in networks(right["AllowedIPs"].split(","))):
raise AgentError("AllowedIPs управляемого пира пересекаются с другим пиром")
return rows
def render(interface, peers, native=False):
rows = ["[Interface]"]
rows += [f"{k} = {v}" for k, v in interface.items() if not native or k not in ("Address", "MTU")]
for peer in peers:
rows += ["", "[Peer]"] + [f"{k} = {v}" for k, v in peer.items() if k != "AllowedIPs" or v]
return "\n".join(rows) + "\n"
def atomic_write(path, text):
fd, name = tempfile.mkstemp(prefix=".flow-", dir=path.parent)
try:
with os.fdopen(fd, "w") as stream:
stream.write(text)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, path)
if os.name == "posix":
directory = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
if os.path.exists(name):
os.unlink(name)
def sync(text):
# wg requires a file. mkstemp creates it with mode 0600.
fd, name = tempfile.mkstemp(prefix="flow-sync-")
try:
with os.fdopen(fd, "w") as stream:
stream.write(text)
run("wg", "syncconf", IFACE, name)
finally:
os.unlink(name)
def live():
dump = run("wg", "show", IFACE, "dump").splitlines()
if not dump or len(dump[0].split("\t")) != 4:
raise AgentError("Интерфейс wg-flow недоступен")
interface = dump[0].split("\t")
peers, raw_peers = [], []
for line in dump[1:]:
cols = line.split("\t")
if len(cols) != 8:
raise AgentError("Некорректный ответ WireGuard")
public, psk, ep, ips, handshake, rx, tx, keepalive = cols
peers.append({"publicKey": public, "allowedIps": ips.split(",") if ips != "(none)" else [],
"lastHandshakeAt": int(handshake) or None, "receivedBytes": int(rx), "sentBytes": int(tx)})
item = {"PublicKey": public, "AllowedIPs": ips if ips != "(none)" else ""}
if psk != "(none)":
item["PresharedKey"] = psk
if ep != "(none)":
item["Endpoint"] = ep
item["PersistentKeepalive"] = keepalive
raw_peers.append(item)
addresses = json.loads(run("ip", "-j", "-4", "address", "show", "dev", IFACE))
cidrs = [f"{a['local']}/{a['prefixlen']}" for row in addresses for a in row.get("addr_info", []) if a.get("family") == "inet"]
return {"interfaceUp": any("UP" in row.get("flags", []) for row in addresses), "publicKey": interface[1], "listenPort": int(interface[2]), "addresses": cidrs, "peers": peers}, raw_peers
def check(raw, apply=False):
desired = validate(raw)
path = CONFIG / "wg-flow.conf"
previous = path.read_text()
interface, stored = parse_conf(previous)
expected_address = f"{raw['collectorIp']}/{raw['prefix'].split('/')[1]}"
if run("wg", "pubkey", input=interface["PrivateKey"] + "\n") != raw["hostPublicKey"] or interface["Address"] != expected_address or int(interface["ListenPort"]) != raw["wgListenPort"]:
raise AgentError("Ключ, адрес или порт существующего wg-flow отличаются от MM; требуется ручная проверка")
status, runtime = live()
if not status["interfaceUp"]:
raise AgentError("Интерфейс wg-flow выключен; проверьте wg-quick на хосте")
if status["publicKey"] != raw["hostPublicKey"] or expected_address not in status["addresses"] or status["listenPort"] != raw["wgListenPort"]:
raise AgentError("Работающий wg-flow отличается от конфигурации; требуется ручная проверка")
# Preserve both persistent and runtime-only third-party peers. Detect conflicts before writing.
combined = {p["PublicKey"]: p for p in runtime}
combined.update({p["PublicKey"]: p for p in stored})
merged = merge_peers(list(combined.values()), desired)
runtime_merged = merge_peers(runtime, desired)
# Do not reset a third-party peer's runtime settings to an older persistent file.
# Preserve roaming endpoints of managed peers unless the persisted endpoint was edited.
stored_by_key = {p["PublicKey"]: p for p in stored}
runtime_by_key = {p["PublicKey"]: p for p in runtime}
desired_by_key = {p["PublicKey"]: p for p in desired}
for peer in runtime_merged:
public = peer["PublicKey"]
if public in desired_by_key and stored_by_key.get(public, {}).get("Endpoint") == desired_by_key[public].get("Endpoint"):
if runtime_by_key.get(public, {}).get("Endpoint"):
peer["Endpoint"] = runtime_by_key[public]["Endpoint"]
def matches(actual, persistent=False):
by_key = {p["PublicKey"]: p for p in actual}
return all(p["PublicKey"] in by_key and all(by_key[p["PublicKey"]].get(k) == v for k, v in p.items() if persistent or k != "Endpoint") for p in desired)
matched = matches(stored, persistent=True) and matches(runtime)
if apply:
rollback = run("wg", "showconf", IFACE)
try:
atomic_write(path, render(interface, merged))
# Keep the running interface header verbatim (including a possible fwmark).
header = rollback.split("[Peer]", 1)[0].rstrip()
peer_config = render({}, runtime_merged, native=True).removeprefix("[Interface]\n")
sync(header + "\n" + peer_config)
status, after = live()
if not matches(after):
raise AgentError("Применение пиров не подтверждено")
except Exception:
failures = []
for action in (lambda: atomic_write(path, previous), lambda: sync(rollback)):
try:
action()
except Exception:
failures.append(True)
raise AgentError("Применение не удалось; " + ("восстановление требует ручной проверки" if failures else "предыдущая конфигурация восстановлена")) from None
matched = True
return {**status, "configMatches": matched}
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *_):
pass
def handle_request(self):
try:
with LOCK:
if self.command == "GET" and self.path == "/status":
result, _ = live()
result["configMatches"] = None
elif self.command == "POST" and self.path in ("/check", "/apply"):
size = int(self.headers.get("Content-Length", "0"))
if not 0 < size <= MAX_BODY or self.headers.get("Transfer-Encoding"):
raise AgentError("Некорректный размер запроса")
raw = json.loads(self.rfile.read(size))
result = check(raw, apply=self.path == "/apply")
else:
self.respond(404, {"error": "Операция не поддерживается"})
return
self.respond(200, result)
except AgentError as error:
self.respond(409, {"error": str(error)})
except Exception:
self.respond(503, {"error": "Агент NetFlow не готов; проверьте конфигурацию и интерфейс хоста"})
def setup(self):
super().setup()
self.connection.settimeout(15)
def respond(self, code, body):
data = json.dumps(body, ensure_ascii=False).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
do_GET = handle_request
do_POST = handle_request
class Server(socketserver.ThreadingMixIn, getattr(socketserver, "UnixStreamServer", socketserver.TCPServer)):
daemon_threads = True
class UnixConnection(http.client.HTTPConnection):
def connect(self):
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.settimeout(3)
self.sock.connect(SOCKET)
def main():
if not hasattr(socketserver, "UnixStreamServer"):
raise SystemExit("NetFlow agent requires Linux Unix sockets")
if "--healthcheck" in sys.argv:
client = UnixConnection("localhost")
client.request("GET", "/status")
response = client.getresponse()
sys.exit(0 if response.status == 200 and json.loads(response.read()).get("interfaceUp") else 1)
os.umask(0o077)
Path(SOCKET).parent.mkdir(parents=True, exist_ok=True)
if os.path.lexists(SOCKET):
Path(SOCKET).unlink()
with Server(SOCKET, Handler) as server:
os.chmod(SOCKET, 0o660)
print("NetFlow agent: Unix API ready", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# Usage: sudo bash install.sh /absolute/path/wg-flow.conf /opt/cdn-mm 4739
set -euo pipefail
umask 077
[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "Запустите от root" >&2; exit 1; }
SOURCE=$(realpath "${1:?Укажите скачанный wg-flow.conf}")
COMPOSE_DIR=$(realpath "${2:-/opt/cdn-mm}")
FLOW_PORT=${3:-4739}
[[ "$FLOW_PORT" =~ ^[0-9]+$ ]] && (( FLOW_PORT >= 1 && FLOW_PORT <= 65535 )) || exit 1
command -v docker >/dev/null
docker compose version >/dev/null
systemctl show docker.service --property=LoadState --value | grep -qx loaded
if ! command -v wg >/dev/null || ! command -v python3 >/dev/null || ! command -v modprobe >/dev/null; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard-tools python3 iproute2 kmod
fi
modprobe wireguard
[[ -f "$COMPOSE_DIR/docker-compose.yml" ]] || { echo "Не найден docker-compose.yml" >&2; exit 1; }
AGENT_IMAGE=${MM_NETFLOW_AGENT_IMAGE:-git.shx.one/denozord/mikrotikmanager-netflow-agent:latest}
[[ "$AGENT_IMAGE" =~ ^[A-Za-z0-9][A-Za-z0-9._:/@-]+$ ]] || { echo "Некорректное имя образа" >&2; exit 1; }
# The image must be available before changing the host configuration.
docker pull "$AGENT_IMAGE"
TASK_TEMP=$(mktemp -d)
trap 'rm -rf -- "$TASK_TEMP"' EXIT
python3 - "$SOURCE" "$COMPOSE_DIR" "$FLOW_PORT" "$TASK_TEMP" <<'PY'
import base64, ipaddress, json, os, pathlib, re, subprocess, sys
source, directory, port, temp = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), int(sys.argv[3]), pathlib.Path(sys.argv[4])
def read(text):
section, fields, peers = None, {}, []
allowed = {'Interface': {'PrivateKey','Address','ListenPort','MTU'}, 'Peer': {'PublicKey','AllowedIPs','Endpoint','PersistentKeepalive','PresharedKey'}}
for raw in text.splitlines():
line = raw.split('#',1)[0].strip()
if not line: continue
if line == '[Interface]' and section is None: section, current = 'Interface', fields
elif line == '[Peer]' and section is not None:
section, current = 'Peer', {}; peers.append(current)
else:
key, sep, value = line.partition('='); key, value = key.strip(), value.strip()
if not sep or section not in allowed or key not in allowed[section] or key in current or not value:
raise SystemExit('Конфигурация содержит неподдерживаемые параметры; требуется ручная проверка')
current[key] = value
try:
addr = ipaddress.IPv4Interface(fields['Address'])
if addr.network.prefixlen != 24 or addr.ip in (addr.network.network_address, addr.network.broadcast_address): raise ValueError()
if len(base64.b64decode(fields['PrivateKey'], validate=True)) != 32 or not 1 <= int(fields['ListenPort']) <= 65535: raise ValueError()
if 'MTU' in fields and not 576 <= int(fields['MTU']) <= 9000: raise ValueError()
for peer in peers:
if len(base64.b64decode(peer['PublicKey'], validate=True)) != 32: raise ValueError()
for network in peer['AllowedIPs'].split(','): ipaddress.ip_network(network.strip(), strict=False)
if 'Endpoint' in peer and not re.fullmatch(r'(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):[0-9]{1,5}', peer['Endpoint']): raise ValueError()
if 'PersistentKeepalive' in peer and not 0 <= int(peer['PersistentKeepalive']) <= 65535: raise ValueError()
except (ValueError, KeyError): raise SystemExit('Некорректная конфигурация WireGuard; ключи не изменены')
return fields, addr
expected, addr = read(source.read_text())
active = pathlib.Path('/etc/wireguard/wg-flow.conf')
candidate = active.read_text() if active.exists() else source.read_text()
actual, actual_addr = read(candidate)
for key in ('PrivateKey','Address','ListenPort'):
if actual[key] != expected[key]: raise SystemExit('Ключ, адрес или порт существующего туннеля отличаются от MM; установка остановлена')
public = subprocess.run(['wg','pubkey'], input=actual['PrivateKey']+'\n',text=True,capture_output=True,check=True).stdout.strip()
if subprocess.run(['ip','link','show','dev','wg-flow'],capture_output=True).returncode == 0:
dump = subprocess.run(['wg','show','wg-flow','dump'],capture_output=True,text=True,check=True).stdout.splitlines()
head = dump[0].split('\t')
addresses = json.loads(subprocess.run(['ip','-j','-4','address','show','dev','wg-flow'],capture_output=True,text=True,check=True).stdout)
if head[1] != public or int(head[2]) != int(actual['ListenPort']) or not any(a.get('local') == str(addr.ip) and a.get('prefixlen') == addr.network.prefixlen for row in addresses for a in row.get('addr_info',[])):
raise SystemExit('Работающий wg-flow отличается от MM; установка остановлена')
binding = dict(collectorIp=str(addr.ip), prefix=str(addr.network), flowListenPort=port, wgListenPort=int(actual['ListenPort']))
(temp/'binding.json').write_text(json.dumps(binding))
(temp/'wg-flow.conf').write_text(candidate)
(temp/'collector').write_text(str(addr.ip))
PY
COLLECTOR_IP=$(cat "$TASK_TEMP/collector")
# Keep a dedicated override; never replace docker-compose.override.yml.
cat > "$TASK_TEMP/compose.yml" <<EOF
services:
netflow-agent:
image: "$AGENT_IMAGE"
container_name: mmapp-netflow-agent
restart: unless-stopped
network_mode: host
cap_drop: [ALL]
cap_add: [NET_ADMIN]
security_opt: ["no-new-privileges:true"]
read_only: true
tmpfs: ["/tmp:rw,noexec,nosuid,size=8m"]
volumes:
- ./data/netflow-wireguard:/etc/wireguard
- ./data/netflow-agent-socket:/run/mm-netflow-host
logging:
driver: json-file
options: {max-size: "5m", max-file: "2"}
backend:
environment:
FLOW_AGENT_SOCKET: /run/mm-netflow-host/agent.sock
FLOW_LISTEN_HOST: "0.0.0.0"
volumes:
- ./data/netflow-agent-socket:/run/mm-netflow-host:ro
ports:
- "$COLLECTOR_IP:$FLOW_PORT:$FLOW_PORT/udp"
depends_on:
netflow-agent:
condition: service_healthy
EOF
COMPOSE_ARGS=(-f "$COMPOSE_DIR/docker-compose.yml")
if [[ -f "$COMPOSE_DIR/docker-compose.override.yml" ]]; then COMPOSE_ARGS+=(-f "$COMPOSE_DIR/docker-compose.override.yml"); fi
# Render first, and reject an existing public/incorrect IPFIX binding before touching WireGuard.
docker compose --project-directory "$COMPOSE_DIR" "${COMPOSE_ARGS[@]}" -f "$TASK_TEMP/compose.yml" config --format json > "$TASK_TEMP/merged.json"
python3 - "$TASK_TEMP/merged.json" "$COLLECTOR_IP" "$FLOW_PORT" <<'PY'
import json,sys
config=json.load(open(sys.argv[1])); ip=sys.argv[2]; port=int(sys.argv[3])
for bind in config['services']['backend'].get('ports',[]):
if bind.get('protocol','tcp') == 'udp' and (bind['target'] in (4739,port) or str(bind.get('published')) == str(port)):
if bind.get('host_ip') != ip or bind['target'] != port or str(bind.get('published')) != str(port):
raise SystemExit('В Compose уже есть другой IPFIX bind. Согласуйте его с адресом и портом MM и повторите установку.')
PY
if docker container inspect mmapp-netflow-agent >/dev/null 2>&1; then docker stop mmapp-netflow-agent; fi
install -d -m 700 /etc/wireguard "$COMPOSE_DIR/data/netflow-wireguard" "$COMPOSE_DIR/data/netflow-agent-socket"
if [[ -e /etc/wireguard/wg-flow.conf ]]; then
cp -L /etc/wireguard/wg-flow.conf "$COMPOSE_DIR/data/netflow-wireguard/wg-flow.conf.backup.$(date +%s)"
fi
install -m 600 "$TASK_TEMP/wg-flow.conf" "$COMPOSE_DIR/data/netflow-wireguard/wg-flow.conf"
install -m 600 "$TASK_TEMP/binding.json" "$COMPOSE_DIR/data/netflow-wireguard/binding.json"
ln -sfn "$COMPOSE_DIR/data/netflow-wireguard/wg-flow.conf" /etc/wireguard/wg-flow.conf
install -m 600 "$TASK_TEMP/compose.yml" "$COMPOSE_DIR/docker-compose.netflow-agent.yml"
install -d /etc/systemd/system/docker.service.d
cat > /etc/systemd/system/docker.service.d/mm-netflow.conf <<'EOF'
[Unit]
Wants=wg-quick@wg-flow.service
After=wg-quick@wg-flow.service
EOF
systemctl daemon-reload
systemctl enable wg-quick@wg-flow.service
if ! ip link show dev wg-flow >/dev/null 2>&1; then
systemctl start wg-quick@wg-flow.service
else
ip link set dev wg-flow up
fi
cd "$COMPOSE_DIR"
docker compose "${COMPOSE_ARGS[@]}" -f docker-compose.netflow-agent.yml up -d netflow-agent
AGENT_READY=false
for attempt in $(seq 1 60); do
if [[ "$(docker inspect --format '{{.State.Health.Status}}' mmapp-netflow-agent)" == healthy ]]; then
AGENT_READY=true
break
fi
sleep 1
done
[[ "$AGENT_READY" == true ]] || { echo "Агент не готов. Проверьте docker logs mmapp-netflow-agent; backend не пересоздан." >&2; exit 1; }
docker compose "${COMPOSE_ARGS[@]}" -f docker-compose.netflow-agent.yml up -d --no-deps --force-recreate backend
echo "Агент установлен. Используйте те же -f файлы при дальнейших операциях Compose:"
printf 'docker compose '; printf '%q ' "${COMPOSE_ARGS[@]}" -f "$COMPOSE_DIR/docker-compose.netflow-agent.yml"; echo 'up -d'
echo "В MM обновите статус хоста и нажмите «Применить настройки хоста»."
+201
View File
@@ -0,0 +1,201 @@
import base64
import copy
import json
import os
from pathlib import Path
import tempfile
import unittest
import shutil
import uuid
from unittest.mock import patch
import agent
def key(n):
return base64.b64encode(bytes([n]) * 32).decode()
class ControllerTest(unittest.TestCase):
def setUp(self):
# Windows Python 3.13's TemporaryDirectory mode 0700 excludes the restricted sandbox token.
base = Path.cwd() / "tmp" if os.name == "nt" else Path(tempfile.gettempdir())
base.mkdir(exist_ok=True)
self.root = base / ("flow-agent-test-" + uuid.uuid4().hex)
self.root.mkdir()
def cleanup():
if self.root.resolve().parent != base.resolve():
raise RuntimeError("Unexpected test directory")
shutil.rmtree(self.root)
self.addCleanup(cleanup)
self.addCleanup(patch.stopall)
patch.object(agent, "CONFIG", self.root).start()
self.interface = dict(PrivateKey=key(1), Address="10.255.254.1/24", ListenPort="51821", MTU="1420")
self.third = dict(PublicKey=key(3), AllowedIPs="192.0.2.5/32", PresharedKey=key(9), PersistentKeepalive="0")
self.desired = dict(collectorIp="10.255.254.1", prefix="10.255.254.0/24", flowListenPort=4739,
wgListenPort=51821, hostPublicKey=key(2), hostPrivateKey=key(1),
peers=[dict(serverId=1, publicKey=key(4), allowedIps=["10.255.254.2/32"], endpoint="router.test:13232")])
(self.root / "binding.json").write_text(json.dumps({k: self.desired[k] for k in ("collectorIp", "prefix", "flowListenPort", "wgListenPort")}))
self.previous = agent.render(self.interface, [self.third])
(self.root / "wg-flow.conf").write_text(self.previous)
self.runtime = [copy.deepcopy(self.third)]
self.calls = []
def run(*args, input=None):
self.calls.append(args)
if args == ("wg", "pubkey"):
return key(2) if input == key(1) + "\n" else key(8)
if args == ("wg", "showconf", "wg-flow"):
return agent.render(self.interface, self.runtime, native=True)
raise AssertionError(args)
patch.object(agent, "run", run).start()
patch.object(agent, "live", self.live).start()
patch.object(agent, "sync", self.sync).start()
self.synced = []
def live(self):
return dict(interfaceUp=True, publicKey=key(2), listenPort=51821, addresses=["10.255.254.1/24"], peers=[]), copy.deepcopy(self.runtime)
def sync(self, text):
self.synced.append(text)
# Native configs don't include wg-quick's Address; simulate applying their peers.
_, self.runtime = agent.parse_conf(text.replace("ListenPort = 51821", "ListenPort = 51821\nAddress = 10.255.254.1/24"))
def test_apply_preserves_third_party_psk_and_is_idempotent(self):
self.assertFalse(agent.check(self.desired)["configMatches"])
self.assertTrue(agent.check(self.desired, apply=True)["configMatches"])
text = (self.root / "wg-flow.conf").read_text()
self.assertIn(key(3), text)
self.assertIn(key(9), text)
self.assertIn(key(4), text)
self.assertNotIn("Address", self.synced[0])
self.assertTrue(agent.check(self.desired)["configMatches"])
agent.check(self.desired, apply=True)
self.assertEqual((self.root / "wg-flow.conf").read_text(), text)
if os.name != "nt":
self.assertEqual((self.root / "wg-flow.conf").stat().st_mode & 0o777, 0o600)
def test_runtime_only_peer_preserved(self):
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="198.51.100.7/32", PersistentKeepalive="0"))
agent.check(self.desired, apply=True)
self.assertIn(key(6), (self.root / "wg-flow.conf").read_text())
def test_runtime_peer_without_allowed_ips_preserved(self):
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="", PersistentKeepalive="0"))
agent.check(self.desired, apply=True)
self.assertTrue(agent.check(self.desired)["configMatches"])
def test_third_party_runtime_settings_are_not_reset(self):
# Runtime may contain a roaming endpoint and intentionally unsaved settings.
self.runtime[0].update(Endpoint="198.51.100.2:50000", PersistentKeepalive="10")
agent.check(self.desired, apply=True)
third = next(p for p in self.runtime if p["PublicKey"] == key(3))
self.assertEqual(third["Endpoint"], "198.51.100.2:50000")
self.assertEqual(third["PersistentKeepalive"], "10")
_, stored = agent.parse_conf((self.root / "wg-flow.conf").read_text())
self.assertEqual(next(p for p in stored if p["PublicKey"] == key(3))["PersistentKeepalive"], "0")
def test_managed_roaming_endpoint_preserved_without_changing_desired_dns(self):
agent.check(self.desired, apply=True)
next(p for p in self.runtime if p["PublicKey"] == key(4))["Endpoint"] = "198.51.100.2:50000"
agent.check(self.desired, apply=True)
_, stored = agent.parse_conf((self.root / "wg-flow.conf").read_text())
self.assertEqual(next(p for p in stored if p["PublicKey"] == key(4))["Endpoint"], "router.test:13232")
self.assertEqual(next(p for p in self.runtime if p["PublicKey"] == key(4))["Endpoint"], "198.51.100.2:50000")
self.assertTrue(agent.check(self.desired)["configMatches"])
def test_down_interface_cannot_report_applied(self):
status, peers = self.live()
with patch.object(agent, "live", return_value=({**status, "interfaceUp": False}, peers)):
with self.assertRaisesRegex(agent.AgentError, "выключен"):
agent.check(self.desired, apply=True)
self.assertEqual(self.synced, [])
def test_overlap_rejected_before_any_write(self):
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="10.255.254.0/24"))
with self.assertRaisesRegex(agent.AgentError, "пересекаются"):
agent.check(self.desired, apply=True)
self.assertEqual((self.root / "wg-flow.conf").read_text(), self.previous)
self.assertEqual(self.synced, [])
def test_different_interface_identity_rejected(self):
self.desired["hostPrivateKey"], self.desired["hostPublicKey"] = key(7), key(8)
with self.assertRaisesRegex(agent.AgentError, "существующего"):
agent.check(self.desired, apply=True)
self.assertEqual(self.synced, [])
def test_failed_apply_restores_file_and_runtime(self):
rollback = agent.render(self.interface, self.runtime, native=True)
with patch.object(agent, "sync", side_effect=[agent.AgentError("secret error"), None]) as sync:
with self.assertRaisesRegex(agent.AgentError, "предыдущая конфигурация восстановлена"):
agent.check(self.desired, apply=True)
self.assertEqual(sync.call_args_list[-1].args, (rollback,))
self.assertEqual((self.root / "wg-flow.conf").read_text(), self.previous)
def test_failed_rollback_is_explicit(self):
with patch.object(agent, "sync", side_effect=agent.AgentError("secret error")):
with self.assertRaisesRegex(agent.AgentError, "восстановление требует ручной проверки"):
agent.check(self.desired, apply=True)
def test_invalid_or_shell_input_rejected(self):
for change in [dict(flowListenPort=4740), dict(prefix="10.255.254.0/28"), dict(hostPrivateKey="$(id)"), dict(command="id"), dict(peers=[dict(serverId=1, publicKey=key(4), allowedIps=["0.0.0.0/0"])]), dict(peers=[dict(serverId=1, publicKey=key(4), allowedIps=["10.255.254.2/32"], endpoint="router.test:13232\nPostUp = id")])]:
with self.subTest(change=list(change)):
with self.assertRaises(agent.AgentError):
agent.check({**self.desired, **change}, apply=True)
self.assertEqual(self.synced, [])
def test_hooks_and_duplicate_fields_rejected(self):
for suffix in ("\nPostUp = id", "\nListenPort = 3", "\n[Interface]\nPrivateKey = secret"):
with self.assertRaises(agent.AgentError):
agent.parse_conf(agent.render(self.interface, []) + suffix)
def test_endpoint_change_requires_apply(self):
agent.check(self.desired, apply=True)
self.desired["peers"][0]["endpoint"] = "new-router.test:13232"
self.assertFalse(agent.check(self.desired)["configMatches"])
def test_command_errors_do_not_expose_keys(self):
with patch.object(agent.subprocess, "run", return_value=type("Result", (), {"returncode": 1, "stdout": key(1), "stderr": key(9)})()):
# Use the original function, not the fixture runner.
with self.assertRaisesRegex(agent.AgentError, "не выполнена") as error:
ORIGINAL_RUN("wg", "show")
self.assertNotIn(key(1), str(error.exception))
self.assertNotIn(key(9), str(error.exception))
ORIGINAL_RUN = agent.run
@unittest.skipUnless(os.environ.get("FLOW_AGENT_KERNEL_TEST") == "1", "Requires Linux kernel WireGuard and NET_ADMIN in an isolated container")
class KernelTest(unittest.TestCase):
def test_real_interface_apply_and_preservation(self):
with tempfile.TemporaryDirectory() as folder, patch.object(agent, "CONFIG", Path(folder)):
private = agent.run("wg", "genkey")
public = agent.run("wg", "pubkey", input=private + "\n")
other = agent.run("wg", "pubkey", input=agent.run("wg", "genkey") + "\n")
desired_key = agent.run("wg", "pubkey", input=agent.run("wg", "genkey") + "\n")
interface = dict(PrivateKey=private, Address="10.255.254.1/24", ListenPort="51821")
third = dict(PublicKey=other, AllowedIPs="192.0.2.3/32")
path = Path(folder)
(path / "wg-flow.conf").write_text(agent.render(interface, [third]))
snapshot = dict(collectorIp="10.255.254.1", prefix="10.255.254.0/24", flowListenPort=4739, wgListenPort=51821,
hostPublicKey=public, hostPrivateKey=private, peers=[dict(serverId=1, publicKey=desired_key, allowedIps=["10.255.254.2/32"], endpoint="127.0.0.1:13232")])
(path / "binding.json").write_text(json.dumps({k: snapshot[k] for k in ("collectorIp", "prefix", "flowListenPort", "wgListenPort")}))
agent.run("ip", "link", "add", "dev", agent.IFACE, "type", "wireguard")
try:
agent.run("ip", "address", "add", "10.255.254.1/24", "dev", agent.IFACE)
agent.sync(agent.render(interface, [third], native=True))
agent.run("ip", "link", "set", "dev", agent.IFACE, "up")
before = json.loads(agent.run("ip", "-j", "link", "show", "dev", agent.IFACE))[0]["ifindex"]
self.assertTrue(agent.check(snapshot, apply=True)["configMatches"])
self.assertTrue(agent.check(snapshot)["configMatches"])
status, _ = agent.live()
self.assertEqual({p["publicKey"] for p in status["peers"]}, {other, desired_key})
self.assertNotIn(private, json.dumps(status))
after = json.loads(agent.run("ip", "-j", "link", "show", "dev", agent.IFACE))[0]["ifindex"]
self.assertEqual(before, after)
finally:
agent.run("ip", "link", "delete", "dev", agent.IFACE)
if __name__ == "__main__":
unittest.main()
+178
View File
@@ -0,0 +1,178 @@
# Автоматическое подключение NetFlow
`mmapp-netflow-agent` управляет только `wg-flow` на том же Debian/Ubuntu-хосте,
где работает Docker Engine. Используются WireGuard ядра Linux и обычный Docker с
root-правами. Rootless Docker и Docker Desktop не поддерживаются этим режимом.
## Production-состав
С Compose `deploy/docker-compose.cdn-mm.yml` и дополнением агента работают семь контейнеров:
| Контейнер | Назначение |
| --- | --- |
| cdn-mm-traefik | HTTPS и маршрутизация |
| cdnmanager | CDN Manager |
| mmapp-frontend | Интерфейс MM |
| mmapp-backend | API, настройка MikroTik и приём IPFIX |
| mmapp-postgres | Настройки и потоки |
| mmapp-updater | Существующее обновление контейнеров MM |
| mmapp-netflow-agent | Применение пиров WireGuard на хосте |
Собственный systemd-сервис не нужен. Стандартный `wg-quick@wg-flow` поднимает
интерфейс до Docker: backend публикует IPFIX только на его адресе.
```mermaid
flowchart TB
User[Пользователь] -->|HTTPS| Proxy[Traefik]
subgraph Host[Production Linux]
subgraph Docker[Docker Compose]
Proxy --> Frontend[frontend]
Proxy --> CDN[CDN Manager]
Frontend -->|API| Backend[backend + IPFIX collector]
Backend --> PG[PostgreSQL]
Backend <-->|HTTP через Unix socket bind mount| Agent[netflow-agent: host network + NET_ADMIN]
Updater[updater] -.-> Backend
Updater -.-> Frontend
end
Agent -->|atomic write| Conf[wg-flow.conf]
Agent -->|wg syncconf| WG[wg-flow: интерфейс хоста]
Conf --> Boot[wg-quick при загрузке]
Boot --> WG
WG -->|Docker UDP bind на WG-IP| Backend
end
Backend -->|RouterOS API| Router[MikroTik]
Router <-->|WireGuard: внутри IPFIX| WG
```
Агент не обрабатывает потоки: при остановке/пересоздании контейнера интерфейс
и приём IPFIX продолжают работать. `NET_ADMIN` вместе с host network позволяет
управлять сетью хоста; API ограничено фиксированным интерфейсом и структурированными
настройками. Агент не получает Docker socket, `privileged`, корневую ФС хоста,
право загрузки модулей или TCP API.
## Однократная установка
1. Обновить MM до версии с агентом. Образ агента должен быть опубликован CI
в том же реестре; production-хост должен быть авторизован через `docker login`.
2. В «Сбор данных → NetFlow» создать ключи хоста, открыть «Файлы хоста» и скачать
`install-netflow-agent.sh` из вкладки «Агент Docker».
3. Выполнить на Linux-хосте:
```bash
sudo bash install-netflow-agent.sh /opt/cdn-mm
```
Файл содержит приватный ключ. Хранить его как секрет, не коммитить.
При необходимости образ можно задать явно:
```bash
sudo MM_NETFLOW_AGENT_IMAGE=git.shx.one/denozord/mikrotikmanager-netflow-agent:VERSION \
bash install-netflow-agent.sh /opt/cdn-mm
```
4. В MM обновить состояние хоста и применить настройки. Затем подключать
jump-host из раздела трафика: пиры применяются автоматически без перезапуска WG.
Альтернатива из checkout: скачать `wg-flow.conf` из MM и выполнить
`sudo bash deploy/netflow-agent/install.sh /absolute/path/wg-flow.conf /opt/cdn-mm 4739`.
Третий аргумент — фактический IPFIX-порт из настроек.
Установщик сначала проверяет конфигурацию и Compose. Он сохраняет существующие
пиры и делает резервную копию конфигурации; при несовпадении ключа, адреса или порта
останавливается. Hooks, SaveConfig и нестандартные параметры wg-quick требуют
ручной проверки и не принимаются. Интерфейс должен иметь IPv4 /24;
пиры MM получают собственные /32 в этой сети.
Создаются:
- `data/netflow-wireguard/wg-flow.conf` и `binding.json` с правами `0600`;
- ссылка `/etc/wireguard/wg-flow.conf` на постоянную конфигурацию;
- `docker-compose.netflow-agent.yml`, не заменяющий пользовательский override;
- drop-in `/etc/systemd/system/docker.service.d/mm-netflow.conf` для порядка
запуска `wg-quick → Docker`;
- общий каталог `data/netflow-agent-socket` для Unix-сокета, подключённый в backend только для чтения. Bind mount сохраняет этот режим при пересоздании backend существующим updater.
Установщик однократно пересоздаёт backend и не перезапускает Docker или Traefik.
Он не изменяет firewall хоста. Существующий публичный или несовместимый IPFIX bind
в Compose нужно исправить перед повторным запуском. Не публиковать IPFIX на `0.0.0.0`.
## Эксплуатация
При следующих операциях Compose сохранять все файлы дополнений:
```bash
cd /opt/cdn-mm
docker compose -f docker-compose.yml -f docker-compose.override.yml \
-f docker-compose.netflow-agent.yml up -d
```
Если `docker-compose.override.yml` отсутствует, убрать соответствующий `-f`.
Установщик печатает команду для фактического набора файлов.
Образ агента обновляется явно через Compose; существующий updater не включает
его в автоматические цели. При пересоздании backend updater сохраняет его mounts.
Просмотр: `GET /api/traffic/flow/host-status`, право `mm:traffic:read`.
Повторное применение: `POST /api/traffic/flow/host-reconcile`, право `mm:traffic:write`.
Статус не содержит приватных/preshared ключей. `lastHandshakeAt` — Unix seconds
или `null`; факт применения настроек не означает handshake или получение IPFIX.
Время последнего IPFIX-пакета в панели относится ко всему коллектору.
Подключения и изменения настроек сериализуются PostgreSQL advisory lock.
После рестарта backend повторно применяет сохранённые пиры. При конфликте или
недоступности агента приложение и текущий приём продолжают работать.
Если MikroTik уже настроен, но хост не применён, результат подключения содержит
`ok: false`, `stage: host-pending`, `hostError`: нажать «Применить настройки хоста».
При ошибке во время настройки роутера повторить подключение; его изменения
не откатываются автоматически.
Агент сохраняет сторонние persistent и runtime-only пиры, проверяет пересечения
AllowedIPs, атомарно записывает конфигурацию и применяет её через `wg syncconf`.
При сбое восстанавливает предыдущий файл и runtime; сбой восстановления сообщается
отдельно. Добавление/обновление пиров не меняет адреса, маршруты и интерфейс.
Первая версия не удаляет туннели. Адрес коллектора, префикс и порты закреплены
установкой: изменение требует согласования настроек MM, WG и Compose вручную и
повторного запуска установщика. Автоматическая смена адреса или ротация ключа
не выполняется. Другие установки могут продолжать пользоваться ручным экспортом
без `FLOW_AGENT_SOCKET`.
## Проверки
```bash
npm --prefix backend run test:netflow-settings
npx playwright test -c playwright.netflow-agent.config.ts
python3 -m unittest discover -s deploy/netflow-agent -p 'test_*.py' -v
bash -n deploy/netflow-agent/install.sh
docker build --target test -t mm-netflow-agent-test deploy/netflow-agent
docker run --rm --cap-drop ALL --cap-add NET_ADMIN --read-only --tmpfs /tmp \
-e FLOW_AGENT_KERNEL_TEST=1 --entrypoint python3 mm-netflow-agent-test \
-m unittest discover -s /app -p test_agent.py -v
```
Последняя проверка использует отдельное сетевое пространство контейнера, проверяет
настоящий kernel WireGuard, сохранение стороннего пира и неизменность интерфейса.
Она не заменяет приёмку на стенде с двумя MikroTik: handshake/IPFIX от каждого,
сохранение первого соединения при добавлении второго, перезапуск агента/backend
и холодная загрузка Linux-хоста с проверкой UDP bind.
### Приёмка на стенде — 2026-10-16
На `161.104.46.222` проверены временные контейнеры новой ветки с отдельной БД.
15 тестов агента прошли с настоящим kernel WireGuard; 32 профильных скрипта
и две отдельные SQL-проверки (monthly и IPv6 UDP/worker) завершились успешно.
Оба MikroTik (`194.226.121.150`, `104.165.244.7`) подключены через API: подтверждены
handshake, сохранение первого туннеля при добавлении второго и повторное подключение
без дубликатов. В БД записаны IPFIX-потоки обоих MikroTik и синтетического Linux
экспортёра через WireGuard. Реальные перезапуски контейнеров агента и backend
сохранили соединения, приём пакетов продолжился.
При проверке исправлен API настроек: счётчики и время последнего пакета теперь
читаются из работающего коллектора. После исправления полный живой сценарий повторён.
Настройки обоих маршрутизаторов восстановлены; временные контейнеры, интерфейс,
БД/роли и приватные файлы удалены. Основные сервисы остались на прежних образах.
Установщик с интеграцией `wg-quick`/systemd и холодная перезагрузка хоста на этом
стенде не выполнялись. Образы собраны поверх существующего runtime backend;
полная release-сборка frontend и публикация образов в эту проверку не входили.
Результаты, исходные неудачные попытки и границы —
[отчёт приёмки](ux-ui/acceptance/netflow-agent-2026-10-16.json).
+91 -1
View File
@@ -1,6 +1,12 @@
# UX/UI: журнал реализации # UX/UI: журнал реализации
Актуальное состояние 2026-10-15: 264 — лабораторная приёмка основного UX/UI-плана завершена в документированных границах стенда. Приняты1396 основных UI и221 отдельных context-сценарий;120 live default states30 экранов. Runtime backend258/frontend261. Непроверенные внешние группы сохранены в итоговом акте. Публикация main/2.0.0 отдельно разрешена владельцем; [описание релиза](releases/2.0.0.md). Текущее продолжение 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).
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
@@ -38,6 +44,90 @@
| 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)
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 значения не заменяются.
Нагрузка чтения: полные таблицы12441/24864/12451 строк занимали2,4/8,0/2,1с. Каталог теперь использует штатный POST print с .query=[comment] (все комментарии, включая старые метки и неподдерживаемые owned строки), а не static-only фильтр, который мог бы скрыть неподдерживаемый объект. Отбор вернул12/13/7 строк за0,5/1,7/0,5с. Каталог шлюзов сохраняет все маршруты, но запрашивает только .id/gateway/active. Тип POST body расширен под штатные строковые массивы query/proplist, сериализация проверена настоящим локальным HTTP.
Новый код в отдельном процессе на production проверен только чтением, без замены работающего backend:4/3/5 собственных маршрутов успешно разобраны за1,026/3,225/0,643с с7/4/3 дочитываниями. RouterOS-конфигурация не менялась, saveObserved/применение не вызывались. Это замер чтения/разбора, не всего UI; отдельная загрузка шлюзов в него не включена. Большой первоначальный tool input отклонён автоматической проверкой по размеру; читаемый скомпилированный скрипт передан частями через stdin, без файлов/секретов на сервере.
Проверки:4 backend scripts recursive-routes passed;11 HTTP client tests passed;15 page E2E passed; backend build/scoped ESLint/diff check passed. В HTTP tests подтверждены восстановление omitted fields и503 без записи cache при ошибке get. [Анонимизированный протокол](ux-ui/acceptance/prod271-recursive.json), [официальный REST API](https://manual.mikrotik.com/docs/developer-guides/rest-api/). Изменение локальное, без push/deploy. Следующий шаг — согласовать публикацию готовых268/270/271 и обновление backend/frontend; текущая production-страница до этого сохраняет прежнюю ошибку. Остальные задержки/тайм-ауты не объявляются устранёнными.
## Production270 — легенда и контуры фокуса карты (2026-10-15)
По замечанию владельца выполнено сравнение с последним доступным тегом v1.42.0. Там легенда была раскрытой графической панелью, а выбранный узел имел полупрозрачный пунктирный круг. Текущий свёрнутый текстовый details заменён компонентом NetworkMapLegend: образцы статусов, JH/EN/HR, формы WAN/сервисов, GRE/IPsec/NetFlow/модель WAN→JH, пояснения RTT/неизвестных значений и различия потока и ёмкости. На ПК раскрыта, на телефоне изначально свёрнута, содержимое ограничено высотой со скроллом.
Прямоугольный outline по SVG bounding box заменён на focus-visible контур по форме устройства/WAN/сервиса/метрики. Мышь его не показывает, Tab/Enter работают; выделение узла сохранено с opacity0.45 как в1.42. Контуры фокуса удаляются из клона при экспорте SVG, чтобы CSS-зависимое скрытие не создавало лишние обводки в файле. ReUI-примитивы не перерабатывались; Badge используется штатный, цвета — существующие semantic tokens.
Проверки:13 прежних data E2E и4 новых UI E2E passed (legend desktop/mobile, pointer/keyboard focus, экспорт). TypeScript и scoped ESLint passed. Первый прогон выявил неточность текстового locator вокруг Badge; подпись выделена отдельным span и сценарий повторно прошёл. Скриншоты ПК/телефона просмотрены, после просмотра мобильная легенда сделана свёрнутой. Превью tmp/map270-desktop.png и tmp/map270-mobile.png вне Git. Финальная семантическая правка цвета образца GRE не меняет геометрию/поведение. Изменения локальные, без push/deploy; production остаётся2.0.1 из269. Следующий шаг — публикация подготовленных Filters268 и визуального патча270 после разрешения владельца; отдельно остаются read-only DNS и трафик.
## Production269 — фактическое обновление frontend и узлы карты (2026-10-15)
По повторной жалобе подтверждено: работающий frontend всё ещё2.0.0/72bf648c, хотя все jobs CI2467 завершились success и2.0.1 опубликован. API принимает исправленная схема4servers/25GRE/0probes, разовые задержки171/498/56мс. После отдельного разрешения владельца обновлён только frontend по проверенному digest2.0.1/042407f0; новый контейнер healthy, backend/PostgreSQL ID и StartedAt сохранены. Публичный manifest2.0.1 и браузер подтвердили4 узла (3JH/1EN),25GRE в данных и3 отображённые связи. Ошибка первичной валидации исчезла. Права read-only блокируют DNS resolve-hosts (mm:network:write), поэтому все линии/FQDN ещё не подтверждены; права не менялись. [Процедура и откат](../deploy/PRODUCTION-FRONTEND-269.md), приватный скриншот tmp/prod-map269.jpg вне Git. Блок268 Filters остаётся локальным и в этом обновлении отсутствует. Следующее действие — отдельная проверка read-only разрешения DNS и продолжение трафика; готовый Filters-патч требует публикации.
## Production268 — рекурсивные шлюзы Filters после обновления каталога (2026-10-15)
По production-скриншоту Filters показывает «рекурсивный маршрут (нет строки в списке)», хотя каталог формы содержит шлюз. Найден механизм: legacy GET /recursive-routes пересоздаёт строки recursive_routes, а правила могут сохранять прежние rec:id; таблица допускала поиск по hop только при пустой ссылке. Для явной рекурсивной ссылки с отсутствующим ID теперь используется уже имеющийся выбор активного маршрута по dst-address/hop и минимальной distance в каталоге выбранного устройства. Редактирование восстанавливает актуальный rec:id и выделение карточки. Именованный GRE не переопределяется по совпавшему IP; при реальном отсутствии маршрута предупреждение сохранено. Точная ссылка по ID сохраняет приоритет.
6 E2E passed: две новые регрессии и существующие проверки gateway failure/save. Новый сценарий проверяет отображение, выделение, отсутствие записи до подтверждения и payload с новым rec:id; второй — настоящий orphan и GRE с совпавшим IP. TypeScript passed; scoped ESLint0 errors/11 прежних unused-import warnings в filters/page.tsx. Непривилегированный первый запуск E2E не поднял доступный локальный сервер и остановлен; проверки выполнены повторно с разрешением запуска локального сервера/браузера. Backend и production-конфигурация не менялись; механизм пересоздания cache ID этим frontend-патчем не перерабатывается. Исправление сохранено локально, без push/deploy. Следующий шаг — опубликовать этот патч и проверить production Filters после обновления frontend.
## Production267 — подключение PostgreSQL в CI (2026-10-15)
После push main backend-test2466/8410 завершился ECONNREFUSED: PostgreSQL публиковался на loopback Docker-хоста, но тестовый процесс работал в отдельном контейнере Gitea runner. Прежние SQL-тесты пропускали подключение; новый обязательный retention-тест выявил проблему. PostgreSQL теперь присоединяется к сети контейнера задания и доступен по уникальному DNS alias; для host runner сохранён вариант с назначенным Docker портом. Удаление общего mm-pg исключено, cleanup затрагивает только контейнер текущего запуска. Перед тестами выполняется SELECT1 из процесса задания; REQUIRE_TEST_POSTGRES=1 запрещает скрытый skip при недоступности/ошибке bootstrap.
Workflow YAML/scoped ESLint/diff check прошли. Проверены оба режима helper на недоступном localhost-порту: локально false/skip, в обязательном режиме исходная ошибка подключения выходит наружу. После push042407f0 [backend-test2467/8417](https://git.shx.one/denozord/MikrotikManager/actions/runs/2467/jobs/8417) завершён success, включая retention20тыс. строк; обязательное подключение из тестового процесса подтверждено. Production этим блоком напрямую не изменялся.
## Production266 — online-индекс retention и проверка нагрузки (2026-10-15)
После прямого разрешения владельца только на индекс и проверку нагрузки в14:55:45 UTC запущен CREATE INDEX CONCURRENTLY idx_scheduler_runs_finished_at ON public.scheduler_runs(finished_at). Завершён успешно, indisvalid/indisready=true. В15:01:30 UTC индекс23МБ,697 использований; план эквивалентного SELECT сменился с Parallel Seq Scan на Bitmap Index Scan. PostgreSQL CPU66,51% →0,16% →0,13%; секундные vmstat iowait29–34% →0–5% →0–1%, CPU idle0% →74–94% →94–97%. Наблюдения ограничены несколькими минутами обычной нагрузки и не доказывают устранение каждого тайм-аута RouterOS.
Добавлены миграция0013 и индекс в Drizzle schema, автоматический SQL-тест в test:pg, [процедура](../deploy/PRODUCTION-RETENTION-INDEX.md) и [протокол](ux-ui/acceptance/prod266-retention.json). Фактический тест выполнен на отдельном тестовом Debian:20тыс. временных строк, повторный запуск миграции, валидный индекс, смена плана, сохранение результата; fixture откатан. Bootstrap/public-таблицы тест не меняет. Backend build/scoped ESLint passed. Production-контейнеры не перезапускались; ручного удаления данных и изменения срока retention не было. Приложение не развёртывалось, push отсутствует. Следующее действие: завершить независимую проверку расчётов трафика и подготовить обновление REST history; применение frontend-патчей к production требует отдельного разрешения.
## Production265 — исчезновение узлов карты и первичная диагностика (2026-10-15)
Через выданный read-only аккаунт подтверждён production v2.0.0. Карта отклоняет все4 устройства: API polledAt имеет штатный PostgreSQL timestamptz string (+00), frontend требует ISO. В локальной ветке исправлена только нормализация явного offset; отсутствующая зона и невозможная дата по-прежнему отклоняются. Сохранённый приватный ответ production теперь проходит:4 servers/25 GRE/0 probes. Регрессия сначала воспроизведена новым unit test; после исправления6 unit и13 map E2E прошли, TypeScript/scoped ESLint passed. Production не обновлялся; push этого исправления не выполнялся.
SSH только чтением:1 vCPU/1973MiB/no swap, PostgreSQL нагружает CPU/IO. Дважды наблюдался retention DELETE по scheduler_runs.finished_at: около1.06M live rows,853MB, нет индекса по этому столбцу. Исходник выполняет cleanup после каждого задания; EXPLAIN эквивалентного SELECT показывает Parallel Seq Scan. TCP443 из backend до4 устройств сейчас3–50ms; TLS/REST этим не приняты. Причинность всех10s тайм-аутов не установлена; очередь shared maxSockets2 входит в deadline. Трафик: live SSE актуален, REST history/list не обновляются автоматически; вычисления требуют отдельной проверки. [Анонимизированный протокол](ux-ui/acceptance/prod265-diagnostics.json). Приватные сырые ответы и учётные данные в Git не включаются. Следующее действие — подготовить и проверить индекс retention, продолжить трафик; применение DDL и развёртывание production требуют отдельного разрешения владельца, предоставившего доступ для чтения.
## Подготовка версии 2.0.0 (2026-10-15) ## Подготовка версии 2.0.0 (2026-10-15)
По прямому поручению владельца публикация main/2.0 заменяет прежний режим только локальных коммитов. Добавлен major bump для breaking subject со scope/без scope и двух стандартных footer; CI запускает10 integration cases перед вычислением версии. Package.json и теги вручную не меняются. В выпуск включены накопленные изменения ReUI skills/portable MCP config и сохранённые снимки; локальные PID/cache и приватные fixtures не публикуются. Проверена новая история на известные приватные Portal credentials и шаблоны внешних токенов: совпадений0. [Состав и ограничения релиза](releases/2.0.0.md). Отправка main запускает существующую публикацию образов/релиза и настроенный deploy webhook. Фактический результат сборок определяется Gitea Actions. Первый запуск2464 отклонён валидатором Gitea28.1 до jobs: job-level secrets в if больше не принимается. После подтверждения причины read-only ViewPost по официальному handler условие перенесено в step if по job env; side effects webhook не расширены. По прямому поручению владельца публикация main/2.0 заменяет прежний режим только локальных коммитов. Добавлен major bump для breaking subject со scope/без scope и двух стандартных footer; CI запускает10 integration cases перед вычислением версии. Package.json и теги вручную не меняются. В выпуск включены накопленные изменения ReUI skills/portable MCP config и сохранённые снимки; локальные PID/cache и приватные fixtures не публикуются. Проверена новая история на известные приватные Portal credentials и шаблоны внешних токенов: совпадений0. [Состав и ограничения релиза](releases/2.0.0.md). Отправка main запускает существующую публикацию образов/релиза и настроенный deploy webhook. Фактический результат сборок определяется Gitea Actions. Первый запуск2464 отклонён валидатором Gitea28.1 до jobs: job-level secrets в if больше не принимается. После подтверждения причины read-only ViewPost по официальному handler условие перенесено в step if по job env; side effects webhook не расширены.
+11
View File
@@ -2758,3 +2758,14 @@ Runtime backend ux-regression222/frontend ux-routing218. Latest backend25/UI52
2 roundtrip objects,12 unique API scenarios, synthetic native export/CHR contacts0. Own DB/roles/catalog/localfiles/currentobjects cleaned. Versioning Enabled:8versions+8markers removed,3 own prefixes remaining0; bucket configuration unchanged. Native backup/restore/live UI/смена storage origin/retention semantics остаются230. Private env удалены, secrets не в published files. [API](ux-ui/acceptance/s3-application229.json), [version cleanup](ux-ui/acceptance/s3-version-cleanup229.json). 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,78 @@
{
"date": "2026-10-15",
"scope": "Production diagnosis using a read-only application account and read-only SSH/system/PostgreSQL inspection. No production deployment or configuration changes.",
"productionVersion": "2.0.0",
"map": {
"cause": "PostgreSQL polledAt text with explicit offset is rejected by the ISO-only map validator; the whole topology snapshot is discarded.",
"before": {
"serverValidationErrors": 4,
"field": "polledAt",
"error": "invalid_format"
},
"localFix": {
"preservesExplicitTimeZone": true,
"rejectsMissingZoneAndInvalidCalendarDate": true,
"exactPrivateResponseAccepted": {
"servers": 4,
"tunnels": 25,
"probes": 0
}
},
"checks": {
"unitPassed": 6,
"e2ePassed": 13,
"typescript": "passed",
"scopedEslint": "passed"
},
"deployed": false
},
"traffic": {
"confirmed": "REST history/list/detail load once per scope/retry; live SSE keeps updating separately. History can become stale while live rate is current.",
"calculationNotYetFullyAccepted": true,
"rawResponsesPrivate": true
},
"performance": {
"host": {
"vcpu": 1,
"memoryMiB": 1973,
"swapMiB": 0,
"loadAverage": [
4.62,
4.87,
5.09
],
"rootUsedPercent": 46
},
"sample": {
"postgresCpuPercent": 66.51,
"postgresMemory": "642.6MiB",
"backendCpuPercent": 3.26,
"cpuIdlePercent": 0,
"ioWaitPercent": [
34,
29
]
},
"schedulerRuns": {
"estimatedLiveRows": 1064090,
"tableSize": "853 MB",
"indexes": "primary key and (job_key, started_at), no finished_at index",
"observedActiveQuery": "DELETE FROM scheduler_runs WHERE finished_at < $1",
"wait": "IO/AioIoCompletion",
"retention": "30 days after every recorded scheduler job",
"explainEquivalentSelect": "Gather / Parallel Seq Scan"
},
"tcpConnectMsByAnonymousServerId": {
"1": 36,
"2": 3,
"3": 50,
"4": 6
},
"limits": [
"TCP connect success does not prove TLS/REST authentication or rule out previous failures.",
"The native HTTP client timeout also includes time queued in maxSockets=2; actual queue delay has not been measured.",
"Database temp-byte and deadlock counters are cumulative with an unknown reset epoch; not attributed to this release.",
"No proof that database pressure alone caused every REST timeout."
]
}
}
@@ -0,0 +1,40 @@
{
"date": "2026-10-15",
"authorization": "Only the retention index and load verification in production",
"productionAction": {
"startedAt": "2026-10-15T14:55:45Z",
"statement": "CREATE INDEX CONCURRENTLY idx_scheduler_runs_finished_at ON public.scheduler_runs (finished_at)",
"result": "success",
"valid": true,
"ready": true,
"verifiedAt": "2026-10-15T15:01:30Z",
"size": "23 MB",
"indexScans": 697,
"planBefore": "Parallel Seq Scan",
"planAfter": "Bitmap Heap Scan / Bitmap Index Scan idx_scheduler_runs_finished_at",
"restarts": false,
"manualDataDeletion": false,
"retentionPeriodChanged": false,
"applicationDeployed": false
},
"observations": [
{ "period": "before", "postgresCpuPercent": 66.51, "hostIdlePercent": [0, 0], "hostIoWaitPercent": [29, 34], "ioPressureSomeAvg10Percent": 63.67 },
{ "period": "2026-10-15T14:56Z", "postgresCpuPercent": 0.16, "hostIdlePercent": [74, 94], "hostIoWaitPercent": [0, 5], "ioPressureSomeAvg10Percent": 1.70 },
{ "period": "2026-10-15T15:00Z", "postgresCpuPercent": 0.13, "hostIdlePercent": [94, 97], "hostIoWaitPercent": [0, 1], "ioPressureSomeAvg10Percent": 0.48 }
],
"checks": {
"actualMigrationTest": "passed on separate test Debian; bundled from committed TypeScript source",
"temporaryRows": 20000,
"migrationIdempotence": "passed",
"selectivePlanAndResultPreservation": "passed",
"temporaryFixtureRolledBack": true,
"testBootstrapExecuted": false,
"backendBuild": "passed",
"scopedEslint": "passed"
},
"limitations": [
"Several-minute observations under ordinary workload, not a capacity benchmark",
"Not proof that every RouterOS timeout or interface stall has been resolved",
"Map fix remains local; traffic history refresh and numerical validation remain separate work"
]
}
@@ -0,0 +1,44 @@
{
"date": "2026-10-16",
"scope": "Local backend fix plus read-only verification against production JH; no application deployment or RouterOS configuration writes",
"causes": [
"RouterOS list omitted disabled and check-gateway; strict catalog parser returned 503",
"The page loaded full 12k-25k routing tables; an observed 8-second read approached the 10-second timeout"
],
"nativeObservations": {
"disabledGet": { "ret": "false" },
"unsetCheckGatewayGet": [],
"configurationWrites": 0
},
"fix": {
"routeList": "POST /ip/route/print with .query=[comment]; includes all ownership markers and unsupported commented rows",
"gatewayList": "GET /ip/route with .proplist=.id,gateway,active; retains all routes",
"missingProperties": "Read disabled/check-gateway via /ip/route/get only for owned rows with absent fields",
"maximumPropertyConcurrency": 2,
"emptySuccessfulCheckGet": "none",
"malformedAndFailedReads": "rejected, no successful empty snapshot or cache update"
},
"readOnlyVerification": [
{ "serverId": 2, "fullRows": 12441, "fullReadMs": 2394, "commentedRows": 12, "commentedReadMs": 500, "parsedOwnedRows": 4, "effectivePropertyReads": 7, "combinedReadAndParseMs": 1026 },
{ "serverId": 3, "fullRows": 24864, "fullReadMs": 7986, "commentedRows": 13, "commentedReadMs": 1697, "parsedOwnedRows": 3, "effectivePropertyReads": 4, "combinedReadAndParseMs": 3225 },
{ "serverId": 4, "fullRows": 12451, "fullReadMs": 2106, "commentedRows": 7, "commentedReadMs": 488, "parsedOwnedRows": 5, "effectivePropertyReads": 3, "combinedReadAndParseMs": 643 }
],
"checks": {
"recursiveBackendProfile": "4 scripts passed, including new effective-property tests and HTTP failure/no-cache coverage",
"routerOsHttpClient": "11 tests passed, including native query array serialization",
"recursivePageE2E": "15 passed",
"backendBuild": "passed",
"scopedEslint": "passed",
"gitDiffCheck": "passed"
},
"limitations": [
"Read-only native checks, not acceptance of a deployed HTTP/UI fix",
"One-off timings; gateway inventory remains a separate request and is not included in combinedReadAndParseMs",
"No broader timeout/connection-pool redesign or changes to native route application rules",
"Existing cache ID replacement behavior remains separate from this fix"
],
"sources": [
"https://manual.mikrotik.com/docs/developer-guides/rest-api/",
"https://manual.mikrotik.com/docs/cli-reference/ip/route/"
]
}
@@ -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"]
}
+12
View File
@@ -2,6 +2,18 @@ import {test} from "node:test"
import assert from "node:assert/strict" import assert from "node:assert/strict"
import {validateNetworkMapSnapshot} from "./network-map-response" import {validateNetworkMapSnapshot} from "./network-map-response"
const server={id:1,name:"Router",host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:null,latency:0} const server={id:1,name:"Router",host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:null,latency:0}
test("network map retains jump and exit nodes with PostgreSQL timestamps",()=>{
const timestamps=["2026-10-15 13:46:40.855+00","2026-10-15 20:46:40.855+07","2026-10-15T13:46:40.855Z",null]
const rows=timestamps.map((polledAt,index)=>({...server,id:index+1,type:index===0?"exit-node":"jump-host",polledAt}))
const snapshot=validateNetworkMapSnapshot(rows,{tunnels:[],failures:[]},{probes:[]})
assert.equal(snapshot.servers.length,4)
assert.equal(snapshot.servers[0].type,"exit-node")
assert.equal(Date.parse(snapshot.servers[0].polledAt!),Date.parse(snapshot.servers[1].polledAt!))
assert.equal(snapshot.servers[3].polledAt,null)
for(const polledAt of ["2026-10-15 13:46:40.855","2026-02-30 13:46:40+00","not-a-date",42]) {
assert.throws(()=>validateNetworkMapSnapshot([{...server,polledAt}],{tunnels:[],failures:[]},{probes:[]}))
}
})
test("network map retains configured GRE IPsec without key material",()=>{ test("network map retains configured GRE IPsec without key material",()=>{
const tunnel={id:"*1",serverId:"1",name:"own210",localAddress:"192.0.2.1",remoteAddress:"192.0.2.2",localInnerIp:"10.0.0.1/30",remoteInnerIp:"10.0.0.2/30",poolId:"",ipsec:{secret:""},mtu:1476,keepaliveInterval:60,keepaliveRetries:3,dscp:"inherit",clampTcpMss:true,allowFastPath:false,comment:"",enabled:true,status:"up"} const tunnel={id:"*1",serverId:"1",name:"own210",localAddress:"192.0.2.1",remoteAddress:"192.0.2.2",localInnerIp:"10.0.0.1/30",remoteInnerIp:"10.0.0.2/30",poolId:"",ipsec:{secret:""},mtu:1476,keepaliveInterval:60,keepaliveRetries:3,dscp:"inherit",clampTcpMss:true,allowFastPath:false,comment:"",enabled:true,status:"up"}
const result=validateNetworkMapSnapshot([server],{tunnels:[tunnel],failures:[]},{probes:[]}) const result=validateNetworkMapSnapshot([server],{tunnels:[tunnel],failures:[]},{probes:[]})
+7 -1
View File
@@ -2,12 +2,18 @@ import {z} from "zod"
import {optimizerServersSchema,optimizerProbesSchema} from "./route-optimizer-response" import {optimizerServersSchema,optimizerProbesSchema} from "./route-optimizer-response"
import {filtersGreResponseSchema} from "./filters-response" import {filtersGreResponseSchema} from "./filters-response"
import {greResponseSchema} from "./gre-response" import {greResponseSchema} from "./gre-response"
// Drizzle's string-mode timestamptz is returned as PostgreSQL text (+00),
// while test/other API adapters may already return ISO. Preserve the explicit
// offset and validate the resulting calendar date; never infer a missing zone.
const mapObservedTime=z.preprocess(value=>typeof value==="string"
?value.replace(/^(\d{4}-\d{2}-\d{2}) /,"$1T").replace(/([+-]\d{2})$/,"$1:00")
:value,z.string().datetime({offset:true}))
export const networkMapServersSchema=z.array(optimizerServersSchema.element.extend({ export const networkMapServersSchema=z.array(optimizerServersSchema.element.extend({
status:z.enum(["online","offline","degraded"]).nullable(), status:z.enum(["online","offline","degraded"]).nullable(),
asn:z.string().optional(),comment:z.string().optional(),lanSubnet:z.string().optional(),os:z.string().nullable().optional(), asn:z.string().optional(),comment:z.string().optional(),lanSubnet:z.string().optional(),os:z.string().nullable().optional(),
sessions:z.number().int().nonnegative().nullable().optional(),uptime:z.string().nullable().optional(), sessions:z.number().int().nonnegative().nullable().optional(),uptime:z.string().nullable().optional(),
cpuLoad:z.number().finite().min(0).max(100).nullable().optional(),freeMemory:z.number().int().nonnegative().nullable().optional(),totalMemory:z.number().int().nonnegative().nullable().optional(), cpuLoad:z.number().finite().min(0).max(100).nullable().optional(),freeMemory:z.number().int().nonnegative().nullable().optional(),totalMemory:z.number().int().nonnegative().nullable().optional(),
polledAt:z.string().datetime({offset:true}).nullable().optional(), polledAt:mapObservedTime.nullable().optional(),
})).refine(rows=>new Set(rows.map(row=>row.id)).size===rows.length && rows.every(row=>row.freeMemory==null||row.totalMemory==null||row.freeMemory<=row.totalMemory)) })).refine(rows=>new Set(rows.map(row=>row.id)).size===rows.length && rows.every(row=>row.freeMemory==null||row.totalMemory==null||row.freeMemory<=row.totalMemory))
export type NetworkMapServerRow=z.infer<typeof networkMapServersSchema>[number] export type NetworkMapServerRow=z.infer<typeof networkMapServersSchema>[number]
const mapGreSchema=z.object({tunnels:z.array(filtersGreResponseSchema.shape.tunnels.element).refine(rows=>new Set(rows.map(row=>JSON.stringify([row.serverId,row.id]))).size===rows.length),failures:greResponseSchema.shape.failures}).refine(data=>new Set(data.failures.map(row=>row.serverId)).size===data.failures.length&&!data.tunnels.some(tunnel=>data.failures.some(failure=>failure.serverId===tunnel.serverId))) const mapGreSchema=z.object({tunnels:z.array(filtersGreResponseSchema.shape.tunnels.element).refine(rows=>new Set(rows.map(row=>JSON.stringify([row.serverId,row.id]))).size===rows.length),failures:greResponseSchema.shape.failures}).refine(data=>new Set(data.failures.map(row=>row.serverId)).size===data.failures.length&&!data.tunnels.some(tunnel=>data.failures.some(failure=>failure.serverId===tunnel.serverId)))
+20
View File
@@ -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({
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "@playwright/test"
import base from "./playwright.config"
const server = base.webServer
if (!server || Array.isArray(server)) throw new Error("Ожидается один локальный сервер")
export default defineConfig({
...base,
use: { ...base.use, baseURL: "http://127.0.0.1:3102" },
testIgnore: [],
testMatch: ["netflow-agent.spec.ts", "netflow-settings.spec.ts"],
webServer: {
...server,
command: "node node_modules/next/dist/bin/next dev --webpack --hostname 127.0.0.1 --port 3102",
url: "http://127.0.0.1:3102",
},
})
+14
View File
@@ -10,13 +10,27 @@ import type {
TrafficFlowOverlayResult, 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 } from "@playwright/test"
test("Filters: обновлённый ID рекурсивного маршрута не скрывает шлюз и выбор в форме", async ({ page }) => {
const rule = { id: "rule-1", community: "65001:120", gateway: "10.9.9.2", gatewayTunnelId: "rec:42", action: "route", description: "Маршрут" }
let writes = 0
let savedGatewayRef: string | undefined
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (r.request().method() !== "GET") {
writes++
const payload = r.request().postDataJSON()
savedGatewayRef = payload.rules[0].gatewayTunnelId
return r.fulfill({ json: { ok: true, rules: payload.rules } })
}
return r.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [rule] }], live: true }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "jump-host", enabled: true, country: "RU", status: "online", latency: 1 }]
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [{ id: "99", dstAddress: "10.9.9.2/32", gateway: "192.0.2.2%gre-test", distance: 1, routingTable: "main", comment: "Рекурсивный тест", disabled: false }], stale: false }
: path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/filters")
const row = page.locator("tr[data-row-id]").first()
await expect(row).toContainText("10.9.9.2/32")
await expect(row).not.toContainText("нет строки в списке")
await row.getByRole("button", { name: "Редактировать правило", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("tab", { name: /Рекурсив/ })).toHaveAttribute("aria-selected", "true")
await expect(dialog.getByRole("button", { name: /10\.9\.9\.2\/32/ })).toHaveClass(/border-primary/)
await expect(dialog.getByText("Выберите рекурсивный маршрут", { exact: true })).toHaveCount(0)
expect(writes).toBe(0)
await dialog.getByRole("button", { name: "Сохранить", exact: true }).click()
await expect(dialog).toHaveCount(0)
expect(writes).toBe(1)
expect(savedGatewayRef).toBe("rec:99")
})
test("Filters: отсутствующий маршрут и именованный GRE не подменяются по совпавшему IP", async ({ page }) => {
const rules = [
{ id: "orphan", community: "65001:120", gateway: "10.9.9.3", gatewayTunnelId: "rec:42", action: "route", description: "Нет маршрута" },
{ id: "gre", community: "65001:130", gateway: "10.9.9.2", gatewayTunnelId: "gre-test", action: "route", description: "Это GRE" },
]
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules }], live: true }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "jump-host", enabled: true, country: "RU", status: "online", latency: 1 }]
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [{ id: "99", dstAddress: "10.9.9.2/32", gateway: "192.0.2.2", distance: 1, routingTable: "main", comment: "", disabled: false }], stale: false }
: path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/filters")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(2)
await expect(rows.nth(0)).toContainText("нет строки в списке")
await expect(rows.nth(1)).not.toContainText("recursive")
})
+58
View File
@@ -0,0 +1,58 @@
import { test, expect, type Page } from "@playwright/test"
import { keyedNetflowSettings } from "./fixtures/netflow-settings"
const installed = {
configured: true, reachable: true, interfaceUp: true, configMatches: false,
publicKey: "public-fixture", listenPort: 51821, addresses: ["10.255.254.1/24"],
peers: [{ publicKey: "router-fixture", allowedIps: ["10.255.254.2/32"], lastHandshakeAt: null, receivedBytes: 0, sentBytes: 0 }], error: null,
}
async function setup(page: Page) {
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
if (path === "/api/auth/config") return route.fulfill({ json: { required: false } })
if (path === "/api/traffic/flow/settings") return route.fulfill({ json: keyedNetflowSettings })
if (path === "/api/traffic/flow/host-status") return route.fulfill({ json: installed })
return route.fulfill({ status: 503, json: { error: "outside agent fixture" } })
})
}
test("NetFlow agent: apply, handshake waiting and frozen installation fields", async ({ page }, testInfo) => {
await setup(page)
let applied = false, posts = 0
await page.route("**/api/traffic/flow/host-status", route => route.fulfill({ json: { ...installed, configMatches: applied } }))
await page.route("**/api/traffic/flow/host-reconcile", route => { applied = true; posts++; return route.fulfill({ json: { ok: true, hostStatus: { ...installed, configMatches: true } } }) })
await page.goto("/data-collection")
await expect(page.getByRole("textbox", { name: "UDP IPFIX", exact: true })).toBeDisabled()
await expect(page.getByRole("textbox", { name: "Collector IP", exact: true })).toBeDisabled()
await expect(page.getByText("Ожидание handshake", { exact: false })).toBeVisible()
await page.getByRole("button", { name: "Применить настройки хоста", exact: true }).click()
await expect(page.getByText("Настройки применены", { exact: true })).toBeVisible()
expect(posts).toBe(1)
const panel = page.getByRole("region", { name: "Настройки NetFlow", exact: true })
await expect(panel.getByText(/Последний пакет:.*Не получен/)).toBeVisible()
await panel.screenshot({ path: testInfo.outputPath("netflow-agent.png") })
})
test("NetFlow agent: apply conflict stays visible after status refresh", async ({ page }) => {
await setup(page)
let posts = 0
await page.route("**/api/traffic/flow/host-reconcile", route => { posts++; return route.fulfill({ status: 502, json: { error: "AllowedIPs пересекаются с другим пиром" } }) })
await page.goto("/data-collection")
await page.getByRole("button", { name: "Применить настройки хоста", exact: true }).click()
await expect(page.getByText("AllowedIPs пересекаются с другим пиром", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Обновить состояние хоста", exact: true }).click()
await expect(page.getByText("AllowedIPs пересекаются с другим пиром", { exact: true })).toBeVisible()
expect(posts).toBe(1)
})
test("NetFlow agent: unavailable status does not invent success or enable apply", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 900 })
await setup(page)
await page.route("**/api/traffic/flow/host-status", route => route.fulfill({ json: { ...installed, reachable: false, interfaceUp: false, configMatches: null, peers: [], error: "Агент недоступен" } }))
await page.goto("/data-collection")
await expect(page.getByRole("button", { name: "Применить настройки хоста", exact: true })).toBeDisabled()
await expect(page.getByText("Настройки применены", { exact: true })).toHaveCount(0)
await expect(page.getByRole("textbox", { name: "UDP IPFIX", exact: true })).toHaveValue("4739")
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
})
+11
View File
@@ -1,6 +1,17 @@
import {test,expect} from "@playwright/test" import {test,expect} from "@playwright/test"
const paths={servers:"/api/servers",gre:"/api/filters/gre-tunnels",probes:"/api/uptime/speed-probes"} as const const paths={servers:"/api/servers",gre:"/api/filters/gre-tunnels",probes:"/api/uptime/speed-probes"} as const
const body=(path:string,name="Роутер")=>path===paths.servers?[{id:1,name,host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:"online",latency:0}]:path===paths.gre?{tunnels:[],failures:[]}:path===paths.probes?{probes:[]}:path==="/api/auth/config"?{required:false}:{} const body=(path:string,name="Роутер")=>path===paths.servers?[{id:1,name,host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:"online",latency:0}]:path===paths.gre?{tunnels:[],failures:[]}:path===paths.probes?{probes:[]}:path==="/api/auth/config"?{required:false}:{}
test("Карта: PostgreSQL даты не скрывают JumpHost и Exit Node",async({page})=>{
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
const rows=[{id:1,name:"Узел JH",host:"192.0.2.1",type:"jump-host",country:"RU",enabled:true,status:"online",polledAt:"2026-10-15 13:46:40.855+00"},{id:2,name:"Узел EN",host:"192.0.2.2",type:"exit-node",country:"SE",enabled:true,status:"online",polledAt:"2026-10-15 20:46:40.855+07"}]
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p===paths.servers?rows:body(p)})})
await page.goto("/network-map")
const map=page.getByRole("group",{name:"Интерактивная карта сети"})
await expect(map.getByText("Узел JH",{exact:true})).toBeVisible()
await expect(map.getByText("Узел EN",{exact:true})).toBeVisible()
await expect(page.getByRole("combobox",{name:"Устройство на карте"})).toBeEnabled()
await expect(page.getByRole("alert").filter({hasText:"Первоначальная загрузка карты"})).toHaveCount(0)
})
for(const part of ["servers","gre","probes"] as const)for(const malformed of [false,true])test("Карта: первоначальный отказ и повтор "+part+"/"+malformed,async({page})=>{ for(const part of ["servers","gre","probes"] as const)for(const malformed of [false,true])test("Карта: первоначальный отказ и повтор "+part+"/"+malformed,async({page})=>{
let failing=true let failing=true
await page.route("**/health",r=>r.fulfill({json:{ok:true}})) await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
+74
View File
@@ -0,0 +1,74 @@
import { test, expect } from "@playwright/test"
const emptyFlow = { hops: [], live: false, rangeMinutes: 5, windowSec: 300, totalBytes: 0, namedBytes: 0, unclassifiedBytes: 0, asnLoaded: false, countryLoaded: false, services: [], serviceEdges: [], servicePaths: [], countries: [], countryEdges: [], countryPaths: [], countryServiceGroups: [], mapServiceMinSharePct: 5, dedupApplied: false, excludeMeshApplied: false, excludeOverlayApplied: false }
test.beforeEach(async ({ page }) => {
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ json: path === "/api/servers" ? [
{ id: 1, name: "Тест JH", host: "192.0.2.1", type: "jump-host", country: "RU", enabled: true, status: "online" },
{ id: 2, name: "Тест EN", host: "192.0.2.2", type: "exit-node", country: "SE", enabled: true, status: "online" },
] : path === "/api/filters/gre-tunnels" ? { tunnels: [], failures: [] }
: path === "/api/uptime/speed-probes" ? { probes: [] }
: path === "/api/traffic/flow/map-hops" ? emptyFlow
: path === "/api/auth/config" ? { required: false } : {} })
})
})
test("Карта: раскрытая легенда объясняет статусы, типы, линии и неизвестные измерения", async ({ page }) => {
await page.goto("/network-map")
const legend = page.getByRole("region", { name: "Легенда карты" })
await expect(legend).toBeVisible()
for (const text of ["Онлайн", "Внимание", "Оффлайн", "Неизвестно", "JumpHost", "Exit Node", "Home Router", "GRE + IPsec", "Поток NetFlow"]) await expect(legend.getByText(text, { exact: true })).toBeVisible()
await expect(legend).toContainText("нет измерения")
await expect(legend).toContainText("не пропускная способность канала")
await page.locator("[data-map-legend] summary").click()
await expect(legend).not.toBeVisible()
await page.locator("[data-map-legend] summary").press("Enter")
await expect(legend).toBeVisible()
await legend.getByText("Онлайн", { exact: true }).click()
await page.screenshot({ path: "tmp/map270-desktop.png" })
})
test("Карта: мышь не рисует прямоугольную рамку, клавиатурный фокус остаётся видимым", async ({ page }) => {
await page.goto("/network-map")
const node = page.getByRole("button", { name: "Устройство Тест JH", exact: true })
await expect(node).toBeVisible()
await node.click()
await expect(node).toHaveCSS("outline-style", "none")
await expect(node.locator(":scope > .network-map-focus-ring")).toHaveCSS("opacity", "0")
// Return to this object through real keyboard navigation, not programmatic focus.
await node.press("Shift+Tab")
await page.keyboard.press("Tab")
await expect(node).toBeFocused()
await expect(node.locator(":scope > .network-map-focus-ring")).toHaveCSS("opacity", "1")
await node.press("Enter")
await expect(node).toHaveAttribute("aria-pressed", "false")
})
test("Карта: легенда помещается на телефоне и сворачивается", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto("/network-map")
const legend = page.getByRole("region", { name: "Легенда карты" })
await expect(page.locator("[data-map-legend]")).not.toHaveAttribute("open", "")
await page.screenshot({ path: "tmp/map270-mobile.png" })
await page.locator("[data-map-legend] summary").click()
await expect(legend).toBeVisible()
const bounds = await legend.boundingBox()
expect(bounds!.x).toBeGreaterThanOrEqual(0)
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390)
await page.locator("[data-map-legend] summary").click()
await expect(legend).not.toBeVisible()
})
test("Карта: экспорт не добавляет клавиатурные контуры всем объектам", async ({ page }) => {
await page.goto("/network-map")
await expect(page.getByRole("button", { name: "Устройство Тест JH", exact: true })).toBeVisible()
const download = page.waitForEvent("download")
await page.getByRole("button", { name: "Экспорт SVG", exact: true }).click()
const stream = await (await download).createReadStream()
let svg = ""
for await (const chunk of stream!) svg += chunk.toString()
expect(svg).not.toContain("network-map-focus-ring")
expect(svg).toContain("Тест JH")
})