Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e461dc21bd | ||
|
|
2852b11234 | ||
|
|
0abdac82de | ||
|
|
db81cf0f97 | ||
|
|
fcca92706b | ||
|
|
4881306f0b | ||
|
|
ef229ca9c4 | ||
|
|
25ae76dc3f | ||
|
|
552d5f831f | ||
|
|
e6a2e4009d | ||
|
|
eb24a08981 | ||
|
|
1c11a133f3 | ||
|
|
0f0d3aa060 | ||
|
|
042407f03e | ||
|
|
b2cf71fcc7 | ||
|
|
fda7f353ad |
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
deploy/netflow-agent/*.sh text eol=lf
|
||||||
|
deploy/netflow-agent/*.py text eol=lf
|
||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -62,3 +62,11 @@ opencode.json
|
|||||||
|
|
||||||
# Local codegraph process
|
# Local codegraph process
|
||||||
.codegraph/daemon.pid
|
.codegraph/daemon.pid
|
||||||
|
|
||||||
|
# Python controller tests
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# NetFlow agent host state (when Compose is installed in a checkout)
|
||||||
|
/data/netflow-wireguard/
|
||||||
|
/data/netflow-agent-socket/
|
||||||
@@ -568,3 +568,7 @@ docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
|
|||||||
10. Проверить `curl` на `:8000/health` и `:3000/`.
|
10. Проверить `curl` на `:8000/health` и `:3000/`.
|
||||||
11. В UI: режим live и URL бэкенда; сверить с `CORS_ORIGIN`.
|
11. В UI: режим live и URL бэкенда; сверить с `CORS_ORIGIN`.
|
||||||
12. Просмотреть `docker logs mmapp-updater`; настроить регулярный backup тома `backend-data`.
|
12. Просмотреть `docker logs mmapp-updater`; настроить регулярный backup тома `backend-data`.
|
||||||
|
|
||||||
|
### Автоматизация NetFlow через Docker-агент
|
||||||
|
|
||||||
|
[Установка, схема взаимодействия и production-состав](docs/netflow-agent.md).
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -17,3 +17,5 @@ AUTH_REQUIRED=false
|
|||||||
AUTH_JWT_SECRET=dev-secret-change-me
|
AUTH_JWT_SECRET=dev-secret-change-me
|
||||||
AUTH_ISSUER=https://auth.shnt.top
|
AUTH_ISSUER=https://auth.shnt.top
|
||||||
AUTH_PORTAL_URL=http://localhost:5175
|
AUTH_PORTAL_URL=http://localhost:5175
|
||||||
|
# Optional: enabled by the NetFlow agent Compose installer. Linux Unix socket only.
|
||||||
|
# FLOW_AGENT_SOCKET=/run/mm-netflow-host/agent.sock
|
||||||
@@ -57,6 +57,7 @@ COPY --from=build /app/packages/contracts ./packages/contracts
|
|||||||
COPY --from=build /app/backend/dist ./backend/dist
|
COPY --from=build /app/backend/dist ./backend/dist
|
||||||
COPY --from=build /app/backend/package.json ./backend/package.json
|
COPY --from=build /app/backend/package.json ./backend/package.json
|
||||||
COPY --from=build /app/backend/drizzle ./backend/drizzle
|
COPY --from=build /app/backend/drizzle ./backend/drizzle
|
||||||
|
COPY deploy/netflow-agent/install.sh ./deploy/netflow-agent/install.sh
|
||||||
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
|
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
|
||||||
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 : "Не удалось прочитать шлюзы" })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type TestConnectionRequest,
|
type TestConnectionRequest,
|
||||||
} from "../types/server.js"
|
} from "../types/server.js"
|
||||||
import { pollServer } from "../services/poller.js"
|
import { pollServer } from "../services/poller.js"
|
||||||
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
|
import { MikrotikClient, MikrotikError, routerOsAccessHint } from "../services/mikrotik.js"
|
||||||
import { resolveRosSrcIpv4 } from "../utils/ros-src-address.js"
|
import { resolveRosSrcIpv4 } from "../utils/ros-src-address.js"
|
||||||
import {
|
import {
|
||||||
createServer,
|
createServer,
|
||||||
@@ -56,9 +56,7 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
let message = "Не удалось подключиться"
|
let message = "Не удалось подключиться"
|
||||||
|
|
||||||
if (err instanceof MikrotikError) {
|
if (err instanceof MikrotikError) {
|
||||||
if (err.statusCode === 401) message = "Неверный логин или пароль (401 Unauthorized)"
|
message = routerOsAccessHint(err.statusCode, err.body) ?? `Ошибка RouterOS API: ${err.statusCode}`
|
||||||
else if (err.statusCode === 403) message = "Нет прав доступа к REST API (403 Forbidden)"
|
|
||||||
else message = `Ошибка RouterOS API: ${err.statusCode}`
|
|
||||||
} else if (err instanceof Error) {
|
} else if (err instanceof Error) {
|
||||||
const msg = err.message.toLowerCase()
|
const msg = err.message.toLowerCase()
|
||||||
if (msg.includes("timeout") || msg.includes("abort"))
|
if (msg.includes("timeout") || msg.includes("abort"))
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import Fastify from "fastify"
|
||||||
|
import { SignJWT } from "jose"
|
||||||
|
import { createTrafficFlowHostRoutes } from "./traffic-flow-host.js"
|
||||||
|
import { emptyHostStatus } from "../services/traffic-flow-agent-client.js"
|
||||||
|
|
||||||
|
process.env.AUTH_REQUIRED = "true"
|
||||||
|
process.env.AUTH_JWT_SECRET = "flow-host-test-secret"
|
||||||
|
process.env.AUTH_ISSUER = "https://auth.test.local"
|
||||||
|
process.env.NODE_ENV = "test"
|
||||||
|
const { default: authPlugin, requireAuth } = await import("../plugins/auth.js")
|
||||||
|
const app = Fastify()
|
||||||
|
await app.register(authPlugin)
|
||||||
|
app.addHook("preHandler", requireAuth)
|
||||||
|
let applied = 0, fail = false
|
||||||
|
let status = emptyHostStatus(false)
|
||||||
|
await app.register(createTrafficFlowHostRoutes({
|
||||||
|
status: async () => status,
|
||||||
|
reconcile: async () => { applied++; if (fail) throw Error("Туннель не изменён: конфликт адресов"); return status },
|
||||||
|
}), { prefix: "/api" })
|
||||||
|
async function token(permission: string) {
|
||||||
|
return new SignJWT({ sub: "fixture", apps: ["mm"], permissions: [permission] }).setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("flow-host-test-secret"))
|
||||||
|
}
|
||||||
|
for (const permission of ["mm:dashboard:read", "mm:traffic:read", "mm:traffic:write", "mm:traffic:admin"]) {
|
||||||
|
const headers = { authorization: "Bearer " + await token(permission) }
|
||||||
|
assert.equal((await app.inject({ method: "GET", url: "/api/traffic/flow/host-status", headers })).statusCode, permission === "mm:dashboard:read" ? 403 : 200)
|
||||||
|
const before = applied
|
||||||
|
const response = await app.inject({ method: "POST", url: "/api/traffic/flow/host-reconcile", headers })
|
||||||
|
const allowed = permission.endsWith(":write") || permission.endsWith(":admin")
|
||||||
|
assert.equal(response.statusCode, allowed ? 409 : 403)
|
||||||
|
assert.equal(applied, before + Number(allowed))
|
||||||
|
}
|
||||||
|
assert.equal((await app.inject({ method: "GET", url: "/api/traffic/flow/host-status" })).statusCode, 401)
|
||||||
|
const headers = { authorization: "Bearer " + await token("mm:traffic:write") }
|
||||||
|
status = { ...emptyHostStatus(true), reachable: true, interfaceUp: true, configMatches: true }
|
||||||
|
let response = await app.inject({ method: "POST", url: "/api/traffic/flow/host-reconcile", headers })
|
||||||
|
assert.equal(response.statusCode, 200); assert.equal(response.json().hostStatus.configMatches, true)
|
||||||
|
fail = true
|
||||||
|
response = await app.inject({ method: "POST", url: "/api/traffic/flow/host-reconcile", headers })
|
||||||
|
assert.equal(response.statusCode, 502); assert.match(response.json().error, /конфликт адресов/)
|
||||||
|
status = emptyHostStatus(true, "offline")
|
||||||
|
response = await app.inject({ method: "GET", url: "/api/traffic/flow/host-status", headers })
|
||||||
|
assert.equal(response.json().configMatches, null); assert.equal(response.json().reachable, false)
|
||||||
|
await app.close()
|
||||||
|
console.log("NetFlow host HTTP/JWT: read/write permissions, manual mode, reconcile success/conflict and offline status")
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
|
import type { TrafficFlowHostStatus } from "@mmapp/contracts/traffic-flow"
|
||||||
|
|
||||||
|
export function createTrafficFlowHostRoutes(deps: {
|
||||||
|
status: () => Promise<TrafficFlowHostStatus>
|
||||||
|
reconcile: () => Promise<TrafficFlowHostStatus>
|
||||||
|
}): FastifyPluginAsyncZod {
|
||||||
|
return async app => {
|
||||||
|
app.get("/traffic/flow/host-status", async () => deps.status())
|
||||||
|
app.post("/traffic/flow/host-reconcile", async (_req, reply) => {
|
||||||
|
try {
|
||||||
|
const hostStatus = await deps.reconcile()
|
||||||
|
if (!hostStatus.configured) return reply.status(409).send({ error: "Автоматизация хоста NetFlow не настроена" })
|
||||||
|
return { ok: true, hostStatus }
|
||||||
|
} catch (error) {
|
||||||
|
return reply.status(502).send({ error: error instanceof Error ? error.message : "Настройки хоста не применены" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
} from "@mmapp/contracts/traffic-flow"
|
} from "@mmapp/contracts/traffic-flow"
|
||||||
import { createTrafficFlowPurgeRoutes } from "./traffic-flow-purge.js"
|
import { createTrafficFlowPurgeRoutes } from "./traffic-flow-purge.js"
|
||||||
import { createTrafficFlowSettingsRoutes } from "./traffic-flow-settings.js"
|
import { createTrafficFlowSettingsRoutes } from "./traffic-flow-settings.js"
|
||||||
|
import { createTrafficFlowHostRoutes } from "./traffic-flow-host.js"
|
||||||
|
import { getFlowHostStatus, reconcileFlowHost, withFlowConfigurationLock } from "../services/traffic-flow-host-agent.js"
|
||||||
|
import { agentSocket } from "../services/traffic-flow-agent-client.js"
|
||||||
import {
|
import {
|
||||||
ensureHostKeys,
|
ensureHostKeys,
|
||||||
getTrafficFlowSettingsRow,
|
getTrafficFlowSettingsRow,
|
||||||
@@ -16,6 +19,7 @@ import {
|
|||||||
} from "../services/traffic-flow-settings.js"
|
} from "../services/traffic-flow-settings.js"
|
||||||
import {
|
import {
|
||||||
getFlowListenerState,
|
getFlowListenerState,
|
||||||
|
getFlowRuntimeCounters,
|
||||||
purgeTrafficFlowStore,
|
purgeTrafficFlowStore,
|
||||||
startTrafficFlowListener,
|
startTrafficFlowListener,
|
||||||
listFlowTalkers,
|
listFlowTalkers,
|
||||||
@@ -150,11 +154,25 @@ function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
|
await app.register(createTrafficFlowHostRoutes({
|
||||||
|
status: getFlowHostStatus,
|
||||||
|
reconcile: () => withFlowConfigurationLock(reconcileFlowHost),
|
||||||
|
}))
|
||||||
await app.register(createTrafficFlowSettingsRoutes({
|
await app.register(createTrafficFlowSettingsRoutes({
|
||||||
read: () => toTrafficFlowSettingsDto(getFlowListenerState()),
|
read: async () => {
|
||||||
update: async patch => { await updateTrafficFlowSettings(patch) },
|
const settings = await toTrafficFlowSettingsDto(getFlowListenerState())
|
||||||
|
const { packetsReceived, lastExporterIp, lastError, lastDatagramAt } = await getFlowRuntimeCounters()
|
||||||
|
return { ...settings, packetsReceived, lastExporterIp, lastError, lastDatagramAt }
|
||||||
|
},
|
||||||
|
update: async patch => { await withFlowConfigurationLock(async () => {
|
||||||
|
const current = await getTrafficFlowSettingsRow()
|
||||||
|
if (agentSocket() && (["collectorIp", "prefix", "flowListenPort", "wgListenPort"] as const).some(key => patch[key] !== undefined && patch[key] !== current[key])) {
|
||||||
|
throw Object.assign(new Error("Адрес, префикс и порты закреплены установкой агента; повторите установщик хоста"), { statusCode: 409 })
|
||||||
|
}
|
||||||
|
await updateTrafficFlowSettings(patch)
|
||||||
|
}) },
|
||||||
startListener: startTrafficFlowListener,
|
startListener: startTrafficFlowListener,
|
||||||
ensureKeys: ensureHostKeys,
|
ensureKeys: () => withFlowConfigurationLock(ensureHostKeys),
|
||||||
hasPrivateKey: async () => Boolean((await getTrafficFlowSettingsRow()).hostPrivateKey),
|
hasPrivateKey: async () => Boolean((await getTrafficFlowSettingsRow()).hostPrivateKey),
|
||||||
hostFiles: listTrafficFlowHostFiles,
|
hostFiles: listTrafficFlowHostFiles,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,7 +1,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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
ingestParsedFlowsForServerForTests,
|
ingestParsedFlowsForServerForTests,
|
||||||
resetFlowRingsForTests,
|
resetFlowRingsForTests,
|
||||||
} from "./traffic-flow-ingest.js"
|
} from "./traffic-flow-ingest.js"
|
||||||
import { buildFlowAnalytics, formatLiveSseFromBuilder, getFlowMonthly, listFlowClients, listFlowExporters } from "./traffic-flow-analytics.js"
|
import { buildFlowAnalytics, formatLiveSseFromBuilder, getFlowMonthly, listFlowClients, listFlowExporters, resetFlowAnalyticsCacheForTests } from "./traffic-flow-analytics.js"
|
||||||
import { withPgOrSkip } from "../test/pg.js"
|
import { withPgOrSkip } from "../test/pg.js"
|
||||||
import { dbQuery } from "../db/index.js"
|
import { dbQuery } from "../db/index.js"
|
||||||
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
|
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
|
||||||
@@ -556,6 +556,22 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Historical 5m/15m charts survive an empty live ring (e.g. immediately after restart).
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
await dbQuery("DELETE FROM flow_buckets WHERE server_id=$1", [serverId])
|
||||||
|
for (const minutesAgo of [2, 3, 4, 10]) {
|
||||||
|
await dbQuery(`INSERT INTO flow_buckets (server_id,bucket_at,src,dst,in_iface,bytes,packets)
|
||||||
|
VALUES ($1,date_trunc('minute',now())-($2::int*interval '1 minute'),'10.1.1.8','8.8.8.8','2',60000,10)`, [serverId, minutesAgo])
|
||||||
|
}
|
||||||
|
for (const minutes of [5, 15]) for (const dedup of [true, false]) {
|
||||||
|
resetFlowAnalyticsCacheForTests()
|
||||||
|
const history = await buildFlowAnalytics({ minutes, serverId, dedup, excludeMesh:false, excludeOverlay:false })
|
||||||
|
assert.equal(history.rxSeries.length, minutes)
|
||||||
|
assert.equal(history.rxSeries.filter(v => v > 0).length, minutes === 5 ? 3 : 4)
|
||||||
|
const missing = await buildFlowAnalytics({ minutes, serverId, iface:"not-an-interface" })
|
||||||
|
assert(missing.rxSeries.every(v => v === 0), "Historical chart respects interface filter")
|
||||||
|
}
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
for (const table of ["flow_daily_dims", "flow_hour_facts", "flow_daily_facts", "flow_buckets", "traffic_samples", "user_interface_bindings", "server_snapshots"]) {
|
for (const table of ["flow_daily_dims", "flow_hour_facts", "flow_daily_facts", "flow_buckets", "traffic_samples", "user_interface_bindings", "server_snapshots"]) {
|
||||||
await dbQuery("DELETE FROM " + table + " WHERE server_id = $1", [serverId])
|
await dbQuery("DELETE FROM " + table + " WHERE server_id = $1", [serverId])
|
||||||
|
|||||||
@@ -423,9 +423,10 @@ async function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): Promise<FlowAn
|
|||||||
? getRingMbps(ringServer, ifaceFilter === "" ? "__all__" : (ifacesMap.get(ifaceFilter)?.index || ifaceFilter))
|
? getRingMbps(ringServer, ifaceFilter === "" ? "__all__" : (ifacesMap.get(ifaceFilter)?.index || ifaceFilter))
|
||||||
: { rx: Array(60).fill(0) as number[], tx: Array(60).fill(0) as number[], rxNow: 0, txNow: 0 }
|
: { rx: Array(60).fill(0) as number[], tx: Array(60).fill(0) as number[], rxNow: 0, txNow: 0 }
|
||||||
|
|
||||||
|
// The live ring covers about one minute; historical charts must use the requested window.
|
||||||
const fromBuckets = seriesFromRows(matched, q.minutes)
|
const fromBuckets = seriesFromRows(matched, q.minutes)
|
||||||
const rxSeries = q.minutes <= 15 ? ring.rx : fromBuckets.rx
|
const rxSeries = fromBuckets.rx
|
||||||
const txSeries = q.minutes <= 15 ? ring.tx : fromBuckets.tx
|
const txSeries = fromBuckets.tx
|
||||||
|
|
||||||
const ifaceRows = [...ifacesMap.entries()]
|
const ifaceRows = [...ifacesMap.entries()]
|
||||||
.sort((a, b) => b[1].bytes - a[1].bytes)
|
.sort((a, b) => b[1].bytes - a[1].bytes)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { withAdvisoryLock } from "../db/index.js"
|
||||||
|
import { parseJsonArray } from "../db/json.js"
|
||||||
|
import { getTrafficFlowSettingsRow, invalidateTrafficFlowSettingsCache } from "./traffic-flow-settings.js"
|
||||||
|
import { agentSocket, callFlowAgent, emptyHostStatus, type FlowAgentSnapshot } from "./traffic-flow-agent-client.js"
|
||||||
|
import { FlowConfigurationQueue } from "./traffic-flow-operation-queue.js"
|
||||||
|
|
||||||
|
// Session lock: RouterOS/network operations use other pooled connections. Never lock the settings row here.
|
||||||
|
const queue = new FlowConfigurationQueue()
|
||||||
|
export const withFlowConfigurationLock = <T>(fn: () => Promise<T>) => queue.run(() => withAdvisoryLock(7340291, async () => {
|
||||||
|
invalidateTrafficFlowSettingsCache()
|
||||||
|
return fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
async function snapshot(): Promise<FlowAgentSnapshot> {
|
||||||
|
const row = await getTrafficFlowSettingsRow()
|
||||||
|
return {
|
||||||
|
collectorIp: row.collectorIp, prefix: row.prefix, flowListenPort: row.flowListenPort, wgListenPort: row.wgListenPort,
|
||||||
|
hostPublicKey: row.hostPublicKey, hostPrivateKey: row.hostPrivateKey,
|
||||||
|
peers: (parseJsonArray(row.peersJson) as FlowAgentSnapshot["peers"]).map(p => ({
|
||||||
|
serverId: p.serverId, publicKey: p.publicKey, allowedIps: p.allowedIps, ...(p.endpoint ? { endpoint: p.endpoint } : {}),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFlowHostStatus() {
|
||||||
|
if (!agentSocket()) return emptyHostStatus(false)
|
||||||
|
try { return await callFlowAgent("/check", await snapshot()) }
|
||||||
|
catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Проверка агента не выполнена"
|
||||||
|
// Preserve live measurements on a settings conflict; never claim configuration agreement.
|
||||||
|
try { return { ...await callFlowAgent("/status"), configMatches: false, error: message } }
|
||||||
|
catch { return emptyHostStatus(true, message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preflightFlowHost() {
|
||||||
|
if (!agentSocket()) return
|
||||||
|
try { await callFlowAgent("/check", await snapshot()) }
|
||||||
|
catch (error) { throw Object.assign(new Error(error instanceof Error ? error.message : "Агент NetFlow не готов"), { statusCode: 409 }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller holds withFlowConfigurationLock. This is also used after saving a newly discovered router key.
|
||||||
|
export async function reconcileFlowHost() {
|
||||||
|
if (!agentSocket()) return emptyHostStatus(false)
|
||||||
|
return callFlowAgent("/apply", await snapshot())
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
|
import { 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]
|
||||||
}
|
}
|
||||||
@@ -439,7 +439,7 @@ export async function listStoredFlowRows(sinceIso: string): Promise<PendingFlowR
|
|||||||
|
|
||||||
export async function listFlowRowsForWindow(minutes: number): Promise<PendingFlowRow[]> {
|
export async function listFlowRowsForWindow(minutes: number): Promise<PendingFlowRow[]> {
|
||||||
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
|
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
|
||||||
if (minutes <= 15 && !worker) return await listLiveFlowRows(sinceIso)
|
// History remains available while the collector starts or is stopped; pending rows are merged below.
|
||||||
return await listStoredFlowRows(sinceIso)
|
return await listStoredFlowRows(sinceIso)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { FlowConfigurationQueue } from "./traffic-flow-operation-queue.js"
|
||||||
|
const queue = new FlowConfigurationQueue()
|
||||||
|
let release!: () => void, active = 0, maximum = 0
|
||||||
|
const hold = new Promise<void>(resolve => { release = resolve })
|
||||||
|
const events: number[] = []
|
||||||
|
const tasks = Array.from({ length: 32 }, (_, i) => queue.run(async () => {
|
||||||
|
active++; maximum = Math.max(active, maximum); events.push(i)
|
||||||
|
try { if (i === 0) await hold; if (i === 1) throw Error("fixture failure"); return i }
|
||||||
|
finally { active-- }
|
||||||
|
}))
|
||||||
|
const done = Promise.allSettled(tasks)
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
assert.deepEqual(events, [0]); assert.equal(active, 1)
|
||||||
|
release()
|
||||||
|
const results = await done
|
||||||
|
assert.equal(maximum, 1); assert.equal(results[1].status, "rejected")
|
||||||
|
assert.equal(results.filter(result => result.status === "fulfilled").length, 31)
|
||||||
|
assert.deepEqual(events, Array.from({ length: 32 }, (_, i) => i))
|
||||||
|
assert.equal(await queue.run(async () => "recovered"), "recovered")
|
||||||
|
console.log("NetFlow queue: 32 concurrent operations use one lock waiter, failures release the queue")
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** Only one local operation may wait for the PostgreSQL advisory lock.
|
||||||
|
* Otherwise queued requests could exhaust the pool needed by its current owner.
|
||||||
|
*/
|
||||||
|
export class FlowConfigurationQueue {
|
||||||
|
private tail: Promise<void> = Promise.resolve()
|
||||||
|
|
||||||
|
run<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
const result = this.tail.then(operation)
|
||||||
|
this.tail = result.then(() => undefined, () => undefined)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,7 +132,7 @@ const observed=(rows:ReturnType<typeof observationSample>[])=>buildTrafficFromSa
|
|||||||
assert.ok(observed([]).observations?.rxSeries.every(v=>!v));assert.equal(observed([]).observations?.rxNow,false)
|
assert.ok(observed([]).observations?.rxSeries.every(v=>!v));assert.equal(observed([]).observations?.rxNow,false)
|
||||||
assert.equal(observed([observationSample(0,10,10)]).observations?.rxNow,false)
|
assert.equal(observed([observationSample(0,10,10)]).observations?.rxNow,false)
|
||||||
const observedZero=observed([observationSample(0,10,10),observationSample(30_000,10,10)])
|
const observedZero=observed([observationSample(0,10,10),observationSample(30_000,10,10)])
|
||||||
assert.equal(observedZero.rxNow,0);assert.equal(observedZero.observations?.rxNow,true);assert.equal(observedZero.observations?.rxSeries.filter(Boolean).length,1)
|
assert.equal(observedZero.rxNow,0);assert.equal(observedZero.observations?.rxNow,true);assert.equal(observedZero.observations?.rxSeries.filter(Boolean).length,30)
|
||||||
const partialReset=observed([observationSample(0,10,10),observationSample(30_000,1,20)])
|
const partialReset=observed([observationSample(0,10,10),observationSample(30_000,1,20)])
|
||||||
assert.equal(partialReset.observations?.rxNow,false);assert.equal(partialReset.observations?.txNow,true)
|
assert.equal(partialReset.observations?.rxNow,false);assert.equal(partialReset.observations?.txNow,true)
|
||||||
const fullReset=observed([observationSample(0,10,10),observationSample(20_000,20,20),observationSample(40_000,1,1)])
|
const fullReset=observed([observationSample(0,10,10),observationSample(20_000,20,20),observationSample(40_000,1,1)])
|
||||||
@@ -147,3 +147,28 @@ console.log("traffic observation metadata tests ok")
|
|||||||
const complete=observed([observationSample(0,10,10),observationSample(30_000,20,20),observationSample(0,10,10,"ether2"),observationSample(30_000,20,20,"ether2")]);assert.equal(complete.observations?.rxNow,true)
|
const complete=observed([observationSample(0,10,10),observationSample(30_000,20,20),observationSample(0,10,10,"ether2"),observationSample(30_000,20,20,"ether2")]);assert.equal(complete.observations?.rxNow,true)
|
||||||
const badTime=observed([{...observationSample(0,10,10),sampledAt:"invalid"},observationSample(30_000,20,20)]);assert.equal(badTime.observations?.rxNow,false)
|
const badTime=observed([{...observationSample(0,10,10),sampledAt:"invalid"},observationSample(30_000,20,20)]);assert.equal(badTime.observations?.rxNow,false)
|
||||||
assert.equal(rateBpsFromDelta(0,Number.MAX_VALUE,0,1000),null)
|
assert.equal(rateBpsFromDelta(0,Number.MAX_VALUE,0,1000),null)
|
||||||
|
|
||||||
|
// A 30-second collector must produce a line across measured intervals on short ranges.
|
||||||
|
for (const minutes of [5, 15]) {
|
||||||
|
const span = minutes * 60_000
|
||||||
|
const rows = Array.from({ length: span / 30_000 + 1 }, (_, i) => ({
|
||||||
|
...observationSample(i * 30_000, i * 3_750_000, i * 1_875_000),
|
||||||
|
continuityKnown: true, nativeCounterIdentity: "ether1-v1",
|
||||||
|
}))
|
||||||
|
const history = buildTrafficFromSamples(rows, observationStart, observationStart + span, undefined, undefined, true)
|
||||||
|
assert(history.observations!.rxSeries.every(Boolean), `${minutes}m continuous measured RX`)
|
||||||
|
assert(history.observations!.txSeries.every(Boolean), `${minutes}m continuous measured TX`)
|
||||||
|
assert(history.rxSeries.every(v => v === 1)); assert(history.txSeries.every(v => v === 0.5))
|
||||||
|
assert.equal(history.rxTotalGiB, (span / 30_000) * 3_750_000 / 1024 ** 3)
|
||||||
|
// Explicit loss of continuity stays unknown; it must not be bridged by chart rendering.
|
||||||
|
rows[2].continuityKnown = false
|
||||||
|
const gap = buildTrafficFromSamples(rows, observationStart, observationStart + span, undefined, undefined, true)
|
||||||
|
assert.equal(gap.observations!.rxSeries.filter(v => !v).length, 30_000 / (span / 60))
|
||||||
|
assert(gap.observations!.rxSeries.slice(0, 30_000 / (span / 60)).every(Boolean))
|
||||||
|
const partial = buildTrafficFromSamples(rows.slice(4), observationStart, observationStart + span, undefined, undefined, true)
|
||||||
|
assert(partial.observations!.rxSeries.slice(0, 120_000 / (span / 60)).every(v => !v), "No history invented before first sample")
|
||||||
|
}
|
||||||
|
// Unequal poll intervals contribute by covered duration when sharing a chart bucket.
|
||||||
|
const weighted = observed([observationSample(0,0,0),observationSample(500,62_500,62_500),observationSample(1500,312_500,312_500)])
|
||||||
|
assert.equal(weighted.rxSeries[0], 1.5)
|
||||||
|
assert.equal(weighted.rxSeries[1], 2)
|
||||||
@@ -114,6 +114,34 @@ function parseIsoMs(iso: string): number {
|
|||||||
return Date.parse(iso)
|
return Date.parse(iso)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A counter delta measures its entire interval, not just the next poll's timestamp. */
|
||||||
|
function intervalSeries(
|
||||||
|
waves: Map<number, PeakWave>, start: number, end: number, side: "rx" | "tx",
|
||||||
|
): { values: number[]; observed: boolean[] } {
|
||||||
|
const sums = Array(SERIES_POINTS).fill(0) as number[]
|
||||||
|
const durations = Array(SERIES_POINTS).fill(0) as number[]
|
||||||
|
const unknown = Array(SERIES_POINTS).fill(false) as boolean[]
|
||||||
|
const width = (end - start) / SERIES_POINTS
|
||||||
|
if (!(width > 0)) return { values: sums, observed: unknown }
|
||||||
|
for (const [at, wave] of waves) {
|
||||||
|
const from = Math.max(start, wave.start), to = Math.min(end, at)
|
||||||
|
if (!(to > from)) continue
|
||||||
|
const first = Math.max(0, Math.floor((from - start) / width))
|
||||||
|
const last = Math.min(SERIES_POINTS - 1, Math.ceil((to - start) / width) - 1)
|
||||||
|
for (let i = first; i <= last; i++) {
|
||||||
|
const overlap = Math.min(to, start + (i + 1) * width) - Math.max(from, start + i * width)
|
||||||
|
if (!(overlap > 0)) continue
|
||||||
|
sums[i] += wave[side] * overlap
|
||||||
|
durations[i] += overlap
|
||||||
|
unknown[i] ||= !wave[side === "rx" ? "rxKnown" : "txKnown"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
values: sums.map((sum, i) => durations[i] > 0 ? sum / durations[i] : 0),
|
||||||
|
observed: durations.map((duration, i) => duration > 0 && !unknown[i]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function sampleSeriesKey(interfaceName: string, peerPublicKey = ""): string {
|
export function sampleSeriesKey(interfaceName: string, peerPublicKey = ""): string {
|
||||||
return `${interfaceName}\0${peerPublicKey}`
|
return `${interfaceName}\0${peerPublicKey}`
|
||||||
}
|
}
|
||||||
@@ -232,8 +260,6 @@ export function buildTrafficFromSamples(
|
|||||||
const latestWave = membership.get(latestMembershipAt)
|
const latestWave = membership.get(latestMembershipAt)
|
||||||
if (latestWave?.declared) sessions = latestWave.active
|
if (latestWave?.declared) sessions = latestWave.active
|
||||||
|
|
||||||
const rxSeries = bucketAvg(rxPoints, rangeStartMs, rangeEndMs)
|
|
||||||
const txSeries = bucketAvg(txPoints, rangeStartMs, rangeEndMs)
|
|
||||||
const lastTs = [...byTs.keys()].sort((a, b) => a - b).at(-1)
|
const lastTs = [...byTs.keys()].sort((a, b) => a - b).at(-1)
|
||||||
const last = lastTs != null ? byTs.get(lastTs) : undefined
|
const last = lastTs != null ? byTs.get(lastTs) : undefined
|
||||||
const peak = (points: Array<{t:number;v:number}>, side: "rxObserved"|"txObserved") => points.reduce((m,p) => {
|
const peak = (points: Array<{t:number;v:number}>, side: "rxObserved"|"txObserved") => points.reduce((m,p) => {
|
||||||
@@ -249,6 +275,8 @@ export function buildTrafficFromSamples(
|
|||||||
const complete = wave.sameStart && wave.count === expectedAt(t)
|
const complete = wave.sameStart && wave.count === expectedAt(t)
|
||||||
waves.set(t,{start:wave.start,rx:wave.rx,tx:wave.tx,rxKnown:complete&&wave.rxObserved,txKnown:complete&&wave.txObserved})
|
waves.set(t,{start:wave.start,rx:wave.rx,tx:wave.tx,rxKnown:complete&&wave.rxObserved,txKnown:complete&&wave.txObserved})
|
||||||
}
|
}
|
||||||
|
const rxHistory = intervalSeries(waves, rangeStartMs, rangeEndMs, "rx")
|
||||||
|
const txHistory = intervalSeries(waves, rangeStartMs, rangeEndMs, "tx")
|
||||||
return retainPeakWaves({
|
return retainPeakWaves({
|
||||||
rxNow: last?.rx ?? 0,
|
rxNow: last?.rx ?? 0,
|
||||||
txNow: last?.tx ?? 0,
|
txNow: last?.tx ?? 0,
|
||||||
@@ -257,13 +285,13 @@ export function buildTrafficFromSamples(
|
|||||||
rxTotalGiB: rxBytesDelta / (1024 ** 3),
|
rxTotalGiB: rxBytesDelta / (1024 ** 3),
|
||||||
txTotalGiB: txBytesDelta / (1024 ** 3),
|
txTotalGiB: txBytesDelta / (1024 ** 3),
|
||||||
sessions,
|
sessions,
|
||||||
rxSeries,
|
rxSeries: rxHistory.values,
|
||||||
txSeries,
|
txSeries: txHistory.values,
|
||||||
...(includeObservations?{observations:{
|
...(includeObservations?{observations:{
|
||||||
rxNow:lastAttemptAt!==null&&lastTs===lastAttemptAt&&!!last?.rxObserved&&last.count===expectedAt(lastTs!),
|
rxNow:lastAttemptAt!==null&&lastTs===lastAttemptAt&&!!last?.rxObserved&&last.count===expectedAt(lastTs!),
|
||||||
txNow:lastAttemptAt!==null&&lastTs===lastAttemptAt&&!!last?.txObserved&&last.count===expectedAt(lastTs!),
|
txNow:lastAttemptAt!==null&&lastTs===lastAttemptAt&&!!last?.txObserved&&last.count===expectedAt(lastTs!),
|
||||||
rxSeries:bucketAvg([...byTs].map(([t,v])=>({t,v:Number(v.rxObserved&&v.count===expectedAt(t)&&t>=rangeStartMs&&t<=rangeEndMs)})),rangeStartMs,rangeEndMs).map(v=>v===1),
|
rxSeries:rxHistory.observed,
|
||||||
txSeries:bucketAvg([...byTs].map(([t,v])=>({t,v:Number(v.txObserved&&v.count===expectedAt(t)&&t>=rangeStartMs&&t<=rangeEndMs)})),rangeStartMs,rangeEndMs).map(v=>v===1),
|
txSeries:txHistory.observed,
|
||||||
sampledAt:lastAttemptAt===null?null:new Date(lastAttemptAt).toISOString(),
|
sampledAt:lastAttemptAt===null?null:new Date(lastAttemptAt).toISOString(),
|
||||||
measurementIntervalMs:lastTs===lastAttemptAt?last?.intervalMs??null:null,
|
measurementIntervalMs:lastTs===lastAttemptAt?last?.intervalMs??null:null,
|
||||||
rxPeak:[...waves.values()].some(w=>w.rxKnown),
|
rxPeak:[...waves.values()].some(w=>w.rxKnown),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { can } from "@/lib/auth"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||||
|
import { AlertCircleIcon } from "lucide-react"
|
||||||
|
import { getTrafficFlowHostStatus, reconcileTrafficFlowHost } from "@/shared/api/traffic-flow"
|
||||||
|
import type { TrafficFlowHostStatus } from "@mmapp/contracts/traffic-flow"
|
||||||
|
|
||||||
|
export function FlowHostPanel({ backendUrl, enabled = true, onConfigured }: {
|
||||||
|
backendUrl: string; enabled?: boolean; onConfigured?: (configured: boolean) => void
|
||||||
|
}) {
|
||||||
|
const [status, setStatus] = useState<TrafficFlowHostStatus | null>(null)
|
||||||
|
const [receivedAt, setReceivedAt] = useState<number | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [operationError, setOperationError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const operation = useRef<AbortController | null>(null)
|
||||||
|
const [revision, setRevision] = useState(0)
|
||||||
|
const accept = useCallback((value: TrafficFlowHostStatus) => {
|
||||||
|
setStatus(value); setReceivedAt(Date.now()); setError(null); onConfigured?.(value.configured)
|
||||||
|
}, [onConfigured])
|
||||||
|
useEffect(() => () => { operation.current?.abort() }, [])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !can("mm:traffic:read") || busy) return
|
||||||
|
const controller = new AbortController()
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
async function refresh() {
|
||||||
|
try { const value = await getTrafficFlowHostStatus(backendUrl, { signal: controller.signal }); if (!controller.signal.aborted) accept(value) }
|
||||||
|
catch (reason) { if (!controller.signal.aborted) setError(reason instanceof Error ? reason.message : "Статус хоста не получен") }
|
||||||
|
finally { if (!controller.signal.aborted) timer = setTimeout(() => { void refresh() }, 10_000) }
|
||||||
|
}
|
||||||
|
void refresh()
|
||||||
|
return () => { controller.abort(); if (timer) clearTimeout(timer) }
|
||||||
|
}, [accept, backendUrl, enabled, busy, revision])
|
||||||
|
|
||||||
|
async function apply() {
|
||||||
|
if (operation.current) return
|
||||||
|
const controller = new AbortController(); operation.current = controller
|
||||||
|
setBusy(true); setOperationError(null)
|
||||||
|
try { const value = await reconcileTrafficFlowHost(backendUrl, { signal: controller.signal }); if (!controller.signal.aborted) accept(value) }
|
||||||
|
catch (reason) { if (!controller.signal.aborted) setOperationError(reason instanceof Error ? reason.message : "Применение не подтверждено; обновите состояние хоста") }
|
||||||
|
finally { operation.current = null; if (!controller.signal.aborted) setBusy(false) }
|
||||||
|
}
|
||||||
|
if (!enabled || !can("mm:traffic:read")) return null
|
||||||
|
return <div className="flex flex-col gap-3 rounded-md border px-4 py-3" aria-label="Автоматизация WireGuard на хосте">
|
||||||
|
<p className="text-sm font-medium">WireGuard на сервере MM</p>
|
||||||
|
<p role="status" className="text-sm">{!status ? "Получение состояния агента…" : !status.configured ? "Ручной режим. Установите агент из раздела «Файлы хоста»." : !status.reachable ? "Агент недоступен" : status.configMatches ? "Настройки применены" : "Настройки требуют проверки или применения"}</p>
|
||||||
|
{error && receivedAt ? <p className="text-xs text-muted-foreground">Показано последнее состояние: {new Date(receivedAt).toLocaleString("ru-RU")}</p> : null}
|
||||||
|
{status?.configured && status.reachable ? <>
|
||||||
|
<p className="text-xs text-muted-foreground">Интерфейс: {status.interfaceUp ? status.addresses.join(", ") : "не поднят"} · UDP {status.listenPort}</p>
|
||||||
|
<ul className="flex flex-col gap-1 text-xs text-muted-foreground" aria-label="Handshake пиров">
|
||||||
|
{status.peers.map(peer => <li key={peer.publicKey}>{peer.allowedIps.join(", ") || "Без адреса"} · {peer.lastHandshakeAt ? "Handshake: " + new Date(peer.lastHandshakeAt * 1000).toLocaleString("ru-RU") + ((receivedAt ?? 0) / 1000 - peer.lastHandshakeAt > 180 ? " (давно)" : "") : "Ожидание handshake"}</li>)}
|
||||||
|
</ul>
|
||||||
|
</> : null}
|
||||||
|
{operationError || error || status?.error ? <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Состояние хоста требует внимания</AlertTitle><AlertDescription>{operationError || error || status?.error}</AlertDescription></Alert> : null}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => setRevision(value => value + 1)}>Обновить состояние хоста</Button>
|
||||||
|
{status?.configured && can("mm:traffic:write") ? <Button type="button" size="sm" disabled={busy || !status.reachable} onClick={() => { void apply() }}>{busy ? "Применение…" : "Применить настройки хоста"}</Button> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||||
SheetDescription, SheetFooter, SheetClose,
|
SheetDescription, SheetFooter, SheetClose,
|
||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
|
import { FlowHostPanel } from "./flow-host-panel"
|
||||||
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
|
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
|
||||||
import type { ServerRead } from "@mmapp/contracts/servers"
|
import type { ServerRead } from "@mmapp/contracts/servers"
|
||||||
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
||||||
@@ -95,7 +96,8 @@ function FlowOverlaySheet({
|
|||||||
})
|
})
|
||||||
setResult(res)
|
setResult(res)
|
||||||
setTab(res.hostFiles[0]?.id ?? "linux")
|
setTab(res.hostFiles[0]?.id ?? "linux")
|
||||||
toast.success(`wg-flow на ${res.address}`)
|
if (res.ok) toast.success(`wg-flow на ${res.address}`)
|
||||||
|
else toast.warning(res.hostError || "MikroTik настроен; требуется применение на хосте")
|
||||||
onDone?.(res)
|
onDone?.(res)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Не удалось подключить JH")
|
toast.error(e instanceof Error ? e.message : "Не удалось подключить JH")
|
||||||
@@ -125,7 +127,7 @@ function FlowOverlaySheet({
|
|||||||
Подключить jump-host
|
Подключить jump-host
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Создаст wg-flow на MikroTik (сервер, listen 13232) и выдаст готовый bash для Linux-хоста Docker MM (клиент).
|
Настроит wg-flow и IPFIX на MikroTik. Если агент Docker установлен, автоматически применит пир на Linux-хосте MM.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
|
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
|
||||||
@@ -170,13 +172,14 @@ function FlowOverlaySheet({
|
|||||||
</FormField>
|
</FormField>
|
||||||
{result ? (
|
{result ? (
|
||||||
<div className="flex min-h-0 flex-col gap-4">
|
<div className="flex min-h-0 flex-col gap-4">
|
||||||
<Alert variant="success">
|
<Alert variant={result.stage === "host-pending" ? "warning" : "success"}>
|
||||||
<InfoIcon />
|
<InfoIcon />
|
||||||
<AlertTitle>Linux-хост /opt/cdn-mm</AlertTitle>
|
<AlertTitle>{result.stage === "applied" ? "Настройки применены" : result.stage === "host-pending" ? "MikroTik настроен, хост ожидает применения" : "Установка агента на Linux-хосте"}</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Скопируйте вкладку Linux и выполните от root. WG — клиент к JH:13232; контейнер слушает только WG-IP:4739, не 0.0.0.0. Ключ не кладите в git.
|
{result.hostError || (result.stage === "applied" ? "Пир добавлен без перезапуска WireGuard. Ожидайте handshake и поступления IPFIX в панели NetFlow." : "Выполните установщик из вкладки «Агент Docker» от root на сервере MM. Он однократно пересоздаст backend. Экспорт содержит приватный ключ — храните его как секрет.")}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
<FlowHostPanel backendUrl={backendUrl} />
|
||||||
<ul className="flex flex-col gap-1.5">
|
<ul className="flex flex-col gap-1.5">
|
||||||
{result.steps.map((s) => (
|
{result.steps.map((s) => (
|
||||||
<li key={s} className="flex items-start gap-2 text-sm">
|
<li key={s} className="flex items-start gap-2 text-sm">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { CodeExportSheet, type CodeExportFormat } from "@/components/reui-kit/code-export-sheet"
|
import { CodeExportSheet, type CodeExportFormat } from "@/components/reui-kit/code-export-sheet"
|
||||||
import type { TrafficFlowSettingsDto } from "@mmapp/contracts/traffic-flow"
|
import type { TrafficFlowSettingsDto } from "@mmapp/contracts/traffic-flow"
|
||||||
import { GeoipSettingsSection } from "./geoip-settings-section"
|
import { GeoipSettingsSection } from "./geoip-settings-section"
|
||||||
|
import { FlowHostPanel } from "./flow-host-panel"
|
||||||
import {
|
import {
|
||||||
generateTrafficFlowKeys,
|
generateTrafficFlowKeys,
|
||||||
getTrafficFlowHostFiles,
|
getTrafficFlowHostFiles,
|
||||||
@@ -24,11 +25,11 @@ import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic
|
|||||||
import { KeyRoundIcon, DownloadIcon, InfoIcon, AlertCircleIcon } from "lucide-react"
|
import { KeyRoundIcon, DownloadIcon, InfoIcon, AlertCircleIcon } from "lucide-react"
|
||||||
|
|
||||||
const HOST_STEPS = [
|
const HOST_STEPS = [
|
||||||
"На хосте Docker (не в контейнере mmapp-backend): apt install wireguard (или эквивалент).",
|
"Создайте ключи хоста, откройте «Файлы хоста» и скачайте вкладку «Агент Docker».",
|
||||||
"Скачайте wg-flow.conf и положите в /etc/wireguard/wg-flow.conf.",
|
"Выполните sudo bash install-netflow-agent.sh /opt/cdn-mm на хосте Docker. При установке backend будет пересоздан.",
|
||||||
"wg-quick up wg-flow (или systemctl enable --now wg-quick@wg-flow).",
|
"Обновите статус хоста. Дальнейшие подключения автоматически применяются агентом.",
|
||||||
"Firewall: разрешите UDP listen WireGuard. UDP 4739 наружу не открывайте.",
|
"Firewall: разрешите UDP listen WireGuard. UDP 4739 наружу не открывайте.",
|
||||||
"В docker-compose у backend раскомментируйте bind IPFIX только на адресе wg-flow.",
|
"Установщик публикует IPFIX только на адресе wg-flow. Ручные файлы доступны в остальных вкладках экспорта.",
|
||||||
"Проверка: wg show · ss -ulnp | grep 4739 · в этой панели — last datagram.",
|
"Проверка: wg show · ss -ulnp | grep 4739 · в этой панели — last datagram.",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ function NetflowSettingsContent({
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
}) {
|
}) {
|
||||||
const [settings,setSettings] = useState<TrafficFlowSettingsDto|null>(null)
|
const [settings,setSettings] = useState<TrafficFlowSettingsDto|null>(null)
|
||||||
|
const [automatic,setAutomatic] = useState(false)
|
||||||
const [draft,setDraft] = useState<NetflowDraft>(emptyNetflowDraft)
|
const [draft,setDraft] = useState<NetflowDraft>(emptyNetflowDraft)
|
||||||
const dirty = useRef(new Set<keyof NetflowDraft>())
|
const dirty = useRef(new Set<keyof NetflowDraft>())
|
||||||
const [dirtyCount,setDirtyCount] = useState(0)
|
const [dirtyCount,setDirtyCount] = useState(0)
|
||||||
@@ -147,16 +149,16 @@ function NetflowSettingsContent({
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<FormField label="Collector IP" hint="Адрес в туннеле, куда JH шлёт flow">
|
<FormField label="Collector IP" hint="Адрес в туннеле, куда JH шлёт flow">
|
||||||
<Input className="font-mono" aria-label="Collector IP" disabled={disabled} value={draft.collectorIp} onChange={(e) => change("collectorIp", e.target.value)} />
|
<Input className="font-mono" aria-label="Collector IP" disabled={disabled || automatic} value={draft.collectorIp} onChange={(e) => change("collectorIp", e.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Префикс overlay">
|
<FormField label="Префикс overlay">
|
||||||
<Input className="font-mono" aria-label="Префикс overlay" disabled={disabled} value={draft.prefix} onChange={(e) => change("prefix", e.target.value)} />
|
<Input className="font-mono" aria-label="Префикс overlay" disabled={disabled || automatic} value={draft.prefix} onChange={(e) => change("prefix", e.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="UDP IPFIX">
|
<FormField label="UDP IPFIX">
|
||||||
<Input className="font-mono" aria-label="UDP IPFIX" disabled={disabled} value={draft.flowListenPort} onChange={(e) => change("flowListenPort", e.target.value)} />
|
<Input className="font-mono" aria-label="UDP IPFIX" disabled={disabled || automatic} value={draft.flowListenPort} onChange={(e) => change("flowListenPort", e.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="WG listen">
|
<FormField label="WG listen">
|
||||||
<Input className="font-mono" aria-label="WG listen" disabled={disabled} value={draft.wgListenPort} onChange={(e) => change("wgListenPort", e.target.value)} />
|
<Input className="font-mono" aria-label="WG listen" disabled={disabled || automatic} value={draft.wgListenPort} onChange={(e) => change("wgListenPort", e.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Публичный endpoint хоста MM" hint="IP или DNS, который видят JH">
|
<FormField label="Публичный endpoint хоста MM" hint="IP или DNS, который видят JH">
|
||||||
<Input className="font-mono" aria-label="Публичный endpoint хоста MM" disabled={disabled} value={draft.publicEndpoint} onChange={(e) => change("publicEndpoint", e.target.value)} placeholder="203.0.113.10" />
|
<Input className="font-mono" aria-label="Публичный endpoint хоста MM" disabled={disabled} value={draft.publicEndpoint} onChange={(e) => change("publicEndpoint", e.target.value)} placeholder="203.0.113.10" />
|
||||||
@@ -201,14 +203,15 @@ function NetflowSettingsContent({
|
|||||||
{settings?.lastError ? ` · ${settings.lastError}` : ""}
|
{settings?.lastError ? ` · ${settings.lastError}` : ""}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="rounded-md border px-4 py-3 flex flex-col gap-2">
|
<FlowHostPanel backendUrl={backendUrl} enabled={enabled} onConfigured={setAutomatic} />
|
||||||
|
{automatic ? <p className="text-xs text-muted-foreground">Адрес, префикс и порты закреплены установкой агента. Для их изменения повторите установщик.</p> : <div className="rounded-md border px-4 py-3 flex flex-col gap-2">
|
||||||
<p className="text-sm font-medium">Туннель на сервере Docker MM</p>
|
<p className="text-sm font-medium">Туннель на сервере Docker MM</p>
|
||||||
<ol className="text-xs text-muted-foreground flex flex-col gap-1.5 list-decimal pl-4">
|
<ol className="text-xs text-muted-foreground flex flex-col gap-1.5 list-decimal pl-4">
|
||||||
{HOST_STEPS.map((s) => (
|
{HOST_STEPS.map((s) => (
|
||||||
<li key={s}>{s.replaceAll("4739", settings ? String(settings.flowListenPort) : "IPFIX")}</li>
|
<li key={s}>{s.replaceAll("4739", settings ? String(settings.flowListenPort) : "IPFIX")}</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button size="sm" disabled={disabled || !dirtyCount} onClick={() => { void handleSave() }}>
|
<Button size="sm" disabled={disabled || !dirtyCount} onClick={() => { void handleSave() }}>
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Production275 — SYN и задержки API arn-gw01 (2026-10-16)
|
||||||
|
|
||||||
|
Поручение: исследовать предупреждения SYN flooding на80/443 и периодически
|
||||||
|
медленный HTTPS REST на `arn-gw01.rtnt.top`. Выполнены только чтение и короткие
|
||||||
|
диагностические подключения. Конфигурация production/RouterOS не менялась.
|
||||||
|
|
||||||
|
## Результаты
|
||||||
|
|
||||||
|
Устройство ID1, DNS IPv4 `62.182.194.146`, REST HTTPS443 с проверкой сертификата.
|
||||||
|
RouterOS7.24.5 stable,1 CPU. В разовом resource: CPU6%, свободно933036032 байт
|
||||||
|
из1207959552, uptime5m41s. Перезапуск выполнен до диагностики, не агентом.
|
||||||
|
|
||||||
|
1. Первый диагностический GET system/resource завершился тайм-аутом10с.
|
||||||
|
Следующие чтения: system/clock2307мс, ip/settings638мс, ip/service145мс,
|
||||||
|
log160мс; отдельный полный resource1170мс. Неверная авторизация не наблюдалась.
|
||||||
|
2. В первоначальном журнале41 событие: первая отметка02:43:13, оба SYN warning
|
||||||
|
на80/443 —02:43:20. Совпадение с uptime указывает на события первых секунд
|
||||||
|
запуска. Позднее часы используют Europe/Stockholm/+02:00, в журнале есть
|
||||||
|
скачок02:43→04:44; ранние отметки нельзя механически переводить как уже
|
||||||
|
синхронизированное местное время. Непрерывную SYN-атаку из этих двух записей
|
||||||
|
вывести нельзя. Последующее кольцо1000 записей уже не содержало предупреждений.
|
||||||
|
3. Четыре последовательных GET system/identity с новым TCP/TLS, один за раз:
|
||||||
|
|
||||||
|
| Замер | DNS | TCP установлен | TLS завершён | Заголовки ответа | Всего |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | 6 | 1042 | 1141 | 1161 | 1162 |
|
||||||
|
| 2 | 2 | 3071 | 3189 | 3208 | 3208 |
|
||||||
|
| 3 | 3 | 3088 | 3233 | 3254 | 3254 |
|
||||||
|
| 4 | 2 | 5136 | 5254 | 5273 | 5274 |
|
||||||
|
|
||||||
|
Значения — накопленные миллисекунды от начала запроса. Все HTTP200;
|
||||||
|
после TLS до ответа19–21мс. Основная задержка находится до TCP connect.
|
||||||
|
4. Захват только SYN/SYN-ACK заголовков на eth0 production-хоста,20с,
|
||||||
|
фильтр строго target IPv4/TCP443. Полезная нагрузка не сохранялась.
|
||||||
|
Два наших контрольных GET прошли155/149мс; SYN-ACK через18–21мс.
|
||||||
|
Затем в обычном опросе приложения зафиксированы повторения исходящих SYN:
|
||||||
|
один исходный порт — первое SYN1792119069.651615, SYN-ACK1792119073.773299
|
||||||
|
(4.122с, несколько повторов); другой —1792119069.713556→1792119071.757179
|
||||||
|
(2.044с). При полученном ответе на SYN RTT около18мс. Захват16 пакетов,
|
||||||
|
dropped by kernel0, завершён автоматически через timeout20s.
|
||||||
|
5. В02:52:33 UTC (09:52:33 Новосибирск) среди239 TCP conntrack записей4 к
|
||||||
|
локальным80/443: time-wait1/syn-sent2/syn-recv1. Две от приложения,
|
||||||
|
две с других внешних адресов — по одной80 и443. Массовый текущий SYN flood
|
||||||
|
этим одиночным снимком не подтверждён. В этой версии адрес и порт — отдельные
|
||||||
|
поля; для итогового подсчёта использованы dst-address **и** dst-port.
|
||||||
|
6. tcp-syncookies=false. www80/www-ssl443/reverse-proxy443 включены,
|
||||||
|
max-sessions20; FTP/Telnet/SSH уже выключены. В input есть блокировка EVOFW_DENY,
|
||||||
|
drop UDP53 и accept WireGuard; отдельных ограничений80/443 доверенными
|
||||||
|
источниками и завершающего drop input в прочитанных правилах нет. RAW пустой.
|
||||||
|
Правила не менялись; обратный прокси потенциально использует те же порты,
|
||||||
|
поэтому закрывать их без проверки потребителей нельзя.
|
||||||
|
7. Scheduler20минут на02:49 UTC: traffic4 error/36 ok, gre_bgp4 error/36 ok.
|
||||||
|
Выборка последних ошибок называет arn и тайм-аут10с; последние в02:43:49–50 UTC,
|
||||||
|
рядом с запуском устройства. Успешные задания иногда занимают9–11с суммарно.
|
||||||
|
Это не доказательство медленного SQL или обработки REST на RouterOS.
|
||||||
|
|
||||||
|
## Вывод
|
||||||
|
|
||||||
|
Воспроизведены задержки TCP-handshake и повторные SYN **до** TLS/REST.
|
||||||
|
Разовые низкие CPU и conntrack не показывают постоянного истощения ресурсов.
|
||||||
|
Причину нужно различать между сетью/фильтрацией по пути и обработкой SYN на CHR;
|
||||||
|
захват только на отправителе не определяет точное место потери. Историческое
|
||||||
|
предупреждение при запуске само по себе не доказывает текущую DDoS-атаку.
|
||||||
|
|
||||||
|
Приложение сейчас создаёт новые соединения (keepAlive=false, maxSockets2 на origin).
|
||||||
|
Это увеличивает количество TCP/TLS-handshake, но не доказывает, что приложение
|
||||||
|
создаёт SYN flood. Менять поведение пула вслепую нельзя: прежние проверки273 касались
|
||||||
|
также отказов входа. Повышение API timeout лишь скроет часть проявлений.
|
||||||
|
|
||||||
|
## Подготовленный следующий шаг
|
||||||
|
|
||||||
|
После отдельного разрешения владельца проверить ID1/host и исходный
|
||||||
|
tcp-syncookies=false, изменить **только** `/ip/settings tcp-syncookies=true`
|
||||||
|
через REST PATCH. Без перезагрузки, изменения паролей, служб, правил firewall
|
||||||
|
и маршрутов. Повторно прочитать значение, повторить небольшой последовательный
|
||||||
|
замер TCP/TLS/HTTP и сверить штатные опросы. Откат — вернуть исходное false после
|
||||||
|
решения владельца; не объявлять положительный краткий замер доказательством
|
||||||
|
устранения причины. Это пока предложение, изменение не применялось.
|
||||||
|
|
||||||
|
[IP Settings MikroTik](https://help.mikrotik.com/docs/spaces/ROS/pages/103841817/IP%2BSettings)
|
||||||
|
описывает SYN cookies при переполнении SYN backlog и ограничения TCP extensions.
|
||||||
|
Это защита от переполнения очереди, она не устраняет потери в сети провайдера.
|
||||||
|
Точный scope изменения и проверка должны быть согласованы до записи в production.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Production276 — включение SYN cookies на arn-gw01 (2026-10-16)
|
||||||
|
|
||||||
|
Владелец явно разрешил: «Включить tcp-syncookies и проверить».
|
||||||
|
Исходная диагностика: [Production275](PRODUCTION-ARN-275.md).
|
||||||
|
|
||||||
|
## Применение
|
||||||
|
|
||||||
|
Перед записью проверены server ID1/host `arn-gw01.rtnt.top` и исходное
|
||||||
|
`tcp-syncookies=false`. Первая попытка PATCH `/ip/settings` была отклонена400
|
||||||
|
`missing or invalid resource identifier`; значение после отказа осталосьfalse.
|
||||||
|
Этот singleton-раздел изменяется командой POST `/ip/settings/set`.
|
||||||
|
|
||||||
|
Выполнена ровно одна успешная команда с единственным параметром
|
||||||
|
`{"tcp-syncookies":"true"}`. Подтверждение в02:55:01.749 UTC,
|
||||||
|
повторное чтение02:55:01.893 UTC —true и корректная identity.
|
||||||
|
Местное время: **2026-10-16 09:55:01 по Новосибирску**.
|
||||||
|
|
||||||
|
Пароли, пользователи, firewall, порты, маршруты, приложение, БД и контейнеры
|
||||||
|
не менялись; перезапуск не выполнялся. Уточнение протокола PATCH→POST не расширяет
|
||||||
|
согласованный scope и не меняет дополнительные параметры.
|
||||||
|
|
||||||
|
## Метод проверки
|
||||||
|
|
||||||
|
С тем же production-хостом, адресом устройства, учётной записью и проверкой TLS
|
||||||
|
повторяются GET system/identity с новым TCP/TLS, последовательно, без повторов
|
||||||
|
при ошибке. Один запрос каждые4 секунды,8 измерений. Отдельно захват45 секунд
|
||||||
|
только SYN/SYN-ACK на eth0, фильтр строго target IPv4/TCP443, без сохранения payload.
|
||||||
|
Проверяются результат чтения настройки, uptime и штатные задания scheduler.
|
||||||
|
|
||||||
|
## Контрольные запросы после изменения
|
||||||
|
|
||||||
|
С02:55:24 по02:55:53 UTC выполнены8 измерений. Все HTTP200, без повторов.
|
||||||
|
|
||||||
|
| Замер | DNS | TCP установлен | TLS завершён | Заголовки ответа | Всего |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | 6 | 25 | 138 | 158 | 159 |
|
||||||
|
| 2 | 9 | 29 | 137 | 158 | 158 |
|
||||||
|
| 3 | 3 | 22 | 125 | 144 | 145 |
|
||||||
|
| 4 | 3 | 22 | 123 | 143 | 143 |
|
||||||
|
| 5 | 3 | 25 | 130 | 152 | 152 |
|
||||||
|
| 6 | 2 | 22 | 126 | 168 | 169 |
|
||||||
|
| 7 | 2 | 21 | 126 | 145 | 145 |
|
||||||
|
| 8 | 2 | 45 | 313 | 334 | 334 |
|
||||||
|
|
||||||
|
Все значения — накопленные миллисекунды от начала запроса. Диапазон полного
|
||||||
|
запроса143–334мс против1162–5274мс в четырёх исходных измерениях; TCP21–45мс
|
||||||
|
против1042–5136мс. Настройка повторноtrue, uptime12m42s/CPU14% в замере02:55:54.
|
||||||
|
|
||||||
|
В повторном45-секундном захвате42 SYN/SYN-ACK пакета (44 received by filter,
|
||||||
|
0 dropped by kernel), повторов SYN с тем же исходным портом/sequence в полученном
|
||||||
|
выводе не обнаружено. Запись payload не выполнялась. Захват завершился по timeout,
|
||||||
|
фонового процесса захвата после него нет. Это короткая выборка, а не гарантия
|
||||||
|
отсутствия сетевых потерь при любой нагрузке.
|
||||||
|
|
||||||
|
На02:57:30 UTC /09:57:30 Новосибирск, через2мин29с после применения:
|
||||||
|
|
||||||
|
| Штатное задание | Успешно | Ошибки | Максимальная длительность |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| traffic | 5 | 0 | 1091мс |
|
||||||
|
| gre_bgp | 5 | 0 | 426мс |
|
||||||
|
| uptime_resources | 10 | 0 | 854мс |
|
||||||
|
| internet_path | 1 | 0 | 284мс |
|
||||||
|
|
||||||
|
Считались запуски с started_at после02:55:01.893 UTC. Финальный readbacktrue,
|
||||||
|
CPU6%, uptime14m19s, RouterOS7.24.5 stable — перезапуска не было. Доступное кольцо
|
||||||
|
логов04:55:53–04:57:16 по часам устройства не содержит новых SYN warning;
|
||||||
|
оно не покрывает весь интервал после изменения. Старые события не удалялись.
|
||||||
|
|
||||||
|
В коротком сравнении после включения SYN cookies многосекундная задержка TCP
|
||||||
|
не воспроизвелась, результаты существенно лучше. Это согласуется с гипотезой
|
||||||
|
проблемы обработки входящих SYN на CHR, но не устанавливает источник всех прежних
|
||||||
|
потерь и не доказывает результат под другой нагрузкой. Дополнительные изменения
|
||||||
|
не применялись. Наблюдение и захват завершены; новых фоновых циклов нет.
|
||||||
|
|
||||||
|
## Откат
|
||||||
|
|
||||||
|
При необходимости по решению владельца повторно проверить устройство и текущее
|
||||||
|
значение, выполнить POST `/ip/settings/set` с `tcp-syncookies=false`, затем чтение
|
||||||
|
`/ip/settings?.proplist=tcp-syncookies`. Автоматического отката после успешной
|
||||||
|
проверки не предусмотрено: включение разрешено явно. Другие настройки не трогать.
|
||||||
@@ -0,0 +1,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 не включён.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Обновление production до v2.0.2 (2026-10-16)
|
||||||
|
|
||||||
|
Владелец разрешил публикацию готовых Filters268, карты270 и recursive271 в main и обновление backend/frontend после успешного CI. Четыре коммита0f0d3aa0..e6a2e400 отправлены без перезаписи истории. Все7 jobs [CI2468](https://git.shx.one/denozord/MikrotikManager/actions/runs/2468) завершились success, опубликован v2.0.2. Версия/тег вычислены CI, package.json вручную не менялся.
|
||||||
|
|
||||||
|
Оба образа скачаны по digest, OCI version2.0.2/revision e6a2e4009d8af9e0e2f98c645791981c2b6d6ca8 проверены до перезапуска:
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend: git.shx.one/denozord/mikrotikmanager-backend@sha256:1a02d517b16d9d475b478e7fae1a2749bad88a156f0e49183e8cb57e2d1a7f10
|
||||||
|
frontend: git.shx.one/denozord/mikrotikmanager-frontend@sha256:fbd0c3743613a60d18dc6a13120cf1d755f89bc574c490e363d235301a267765
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверенные digest локально помечены соответствующими frontend:latest/backend:latest. Compose-файл не редактировался. Выполнено последовательно:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never backend
|
||||||
|
# Проверка backend healthy; затем:
|
||||||
|
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
По Новосибирску backend запущен16.10 в00:41:33, frontend в00:42:11. Оба healthy. PostgreSQL сохранил container ID и StartedAt; конфигурация RouterOS и права агентом не менялись. SHA256 Compose до/после одинаков: e675c00d1cbdf6558f9cad601c2674007d899d1b47c92acd0d866ff51bb61bd31. Краткое время Compose recreate не используется как измерение пользовательского простоя.
|
||||||
|
|
||||||
|
До обновления ошибка JH воспроизведена в браузере. После: HTTP200/live=true/stale=false для3 JH,4/3/5 endpoints; соответствующие UI-срезы загружены без прежней ошибки. Группировка нескольких endpoints одного dst/table в одну строку сохранена. Filters выбранного московского JH:10 правил,0 ложных missing-route warnings. Карта:4 узла/новая легенда/25GRE в данных; после клика computed outline=none, keyboard ring opacity=0. Read-only DNS по-прежнему запрещён mm:network:write, нарисованы3/25 GRE; все25 связей подтверждёнными не объявляются.
|
||||||
|
|
||||||
|
[Структурированный протокол](../docs/ux-ui/acceptance/prod272-release.json). Скриншоты production в игнорируемом tmp, в Git не включены. Изменение маршрутов, формы записи и удаление в production не выполнялись.
|
||||||
|
|
||||||
|
Старые образы оставлены для отката, prune не запускался. Для восстановления прежних сервисов при необходимости:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker tag sha256:6f344eecb5dd0ec820c23b8d71f89a16ebccff3ac898d954eadd159f2611b6a4 git.shx.one/denozord/mikrotikmanager-backend:latest
|
||||||
|
docker tag sha256:73ac48979084c0c93b77e75308a9e2f002481586704d0c03fd9a7b5e39511647 git.shx.one/denozord/mikrotikmanager-frontend:latest
|
||||||
|
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never backend
|
||||||
|
# Дождаться healthy, затем:
|
||||||
|
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Откат вернёт исправленные дефекты. Он не понадобился и не выполнялся. Настоящий протокол сохранён отдельным локальным коммитом без повторного push, чтобы документирование приёмки само не запускало следующий релиз.
|
||||||
@@ -0,0 +1,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).
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Production273 — отказы входа RouterOS (2026-10-16)
|
||||||
|
|
||||||
|
Диагностика production выполнена только чтением. Пароли, пользователи, службы,
|
||||||
|
firewall, задания RouterOS и контейнеры не изменялись. Секреты не включены.
|
||||||
|
|
||||||
|
## Наблюдения
|
||||||
|
|
||||||
|
- `nov-gw01.rtnt.top`, server ID3: RouterOS **7.24.4 stable**.
|
||||||
|
- Рядом зафиксированы `500 / too many concurrent logins (6)` и `401 Unauthorized`
|
||||||
|
на `/interface`, `/interface/gre`, `/routing/bgp/session`. Последние отказы серии:
|
||||||
|
**2026-10-16 01:11:40–44 по Новосибирску** (2026-10-15 18:11:40–44 UTC).
|
||||||
|
- В последних15 минутах на01:27: traffic30/30 ok, gre_bgp30/30 ok,
|
||||||
|
internet_path3/3 ok, ошибок этих заданий нет.
|
||||||
|
- Тот же сохранённый пароль успешно использован для system/resource, user/active,
|
||||||
|
ip/service, interface/gre (3 записи), routing/bgp/session (3 записи).
|
||||||
|
Пользователь приложения: full, disabled=false, address пустой, api/rest-api
|
||||||
|
разрешены. Это подтверждает текущий доступ, но не объясняет каждый старый401.
|
||||||
|
- FTP/Telnet включены. В кольце журнала01:26:00–01:26:47:10 отказов входа
|
||||||
|
с посторонних адресов —9 FTP и1 Telnet; отказов с адреса приложения в этой
|
||||||
|
выборке нет. Следующий снимок показывает **7 внешних Telnet-соединений**.
|
||||||
|
Подбор учётных данных наблюдается; успешный посторонний вход не установлен.
|
||||||
|
- У www/www-ssl/api/api-ssl max-sessions=20. Число6 в ошибке нельзя автоматически
|
||||||
|
отождествлять с этим параметром или количеством TCP-соединений приложения.
|
||||||
|
- user/active содержит много старых rest-api записей с адреса приложения;
|
||||||
|
api запись в момент чтения одна. По одному списку нельзя доказывать утечку
|
||||||
|
живых TCP-соединений или принудительно удалять записи.
|
||||||
|
- RouterOS scheduler `evofw-sync`: enabled, interval1m, run-count625.
|
||||||
|
В доступном кольце01:26–01:30:866 изменений от scheduler, из них860 добавлений
|
||||||
|
address-list и6 изменений filter rule. Два script/job в снимке, включая начатый
|
||||||
|
01:22:08. Скрипты не выгружались и не менялись; связь с отказами не доказана.
|
||||||
|
- Общий HTTP/HTTPS Agent backend ограничивает сокеты до2 **на origin**.
|
||||||
|
Одно задание планировщика защищено от повторного запуска; разные задания
|
||||||
|
работают одновременно. Шесть одновременных REST-запросов из основного процесса
|
||||||
|
приложения не установлены.
|
||||||
|
|
||||||
|
## Вывод и границы
|
||||||
|
|
||||||
|
Неверный пароль как причина текущего доступа не подтвердился. RouterOS явно
|
||||||
|
сообщил о насыщении механизма входа;401 возникали рядом, затем без изменения
|
||||||
|
пароля прекратились. Связь с внешним подбором, продлением REST-сессии или локальным
|
||||||
|
scheduler необходимо различить дальнейшим наблюдением.15 минут успеха не доказывают
|
||||||
|
устранение причины.
|
||||||
|
|
||||||
|
[Официальная документация REST](https://manual.mikrotik.com/docs/developer-guides/rest-api/)
|
||||||
|
описывает внутренний API login, повторное использование логина10 минут и политики
|
||||||
|
api/rest-api. Страница относится к7.26; поведение7.24.4 не считать идентичным
|
||||||
|
без отдельного подтверждения.
|
||||||
|
|
||||||
|
## Локальная правка
|
||||||
|
|
||||||
|
- 500 с точным JSON detail `too many concurrent logins (...)` получает подсказку
|
||||||
|
«RouterOS занят: превышен лимит одновременных входов».
|
||||||
|
- 401 больше не обозначается безусловно как неверный пароль: предлагается проверить
|
||||||
|
учётные данные, права API/REST и ограничения входа.
|
||||||
|
- Исходные HTTP status/path/body сохранены; повторов чтения/записи не добавлено.
|
||||||
|
Другие500 не классифицируются как лимит входов.
|
||||||
|
- 12 HTTP-проверок и профиль ros-file, backend production build, scoped ESLint,
|
||||||
|
diff check прошли. Локально, без push/deploy.
|
||||||
|
|
||||||
|
## Следующий шаг, требующий разрешения
|
||||||
|
|
||||||
|
Временно отключить **только** ftp (`*1`,21) и telnet (`*0`,23) на ID3.
|
||||||
|
Перед записью повторно проверить имя/ID и исходный disabled каждой службы.
|
||||||
|
Это отключит FTP/Telnet подключения; Winbox, HTTPS, API и маршрутизация сохраняются.
|
||||||
|
Для этой диагностики production доступ предоставлен на чтение: запись требует
|
||||||
|
отдельного разрешения владельца. Если эти службы нужны, согласовать доверенные адреса.
|
||||||
|
|
||||||
|
После разрешения: сохранить исходные disabled, отключить две службы, проверить
|
||||||
|
применение и REST-доступ; наблюдать обычный опрос не менее двух периодов продления
|
||||||
|
REST-логина, сравнить ошибки и login failures. Не повышать max-sessions, не менять
|
||||||
|
пароль, не удалять user/active, не перезапускать маршрутизатор. Результат записать
|
||||||
|
отдельно: отключение ещё не выполнялось и причинная связь не доказана.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Production274 — отключение FTP/Telnet и наблюдение (2026-10-16)
|
||||||
|
|
||||||
|
Владелец явно разрешил отключить FTP и Telnet на `nov-gw01.rtnt.top`.
|
||||||
|
Предыдущая диагностика: [Production273](PRODUCTION-ROUTEROS-273.md).
|
||||||
|
|
||||||
|
## Изменение
|
||||||
|
|
||||||
|
Перед записью проверены server ID3/host, единственность статических служб,
|
||||||
|
их ID, порты21/23 и исходный disabled=false. Поочерёдно применено только
|
||||||
|
`disabled=true`:
|
||||||
|
|
||||||
|
| Служба | ID | До | После | Время UTC 2026-10-15 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| ftp | *1 | false | true | 18:33:45.013 |
|
||||||
|
| telnet | *0 | false | true | 18:33:45.322 |
|
||||||
|
|
||||||
|
Повторное чтение18:33:45.582 UTC подтвердило обе службы выключенными.
|
||||||
|
Это **2026-10-16 01:33:45 по Новосибирску**.
|
||||||
|
www80/www-ssl443/winbox8291/api8728/api-ssl8729 остались disabled=false,
|
||||||
|
как до операции. REST identity подтвердил имя устройства; resource —7.24.4 stable,
|
||||||
|
uptime10h28m24s, без перезапуска.
|
||||||
|
|
||||||
|
Пароль, пользователи, firewall, маршруты, локальные задания RouterOS, контейнеры
|
||||||
|
и приложение не изменялись. Локальная правка подсказок273 не развёртывалась.
|
||||||
|
|
||||||
|
## Метод наблюдения
|
||||||
|
|
||||||
|
Штатные задания продолжают работать с прежними интервалами. Диагностический
|
||||||
|
процесс раз в минуту только читает scheduler_runs и RouterOS log; дополнительных
|
||||||
|
запусков заданий/нагрузочных тестов нет. Подсчитываются ok/error после18:33:45.582 UTC
|
||||||
|
для traffic, gre_bgp, internet_path, uptime_resources. Журнал логинов проверяется
|
||||||
|
с продвижением по ID и контролем перекрытия кольцевого буфера, без вывода секретов.
|
||||||
|
|
||||||
|
## Итог наблюдения
|
||||||
|
|
||||||
|
Окончание:2026-10-15 18:54:44 UTC /2026-10-16 01:54:44 по Новосибирску,
|
||||||
|
почти21 минута после применения. После изменения в scheduler_runs:
|
||||||
|
|
||||||
|
| Задание | Успешно | Ошибки | Максимальная длительность |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| traffic | 42 | 0 | 2278мс |
|
||||||
|
| gre_bgp | 42 | 0 | 910мс |
|
||||||
|
| internet_path | 4 | 0 | 809мс |
|
||||||
|
| uptime_resources | 84 | 0 | 1937мс |
|
||||||
|
|
||||||
|
Итоговое чтение: статические ftp/telnet disabled=true; www/www-ssl/winbox/api/api-ssl
|
||||||
|
остались включены. Identity/REST работает, RouterOS7.24.4 stable,
|
||||||
|
uptime10h49m22s — перезапуска не было. В списке остаются6 динамических строк Telnet;
|
||||||
|
отключение службы не доказывает завершение всех прежних соединений. Другие службы,
|
||||||
|
соединения и firewall принудительно не менялись.
|
||||||
|
|
||||||
|
В18:55:47 UTC проверено новое TCP-подключение с production backend к имени
|
||||||
|
устройства:21 и23 вернули ECONNREFUSED. Новые подключения к этим портам отклоняются.
|
||||||
|
|
||||||
|
В наблюдавшихся новых логах нет отказов FTP/Telnet, но есть2 отказа Winbox.
|
||||||
|
Их источники не были сохранены: не объявлять эти2 события доказанным внешним
|
||||||
|
подбором. Кольцо RouterOS переполнялось scheduler-изменениями; обнаружено4 пропуска
|
||||||
|
перекрытия, поэтому отсутствие FTP/Telnet failures не доказано для всего интервала.
|
||||||
|
Статистика scheduler_runs этим ограничением не затронута.
|
||||||
|
|
||||||
|
Изменение служб само по себе не доказывает причину предыдущих401/500: они
|
||||||
|
прекратились ещё до операции. Зафиксировано отсутствие повторения после отключения,
|
||||||
|
а не гарантированное устранение первопричины. Автоматический сбор наблюдений завершён.
|
||||||
|
|
||||||
|
## Восстановление исходного состояния
|
||||||
|
|
||||||
|
Владелец отдельно подтвердил: **«Оставить FTP и Telnet выключенными»**.
|
||||||
|
После наблюдения службы не включать обратно. При необходимости отдельного отката:
|
||||||
|
повторно проверить host, имена/ID, вернуть disabled=false только ftp/telnet и
|
||||||
|
сверить чтением. Других изменений конфигурации этим блоком не выполнять.
|
||||||
|
|
||||||
|
## Дополнительный вопрос о PostgreSQL
|
||||||
|
|
||||||
|
По вопросу владельца выполнена только читающая проверка production PostgreSQL:
|
||||||
|
pg_is_in_recovery=false, wal_level=replica, pg_stat_replication count=0,
|
||||||
|
pg_replication_slots count=0, pg_subscription count=0, synchronous_standby_names
|
||||||
|
пустой. В конфигурации проекта один PostgreSQL и один DATABASE_URL backend.
|
||||||
|
Активная физическая/логическая репликация не обнаружена; параметры БД не менялись.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Optional addition to docker-compose.cdn-mm.yml. Run the exported installer first.
|
||||||
|
# Keep this file in every subsequent docker compose -f invocation.
|
||||||
|
services:
|
||||||
|
netflow-agent:
|
||||||
|
image: git.shx.one/denozord/mikrotikmanager-netflow-agent:${MM_NETFLOW_AGENT_IMAGE_TAG:-latest}
|
||||||
|
container_name: mmapp-netflow-agent
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
cap_drop: [ALL]
|
||||||
|
cap_add: [NET_ADMIN]
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
read_only: true
|
||||||
|
tmpfs: ["/tmp:rw,noexec,nosuid,size=8m"]
|
||||||
|
volumes:
|
||||||
|
- ./data/netflow-wireguard:/etc/wireguard
|
||||||
|
- ./data/netflow-agent-socket:/run/mm-netflow-host
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options: {max-size: "5m", max-file: "2"}
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
FLOW_AGENT_SOCKET: /run/mm-netflow-host/agent.sock
|
||||||
|
volumes:
|
||||||
|
- ./data/netflow-agent-socket:/run/mm-netflow-host:ro
|
||||||
|
depends_on:
|
||||||
|
netflow-agent:
|
||||||
|
condition: service_healthy
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM alpine:3.22 AS base
|
||||||
|
RUN apk add --no-cache python3 wireguard-tools iproute2
|
||||||
|
COPY agent.py /app/agent.py
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||||
|
HEALTHCHECK --interval=15s --timeout=5s --retries=3 CMD ["python3", "/app/agent.py", "--healthcheck"]
|
||||||
|
ENTRYPOINT ["python3", "/app/agent.py"]
|
||||||
|
|
||||||
|
FROM base AS test
|
||||||
|
COPY test_agent.py /app/test_agent.py
|
||||||
|
|
||||||
|
FROM base AS runtime
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
"""Local, single-interface WireGuard controller. Never executes wg-quick/hooks."""
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import http.client
|
||||||
|
import http.server
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import socketserver
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
|
||||||
|
IFACE = "wg-flow"
|
||||||
|
CONFIG = Path(os.environ.get("FLOW_AGENT_CONFIG_DIR", "/etc/wireguard"))
|
||||||
|
SOCKET = os.environ.get("FLOW_AGENT_SOCKET", "/run/mm-netflow-host/agent.sock")
|
||||||
|
LOCK = threading.RLock()
|
||||||
|
MAX_BODY = 262144
|
||||||
|
|
||||||
|
|
||||||
|
class AgentError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def run(*args, input=None):
|
||||||
|
try:
|
||||||
|
result = subprocess.run(args, input=input, text=True, capture_output=True, timeout=10)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
raise AgentError("Команда WireGuard/IP недоступна или превысила время ожидания") from None
|
||||||
|
if result.returncode:
|
||||||
|
# stderr/stdout can contain keys and must never reach an HTTP response/log.
|
||||||
|
raise AgentError("Операция WireGuard/IP не выполнена")
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def key(value):
|
||||||
|
try:
|
||||||
|
if not isinstance(value, str) or len(value) != 44 or len(base64.b64decode(value, validate=True)) != 32:
|
||||||
|
raise ValueError()
|
||||||
|
except (ValueError, binascii.Error):
|
||||||
|
raise AgentError("Некорректный ключ WireGuard") from None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def port(value):
|
||||||
|
if type(value) is not int or not 1 <= value <= 65535:
|
||||||
|
raise AgentError("Некорректный UDP-порт")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def endpoint(value):
|
||||||
|
if not isinstance(value, str) or not re.fullmatch(r"(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):[0-9]{1,5}", value):
|
||||||
|
raise AgentError("Некорректный endpoint")
|
||||||
|
port(int(value.rsplit(":", 1)[1]))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def parse_conf(text):
|
||||||
|
interface, peers, section = {}, [], None
|
||||||
|
allowed = {"interface": {"PrivateKey", "Address", "ListenPort", "MTU"},
|
||||||
|
"peer": {"PublicKey", "PresharedKey", "AllowedIPs", "Endpoint", "PersistentKeepalive"}}
|
||||||
|
for raw in text.splitlines():
|
||||||
|
line = raw.split("#", 1)[0].strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if line == "[Interface]" and section is None:
|
||||||
|
section, current = "interface", interface
|
||||||
|
elif line == "[Peer]" and section is not None:
|
||||||
|
section, current = "peer", {}
|
||||||
|
peers.append(current)
|
||||||
|
else:
|
||||||
|
name, sep, value = line.partition("=")
|
||||||
|
name, value = name.strip(), value.strip()
|
||||||
|
if not sep or section is None or name not in allowed[section] or name in current or not value:
|
||||||
|
raise AgentError("Конфигурация wg-flow содержит неподдерживаемые или повторные параметры; требуется ручная проверка")
|
||||||
|
current[name] = value
|
||||||
|
if not interface or not {"PrivateKey", "Address", "ListenPort"} <= interface.keys():
|
||||||
|
raise AgentError("Неполная конфигурация wg-flow")
|
||||||
|
key(interface["PrivateKey"])
|
||||||
|
try:
|
||||||
|
ipaddress.IPv4Interface(interface["Address"])
|
||||||
|
port(int(interface["ListenPort"]))
|
||||||
|
if "MTU" in interface and not 576 <= int(interface["MTU"]) <= 9000:
|
||||||
|
raise ValueError()
|
||||||
|
for peer in peers:
|
||||||
|
key(peer.get("PublicKey"))
|
||||||
|
if "PresharedKey" in peer:
|
||||||
|
key(peer["PresharedKey"])
|
||||||
|
if "Endpoint" in peer:
|
||||||
|
endpoint(peer["Endpoint"])
|
||||||
|
if peer.get("AllowedIPs"):
|
||||||
|
networks(peer["AllowedIPs"].split(","))
|
||||||
|
if "PersistentKeepalive" in peer and not 0 <= int(peer["PersistentKeepalive"]) <= 65535:
|
||||||
|
raise ValueError()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise AgentError("Некорректные параметры wg-flow") from None
|
||||||
|
if len({p["PublicKey"] for p in peers}) != len(peers):
|
||||||
|
raise AgentError("Повторный публичный ключ пира")
|
||||||
|
return interface, peers
|
||||||
|
|
||||||
|
|
||||||
|
def networks(values):
|
||||||
|
if not isinstance(values, list) or not values:
|
||||||
|
raise AgentError("AllowedIPs должны содержать адреса")
|
||||||
|
try:
|
||||||
|
return [ipaddress.ip_network(v.strip(), strict=False) for v in values]
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
raise AgentError("Некорректные AllowedIPs") from None
|
||||||
|
|
||||||
|
|
||||||
|
def validate(raw):
|
||||||
|
fields = {"collectorIp", "prefix", "flowListenPort", "wgListenPort", "hostPublicKey", "hostPrivateKey", "peers"}
|
||||||
|
if not isinstance(raw, dict) or set(raw) != fields:
|
||||||
|
raise AgentError("Некорректный снимок NetFlow")
|
||||||
|
try:
|
||||||
|
collector = ipaddress.IPv4Address(raw["collectorIp"])
|
||||||
|
prefix = ipaddress.IPv4Network(raw["prefix"], strict=False)
|
||||||
|
if prefix.prefixlen != 24 or collector not in prefix or collector in (prefix.network_address, prefix.broadcast_address):
|
||||||
|
raise ValueError()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise AgentError("Требуется IPv4 overlay /24 и адрес коллектора внутри него") from None
|
||||||
|
port(raw["flowListenPort"])
|
||||||
|
port(raw["wgListenPort"])
|
||||||
|
key(raw["hostPublicKey"])
|
||||||
|
key(raw["hostPrivateKey"])
|
||||||
|
if run("wg", "pubkey", input=raw["hostPrivateKey"] + "\n") != raw["hostPublicKey"]:
|
||||||
|
raise AgentError("Публичный и приватный ключи MM не соответствуют друг другу")
|
||||||
|
binding = json.loads((CONFIG / "binding.json").read_text())
|
||||||
|
for name in ("collectorIp", "prefix", "flowListenPort", "wgListenPort"):
|
||||||
|
if binding.get(name) != raw[name]:
|
||||||
|
raise AgentError("Адрес, префикс или порт отличаются от установки агента; повторите установщик")
|
||||||
|
if not isinstance(raw["peers"], list) or len(raw["peers"]) > 253:
|
||||||
|
raise AgentError("Некорректный список пиров")
|
||||||
|
peers, ids, keys = [], set(), set()
|
||||||
|
for peer in raw["peers"]:
|
||||||
|
if not isinstance(peer, dict) or not {"serverId", "publicKey", "allowedIps"} <= peer.keys() or set(peer) - {"serverId", "publicKey", "allowedIps", "endpoint"}:
|
||||||
|
raise AgentError("Некорректный пир")
|
||||||
|
peer_key = key(peer["publicKey"])
|
||||||
|
if type(peer["serverId"]) is not int or peer["serverId"] <= 0 or peer["serverId"] in ids or peer_key in keys or peer_key == raw["hostPublicKey"]:
|
||||||
|
raise AgentError("Повторный или некорректный пир")
|
||||||
|
ids.add(peer["serverId"])
|
||||||
|
keys.add(peer_key)
|
||||||
|
nets = networks(peer["allowedIps"])
|
||||||
|
if len(nets) != 1 or nets[0].version != 4 or nets[0].prefixlen != 32 or nets[0].network_address not in prefix or nets[0].network_address in (collector, prefix.network_address, prefix.broadcast_address):
|
||||||
|
raise AgentError("Пиру разрешён только собственный IPv4 /32 внутри overlay")
|
||||||
|
data = {"PublicKey": peer_key, "AllowedIPs": str(nets[0])}
|
||||||
|
if peer.get("endpoint"):
|
||||||
|
data.update(Endpoint=endpoint(peer["endpoint"]), PersistentKeepalive="25")
|
||||||
|
peers.append(data)
|
||||||
|
return peers
|
||||||
|
|
||||||
|
|
||||||
|
def merge_peers(existing, desired):
|
||||||
|
merged = {peer["PublicKey"]: dict(peer) for peer in existing}
|
||||||
|
for peer in desired:
|
||||||
|
public = peer["PublicKey"]
|
||||||
|
# Replacing a PSK would silently break a previously working tunnel.
|
||||||
|
if merged.get(public, {}).get("PresharedKey", "A" * 43 + "=") != "A" * 43 + "=":
|
||||||
|
raise AgentError("Управляемый пир использует preshared key; требуется ручная проверка")
|
||||||
|
merged[public] = dict(peer)
|
||||||
|
rows = list(merged.values())
|
||||||
|
wanted = {p["PublicKey"] for p in desired}
|
||||||
|
for i, left in enumerate(rows):
|
||||||
|
for right in rows[i + 1:]:
|
||||||
|
if left["PublicKey"] not in wanted and right["PublicKey"] not in wanted:
|
||||||
|
continue
|
||||||
|
if not left.get("AllowedIPs") or not right.get("AllowedIPs"):
|
||||||
|
continue
|
||||||
|
if any(a.version == b.version and a.overlaps(b) for a in networks(left["AllowedIPs"].split(",")) for b in networks(right["AllowedIPs"].split(","))):
|
||||||
|
raise AgentError("AllowedIPs управляемого пира пересекаются с другим пиром")
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def render(interface, peers, native=False):
|
||||||
|
rows = ["[Interface]"]
|
||||||
|
rows += [f"{k} = {v}" for k, v in interface.items() if not native or k not in ("Address", "MTU")]
|
||||||
|
for peer in peers:
|
||||||
|
rows += ["", "[Peer]"] + [f"{k} = {v}" for k, v in peer.items() if k != "AllowedIPs" or v]
|
||||||
|
return "\n".join(rows) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write(path, text):
|
||||||
|
fd, name = tempfile.mkstemp(prefix=".flow-", dir=path.parent)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as stream:
|
||||||
|
stream.write(text)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(name, path)
|
||||||
|
if os.name == "posix":
|
||||||
|
directory = os.open(path.parent, os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
os.fsync(directory)
|
||||||
|
finally:
|
||||||
|
os.close(directory)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(name):
|
||||||
|
os.unlink(name)
|
||||||
|
|
||||||
|
|
||||||
|
def sync(text):
|
||||||
|
# wg requires a file. mkstemp creates it with mode 0600.
|
||||||
|
fd, name = tempfile.mkstemp(prefix="flow-sync-")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as stream:
|
||||||
|
stream.write(text)
|
||||||
|
run("wg", "syncconf", IFACE, name)
|
||||||
|
finally:
|
||||||
|
os.unlink(name)
|
||||||
|
|
||||||
|
|
||||||
|
def live():
|
||||||
|
dump = run("wg", "show", IFACE, "dump").splitlines()
|
||||||
|
if not dump or len(dump[0].split("\t")) != 4:
|
||||||
|
raise AgentError("Интерфейс wg-flow недоступен")
|
||||||
|
interface = dump[0].split("\t")
|
||||||
|
peers, raw_peers = [], []
|
||||||
|
for line in dump[1:]:
|
||||||
|
cols = line.split("\t")
|
||||||
|
if len(cols) != 8:
|
||||||
|
raise AgentError("Некорректный ответ WireGuard")
|
||||||
|
public, psk, ep, ips, handshake, rx, tx, keepalive = cols
|
||||||
|
peers.append({"publicKey": public, "allowedIps": ips.split(",") if ips != "(none)" else [],
|
||||||
|
"lastHandshakeAt": int(handshake) or None, "receivedBytes": int(rx), "sentBytes": int(tx)})
|
||||||
|
item = {"PublicKey": public, "AllowedIPs": ips if ips != "(none)" else ""}
|
||||||
|
if psk != "(none)":
|
||||||
|
item["PresharedKey"] = psk
|
||||||
|
if ep != "(none)":
|
||||||
|
item["Endpoint"] = ep
|
||||||
|
item["PersistentKeepalive"] = keepalive
|
||||||
|
raw_peers.append(item)
|
||||||
|
addresses = json.loads(run("ip", "-j", "-4", "address", "show", "dev", IFACE))
|
||||||
|
cidrs = [f"{a['local']}/{a['prefixlen']}" for row in addresses for a in row.get("addr_info", []) if a.get("family") == "inet"]
|
||||||
|
return {"interfaceUp": any("UP" in row.get("flags", []) for row in addresses), "publicKey": interface[1], "listenPort": int(interface[2]), "addresses": cidrs, "peers": peers}, raw_peers
|
||||||
|
|
||||||
|
|
||||||
|
def check(raw, apply=False):
|
||||||
|
desired = validate(raw)
|
||||||
|
path = CONFIG / "wg-flow.conf"
|
||||||
|
previous = path.read_text()
|
||||||
|
interface, stored = parse_conf(previous)
|
||||||
|
expected_address = f"{raw['collectorIp']}/{raw['prefix'].split('/')[1]}"
|
||||||
|
if run("wg", "pubkey", input=interface["PrivateKey"] + "\n") != raw["hostPublicKey"] or interface["Address"] != expected_address or int(interface["ListenPort"]) != raw["wgListenPort"]:
|
||||||
|
raise AgentError("Ключ, адрес или порт существующего wg-flow отличаются от MM; требуется ручная проверка")
|
||||||
|
status, runtime = live()
|
||||||
|
if not status["interfaceUp"]:
|
||||||
|
raise AgentError("Интерфейс wg-flow выключен; проверьте wg-quick на хосте")
|
||||||
|
if status["publicKey"] != raw["hostPublicKey"] or expected_address not in status["addresses"] or status["listenPort"] != raw["wgListenPort"]:
|
||||||
|
raise AgentError("Работающий wg-flow отличается от конфигурации; требуется ручная проверка")
|
||||||
|
# Preserve both persistent and runtime-only third-party peers. Detect conflicts before writing.
|
||||||
|
combined = {p["PublicKey"]: p for p in runtime}
|
||||||
|
combined.update({p["PublicKey"]: p for p in stored})
|
||||||
|
merged = merge_peers(list(combined.values()), desired)
|
||||||
|
runtime_merged = merge_peers(runtime, desired)
|
||||||
|
# Do not reset a third-party peer's runtime settings to an older persistent file.
|
||||||
|
# Preserve roaming endpoints of managed peers unless the persisted endpoint was edited.
|
||||||
|
stored_by_key = {p["PublicKey"]: p for p in stored}
|
||||||
|
runtime_by_key = {p["PublicKey"]: p for p in runtime}
|
||||||
|
desired_by_key = {p["PublicKey"]: p for p in desired}
|
||||||
|
for peer in runtime_merged:
|
||||||
|
public = peer["PublicKey"]
|
||||||
|
if public in desired_by_key and stored_by_key.get(public, {}).get("Endpoint") == desired_by_key[public].get("Endpoint"):
|
||||||
|
if runtime_by_key.get(public, {}).get("Endpoint"):
|
||||||
|
peer["Endpoint"] = runtime_by_key[public]["Endpoint"]
|
||||||
|
def matches(actual, persistent=False):
|
||||||
|
by_key = {p["PublicKey"]: p for p in actual}
|
||||||
|
return all(p["PublicKey"] in by_key and all(by_key[p["PublicKey"]].get(k) == v for k, v in p.items() if persistent or k != "Endpoint") for p in desired)
|
||||||
|
matched = matches(stored, persistent=True) and matches(runtime)
|
||||||
|
if apply:
|
||||||
|
rollback = run("wg", "showconf", IFACE)
|
||||||
|
try:
|
||||||
|
atomic_write(path, render(interface, merged))
|
||||||
|
# Keep the running interface header verbatim (including a possible fwmark).
|
||||||
|
header = rollback.split("[Peer]", 1)[0].rstrip()
|
||||||
|
peer_config = render({}, runtime_merged, native=True).removeprefix("[Interface]\n")
|
||||||
|
sync(header + "\n" + peer_config)
|
||||||
|
status, after = live()
|
||||||
|
if not matches(after):
|
||||||
|
raise AgentError("Применение пиров не подтверждено")
|
||||||
|
except Exception:
|
||||||
|
failures = []
|
||||||
|
for action in (lambda: atomic_write(path, previous), lambda: sync(rollback)):
|
||||||
|
try:
|
||||||
|
action()
|
||||||
|
except Exception:
|
||||||
|
failures.append(True)
|
||||||
|
raise AgentError("Применение не удалось; " + ("восстановление требует ручной проверки" if failures else "предыдущая конфигурация восстановлена")) from None
|
||||||
|
matched = True
|
||||||
|
return {**status, "configMatches": matched}
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def handle_request(self):
|
||||||
|
try:
|
||||||
|
with LOCK:
|
||||||
|
if self.command == "GET" and self.path == "/status":
|
||||||
|
result, _ = live()
|
||||||
|
result["configMatches"] = None
|
||||||
|
elif self.command == "POST" and self.path in ("/check", "/apply"):
|
||||||
|
size = int(self.headers.get("Content-Length", "0"))
|
||||||
|
if not 0 < size <= MAX_BODY or self.headers.get("Transfer-Encoding"):
|
||||||
|
raise AgentError("Некорректный размер запроса")
|
||||||
|
raw = json.loads(self.rfile.read(size))
|
||||||
|
result = check(raw, apply=self.path == "/apply")
|
||||||
|
else:
|
||||||
|
self.respond(404, {"error": "Операция не поддерживается"})
|
||||||
|
return
|
||||||
|
self.respond(200, result)
|
||||||
|
except AgentError as error:
|
||||||
|
self.respond(409, {"error": str(error)})
|
||||||
|
except Exception:
|
||||||
|
self.respond(503, {"error": "Агент NetFlow не готов; проверьте конфигурацию и интерфейс хоста"})
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
|
super().setup()
|
||||||
|
self.connection.settimeout(15)
|
||||||
|
|
||||||
|
def respond(self, code, body):
|
||||||
|
data = json.dumps(body, ensure_ascii=False).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
do_GET = handle_request
|
||||||
|
do_POST = handle_request
|
||||||
|
|
||||||
|
|
||||||
|
class Server(socketserver.ThreadingMixIn, getattr(socketserver, "UnixStreamServer", socketserver.TCPServer)):
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
|
||||||
|
class UnixConnection(http.client.HTTPConnection):
|
||||||
|
def connect(self):
|
||||||
|
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
self.sock.settimeout(3)
|
||||||
|
self.sock.connect(SOCKET)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not hasattr(socketserver, "UnixStreamServer"):
|
||||||
|
raise SystemExit("NetFlow agent requires Linux Unix sockets")
|
||||||
|
if "--healthcheck" in sys.argv:
|
||||||
|
client = UnixConnection("localhost")
|
||||||
|
client.request("GET", "/status")
|
||||||
|
response = client.getresponse()
|
||||||
|
sys.exit(0 if response.status == 200 and json.loads(response.read()).get("interfaceUp") else 1)
|
||||||
|
os.umask(0o077)
|
||||||
|
Path(SOCKET).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if os.path.lexists(SOCKET):
|
||||||
|
Path(SOCKET).unlink()
|
||||||
|
with Server(SOCKET, Handler) as server:
|
||||||
|
os.chmod(SOCKET, 0o660)
|
||||||
|
print("NetFlow agent: Unix API ready", flush=True)
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage: sudo bash install.sh /absolute/path/wg-flow.conf /opt/cdn-mm 4739
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "Запустите от root" >&2; exit 1; }
|
||||||
|
SOURCE=$(realpath "${1:?Укажите скачанный wg-flow.conf}")
|
||||||
|
COMPOSE_DIR=$(realpath "${2:-/opt/cdn-mm}")
|
||||||
|
FLOW_PORT=${3:-4739}
|
||||||
|
[[ "$FLOW_PORT" =~ ^[0-9]+$ ]] && (( FLOW_PORT >= 1 && FLOW_PORT <= 65535 )) || exit 1
|
||||||
|
command -v docker >/dev/null
|
||||||
|
docker compose version >/dev/null
|
||||||
|
systemctl show docker.service --property=LoadState --value | grep -qx loaded
|
||||||
|
if ! command -v wg >/dev/null || ! command -v python3 >/dev/null || ! command -v modprobe >/dev/null; then
|
||||||
|
apt-get update
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard-tools python3 iproute2 kmod
|
||||||
|
fi
|
||||||
|
modprobe wireguard
|
||||||
|
[[ -f "$COMPOSE_DIR/docker-compose.yml" ]] || { echo "Не найден docker-compose.yml" >&2; exit 1; }
|
||||||
|
AGENT_IMAGE=${MM_NETFLOW_AGENT_IMAGE:-git.shx.one/denozord/mikrotikmanager-netflow-agent:latest}
|
||||||
|
[[ "$AGENT_IMAGE" =~ ^[A-Za-z0-9][A-Za-z0-9._:/@-]+$ ]] || { echo "Некорректное имя образа" >&2; exit 1; }
|
||||||
|
# The image must be available before changing the host configuration.
|
||||||
|
docker pull "$AGENT_IMAGE"
|
||||||
|
TASK_TEMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf -- "$TASK_TEMP"' EXIT
|
||||||
|
|
||||||
|
python3 - "$SOURCE" "$COMPOSE_DIR" "$FLOW_PORT" "$TASK_TEMP" <<'PY'
|
||||||
|
import base64, ipaddress, json, os, pathlib, re, subprocess, sys
|
||||||
|
source, directory, port, temp = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), int(sys.argv[3]), pathlib.Path(sys.argv[4])
|
||||||
|
def read(text):
|
||||||
|
section, fields, peers = None, {}, []
|
||||||
|
allowed = {'Interface': {'PrivateKey','Address','ListenPort','MTU'}, 'Peer': {'PublicKey','AllowedIPs','Endpoint','PersistentKeepalive','PresharedKey'}}
|
||||||
|
for raw in text.splitlines():
|
||||||
|
line = raw.split('#',1)[0].strip()
|
||||||
|
if not line: continue
|
||||||
|
if line == '[Interface]' and section is None: section, current = 'Interface', fields
|
||||||
|
elif line == '[Peer]' and section is not None:
|
||||||
|
section, current = 'Peer', {}; peers.append(current)
|
||||||
|
else:
|
||||||
|
key, sep, value = line.partition('='); key, value = key.strip(), value.strip()
|
||||||
|
if not sep or section not in allowed or key not in allowed[section] or key in current or not value:
|
||||||
|
raise SystemExit('Конфигурация содержит неподдерживаемые параметры; требуется ручная проверка')
|
||||||
|
current[key] = value
|
||||||
|
try:
|
||||||
|
addr = ipaddress.IPv4Interface(fields['Address'])
|
||||||
|
if addr.network.prefixlen != 24 or addr.ip in (addr.network.network_address, addr.network.broadcast_address): raise ValueError()
|
||||||
|
if len(base64.b64decode(fields['PrivateKey'], validate=True)) != 32 or not 1 <= int(fields['ListenPort']) <= 65535: raise ValueError()
|
||||||
|
if 'MTU' in fields and not 576 <= int(fields['MTU']) <= 9000: raise ValueError()
|
||||||
|
for peer in peers:
|
||||||
|
if len(base64.b64decode(peer['PublicKey'], validate=True)) != 32: raise ValueError()
|
||||||
|
for network in peer['AllowedIPs'].split(','): ipaddress.ip_network(network.strip(), strict=False)
|
||||||
|
if 'Endpoint' in peer and not re.fullmatch(r'(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):[0-9]{1,5}', peer['Endpoint']): raise ValueError()
|
||||||
|
if 'PersistentKeepalive' in peer and not 0 <= int(peer['PersistentKeepalive']) <= 65535: raise ValueError()
|
||||||
|
except (ValueError, KeyError): raise SystemExit('Некорректная конфигурация WireGuard; ключи не изменены')
|
||||||
|
return fields, addr
|
||||||
|
expected, addr = read(source.read_text())
|
||||||
|
active = pathlib.Path('/etc/wireguard/wg-flow.conf')
|
||||||
|
candidate = active.read_text() if active.exists() else source.read_text()
|
||||||
|
actual, actual_addr = read(candidate)
|
||||||
|
for key in ('PrivateKey','Address','ListenPort'):
|
||||||
|
if actual[key] != expected[key]: raise SystemExit('Ключ, адрес или порт существующего туннеля отличаются от MM; установка остановлена')
|
||||||
|
public = subprocess.run(['wg','pubkey'], input=actual['PrivateKey']+'\n',text=True,capture_output=True,check=True).stdout.strip()
|
||||||
|
if subprocess.run(['ip','link','show','dev','wg-flow'],capture_output=True).returncode == 0:
|
||||||
|
dump = subprocess.run(['wg','show','wg-flow','dump'],capture_output=True,text=True,check=True).stdout.splitlines()
|
||||||
|
head = dump[0].split('\t')
|
||||||
|
addresses = json.loads(subprocess.run(['ip','-j','-4','address','show','dev','wg-flow'],capture_output=True,text=True,check=True).stdout)
|
||||||
|
if head[1] != public or int(head[2]) != int(actual['ListenPort']) or not any(a.get('local') == str(addr.ip) and a.get('prefixlen') == addr.network.prefixlen for row in addresses for a in row.get('addr_info',[])):
|
||||||
|
raise SystemExit('Работающий wg-flow отличается от MM; установка остановлена')
|
||||||
|
binding = dict(collectorIp=str(addr.ip), prefix=str(addr.network), flowListenPort=port, wgListenPort=int(actual['ListenPort']))
|
||||||
|
(temp/'binding.json').write_text(json.dumps(binding))
|
||||||
|
(temp/'wg-flow.conf').write_text(candidate)
|
||||||
|
(temp/'collector').write_text(str(addr.ip))
|
||||||
|
PY
|
||||||
|
|
||||||
|
COLLECTOR_IP=$(cat "$TASK_TEMP/collector")
|
||||||
|
# Keep a dedicated override; never replace docker-compose.override.yml.
|
||||||
|
cat > "$TASK_TEMP/compose.yml" <<EOF
|
||||||
|
services:
|
||||||
|
netflow-agent:
|
||||||
|
image: "$AGENT_IMAGE"
|
||||||
|
container_name: mmapp-netflow-agent
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
cap_drop: [ALL]
|
||||||
|
cap_add: [NET_ADMIN]
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
read_only: true
|
||||||
|
tmpfs: ["/tmp:rw,noexec,nosuid,size=8m"]
|
||||||
|
volumes:
|
||||||
|
- ./data/netflow-wireguard:/etc/wireguard
|
||||||
|
- ./data/netflow-agent-socket:/run/mm-netflow-host
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options: {max-size: "5m", max-file: "2"}
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
FLOW_AGENT_SOCKET: /run/mm-netflow-host/agent.sock
|
||||||
|
FLOW_LISTEN_HOST: "0.0.0.0"
|
||||||
|
volumes:
|
||||||
|
- ./data/netflow-agent-socket:/run/mm-netflow-host:ro
|
||||||
|
ports:
|
||||||
|
- "$COLLECTOR_IP:$FLOW_PORT:$FLOW_PORT/udp"
|
||||||
|
depends_on:
|
||||||
|
netflow-agent:
|
||||||
|
condition: service_healthy
|
||||||
|
EOF
|
||||||
|
COMPOSE_ARGS=(-f "$COMPOSE_DIR/docker-compose.yml")
|
||||||
|
if [[ -f "$COMPOSE_DIR/docker-compose.override.yml" ]]; then COMPOSE_ARGS+=(-f "$COMPOSE_DIR/docker-compose.override.yml"); fi
|
||||||
|
# Render first, and reject an existing public/incorrect IPFIX binding before touching WireGuard.
|
||||||
|
docker compose --project-directory "$COMPOSE_DIR" "${COMPOSE_ARGS[@]}" -f "$TASK_TEMP/compose.yml" config --format json > "$TASK_TEMP/merged.json"
|
||||||
|
python3 - "$TASK_TEMP/merged.json" "$COLLECTOR_IP" "$FLOW_PORT" <<'PY'
|
||||||
|
import json,sys
|
||||||
|
config=json.load(open(sys.argv[1])); ip=sys.argv[2]; port=int(sys.argv[3])
|
||||||
|
for bind in config['services']['backend'].get('ports',[]):
|
||||||
|
if bind.get('protocol','tcp') == 'udp' and (bind['target'] in (4739,port) or str(bind.get('published')) == str(port)):
|
||||||
|
if bind.get('host_ip') != ip or bind['target'] != port or str(bind.get('published')) != str(port):
|
||||||
|
raise SystemExit('В Compose уже есть другой IPFIX bind. Согласуйте его с адресом и портом MM и повторите установку.')
|
||||||
|
PY
|
||||||
|
if docker container inspect mmapp-netflow-agent >/dev/null 2>&1; then docker stop mmapp-netflow-agent; fi
|
||||||
|
install -d -m 700 /etc/wireguard "$COMPOSE_DIR/data/netflow-wireguard" "$COMPOSE_DIR/data/netflow-agent-socket"
|
||||||
|
if [[ -e /etc/wireguard/wg-flow.conf ]]; then
|
||||||
|
cp -L /etc/wireguard/wg-flow.conf "$COMPOSE_DIR/data/netflow-wireguard/wg-flow.conf.backup.$(date +%s)"
|
||||||
|
fi
|
||||||
|
install -m 600 "$TASK_TEMP/wg-flow.conf" "$COMPOSE_DIR/data/netflow-wireguard/wg-flow.conf"
|
||||||
|
install -m 600 "$TASK_TEMP/binding.json" "$COMPOSE_DIR/data/netflow-wireguard/binding.json"
|
||||||
|
ln -sfn "$COMPOSE_DIR/data/netflow-wireguard/wg-flow.conf" /etc/wireguard/wg-flow.conf
|
||||||
|
install -m 600 "$TASK_TEMP/compose.yml" "$COMPOSE_DIR/docker-compose.netflow-agent.yml"
|
||||||
|
install -d /etc/systemd/system/docker.service.d
|
||||||
|
cat > /etc/systemd/system/docker.service.d/mm-netflow.conf <<'EOF'
|
||||||
|
[Unit]
|
||||||
|
Wants=wg-quick@wg-flow.service
|
||||||
|
After=wg-quick@wg-flow.service
|
||||||
|
EOF
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable wg-quick@wg-flow.service
|
||||||
|
if ! ip link show dev wg-flow >/dev/null 2>&1; then
|
||||||
|
systemctl start wg-quick@wg-flow.service
|
||||||
|
else
|
||||||
|
ip link set dev wg-flow up
|
||||||
|
fi
|
||||||
|
cd "$COMPOSE_DIR"
|
||||||
|
docker compose "${COMPOSE_ARGS[@]}" -f docker-compose.netflow-agent.yml up -d netflow-agent
|
||||||
|
AGENT_READY=false
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
if [[ "$(docker inspect --format '{{.State.Health.Status}}' mmapp-netflow-agent)" == healthy ]]; then
|
||||||
|
AGENT_READY=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
[[ "$AGENT_READY" == true ]] || { echo "Агент не готов. Проверьте docker logs mmapp-netflow-agent; backend не пересоздан." >&2; exit 1; }
|
||||||
|
docker compose "${COMPOSE_ARGS[@]}" -f docker-compose.netflow-agent.yml up -d --no-deps --force-recreate backend
|
||||||
|
echo "Агент установлен. Используйте те же -f файлы при дальнейших операциях Compose:"
|
||||||
|
printf 'docker compose '; printf '%q ' "${COMPOSE_ARGS[@]}" -f "$COMPOSE_DIR/docker-compose.netflow-agent.yml"; echo 'up -d'
|
||||||
|
echo "В MM обновите статус хоста и нажмите «Применить настройки хоста»."
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import base64
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import agent
|
||||||
|
|
||||||
|
|
||||||
|
def key(n):
|
||||||
|
return base64.b64encode(bytes([n]) * 32).decode()
|
||||||
|
|
||||||
|
|
||||||
|
class ControllerTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
# Windows Python 3.13's TemporaryDirectory mode 0700 excludes the restricted sandbox token.
|
||||||
|
base = Path.cwd() / "tmp" if os.name == "nt" else Path(tempfile.gettempdir())
|
||||||
|
base.mkdir(exist_ok=True)
|
||||||
|
self.root = base / ("flow-agent-test-" + uuid.uuid4().hex)
|
||||||
|
self.root.mkdir()
|
||||||
|
def cleanup():
|
||||||
|
if self.root.resolve().parent != base.resolve():
|
||||||
|
raise RuntimeError("Unexpected test directory")
|
||||||
|
shutil.rmtree(self.root)
|
||||||
|
self.addCleanup(cleanup)
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
patch.object(agent, "CONFIG", self.root).start()
|
||||||
|
self.interface = dict(PrivateKey=key(1), Address="10.255.254.1/24", ListenPort="51821", MTU="1420")
|
||||||
|
self.third = dict(PublicKey=key(3), AllowedIPs="192.0.2.5/32", PresharedKey=key(9), PersistentKeepalive="0")
|
||||||
|
self.desired = dict(collectorIp="10.255.254.1", prefix="10.255.254.0/24", flowListenPort=4739,
|
||||||
|
wgListenPort=51821, hostPublicKey=key(2), hostPrivateKey=key(1),
|
||||||
|
peers=[dict(serverId=1, publicKey=key(4), allowedIps=["10.255.254.2/32"], endpoint="router.test:13232")])
|
||||||
|
(self.root / "binding.json").write_text(json.dumps({k: self.desired[k] for k in ("collectorIp", "prefix", "flowListenPort", "wgListenPort")}))
|
||||||
|
self.previous = agent.render(self.interface, [self.third])
|
||||||
|
(self.root / "wg-flow.conf").write_text(self.previous)
|
||||||
|
self.runtime = [copy.deepcopy(self.third)]
|
||||||
|
self.calls = []
|
||||||
|
def run(*args, input=None):
|
||||||
|
self.calls.append(args)
|
||||||
|
if args == ("wg", "pubkey"):
|
||||||
|
return key(2) if input == key(1) + "\n" else key(8)
|
||||||
|
if args == ("wg", "showconf", "wg-flow"):
|
||||||
|
return agent.render(self.interface, self.runtime, native=True)
|
||||||
|
raise AssertionError(args)
|
||||||
|
patch.object(agent, "run", run).start()
|
||||||
|
patch.object(agent, "live", self.live).start()
|
||||||
|
patch.object(agent, "sync", self.sync).start()
|
||||||
|
self.synced = []
|
||||||
|
|
||||||
|
def live(self):
|
||||||
|
return dict(interfaceUp=True, publicKey=key(2), listenPort=51821, addresses=["10.255.254.1/24"], peers=[]), copy.deepcopy(self.runtime)
|
||||||
|
|
||||||
|
def sync(self, text):
|
||||||
|
self.synced.append(text)
|
||||||
|
# Native configs don't include wg-quick's Address; simulate applying their peers.
|
||||||
|
_, self.runtime = agent.parse_conf(text.replace("ListenPort = 51821", "ListenPort = 51821\nAddress = 10.255.254.1/24"))
|
||||||
|
|
||||||
|
def test_apply_preserves_third_party_psk_and_is_idempotent(self):
|
||||||
|
self.assertFalse(agent.check(self.desired)["configMatches"])
|
||||||
|
self.assertTrue(agent.check(self.desired, apply=True)["configMatches"])
|
||||||
|
text = (self.root / "wg-flow.conf").read_text()
|
||||||
|
self.assertIn(key(3), text)
|
||||||
|
self.assertIn(key(9), text)
|
||||||
|
self.assertIn(key(4), text)
|
||||||
|
self.assertNotIn("Address", self.synced[0])
|
||||||
|
self.assertTrue(agent.check(self.desired)["configMatches"])
|
||||||
|
agent.check(self.desired, apply=True)
|
||||||
|
self.assertEqual((self.root / "wg-flow.conf").read_text(), text)
|
||||||
|
if os.name != "nt":
|
||||||
|
self.assertEqual((self.root / "wg-flow.conf").stat().st_mode & 0o777, 0o600)
|
||||||
|
|
||||||
|
def test_runtime_only_peer_preserved(self):
|
||||||
|
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="198.51.100.7/32", PersistentKeepalive="0"))
|
||||||
|
agent.check(self.desired, apply=True)
|
||||||
|
self.assertIn(key(6), (self.root / "wg-flow.conf").read_text())
|
||||||
|
|
||||||
|
def test_runtime_peer_without_allowed_ips_preserved(self):
|
||||||
|
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="", PersistentKeepalive="0"))
|
||||||
|
agent.check(self.desired, apply=True)
|
||||||
|
self.assertTrue(agent.check(self.desired)["configMatches"])
|
||||||
|
|
||||||
|
def test_third_party_runtime_settings_are_not_reset(self):
|
||||||
|
# Runtime may contain a roaming endpoint and intentionally unsaved settings.
|
||||||
|
self.runtime[0].update(Endpoint="198.51.100.2:50000", PersistentKeepalive="10")
|
||||||
|
agent.check(self.desired, apply=True)
|
||||||
|
third = next(p for p in self.runtime if p["PublicKey"] == key(3))
|
||||||
|
self.assertEqual(third["Endpoint"], "198.51.100.2:50000")
|
||||||
|
self.assertEqual(third["PersistentKeepalive"], "10")
|
||||||
|
_, stored = agent.parse_conf((self.root / "wg-flow.conf").read_text())
|
||||||
|
self.assertEqual(next(p for p in stored if p["PublicKey"] == key(3))["PersistentKeepalive"], "0")
|
||||||
|
|
||||||
|
def test_managed_roaming_endpoint_preserved_without_changing_desired_dns(self):
|
||||||
|
agent.check(self.desired, apply=True)
|
||||||
|
next(p for p in self.runtime if p["PublicKey"] == key(4))["Endpoint"] = "198.51.100.2:50000"
|
||||||
|
agent.check(self.desired, apply=True)
|
||||||
|
_, stored = agent.parse_conf((self.root / "wg-flow.conf").read_text())
|
||||||
|
self.assertEqual(next(p for p in stored if p["PublicKey"] == key(4))["Endpoint"], "router.test:13232")
|
||||||
|
self.assertEqual(next(p for p in self.runtime if p["PublicKey"] == key(4))["Endpoint"], "198.51.100.2:50000")
|
||||||
|
self.assertTrue(agent.check(self.desired)["configMatches"])
|
||||||
|
|
||||||
|
def test_down_interface_cannot_report_applied(self):
|
||||||
|
status, peers = self.live()
|
||||||
|
with patch.object(agent, "live", return_value=({**status, "interfaceUp": False}, peers)):
|
||||||
|
with self.assertRaisesRegex(agent.AgentError, "выключен"):
|
||||||
|
agent.check(self.desired, apply=True)
|
||||||
|
self.assertEqual(self.synced, [])
|
||||||
|
|
||||||
|
def test_overlap_rejected_before_any_write(self):
|
||||||
|
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="10.255.254.0/24"))
|
||||||
|
with self.assertRaisesRegex(agent.AgentError, "пересекаются"):
|
||||||
|
agent.check(self.desired, apply=True)
|
||||||
|
self.assertEqual((self.root / "wg-flow.conf").read_text(), self.previous)
|
||||||
|
self.assertEqual(self.synced, [])
|
||||||
|
|
||||||
|
def test_different_interface_identity_rejected(self):
|
||||||
|
self.desired["hostPrivateKey"], self.desired["hostPublicKey"] = key(7), key(8)
|
||||||
|
with self.assertRaisesRegex(agent.AgentError, "существующего"):
|
||||||
|
agent.check(self.desired, apply=True)
|
||||||
|
self.assertEqual(self.synced, [])
|
||||||
|
|
||||||
|
def test_failed_apply_restores_file_and_runtime(self):
|
||||||
|
rollback = agent.render(self.interface, self.runtime, native=True)
|
||||||
|
with patch.object(agent, "sync", side_effect=[agent.AgentError("secret error"), None]) as sync:
|
||||||
|
with self.assertRaisesRegex(agent.AgentError, "предыдущая конфигурация восстановлена"):
|
||||||
|
agent.check(self.desired, apply=True)
|
||||||
|
self.assertEqual(sync.call_args_list[-1].args, (rollback,))
|
||||||
|
self.assertEqual((self.root / "wg-flow.conf").read_text(), self.previous)
|
||||||
|
|
||||||
|
def test_failed_rollback_is_explicit(self):
|
||||||
|
with patch.object(agent, "sync", side_effect=agent.AgentError("secret error")):
|
||||||
|
with self.assertRaisesRegex(agent.AgentError, "восстановление требует ручной проверки"):
|
||||||
|
agent.check(self.desired, apply=True)
|
||||||
|
|
||||||
|
def test_invalid_or_shell_input_rejected(self):
|
||||||
|
for change in [dict(flowListenPort=4740), dict(prefix="10.255.254.0/28"), dict(hostPrivateKey="$(id)"), dict(command="id"), dict(peers=[dict(serverId=1, publicKey=key(4), allowedIps=["0.0.0.0/0"])]), dict(peers=[dict(serverId=1, publicKey=key(4), allowedIps=["10.255.254.2/32"], endpoint="router.test:13232\nPostUp = id")])]:
|
||||||
|
with self.subTest(change=list(change)):
|
||||||
|
with self.assertRaises(agent.AgentError):
|
||||||
|
agent.check({**self.desired, **change}, apply=True)
|
||||||
|
self.assertEqual(self.synced, [])
|
||||||
|
|
||||||
|
def test_hooks_and_duplicate_fields_rejected(self):
|
||||||
|
for suffix in ("\nPostUp = id", "\nListenPort = 3", "\n[Interface]\nPrivateKey = secret"):
|
||||||
|
with self.assertRaises(agent.AgentError):
|
||||||
|
agent.parse_conf(agent.render(self.interface, []) + suffix)
|
||||||
|
|
||||||
|
def test_endpoint_change_requires_apply(self):
|
||||||
|
agent.check(self.desired, apply=True)
|
||||||
|
self.desired["peers"][0]["endpoint"] = "new-router.test:13232"
|
||||||
|
self.assertFalse(agent.check(self.desired)["configMatches"])
|
||||||
|
|
||||||
|
def test_command_errors_do_not_expose_keys(self):
|
||||||
|
with patch.object(agent.subprocess, "run", return_value=type("Result", (), {"returncode": 1, "stdout": key(1), "stderr": key(9)})()):
|
||||||
|
# Use the original function, not the fixture runner.
|
||||||
|
with self.assertRaisesRegex(agent.AgentError, "не выполнена") as error:
|
||||||
|
ORIGINAL_RUN("wg", "show")
|
||||||
|
self.assertNotIn(key(1), str(error.exception))
|
||||||
|
self.assertNotIn(key(9), str(error.exception))
|
||||||
|
|
||||||
|
|
||||||
|
ORIGINAL_RUN = agent.run
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.environ.get("FLOW_AGENT_KERNEL_TEST") == "1", "Requires Linux kernel WireGuard and NET_ADMIN in an isolated container")
|
||||||
|
class KernelTest(unittest.TestCase):
|
||||||
|
def test_real_interface_apply_and_preservation(self):
|
||||||
|
with tempfile.TemporaryDirectory() as folder, patch.object(agent, "CONFIG", Path(folder)):
|
||||||
|
private = agent.run("wg", "genkey")
|
||||||
|
public = agent.run("wg", "pubkey", input=private + "\n")
|
||||||
|
other = agent.run("wg", "pubkey", input=agent.run("wg", "genkey") + "\n")
|
||||||
|
desired_key = agent.run("wg", "pubkey", input=agent.run("wg", "genkey") + "\n")
|
||||||
|
interface = dict(PrivateKey=private, Address="10.255.254.1/24", ListenPort="51821")
|
||||||
|
third = dict(PublicKey=other, AllowedIPs="192.0.2.3/32")
|
||||||
|
path = Path(folder)
|
||||||
|
(path / "wg-flow.conf").write_text(agent.render(interface, [third]))
|
||||||
|
snapshot = dict(collectorIp="10.255.254.1", prefix="10.255.254.0/24", flowListenPort=4739, wgListenPort=51821,
|
||||||
|
hostPublicKey=public, hostPrivateKey=private, peers=[dict(serverId=1, publicKey=desired_key, allowedIps=["10.255.254.2/32"], endpoint="127.0.0.1:13232")])
|
||||||
|
(path / "binding.json").write_text(json.dumps({k: snapshot[k] for k in ("collectorIp", "prefix", "flowListenPort", "wgListenPort")}))
|
||||||
|
agent.run("ip", "link", "add", "dev", agent.IFACE, "type", "wireguard")
|
||||||
|
try:
|
||||||
|
agent.run("ip", "address", "add", "10.255.254.1/24", "dev", agent.IFACE)
|
||||||
|
agent.sync(agent.render(interface, [third], native=True))
|
||||||
|
agent.run("ip", "link", "set", "dev", agent.IFACE, "up")
|
||||||
|
before = json.loads(agent.run("ip", "-j", "link", "show", "dev", agent.IFACE))[0]["ifindex"]
|
||||||
|
self.assertTrue(agent.check(snapshot, apply=True)["configMatches"])
|
||||||
|
self.assertTrue(agent.check(snapshot)["configMatches"])
|
||||||
|
status, _ = agent.live()
|
||||||
|
self.assertEqual({p["publicKey"] for p in status["peers"]}, {other, desired_key})
|
||||||
|
self.assertNotIn(private, json.dumps(status))
|
||||||
|
after = json.loads(agent.run("ip", "-j", "link", "show", "dev", agent.IFACE))[0]["ifindex"]
|
||||||
|
self.assertEqual(before, after)
|
||||||
|
finally:
|
||||||
|
agent.run("ip", "link", "delete", "dev", agent.IFACE)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# Автоматическое подключение NetFlow
|
||||||
|
|
||||||
|
`mmapp-netflow-agent` управляет только `wg-flow` на том же Debian/Ubuntu-хосте,
|
||||||
|
где работает Docker Engine. Используются WireGuard ядра Linux и обычный Docker с
|
||||||
|
root-правами. Rootless Docker и Docker Desktop не поддерживаются этим режимом.
|
||||||
|
|
||||||
|
## Production-состав
|
||||||
|
|
||||||
|
С Compose `deploy/docker-compose.cdn-mm.yml` и дополнением агента работают семь контейнеров:
|
||||||
|
|
||||||
|
| Контейнер | Назначение |
|
||||||
|
| --- | --- |
|
||||||
|
| cdn-mm-traefik | HTTPS и маршрутизация |
|
||||||
|
| cdnmanager | CDN Manager |
|
||||||
|
| mmapp-frontend | Интерфейс MM |
|
||||||
|
| mmapp-backend | API, настройка MikroTik и приём IPFIX |
|
||||||
|
| mmapp-postgres | Настройки и потоки |
|
||||||
|
| mmapp-updater | Существующее обновление контейнеров MM |
|
||||||
|
| mmapp-netflow-agent | Применение пиров WireGuard на хосте |
|
||||||
|
|
||||||
|
Собственный systemd-сервис не нужен. Стандартный `wg-quick@wg-flow` поднимает
|
||||||
|
интерфейс до Docker: backend публикует IPFIX только на его адресе.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
User[Пользователь] -->|HTTPS| Proxy[Traefik]
|
||||||
|
subgraph Host[Production Linux]
|
||||||
|
subgraph Docker[Docker Compose]
|
||||||
|
Proxy --> Frontend[frontend]
|
||||||
|
Proxy --> CDN[CDN Manager]
|
||||||
|
Frontend -->|API| Backend[backend + IPFIX collector]
|
||||||
|
Backend --> PG[PostgreSQL]
|
||||||
|
Backend <-->|HTTP через Unix socket bind mount| Agent[netflow-agent: host network + NET_ADMIN]
|
||||||
|
Updater[updater] -.-> Backend
|
||||||
|
Updater -.-> Frontend
|
||||||
|
end
|
||||||
|
Agent -->|atomic write| Conf[wg-flow.conf]
|
||||||
|
Agent -->|wg syncconf| WG[wg-flow: интерфейс хоста]
|
||||||
|
Conf --> Boot[wg-quick при загрузке]
|
||||||
|
Boot --> WG
|
||||||
|
WG -->|Docker UDP bind на WG-IP| Backend
|
||||||
|
end
|
||||||
|
Backend -->|RouterOS API| Router[MikroTik]
|
||||||
|
Router <-->|WireGuard: внутри IPFIX| WG
|
||||||
|
```
|
||||||
|
|
||||||
|
Агент не обрабатывает потоки: при остановке/пересоздании контейнера интерфейс
|
||||||
|
и приём IPFIX продолжают работать. `NET_ADMIN` вместе с host network позволяет
|
||||||
|
управлять сетью хоста; API ограничено фиксированным интерфейсом и структурированными
|
||||||
|
настройками. Агент не получает Docker socket, `privileged`, корневую ФС хоста,
|
||||||
|
право загрузки модулей или TCP API.
|
||||||
|
|
||||||
|
## Однократная установка
|
||||||
|
|
||||||
|
1. Обновить MM до версии с агентом. Образ агента должен быть опубликован CI
|
||||||
|
в том же реестре; production-хост должен быть авторизован через `docker login`.
|
||||||
|
2. В «Сбор данных → NetFlow» создать ключи хоста, открыть «Файлы хоста» и скачать
|
||||||
|
`install-netflow-agent.sh` из вкладки «Агент Docker».
|
||||||
|
3. Выполнить на Linux-хосте:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash install-netflow-agent.sh /opt/cdn-mm
|
||||||
|
```
|
||||||
|
|
||||||
|
Файл содержит приватный ключ. Хранить его как секрет, не коммитить.
|
||||||
|
При необходимости образ можно задать явно:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo MM_NETFLOW_AGENT_IMAGE=git.shx.one/denozord/mikrotikmanager-netflow-agent:VERSION \
|
||||||
|
bash install-netflow-agent.sh /opt/cdn-mm
|
||||||
|
```
|
||||||
|
|
||||||
|
4. В MM обновить состояние хоста и применить настройки. Затем подключать
|
||||||
|
jump-host из раздела трафика: пиры применяются автоматически без перезапуска WG.
|
||||||
|
|
||||||
|
Альтернатива из checkout: скачать `wg-flow.conf` из MM и выполнить
|
||||||
|
`sudo bash deploy/netflow-agent/install.sh /absolute/path/wg-flow.conf /opt/cdn-mm 4739`.
|
||||||
|
Третий аргумент — фактический IPFIX-порт из настроек.
|
||||||
|
|
||||||
|
Установщик сначала проверяет конфигурацию и Compose. Он сохраняет существующие
|
||||||
|
пиры и делает резервную копию конфигурации; при несовпадении ключа, адреса или порта
|
||||||
|
останавливается. Hooks, SaveConfig и нестандартные параметры wg-quick требуют
|
||||||
|
ручной проверки и не принимаются. Интерфейс должен иметь IPv4 /24;
|
||||||
|
пиры MM получают собственные /32 в этой сети.
|
||||||
|
|
||||||
|
Создаются:
|
||||||
|
|
||||||
|
- `data/netflow-wireguard/wg-flow.conf` и `binding.json` с правами `0600`;
|
||||||
|
- ссылка `/etc/wireguard/wg-flow.conf` на постоянную конфигурацию;
|
||||||
|
- `docker-compose.netflow-agent.yml`, не заменяющий пользовательский override;
|
||||||
|
- drop-in `/etc/systemd/system/docker.service.d/mm-netflow.conf` для порядка
|
||||||
|
запуска `wg-quick → Docker`;
|
||||||
|
- общий каталог `data/netflow-agent-socket` для Unix-сокета, подключённый в backend только для чтения. Bind mount сохраняет этот режим при пересоздании backend существующим updater.
|
||||||
|
|
||||||
|
Установщик однократно пересоздаёт backend и не перезапускает Docker или Traefik.
|
||||||
|
Он не изменяет firewall хоста. Существующий публичный или несовместимый IPFIX bind
|
||||||
|
в Compose нужно исправить перед повторным запуском. Не публиковать IPFIX на `0.0.0.0`.
|
||||||
|
|
||||||
|
## Эксплуатация
|
||||||
|
|
||||||
|
При следующих операциях Compose сохранять все файлы дополнений:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/cdn-mm
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.override.yml \
|
||||||
|
-f docker-compose.netflow-agent.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Если `docker-compose.override.yml` отсутствует, убрать соответствующий `-f`.
|
||||||
|
Установщик печатает команду для фактического набора файлов.
|
||||||
|
Образ агента обновляется явно через Compose; существующий updater не включает
|
||||||
|
его в автоматические цели. При пересоздании backend updater сохраняет его mounts.
|
||||||
|
|
||||||
|
Просмотр: `GET /api/traffic/flow/host-status`, право `mm:traffic:read`.
|
||||||
|
Повторное применение: `POST /api/traffic/flow/host-reconcile`, право `mm:traffic:write`.
|
||||||
|
Статус не содержит приватных/preshared ключей. `lastHandshakeAt` — Unix seconds
|
||||||
|
или `null`; факт применения настроек не означает handshake или получение IPFIX.
|
||||||
|
Время последнего IPFIX-пакета в панели относится ко всему коллектору.
|
||||||
|
|
||||||
|
Подключения и изменения настроек сериализуются PostgreSQL advisory lock.
|
||||||
|
После рестарта backend повторно применяет сохранённые пиры. При конфликте или
|
||||||
|
недоступности агента приложение и текущий приём продолжают работать.
|
||||||
|
Если MikroTik уже настроен, но хост не применён, результат подключения содержит
|
||||||
|
`ok: false`, `stage: host-pending`, `hostError`: нажать «Применить настройки хоста».
|
||||||
|
При ошибке во время настройки роутера повторить подключение; его изменения
|
||||||
|
не откатываются автоматически.
|
||||||
|
|
||||||
|
Агент сохраняет сторонние persistent и runtime-only пиры, проверяет пересечения
|
||||||
|
AllowedIPs, атомарно записывает конфигурацию и применяет её через `wg syncconf`.
|
||||||
|
При сбое восстанавливает предыдущий файл и runtime; сбой восстановления сообщается
|
||||||
|
отдельно. Добавление/обновление пиров не меняет адреса, маршруты и интерфейс.
|
||||||
|
|
||||||
|
Первая версия не удаляет туннели. Адрес коллектора, префикс и порты закреплены
|
||||||
|
установкой: изменение требует согласования настроек MM, WG и Compose вручную и
|
||||||
|
повторного запуска установщика. Автоматическая смена адреса или ротация ключа
|
||||||
|
не выполняется. Другие установки могут продолжать пользоваться ручным экспортом
|
||||||
|
без `FLOW_AGENT_SOCKET`.
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix backend run test:netflow-settings
|
||||||
|
npx playwright test -c playwright.netflow-agent.config.ts
|
||||||
|
python3 -m unittest discover -s deploy/netflow-agent -p 'test_*.py' -v
|
||||||
|
bash -n deploy/netflow-agent/install.sh
|
||||||
|
docker build --target test -t mm-netflow-agent-test deploy/netflow-agent
|
||||||
|
docker run --rm --cap-drop ALL --cap-add NET_ADMIN --read-only --tmpfs /tmp \
|
||||||
|
-e FLOW_AGENT_KERNEL_TEST=1 --entrypoint python3 mm-netflow-agent-test \
|
||||||
|
-m unittest discover -s /app -p test_agent.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Последняя проверка использует отдельное сетевое пространство контейнера, проверяет
|
||||||
|
настоящий kernel WireGuard, сохранение стороннего пира и неизменность интерфейса.
|
||||||
|
Она не заменяет приёмку на стенде с двумя MikroTik: handshake/IPFIX от каждого,
|
||||||
|
сохранение первого соединения при добавлении второго, перезапуск агента/backend
|
||||||
|
и холодная загрузка Linux-хоста с проверкой UDP bind.
|
||||||
|
|
||||||
|
### Приёмка на стенде — 2026-10-16
|
||||||
|
|
||||||
|
На `161.104.46.222` проверены временные контейнеры новой ветки с отдельной БД.
|
||||||
|
15 тестов агента прошли с настоящим kernel WireGuard; 32 профильных скрипта
|
||||||
|
и две отдельные SQL-проверки (monthly и IPv6 UDP/worker) завершились успешно.
|
||||||
|
Оба MikroTik (`194.226.121.150`, `104.165.244.7`) подключены через API: подтверждены
|
||||||
|
handshake, сохранение первого туннеля при добавлении второго и повторное подключение
|
||||||
|
без дубликатов. В БД записаны IPFIX-потоки обоих MikroTik и синтетического Linux
|
||||||
|
экспортёра через WireGuard. Реальные перезапуски контейнеров агента и backend
|
||||||
|
сохранили соединения, приём пакетов продолжился.
|
||||||
|
|
||||||
|
При проверке исправлен API настроек: счётчики и время последнего пакета теперь
|
||||||
|
читаются из работающего коллектора. После исправления полный живой сценарий повторён.
|
||||||
|
Настройки обоих маршрутизаторов восстановлены; временные контейнеры, интерфейс,
|
||||||
|
БД/роли и приватные файлы удалены. Основные сервисы остались на прежних образах.
|
||||||
|
|
||||||
|
Установщик с интеграцией `wg-quick`/systemd и холодная перезагрузка хоста на этом
|
||||||
|
стенде не выполнялись. Образы собраны поверх существующего runtime backend;
|
||||||
|
полная release-сборка frontend и публикация образов в эту проверку не входили.
|
||||||
|
Результаты, исходные неудачные попытки и границы —
|
||||||
|
[отчёт приёмки](ux-ui/acceptance/netflow-agent-2026-10-16.json).
|
||||||
+91
-1
@@ -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 не расширены.
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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:[]})
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -9,6 +9,23 @@ export const flowHostPeerSchema = z.object({
|
|||||||
endpoint: z.string().optional(),
|
endpoint: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const trafficFlowHostStatusSchema = z.object({
|
||||||
|
configured: z.boolean(),
|
||||||
|
reachable: z.boolean(),
|
||||||
|
interfaceUp: z.boolean(),
|
||||||
|
configMatches: z.boolean().nullable(),
|
||||||
|
publicKey: z.string(),
|
||||||
|
listenPort: z.number().int().nonnegative(),
|
||||||
|
addresses: z.array(z.string()),
|
||||||
|
peers: z.array(z.object({
|
||||||
|
publicKey: z.string(), allowedIps: z.array(z.string()),
|
||||||
|
lastHandshakeAt: z.number().int().nonnegative().nullable(),
|
||||||
|
receivedBytes: z.number().nonnegative(), sentBytes: z.number().nonnegative(),
|
||||||
|
})),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
})
|
||||||
|
export type TrafficFlowHostStatus = z.infer<typeof trafficFlowHostStatusSchema>
|
||||||
|
|
||||||
export const trafficFlowSettingsDtoSchema = z.object({
|
export const trafficFlowSettingsDtoSchema = z.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
collectorIp: z.string().min(1),
|
collectorIp: z.string().min(1),
|
||||||
@@ -77,6 +94,9 @@ export const trafficFlowOverlayResultSchema = z.object({
|
|||||||
trafficFlow: z.boolean(),
|
trafficFlow: z.boolean(),
|
||||||
steps: z.array(z.string()),
|
steps: z.array(z.string()),
|
||||||
hostFiles: z.array(trafficFlowHostFileSchema),
|
hostFiles: z.array(trafficFlowHostFileSchema),
|
||||||
|
hostStatus: trafficFlowHostStatusSchema.optional(),
|
||||||
|
stage: z.enum(["manual", "host-pending", "applied"]).optional(),
|
||||||
|
hostError: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const flowTalkerDtoSchema = z.object({
|
export const flowTalkerDtoSchema = z.object({
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from "@playwright/test"
|
||||||
|
import base from "./playwright.config"
|
||||||
|
|
||||||
|
const server = base.webServer
|
||||||
|
if (!server || Array.isArray(server)) throw new Error("Ожидается один локальный сервер")
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
...base,
|
||||||
|
use: { ...base.use, baseURL: "http://127.0.0.1:3102" },
|
||||||
|
testIgnore: [],
|
||||||
|
testMatch: ["netflow-agent.spec.ts", "netflow-settings.spec.ts"],
|
||||||
|
webServer: {
|
||||||
|
...server,
|
||||||
|
command: "node node_modules/next/dist/bin/next dev --webpack --hostname 127.0.0.1 --port 3102",
|
||||||
|
url: "http://127.0.0.1:3102",
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -10,13 +10,27 @@ import type {
|
|||||||
TrafficFlowOverlayResult,
|
TrafficFlowOverlayResult,
|
||||||
TrafficFlowSettingsDto,
|
TrafficFlowSettingsDto,
|
||||||
TrafficFlowSettingsPatch,
|
TrafficFlowSettingsPatch,
|
||||||
|
TrafficFlowHostStatus,
|
||||||
} from "@mmapp/contracts/traffic-flow"
|
} from "@mmapp/contracts/traffic-flow"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { trafficFlowSettingsDtoSchema, trafficFlowSettingsPatchSchema, trafficFlowHostFileSchema, flowPurgeDtoSchema, flowAnalyticsDtoSchema, flowMonthlyDtoSchema } from "@mmapp/contracts/traffic-flow"
|
import { trafficFlowSettingsDtoSchema, trafficFlowSettingsPatchSchema, trafficFlowHostFileSchema, flowPurgeDtoSchema, flowAnalyticsDtoSchema, flowMonthlyDtoSchema } from "@mmapp/contracts/traffic-flow"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { trafficFlowHostStatusSchema } from "@mmapp/contracts/traffic-flow"
|
||||||
|
|
||||||
export type { TrafficFlowHostFile }
|
export type { TrafficFlowHostFile }
|
||||||
|
|
||||||
|
export async function getTrafficFlowHostStatus(baseUrl: string, init?: RequestInit): Promise<TrafficFlowHostStatus> {
|
||||||
|
return flowConfirmed(trafficFlowHostStatusSchema, await requestJson(baseUrl, "/api/traffic/flow/host-status", {
|
||||||
|
...init, signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(65_000)]),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reconcileTrafficFlowHost(baseUrl: string, init?: RequestInit): Promise<TrafficFlowHostStatus> {
|
||||||
|
const response = flowConfirmed(z.object({ ok: z.literal(true), hostStatus: trafficFlowHostStatusSchema }),
|
||||||
|
await requestJson(baseUrl, "/api/traffic/flow/host-reconcile", { ...init, method: "POST", signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(120_000)]) }))
|
||||||
|
return response.hostStatus
|
||||||
|
}
|
||||||
|
|
||||||
const flowSettingsSchema = trafficFlowSettingsDtoSchema.extend({ flowListenPort: z.number().int().min(1).max(65535), wgListenPort: z.number().int().min(1).max(65535) }).refine(data => (!data.hasHostPrivateKey || data.hostPublicKey.length > 0) && (!data.listenerBound || !!data.listenerAddress) && (data.lastDatagramAt === null || Number.isFinite(Date.parse(data.lastDatagramAt))) && new Set(data.peers.map(peer => peer.serverId)).size === data.peers.length, "Несогласованные настройки NetFlow")
|
const flowSettingsSchema = trafficFlowSettingsDtoSchema.extend({ flowListenPort: z.number().int().min(1).max(65535), wgListenPort: z.number().int().min(1).max(65535) }).refine(data => (!data.hasHostPrivateKey || data.hostPublicKey.length > 0) && (!data.listenerBound || !!data.listenerAddress) && (data.lastDatagramAt === null || Number.isFinite(Date.parse(data.lastDatagramAt))) && new Set(data.peers.map(peer => peer.serverId)).size === data.peers.length, "Несогласованные настройки NetFlow")
|
||||||
const flowPatchSchema = trafficFlowSettingsPatchSchema.strict()
|
const flowPatchSchema = trafficFlowSettingsPatchSchema.strict()
|
||||||
// Optional overlay/wire measurements may be unavailable independently of the
|
// Optional overlay/wire measurements may be unavailable independently of the
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { test, expect } 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")
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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}}))
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {test, expect} from '@playwright/test'
|
||||||
|
import {buildTrafficFromSamples} from '../../backend/src/services/traffic-rate'
|
||||||
|
|
||||||
|
for (const mode of ['servers','flows'] as const) for (const minutes of [5,15]) {
|
||||||
|
test(`${mode}: ${minutes} минут рисуются линией`, async ({page}) => {
|
||||||
|
const end=Date.now(), name='Короткая история'
|
||||||
|
const history=(span:number)=>buildTrafficFromSamples(Array.from({length:span*2+1},(_,i)=>({
|
||||||
|
interfaceName:'ether1',sampledAt:new Date(end-span*60000+i*30000).toISOString(),
|
||||||
|
rxBytes:i*3750000,txBytes:i*1875000,rxBps:0,txBps:0,running:true,disabled:false,
|
||||||
|
})),end-span*60000,end,undefined,undefined,true)
|
||||||
|
await page.route('**/health',r=>r.fulfill({json:{ok:true}}))
|
||||||
|
await page.route('**/api/**',r=>{
|
||||||
|
const url=new URL(r.request().url()),path=url.pathname,span=url.searchParams.get('range')==='5m'?5:url.searchParams.get('range')==='15m'?15:60
|
||||||
|
const built=history(span),server={id:'1',name,site:'TEST',country:'RU',status:'online',...built,rxTotal:1,txTotal:1,volumeUnit:'GiB'}
|
||||||
|
const card={id:'1',name,subtitle:'',site:'TEST',country:'RU',status:'online',rxNow:1,txNow:0,sessions:1,bytes:60000,rxSeries:[1,2,1],txSeries:[0,0,0]}
|
||||||
|
const analytics={bpsNow:1000000,bytes:60000,packets:10,conversations:1,uniqueSrc:1,uniqueDst:1,topProto:'TCP',rxSeries:Array.from({length:span},(_,i)=>1+i%3),txSeries:Array(span).fill(0),applications:[],protocols:[],sources:[],destinations:[],interfaces:[],conversationsList:[],ifaces:[],live:false}
|
||||||
|
if(path.endsWith('/live'))return r.fulfill({status:503,json:{error:'Live выключен в проверке истории'}})
|
||||||
|
const json=path==='/api/auth/config'?{required:false}:path==='/api/traffic/servers'?{servers:[server]}:
|
||||||
|
path==='/api/traffic/servers/1'?{server}:path==='/api/traffic/flow/exporters'?{exporters:[card]}:
|
||||||
|
path==='/api/traffic/flow/analytics'?analytics:path==='/api/traffic/flow/clients'?{clients:[]}:
|
||||||
|
path==='/api/traffic/flows'?{exportersOnline:1,bytesPerMin:1,uniqueSrc:1,uniqueDst:1,topProto:'TCP',talkers:[],listenerBound:true}:
|
||||||
|
path==='/api/traffic/users'?{users:[]}:path.endsWith('/interfaces')||path.endsWith('/bound-interfaces')?{interfaces:[]}:
|
||||||
|
path==='/api/servers'?[]:{}
|
||||||
|
return r.fulfill({json})
|
||||||
|
})
|
||||||
|
await page.goto('/traffic')
|
||||||
|
if(mode==='flows')await page.getByRole('group',{name:'Разделы трафика'}).getByRole('button',{name:'Потоки',exact:true}).click()
|
||||||
|
await page.getByRole('button',{name:`${minutes}м`,exact:true}).click()
|
||||||
|
const chart=page.getByRole('region',{name:'История RX/TX'})
|
||||||
|
await expect(chart).toBeVisible()
|
||||||
|
const curves=chart.locator('path.recharts-line-curve')
|
||||||
|
await expect(curves).toHaveCount(2)
|
||||||
|
await expect.poll(async()=>((await curves.first().getAttribute('d'))?.match(/[CL]/g)||[]).length).toBeGreaterThan(2)
|
||||||
|
await expect.poll(()=>curves.first().evaluate(el=>(el as SVGGraphicsElement).getBBox().width/el.closest('[role="region"]')!.clientWidth)).toBeGreaterThan(0.7)
|
||||||
|
await expect(chart.locator('[data-isolated-direction]')).toHaveCount(0)
|
||||||
|
await page.screenshot({path:`tmp/netflow-agent-stand/traffic-short-${mode}-${minutes}.png`,fullPage:true})
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in new issue
Block a user