Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e461dc21bd | ||
|
|
2852b11234 | ||
|
|
0abdac82de | ||
|
|
db81cf0f97 | ||
|
|
fcca92706b | ||
|
|
4881306f0b | ||
|
|
ef229ca9c4 | ||
|
|
25ae76dc3f | ||
|
|
552d5f831f | ||
|
|
e6a2e4009d | ||
|
|
eb24a08981 | ||
|
|
1c11a133f3 | ||
|
|
0f0d3aa060 |
No files matched your search
@@ -0,0 +1,2 @@
|
||||
deploy/netflow-agent/*.sh text eol=lf
|
||||
deploy/netflow-agent/*.py text eol=lf
|
||||
@@ -155,6 +155,8 @@ jobs:
|
||||
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
||||
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
|
||||
cp -R backend/src "$STAGING/backend/"
|
||||
mkdir -p "$STAGING/deploy/netflow-agent"
|
||||
cp deploy/netflow-agent/install.sh "$STAGING/deploy/netflow-agent/"
|
||||
if [ -d backend/drizzle ]; then
|
||||
cp -R backend/drizzle "$STAGING/backend/"
|
||||
fi
|
||||
@@ -329,12 +331,69 @@ jobs:
|
||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
|
||||
|
||||
netflow-agent-image:
|
||||
needs: prepare-release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure netflow-agent image name
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
owner=$(echo "${{ gitea.repository }}" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
|
||||
name=$(echo "${{ gitea.repository }}" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
|
||||
stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
|
||||
echo "IMAGE_NAME=${{ env.REGISTRY }}/${owner}/${stem}-netflow-agent" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Test controller and installer
|
||||
run: |
|
||||
python3 -m unittest discover -s deploy/netflow-agent -p "test_*.py" -v
|
||||
bash -n deploy/netflow-agent/install.sh
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
|
||||
- name: Verify image with real WireGuard
|
||||
run: |
|
||||
docker build --target test -t mm-netflow-agent-test deploy/netflow-agent
|
||||
docker run --rm --cap-drop ALL --cap-add NET_ADMIN --read-only --tmpfs /tmp -e FLOW_AGENT_KERNEL_TEST=1 --entrypoint python3 mm-netflow-agent-test -m unittest discover -s /app -p "test_agent.py" -v
|
||||
|
||||
- name: Build and push netflow-agent image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: deploy/netflow-agent
|
||||
file: deploy/netflow-agent/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
${{ env.IMAGE_NAME }}:${{ needs.prepare-release.outputs.version }}
|
||||
cache-from: type=gha,scope=netflow-agent
|
||||
cache-to: type=gha,mode=max,scope=netflow-agent
|
||||
labels: |
|
||||
org.opencontainers.image.title=mmapp-netflow-agent
|
||||
org.opencontainers.image.description=MikroTik Manager local WireGuard controller
|
||||
org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }}
|
||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
|
||||
|
||||
publish-release:
|
||||
needs:
|
||||
- prepare-release
|
||||
- backend-image
|
||||
- frontend-image
|
||||
- updater-image
|
||||
- netflow-agent-image
|
||||
if: needs.prepare-release.outputs.should_release == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -420,6 +479,7 @@ jobs:
|
||||
- prepare-release
|
||||
- backend-image
|
||||
- frontend-image
|
||||
- netflow-agent-image
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
|
||||
|
||||
@@ -62,3 +62,11 @@ opencode.json
|
||||
|
||||
# Local codegraph process
|
||||
.codegraph/daemon.pid
|
||||
|
||||
# Python controller tests
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# NetFlow agent host state (when Compose is installed in a checkout)
|
||||
/data/netflow-wireguard/
|
||||
/data/netflow-agent-socket/
|
||||
@@ -568,3 +568,7 @@ docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
|
||||
10. Проверить `curl` на `:8000/health` и `:3000/`.
|
||||
11. В UI: режим live и URL бэкенда; сверить с `CORS_ORIGIN`.
|
||||
12. Просмотреть `docker logs mmapp-updater`; настроить регулярный backup тома `backend-data`.
|
||||
|
||||
### Автоматизация NetFlow через Docker-агент
|
||||
|
||||
[Установка, схема взаимодействия и production-состав](docs/netflow-agent.md).
|
||||
@@ -1676,11 +1676,17 @@ function FiltersPage() {
|
||||
}
|
||||
const openEdit = (rule: FilterRule) => {
|
||||
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({
|
||||
action: rule.action ?? "route",
|
||||
community: rule.community,
|
||||
gateway: rule.gateway,
|
||||
gatewayTunnelId: rule.gatewayTunnelId,
|
||||
gatewayTunnelId: recursive ? `rec:${recursive.id}` : rule.gatewayTunnelId,
|
||||
gatewayKind: isRecursiveGatewayRef(rule.gatewayTunnelId) ? "recursive" : "gre",
|
||||
description: rule.description,
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {Dialog,DialogContent,DialogHeader,DialogTitle,DialogDescription} from "@
|
||||
import {usePrefersReducedMotion} from "@/hooks/use-prefers-reduced-motion"
|
||||
import {toast} from "sonner"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { NetworkMapLegend } from "@/components/network-map-legend"
|
||||
import {
|
||||
servers as mockServers,
|
||||
greTunnels as mockGreTunnels,
|
||||
@@ -556,7 +557,7 @@ function GreEdgeMetricBadge({
|
||||
"Клик — панель справа."
|
||||
return (
|
||||
<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)}}}
|
||||
transform={`translate(${mx},${my})`}
|
||||
style={{ cursor: "pointer" }}
|
||||
@@ -564,6 +565,7 @@ function GreEdgeMetricBadge({
|
||||
onClick={(e) => { e.stopPropagation(); onOpen(e) }}
|
||||
>
|
||||
<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
|
||||
x={-bw / 2}
|
||||
y={-bh / 2}
|
||||
@@ -607,7 +609,7 @@ function NetflowRateBadge({
|
||||
const bh = showDir ? 32 : 20
|
||||
return (
|
||||
<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)}}}
|
||||
transform={`translate(${mx},${my})`}
|
||||
style={{ cursor: onOpen ? "pointer" : "default" }}
|
||||
@@ -617,6 +619,7 @@ function NetflowRateBadge({
|
||||
<title>
|
||||
Поток NetFlow между узлами (как в «Трафик»: 5 мин, без overlay/mesh). Скорость канала — отдельно.
|
||||
</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
|
||||
x={-bw / 2}
|
||||
y={-bh / 2}
|
||||
@@ -687,7 +690,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat
|
||||
const ts = TYPE_STYLE[n.type]
|
||||
|
||||
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()}}}
|
||||
transform={`translate(${n.x},${n.y})`}
|
||||
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)}
|
||||
onMouseLeave={() => onHoverChange(false)}
|
||||
>
|
||||
<circle className="network-map-focus-ring" r={ts.r + 6} fill="none" stroke="var(--ring)" strokeWidth="2" />
|
||||
{n.status === "online" && isVis && (
|
||||
<circle r={ts.r + 14} fill={ss.glow} opacity="0.7">
|
||||
</circle>
|
||||
@@ -707,7 +711,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat
|
||||
<circle r={ts.r + 12} fill={ss.glow} />
|
||||
)}
|
||||
{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} />
|
||||
<g transform={`translate(${ts.r - 10},-${ts.r - 10})`}>
|
||||
@@ -807,7 +811,7 @@ function ServiceNode({
|
||||
const bh = MAP_SERVICE_NODE_H
|
||||
const flagIso = destMode === "countries" && iso && iso !== "Прочее" ? iso : ""
|
||||
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()}}}
|
||||
transform={`translate(${x},${y})`}
|
||||
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
|
||||
@@ -816,6 +820,7 @@ function ServiceNode({
|
||||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||||
>
|
||||
<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 && (
|
||||
<rect
|
||||
x={-bw / 2 - 6}
|
||||
@@ -925,12 +930,13 @@ function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onPo
|
||||
}) {
|
||||
const r = 18
|
||||
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()}}}
|
||||
transform={`translate(${x},${y})`}
|
||||
style={{ cursor: isDragged ? "grabbing" : "grab" }}
|
||||
onPointerDown={e => { e.stopPropagation(); onPointerDown(e) }}
|
||||
onClick={e => { e.stopPropagation(); onSelect() }}>
|
||||
<circle className="network-map-focus-ring" r={r + 4} fill="none" stroke="var(--ring)" strokeWidth="2" />
|
||||
{active && (
|
||||
<circle r={r + 6} fill={color} opacity="0.12">
|
||||
</circle>
|
||||
@@ -1718,6 +1724,7 @@ function NetworkMapContent() {
|
||||
if (!svg) return
|
||||
// Clone with full viewBox (reset pan/zoom)
|
||||
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("width", String(W))
|
||||
clone.setAttribute("height", String(H))
|
||||
@@ -2252,10 +2259,7 @@ function NetworkMapContent() {
|
||||
|
||||
{/* ── 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")}>
|
||||
<details className="absolute top-3 left-3 z-20 rounded-md border bg-popover p-2 text-xs shadow-sm">
|
||||
<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>
|
||||
<NetworkMapLegend />
|
||||
<p id="network-map-help" className="sr-only">Стрелки перемещают карту, плюс и минус меняют масштаб, F вписывает объекты. Tab выбирает объект, Enter открывает детали.</p>
|
||||
<svg
|
||||
ref={svgRef} role="group" aria-label="Интерактивная карта сети" tabIndex={0} aria-describedby="network-map-help"
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
@import "tw-animate-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 *));
|
||||
|
||||
@theme inline {
|
||||
|
||||
@@ -17,3 +17,5 @@ AUTH_REQUIRED=false
|
||||
AUTH_JWT_SECRET=dev-secret-change-me
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
AUTH_PORTAL_URL=http://localhost:5175
|
||||
# Optional: enabled by the NetFlow agent Compose installer. Linux Unix socket only.
|
||||
# FLOW_AGENT_SOCKET=/run/mm-netflow-host/agent.sock
|
||||
@@ -57,6 +57,7 @@ COPY --from=build /app/packages/contracts ./packages/contracts
|
||||
COPY --from=build /app/backend/dist ./backend/dist
|
||||
COPY --from=build /app/backend/package.json ./backend/package.json
|
||||
COPY --from=build /app/backend/drizzle ./backend/drizzle
|
||||
COPY deploy/netflow-agent/install.sh ./deploy/netflow-agent/install.sh
|
||||
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
|
||||
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
||||
EXPOSE 8000
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"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: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: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",
|
||||
@@ -33,7 +33,7 @@
|
||||
"test": "node scripts/test.mjs",
|
||||
"test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts",
|
||||
"test:netflow-purge": "tsx src/db/operation-lock.test.ts && tsx src/services/traffic-flow-purge-execution.test.ts && tsx src/routes/traffic-flow-purge.test.ts",
|
||||
"test:netflow-settings": "tsx src/services/traffic-flow-host-file-builders.test.ts && tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts",
|
||||
"test:netflow-settings": "tsx src/services/traffic-flow-host-file-builders.test.ts && tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts && tsx src/services/traffic-flow-agent-install.test.ts && tsx src/services/traffic-flow-agent-client.test.ts && tsx src/services/traffic-flow-operation-queue.test.ts && tsx src/routes/traffic-flow-host.test.ts",
|
||||
"test:geoip": "tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/plugins/auth.geoip.test.ts",
|
||||
"test:ospf-optimizer": "tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/services/ospf-effective-disabled.test.ts && tsx src/routes/ospf-optimizer.test.ts",
|
||||
"test:containers": "tsx src/services/containers-live.test.ts && tsx src/services/containers-state.test.ts && tsx src/services/container-removal.test.ts && tsx src/routes/containers-state.test.ts",
|
||||
|
||||
@@ -39,6 +39,8 @@ import usersRoutes from "./routes/users.js"
|
||||
import statisticsRoutes from "./routes/statistics.js"
|
||||
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||
import { getFlowWorkerHealth, startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
|
||||
import { agentSocket } from "./services/traffic-flow-agent-client.js"
|
||||
import { reconcileFlowHost, withFlowConfigurationLock } from "./services/traffic-flow-host-agent.js"
|
||||
import { initGeoip } from "./services/traffic-flow-geoip.js"
|
||||
|
||||
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 })
|
||||
@@ -147,6 +149,10 @@ export async function buildApp(opts?: {
|
||||
await refreshScheduler()
|
||||
await initGeoip()
|
||||
await startTrafficFlowListener()
|
||||
if (agentSocket()) {
|
||||
try { await withFlowConfigurationLock(reconcileFlowHost) }
|
||||
catch { app.log.warn("NetFlow: агент хоста не синхронизирован; проверьте состояние в настройках NetFlow") }
|
||||
}
|
||||
app.addHook("onClose", async () => {
|
||||
stopScheduler()
|
||||
stopTrafficFlowListener()
|
||||
|
||||
@@ -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"
|
||||
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 propertyFailure = false, propertyReads = 0
|
||||
const applied: Array<{ rows: typeof row[]; source: string }> = []
|
||||
const app = Fastify()
|
||||
app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler)
|
||||
@@ -16,6 +17,7 @@ await app.register(createRecursiveRoutes({
|
||||
getServer: async id => { lookup++; return id === 1 ? server : undefined },
|
||||
getRoutes: async () => { if (lateReadFailure) throw Error("Сбой чтения после применения"); return stored },
|
||||
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[] },
|
||||
apply: async (device, rows, 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)
|
||||
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})
|
||||
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"}]
|
||||
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) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { asc, eq } from "drizzle-orm"
|
||||
import { z } from "zod"
|
||||
@@ -176,7 +176,8 @@ function recursiveFailureReceipt(error: unknown, result?: { pushed: number; dele
|
||||
|
||||
export interface RecursiveRouteDependencies {
|
||||
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>
|
||||
getRoutes: typeof mapDbRoutes
|
||||
apply: typeof applyRecursiveToServer
|
||||
@@ -187,7 +188,15 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
|
||||
const deps: RecursiveRouteDependencies = {
|
||||
getServer: async id => (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0],
|
||||
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) => {
|
||||
await replaceDbRoutes(serverId, 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.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
||||
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)
|
||||
return reply.send({ serverId, routes, live: true, stale: false })
|
||||
} 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.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
||||
try {
|
||||
return reply.send({ serverId, gateways: parseRecursiveGatewayCatalog(await deps.readRaw(server)) })
|
||||
return reply.send({ serverId, gateways: parseRecursiveGatewayCatalog(await deps.readRaw(server, "gateways")) })
|
||||
} catch (error) {
|
||||
return reply.status(503).send({ error: error instanceof Error ? error.message : "Не удалось прочитать шлюзы" })
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type TestConnectionRequest,
|
||||
} from "../types/server.js"
|
||||
import { pollServer } from "../services/poller.js"
|
||||
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
|
||||
import { MikrotikClient, MikrotikError, routerOsAccessHint } from "../services/mikrotik.js"
|
||||
import { resolveRosSrcIpv4 } from "../utils/ros-src-address.js"
|
||||
import {
|
||||
createServer,
|
||||
@@ -56,9 +56,7 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
let message = "Не удалось подключиться"
|
||||
|
||||
if (err instanceof MikrotikError) {
|
||||
if (err.statusCode === 401) message = "Неверный логин или пароль (401 Unauthorized)"
|
||||
else if (err.statusCode === 403) message = "Нет прав доступа к REST API (403 Forbidden)"
|
||||
else message = `Ошибка RouterOS API: ${err.statusCode}`
|
||||
message = routerOsAccessHint(err.statusCode, err.body) ?? `Ошибка RouterOS API: ${err.statusCode}`
|
||||
} else if (err instanceof Error) {
|
||||
const msg = err.message.toLowerCase()
|
||||
if (msg.includes("timeout") || msg.includes("abort"))
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict"
|
||||
import Fastify from "fastify"
|
||||
import { SignJWT } from "jose"
|
||||
import { createTrafficFlowHostRoutes } from "./traffic-flow-host.js"
|
||||
import { emptyHostStatus } from "../services/traffic-flow-agent-client.js"
|
||||
|
||||
process.env.AUTH_REQUIRED = "true"
|
||||
process.env.AUTH_JWT_SECRET = "flow-host-test-secret"
|
||||
process.env.AUTH_ISSUER = "https://auth.test.local"
|
||||
process.env.NODE_ENV = "test"
|
||||
const { default: authPlugin, requireAuth } = await import("../plugins/auth.js")
|
||||
const app = Fastify()
|
||||
await app.register(authPlugin)
|
||||
app.addHook("preHandler", requireAuth)
|
||||
let applied = 0, fail = false
|
||||
let status = emptyHostStatus(false)
|
||||
await app.register(createTrafficFlowHostRoutes({
|
||||
status: async () => status,
|
||||
reconcile: async () => { applied++; if (fail) throw Error("Туннель не изменён: конфликт адресов"); return status },
|
||||
}), { prefix: "/api" })
|
||||
async function token(permission: string) {
|
||||
return new SignJWT({ sub: "fixture", apps: ["mm"], permissions: [permission] }).setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("flow-host-test-secret"))
|
||||
}
|
||||
for (const permission of ["mm:dashboard:read", "mm:traffic:read", "mm:traffic:write", "mm:traffic:admin"]) {
|
||||
const headers = { authorization: "Bearer " + await token(permission) }
|
||||
assert.equal((await app.inject({ method: "GET", url: "/api/traffic/flow/host-status", headers })).statusCode, permission === "mm:dashboard:read" ? 403 : 200)
|
||||
const before = applied
|
||||
const response = await app.inject({ method: "POST", url: "/api/traffic/flow/host-reconcile", headers })
|
||||
const allowed = permission.endsWith(":write") || permission.endsWith(":admin")
|
||||
assert.equal(response.statusCode, allowed ? 409 : 403)
|
||||
assert.equal(applied, before + Number(allowed))
|
||||
}
|
||||
assert.equal((await app.inject({ method: "GET", url: "/api/traffic/flow/host-status" })).statusCode, 401)
|
||||
const headers = { authorization: "Bearer " + await token("mm:traffic:write") }
|
||||
status = { ...emptyHostStatus(true), reachable: true, interfaceUp: true, configMatches: true }
|
||||
let response = await app.inject({ method: "POST", url: "/api/traffic/flow/host-reconcile", headers })
|
||||
assert.equal(response.statusCode, 200); assert.equal(response.json().hostStatus.configMatches, true)
|
||||
fail = true
|
||||
response = await app.inject({ method: "POST", url: "/api/traffic/flow/host-reconcile", headers })
|
||||
assert.equal(response.statusCode, 502); assert.match(response.json().error, /конфликт адресов/)
|
||||
status = emptyHostStatus(true, "offline")
|
||||
response = await app.inject({ method: "GET", url: "/api/traffic/flow/host-status", headers })
|
||||
assert.equal(response.json().configMatches, null); assert.equal(response.json().reachable, false)
|
||||
await app.close()
|
||||
console.log("NetFlow host HTTP/JWT: read/write permissions, manual mode, reconcile success/conflict and offline status")
|
||||
@@ -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"
|
||||
import { createTrafficFlowPurgeRoutes } from "./traffic-flow-purge.js"
|
||||
import { createTrafficFlowSettingsRoutes } from "./traffic-flow-settings.js"
|
||||
import { createTrafficFlowHostRoutes } from "./traffic-flow-host.js"
|
||||
import { getFlowHostStatus, reconcileFlowHost, withFlowConfigurationLock } from "../services/traffic-flow-host-agent.js"
|
||||
import { agentSocket } from "../services/traffic-flow-agent-client.js"
|
||||
import {
|
||||
ensureHostKeys,
|
||||
getTrafficFlowSettingsRow,
|
||||
@@ -16,6 +19,7 @@ import {
|
||||
} from "../services/traffic-flow-settings.js"
|
||||
import {
|
||||
getFlowListenerState,
|
||||
getFlowRuntimeCounters,
|
||||
purgeTrafficFlowStore,
|
||||
startTrafficFlowListener,
|
||||
listFlowTalkers,
|
||||
@@ -150,11 +154,25 @@ function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
}
|
||||
|
||||
const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
await app.register(createTrafficFlowHostRoutes({
|
||||
status: getFlowHostStatus,
|
||||
reconcile: () => withFlowConfigurationLock(reconcileFlowHost),
|
||||
}))
|
||||
await app.register(createTrafficFlowSettingsRoutes({
|
||||
read: () => toTrafficFlowSettingsDto(getFlowListenerState()),
|
||||
update: async patch => { await updateTrafficFlowSettings(patch) },
|
||||
read: async () => {
|
||||
const settings = await toTrafficFlowSettingsDto(getFlowListenerState())
|
||||
const { packetsReceived, lastExporterIp, lastError, lastDatagramAt } = await getFlowRuntimeCounters()
|
||||
return { ...settings, packetsReceived, lastExporterIp, lastError, lastDatagramAt }
|
||||
},
|
||||
update: async patch => { await withFlowConfigurationLock(async () => {
|
||||
const current = await getTrafficFlowSettingsRow()
|
||||
if (agentSocket() && (["collectorIp", "prefix", "flowListenPort", "wgListenPort"] as const).some(key => patch[key] !== undefined && patch[key] !== current[key])) {
|
||||
throw Object.assign(new Error("Адрес, префикс и порты закреплены установкой агента; повторите установщик хоста"), { statusCode: 409 })
|
||||
}
|
||||
await updateTrafficFlowSettings(patch)
|
||||
}) },
|
||||
startListener: startTrafficFlowListener,
|
||||
ensureKeys: ensureHostKeys,
|
||||
ensureKeys: () => withFlowConfigurationLock(ensureHostKeys),
|
||||
hasPrivateKey: async () => Boolean((await getTrafficFlowSettingsRow()).hostPrivateKey),
|
||||
hostFiles: listTrafficFlowHostFiles,
|
||||
}))
|
||||
|
||||
@@ -1,7 +1,56 @@
|
||||
import { test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { createServer } from "node:http"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
import { MikrotikClient, MikrotikError, routerOsAccessHint } from "./mikrotik.js"
|
||||
|
||||
test("RouterOS access diagnostics distinguish login saturation without assuming a wrong password or retrying", async () => {
|
||||
let requests = 0
|
||||
const body = '{"detail":"too many concurrent logins (6)","error":500,"message":"Internal Server Error"}'
|
||||
const server = createServer((req, res) => { requests++; req.resume(); res.writeHead(500); res.end(body) })
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
|
||||
const address = server.address(); assert(address && typeof address !== "string")
|
||||
const client = new MikrotikClient({ host: "127.0.0.1", port: address.port, useSsl: false, verifySsl: false, username: "fixture", password: "fixture" })
|
||||
try {
|
||||
for (const operation of [() => client.get("/interface"), () => client.put("/own", {})]) {
|
||||
await assert.rejects(operation, (error: unknown) => {
|
||||
assert(error instanceof MikrotikError)
|
||||
assert.equal(error.statusCode, 500); assert.equal(error.body, body)
|
||||
assert.match(error.message, /превышен лимит одновременных входов/)
|
||||
return true
|
||||
})
|
||||
}
|
||||
assert.equal(requests, 2, "Neither reads nor writes may silently retry a refused login")
|
||||
assert.match(routerOsAccessHint(401, '{"message":"Unauthorized"}')!, /ограничения входа/)
|
||||
assert.doesNotMatch(routerOsAccessHint(401, "")!, /Неверный логин или пароль/)
|
||||
for (const unknown of ["not JSON", "null", '{"detail":6}', '{"detail":"disk failure"}']) {
|
||||
assert.equal(routerOsAccessHint(500, unknown), undefined)
|
||||
}
|
||||
assert.equal(routerOsAccessHint(400, body), undefined)
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
||||
}
|
||||
})
|
||||
|
||||
test("RouterOS print preserves native query arrays and property selection", async () => {
|
||||
let received: unknown, target = "", method = ""
|
||||
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 () => {
|
||||
let active=0,peak=0,requests=0
|
||||
|
||||
@@ -104,7 +104,7 @@ function rosRequest(
|
||||
function rosPost(
|
||||
params: MikrotikConnectParams,
|
||||
path: string,
|
||||
body: Record<string, string>,
|
||||
body: Record<string, string | string[]>,
|
||||
timeoutMs: number,
|
||||
externalSignal?: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
@@ -595,7 +595,7 @@ export class MikrotikClient {
|
||||
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>
|
||||
}
|
||||
|
||||
@@ -1110,13 +1110,29 @@ export class MikrotikClient {
|
||||
|
||||
// ── Error type ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** HTTP 401 alone does not distinguish wrong credentials from a refused login. */
|
||||
export function routerOsAccessHint(statusCode: number, body: string): string | undefined {
|
||||
if (statusCode === 401) return "RouterOS отклонил авторизацию: проверьте учётные данные, права API/REST и ограничения входа"
|
||||
if (statusCode === 403) return "RouterOS запретил доступ к REST API"
|
||||
if (statusCode === 500) {
|
||||
try {
|
||||
const detail: unknown = JSON.parse(body)?.detail
|
||||
if (typeof detail === "string" && /^too many concurrent logins(?:\s*\(\d+\))?$/i.test(detail.trim())) {
|
||||
return "RouterOS занят: превышен лимит одновременных входов"
|
||||
}
|
||||
} catch { /* Keep an unrecognized response unchanged. */ }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export class MikrotikError extends Error {
|
||||
constructor(
|
||||
public readonly statusCode: number,
|
||||
public readonly path: string,
|
||||
public readonly body: string,
|
||||
) {
|
||||
super(`RouterOS API error ${statusCode} on ${path}: ${body}`)
|
||||
const hint = routerOsAccessHint(statusCode, body)
|
||||
super(`${hint ? `${hint}. ` : ""}RouterOS API error ${statusCode} on ${path}: ${body}`)
|
||||
this.name = "MikrotikError"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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 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[] {
|
||||
return rawRows(payload).filter(row => typeof row.comment === "string" && isOwnedRecursiveComment(row.comment)).map(row => {
|
||||
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("[email protected]"))
|
||||
assert.ok(script.includes("docker-compose.netflow-agent.yml"))
|
||||
assert.ok(!script.includes("\r"))
|
||||
assert.throws(() => buildFlowAgentInstaller("MM_CONFIG_EOF\n", 4739, source))
|
||||
assert.throws(() => buildFlowAgentInstaller(conf, 0, source))
|
||||
console.log("NetFlow agent installer export: validated snapshot, custom port/path, embedded installer and LF-only output")
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Call only with a configuration already validated by buildTrafficFlowHostFiles. */
|
||||
export function buildFlowAgentInstaller(conf: string, flowPort: number, installer: string): string {
|
||||
if (!Number.isInteger(flowPort) || flowPort < 1 || flowPort > 65535 || conf.split(/\r?\n/).includes("MM_CONFIG_EOF")) {
|
||||
throw new Error("Некорректная конфигурация установщика NetFlow")
|
||||
}
|
||||
const clean = (text: string) => text.replaceAll("\r\n", "\n").replace(/\s*$/, "") + "\n"
|
||||
return "#!/usr/bin/env bash\nset -euo pipefail\numask 077\nEXPORT_DIR=$(mktemp -d)\ntrap 'rm -rf -- \"$EXPORT_DIR\"' EXIT\ncat > \"$EXPORT_DIR/wg-flow.conf\" <<'MM_CONFIG_EOF'\n" + clean(conf) + "MM_CONFIG_EOF\ncat > \"$EXPORT_DIR/install.sh\" <<'MM_INSTALL_EOF'\n" + clean(installer) + "MM_INSTALL_EOF\nbash \"$EXPORT_DIR/install.sh\" \"$EXPORT_DIR/wg-flow.conf\" \"${1:-/opt/cdn-mm}\" " + flowPort + "\n"
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
ingestParsedFlowsForServerForTests,
|
||||
resetFlowRingsForTests,
|
||||
} from "./traffic-flow-ingest.js"
|
||||
import { buildFlowAnalytics, formatLiveSseFromBuilder, getFlowMonthly, listFlowClients, listFlowExporters } from "./traffic-flow-analytics.js"
|
||||
import { buildFlowAnalytics, formatLiveSseFromBuilder, getFlowMonthly, listFlowClients, listFlowExporters, resetFlowAnalyticsCacheForTests } from "./traffic-flow-analytics.js"
|
||||
import { withPgOrSkip } from "../test/pg.js"
|
||||
import { dbQuery } from "../db/index.js"
|
||||
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
|
||||
@@ -556,6 +556,22 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
// Historical 5m/15m charts survive an empty live ring (e.g. immediately after restart).
|
||||
resetFlowRingsForTests()
|
||||
await dbQuery("DELETE FROM flow_buckets WHERE server_id=$1", [serverId])
|
||||
for (const minutesAgo of [2, 3, 4, 10]) {
|
||||
await dbQuery(`INSERT INTO flow_buckets (server_id,bucket_at,src,dst,in_iface,bytes,packets)
|
||||
VALUES ($1,date_trunc('minute',now())-($2::int*interval '1 minute'),'10.1.1.8','8.8.8.8','2',60000,10)`, [serverId, minutesAgo])
|
||||
}
|
||||
for (const minutes of [5, 15]) for (const dedup of [true, false]) {
|
||||
resetFlowAnalyticsCacheForTests()
|
||||
const history = await buildFlowAnalytics({ minutes, serverId, dedup, excludeMesh:false, excludeOverlay:false })
|
||||
assert.equal(history.rxSeries.length, minutes)
|
||||
assert.equal(history.rxSeries.filter(v => v > 0).length, minutes === 5 ? 3 : 4)
|
||||
const missing = await buildFlowAnalytics({ minutes, serverId, iface:"not-an-interface" })
|
||||
assert(missing.rxSeries.every(v => v === 0), "Historical chart respects interface filter")
|
||||
}
|
||||
|
||||
} finally {
|
||||
for (const table of ["flow_daily_dims", "flow_hour_facts", "flow_daily_facts", "flow_buckets", "traffic_samples", "user_interface_bindings", "server_snapshots"]) {
|
||||
await dbQuery("DELETE FROM " + table + " WHERE server_id = $1", [serverId])
|
||||
|
||||
@@ -423,9 +423,10 @@ async function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): Promise<FlowAn
|
||||
? getRingMbps(ringServer, ifaceFilter === "" ? "__all__" : (ifacesMap.get(ifaceFilter)?.index || ifaceFilter))
|
||||
: { rx: Array(60).fill(0) as number[], tx: Array(60).fill(0) as number[], rxNow: 0, txNow: 0 }
|
||||
|
||||
// The live ring covers about one minute; historical charts must use the requested window.
|
||||
const fromBuckets = seriesFromRows(matched, q.minutes)
|
||||
const rxSeries = q.minutes <= 15 ? ring.rx : fromBuckets.rx
|
||||
const txSeries = q.minutes <= 15 ? ring.tx : fromBuckets.tx
|
||||
const rxSeries = fromBuckets.rx
|
||||
const txSeries = fromBuckets.tx
|
||||
|
||||
const ifaceRows = [...ifacesMap.entries()]
|
||||
.sort((a, b) => b[1].bytes - a[1].bytes)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { withAdvisoryLock } from "../db/index.js"
|
||||
import { parseJsonArray } from "../db/json.js"
|
||||
import { getTrafficFlowSettingsRow, invalidateTrafficFlowSettingsCache } from "./traffic-flow-settings.js"
|
||||
import { agentSocket, callFlowAgent, emptyHostStatus, type FlowAgentSnapshot } from "./traffic-flow-agent-client.js"
|
||||
import { FlowConfigurationQueue } from "./traffic-flow-operation-queue.js"
|
||||
|
||||
// Session lock: RouterOS/network operations use other pooled connections. Never lock the settings row here.
|
||||
const queue = new FlowConfigurationQueue()
|
||||
export const withFlowConfigurationLock = <T>(fn: () => Promise<T>) => queue.run(() => withAdvisoryLock(7340291, async () => {
|
||||
invalidateTrafficFlowSettingsCache()
|
||||
return fn()
|
||||
}))
|
||||
|
||||
async function snapshot(): Promise<FlowAgentSnapshot> {
|
||||
const row = await getTrafficFlowSettingsRow()
|
||||
return {
|
||||
collectorIp: row.collectorIp, prefix: row.prefix, flowListenPort: row.flowListenPort, wgListenPort: row.wgListenPort,
|
||||
hostPublicKey: row.hostPublicKey, hostPrivateKey: row.hostPrivateKey,
|
||||
peers: (parseJsonArray(row.peersJson) as FlowAgentSnapshot["peers"]).map(p => ({
|
||||
serverId: p.serverId, publicKey: p.publicKey, allowedIps: p.allowedIps, ...(p.endpoint ? { endpoint: p.endpoint } : {}),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFlowHostStatus() {
|
||||
if (!agentSocket()) return emptyHostStatus(false)
|
||||
try { return await callFlowAgent("/check", await snapshot()) }
|
||||
catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Проверка агента не выполнена"
|
||||
// Preserve live measurements on a settings conflict; never claim configuration agreement.
|
||||
try { return { ...await callFlowAgent("/status"), configMatches: false, error: message } }
|
||||
catch { return emptyHostStatus(true, message) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function preflightFlowHost() {
|
||||
if (!agentSocket()) return
|
||||
try { await callFlowAgent("/check", await snapshot()) }
|
||||
catch (error) { throw Object.assign(new Error(error instanceof Error ? error.message : "Агент NetFlow не готов"), { statusCode: 409 }) }
|
||||
}
|
||||
|
||||
// Caller holds withFlowConfigurationLock. This is also used after saving a newly discovered router key.
|
||||
export async function reconcileFlowHost() {
|
||||
if (!agentSocket()) return emptyHostStatus(false)
|
||||
return callFlowAgent("/apply", await snapshot())
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
|
||||
import { parseJsonArray } from "../db/json.js"
|
||||
import { buildTrafficFlowHostFiles } from "./traffic-flow-host-file-builders.js"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { buildFlowAgentInstaller } from "./traffic-flow-agent-install.js"
|
||||
|
||||
export async function listTrafficFlowHostFiles() {
|
||||
const row = await getTrafficFlowSettingsRow()
|
||||
return buildTrafficFlowHostFiles({ ...row, peers: parseJsonArray(row.peersJson) })
|
||||
const files = buildTrafficFlowHostFiles({ ...row, peers: parseJsonArray(row.peersJson) })
|
||||
// The same source is used by a repository install and by the downloadable self-contained installer.
|
||||
const installer = await readFile(new URL("../../../deploy/netflow-agent/install.sh", import.meta.url), "utf8")
|
||||
const conf = files.find(file => file.id === "wg-quick")!.code
|
||||
const code = buildFlowAgentInstaller(conf, row.flowListenPort, installer)
|
||||
return [{ id: "agent", label: "Агент Docker", filename: "install-netflow-agent.sh", code }, ...files]
|
||||
}
|
||||
@@ -439,7 +439,7 @@ export async function listStoredFlowRows(sinceIso: string): Promise<PendingFlowR
|
||||
|
||||
export async function listFlowRowsForWindow(minutes: number): Promise<PendingFlowRow[]> {
|
||||
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
|
||||
if (minutes <= 15 && !worker) return await listLiveFlowRows(sinceIso)
|
||||
// History remains available while the collector starts or is stopped; pending rows are merged below.
|
||||
return await listStoredFlowRows(sinceIso)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { FlowConfigurationQueue } from "./traffic-flow-operation-queue.js"
|
||||
const queue = new FlowConfigurationQueue()
|
||||
let release!: () => void, active = 0, maximum = 0
|
||||
const hold = new Promise<void>(resolve => { release = resolve })
|
||||
const events: number[] = []
|
||||
const tasks = Array.from({ length: 32 }, (_, i) => queue.run(async () => {
|
||||
active++; maximum = Math.max(active, maximum); events.push(i)
|
||||
try { if (i === 0) await hold; if (i === 1) throw Error("fixture failure"); return i }
|
||||
finally { active-- }
|
||||
}))
|
||||
const done = Promise.allSettled(tasks)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.deepEqual(events, [0]); assert.equal(active, 1)
|
||||
release()
|
||||
const results = await done
|
||||
assert.equal(maximum, 1); assert.equal(results[1].status, "rejected")
|
||||
assert.equal(results.filter(result => result.status === "fulfilled").length, 31)
|
||||
assert.deepEqual(events, Array.from({ length: 32 }, (_, i) => i))
|
||||
assert.equal(await queue.run(async () => "recovered"), "recovered")
|
||||
console.log("NetFlow queue: 32 concurrent operations use one lock waiter, failures release the queue")
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Only one local operation may wait for the PostgreSQL advisory lock.
|
||||
* Otherwise queued requests could exhaust the pool needed by its current owner.
|
||||
*/
|
||||
export class FlowConfigurationQueue {
|
||||
private tail: Promise<void> = Promise.resolve()
|
||||
|
||||
run<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.tail.then(operation)
|
||||
this.tail = result.then(() => undefined, () => undefined)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
import { refreshFlowExporterMap, startTrafficFlowListener } from "./traffic-flow-ingest.js"
|
||||
import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js"
|
||||
import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js"
|
||||
import { agentSocket } from "./traffic-flow-agent-client.js"
|
||||
import { getFlowHostStatus, preflightFlowHost, reconcileFlowHost, withFlowConfigurationLock } from "./traffic-flow-host-agent.js"
|
||||
|
||||
const IFACE_NAME = "wg-flow"
|
||||
const JH_LISTEN_PORT = 13232
|
||||
@@ -202,8 +204,16 @@ export function usablePublicHost(raw: string | undefined): string {
|
||||
export async function applyFlowOverlay(
|
||||
serverIdRaw: string | number,
|
||||
opts?: { publicEndpoint?: string; requestHost?: string; disableGreFastPath?: boolean },
|
||||
): Promise<TrafficFlowOverlayResult> {
|
||||
return withFlowConfigurationLock(() => applyFlowOverlayLocked(serverIdRaw, opts))
|
||||
}
|
||||
|
||||
async function applyFlowOverlayLocked(
|
||||
serverIdRaw: string | number,
|
||||
opts?: { publicEndpoint?: string; requestHost?: string; disableGreFastPath?: boolean },
|
||||
): Promise<TrafficFlowOverlayResult> {
|
||||
const steps: string[] = []
|
||||
await preflightFlowHost()
|
||||
const keys = await ensureHostKeys()
|
||||
const settings = await getTrafficFlowSettingsRow()
|
||||
const hostPublicKey = settings.hostPublicKey || keys.publicKey
|
||||
@@ -338,8 +348,21 @@ export async function applyFlowOverlay(
|
||||
await refreshFlowExporterMap()
|
||||
steps.push("Коллектор IPFIX на MM включён")
|
||||
|
||||
let hostStatus = undefined
|
||||
let hostError: string | undefined
|
||||
if (agentSocket()) {
|
||||
try {
|
||||
hostStatus = await reconcileFlowHost()
|
||||
steps.push("Пир применён на Linux-хосте без перезапуска WireGuard")
|
||||
} catch (error) {
|
||||
hostError = error instanceof Error ? error.message : "Настройки хоста не применены"
|
||||
hostStatus = await getFlowHostStatus()
|
||||
steps.push("MikroTik настроен; требуется повторное применение настроек хоста")
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
ok: !hostError,
|
||||
serverId: server.id,
|
||||
interfaceName: IFACE_NAME,
|
||||
address,
|
||||
@@ -347,11 +370,14 @@ export async function applyFlowOverlay(
|
||||
linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host, endpointHost),
|
||||
trafficFlow: true,
|
||||
steps,
|
||||
hostFiles: await listTrafficFlowHostFiles(),
|
||||
hostFiles: agentSocket() ? [] : await listTrafficFlowHostFiles(),
|
||||
stage: hostError ? "host-pending" : agentSocket() ? "applied" : "manual",
|
||||
...(hostStatus ? { hostStatus } : {}),
|
||||
...(hostError ? { hostError } : {}),
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
|
||||
const err = Object.assign(new Error(`RouterOS: ${msg}`), { statusCode: 502 })
|
||||
const err = Object.assign(new Error(`Настройка MikroTik могла быть выполнена частично: ${msg}. Проверьте состояние и повторите подключение.`), { statusCode: 502 })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ const observed=(rows:ReturnType<typeof observationSample>[])=>buildTrafficFromSa
|
||||
assert.ok(observed([]).observations?.rxSeries.every(v=>!v));assert.equal(observed([]).observations?.rxNow,false)
|
||||
assert.equal(observed([observationSample(0,10,10)]).observations?.rxNow,false)
|
||||
const observedZero=observed([observationSample(0,10,10),observationSample(30_000,10,10)])
|
||||
assert.equal(observedZero.rxNow,0);assert.equal(observedZero.observations?.rxNow,true);assert.equal(observedZero.observations?.rxSeries.filter(Boolean).length,1)
|
||||
assert.equal(observedZero.rxNow,0);assert.equal(observedZero.observations?.rxNow,true);assert.equal(observedZero.observations?.rxSeries.filter(Boolean).length,30)
|
||||
const partialReset=observed([observationSample(0,10,10),observationSample(30_000,1,20)])
|
||||
assert.equal(partialReset.observations?.rxNow,false);assert.equal(partialReset.observations?.txNow,true)
|
||||
const fullReset=observed([observationSample(0,10,10),observationSample(20_000,20,20),observationSample(40_000,1,1)])
|
||||
@@ -147,3 +147,28 @@ console.log("traffic observation metadata tests ok")
|
||||
const complete=observed([observationSample(0,10,10),observationSample(30_000,20,20),observationSample(0,10,10,"ether2"),observationSample(30_000,20,20,"ether2")]);assert.equal(complete.observations?.rxNow,true)
|
||||
const badTime=observed([{...observationSample(0,10,10),sampledAt:"invalid"},observationSample(30_000,20,20)]);assert.equal(badTime.observations?.rxNow,false)
|
||||
assert.equal(rateBpsFromDelta(0,Number.MAX_VALUE,0,1000),null)
|
||||
|
||||
// A 30-second collector must produce a line across measured intervals on short ranges.
|
||||
for (const minutes of [5, 15]) {
|
||||
const span = minutes * 60_000
|
||||
const rows = Array.from({ length: span / 30_000 + 1 }, (_, i) => ({
|
||||
...observationSample(i * 30_000, i * 3_750_000, i * 1_875_000),
|
||||
continuityKnown: true, nativeCounterIdentity: "ether1-v1",
|
||||
}))
|
||||
const history = buildTrafficFromSamples(rows, observationStart, observationStart + span, undefined, undefined, true)
|
||||
assert(history.observations!.rxSeries.every(Boolean), `${minutes}m continuous measured RX`)
|
||||
assert(history.observations!.txSeries.every(Boolean), `${minutes}m continuous measured TX`)
|
||||
assert(history.rxSeries.every(v => v === 1)); assert(history.txSeries.every(v => v === 0.5))
|
||||
assert.equal(history.rxTotalGiB, (span / 30_000) * 3_750_000 / 1024 ** 3)
|
||||
// Explicit loss of continuity stays unknown; it must not be bridged by chart rendering.
|
||||
rows[2].continuityKnown = false
|
||||
const gap = buildTrafficFromSamples(rows, observationStart, observationStart + span, undefined, undefined, true)
|
||||
assert.equal(gap.observations!.rxSeries.filter(v => !v).length, 30_000 / (span / 60))
|
||||
assert(gap.observations!.rxSeries.slice(0, 30_000 / (span / 60)).every(Boolean))
|
||||
const partial = buildTrafficFromSamples(rows.slice(4), observationStart, observationStart + span, undefined, undefined, true)
|
||||
assert(partial.observations!.rxSeries.slice(0, 120_000 / (span / 60)).every(v => !v), "No history invented before first sample")
|
||||
}
|
||||
// Unequal poll intervals contribute by covered duration when sharing a chart bucket.
|
||||
const weighted = observed([observationSample(0,0,0),observationSample(500,62_500,62_500),observationSample(1500,312_500,312_500)])
|
||||
assert.equal(weighted.rxSeries[0], 1.5)
|
||||
assert.equal(weighted.rxSeries[1], 2)
|
||||
@@ -114,6 +114,34 @@ function parseIsoMs(iso: string): number {
|
||||
return Date.parse(iso)
|
||||
}
|
||||
|
||||
/** A counter delta measures its entire interval, not just the next poll's timestamp. */
|
||||
function intervalSeries(
|
||||
waves: Map<number, PeakWave>, start: number, end: number, side: "rx" | "tx",
|
||||
): { values: number[]; observed: boolean[] } {
|
||||
const sums = Array(SERIES_POINTS).fill(0) as number[]
|
||||
const durations = Array(SERIES_POINTS).fill(0) as number[]
|
||||
const unknown = Array(SERIES_POINTS).fill(false) as boolean[]
|
||||
const width = (end - start) / SERIES_POINTS
|
||||
if (!(width > 0)) return { values: sums, observed: unknown }
|
||||
for (const [at, wave] of waves) {
|
||||
const from = Math.max(start, wave.start), to = Math.min(end, at)
|
||||
if (!(to > from)) continue
|
||||
const first = Math.max(0, Math.floor((from - start) / width))
|
||||
const last = Math.min(SERIES_POINTS - 1, Math.ceil((to - start) / width) - 1)
|
||||
for (let i = first; i <= last; i++) {
|
||||
const overlap = Math.min(to, start + (i + 1) * width) - Math.max(from, start + i * width)
|
||||
if (!(overlap > 0)) continue
|
||||
sums[i] += wave[side] * overlap
|
||||
durations[i] += overlap
|
||||
unknown[i] ||= !wave[side === "rx" ? "rxKnown" : "txKnown"]
|
||||
}
|
||||
}
|
||||
return {
|
||||
values: sums.map((sum, i) => durations[i] > 0 ? sum / durations[i] : 0),
|
||||
observed: durations.map((duration, i) => duration > 0 && !unknown[i]),
|
||||
}
|
||||
}
|
||||
|
||||
export function sampleSeriesKey(interfaceName: string, peerPublicKey = ""): string {
|
||||
return `${interfaceName}\0${peerPublicKey}`
|
||||
}
|
||||
@@ -232,8 +260,6 @@ export function buildTrafficFromSamples(
|
||||
const latestWave = membership.get(latestMembershipAt)
|
||||
if (latestWave?.declared) sessions = latestWave.active
|
||||
|
||||
const rxSeries = bucketAvg(rxPoints, rangeStartMs, rangeEndMs)
|
||||
const txSeries = bucketAvg(txPoints, rangeStartMs, rangeEndMs)
|
||||
const lastTs = [...byTs.keys()].sort((a, b) => a - b).at(-1)
|
||||
const last = lastTs != null ? byTs.get(lastTs) : undefined
|
||||
const peak = (points: Array<{t:number;v:number}>, side: "rxObserved"|"txObserved") => points.reduce((m,p) => {
|
||||
@@ -249,6 +275,8 @@ export function buildTrafficFromSamples(
|
||||
const complete = wave.sameStart && wave.count === expectedAt(t)
|
||||
waves.set(t,{start:wave.start,rx:wave.rx,tx:wave.tx,rxKnown:complete&&wave.rxObserved,txKnown:complete&&wave.txObserved})
|
||||
}
|
||||
const rxHistory = intervalSeries(waves, rangeStartMs, rangeEndMs, "rx")
|
||||
const txHistory = intervalSeries(waves, rangeStartMs, rangeEndMs, "tx")
|
||||
return retainPeakWaves({
|
||||
rxNow: last?.rx ?? 0,
|
||||
txNow: last?.tx ?? 0,
|
||||
@@ -257,13 +285,13 @@ export function buildTrafficFromSamples(
|
||||
rxTotalGiB: rxBytesDelta / (1024 ** 3),
|
||||
txTotalGiB: txBytesDelta / (1024 ** 3),
|
||||
sessions,
|
||||
rxSeries,
|
||||
txSeries,
|
||||
rxSeries: rxHistory.values,
|
||||
txSeries: txHistory.values,
|
||||
...(includeObservations?{observations:{
|
||||
rxNow:lastAttemptAt!==null&&lastTs===lastAttemptAt&&!!last?.rxObserved&&last.count===expectedAt(lastTs!),
|
||||
txNow:lastAttemptAt!==null&&lastTs===lastAttemptAt&&!!last?.txObserved&&last.count===expectedAt(lastTs!),
|
||||
rxSeries:bucketAvg([...byTs].map(([t,v])=>({t,v:Number(v.rxObserved&&v.count===expectedAt(t)&&t>=rangeStartMs&&t<=rangeEndMs)})),rangeStartMs,rangeEndMs).map(v=>v===1),
|
||||
txSeries:bucketAvg([...byTs].map(([t,v])=>({t,v:Number(v.txObserved&&v.count===expectedAt(t)&&t>=rangeStartMs&&t<=rangeEndMs)})),rangeStartMs,rangeEndMs).map(v=>v===1),
|
||||
rxSeries:rxHistory.observed,
|
||||
txSeries:txHistory.observed,
|
||||
sampledAt:lastAttemptAt===null?null:new Date(lastAttemptAt).toISOString(),
|
||||
measurementIntervalMs:lastTs===lastAttemptAt?last?.intervalMs??null:null,
|
||||
rxPeak:[...waves.values()].some(w=>w.rxKnown),
|
||||
|
||||
@@ -77,7 +77,7 @@ function FilterGatewayCell({
|
||||
? recursiveRoutes.find((r) => r.id === rule.gatewayTunnelId.slice(4))
|
||||
: undefined
|
||||
const recRowByHop =
|
||||
!isBlackhole && !(rule.gatewayTunnelId ?? "").trim() && rule.gateway.trim()
|
||||
!isBlackhole && !recRowByRef && (isRecRef || !(rule.gatewayTunnelId ?? "").trim()) && rule.gateway.trim()
|
||||
? pickRecursiveRouteByGatewayHop(recursiveRoutes, rule.gateway)
|
||||
: undefined
|
||||
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,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import { FlowHostPanel } from "./flow-host-panel"
|
||||
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
|
||||
import type { ServerRead } from "@mmapp/contracts/servers"
|
||||
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
||||
@@ -95,7 +96,8 @@ function FlowOverlaySheet({
|
||||
})
|
||||
setResult(res)
|
||||
setTab(res.hostFiles[0]?.id ?? "linux")
|
||||
toast.success(`wg-flow на ${res.address}`)
|
||||
if (res.ok) toast.success(`wg-flow на ${res.address}`)
|
||||
else toast.warning(res.hostError || "MikroTik настроен; требуется применение на хосте")
|
||||
onDone?.(res)
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Не удалось подключить JH")
|
||||
@@ -125,7 +127,7 @@ function FlowOverlaySheet({
|
||||
Подключить jump-host
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Создаст wg-flow на MikroTik (сервер, listen 13232) и выдаст готовый bash для Linux-хоста Docker MM (клиент).
|
||||
Настроит wg-flow и IPFIX на MikroTik. Если агент Docker установлен, автоматически применит пир на Linux-хосте MM.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
|
||||
@@ -170,13 +172,14 @@ function FlowOverlaySheet({
|
||||
</FormField>
|
||||
{result ? (
|
||||
<div className="flex min-h-0 flex-col gap-4">
|
||||
<Alert variant="success">
|
||||
<Alert variant={result.stage === "host-pending" ? "warning" : "success"}>
|
||||
<InfoIcon />
|
||||
<AlertTitle>Linux-хост /opt/cdn-mm</AlertTitle>
|
||||
<AlertTitle>{result.stage === "applied" ? "Настройки применены" : result.stage === "host-pending" ? "MikroTik настроен, хост ожидает применения" : "Установка агента на Linux-хосте"}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Скопируйте вкладку Linux и выполните от root. WG — клиент к JH:13232; контейнер слушает только WG-IP:4739, не 0.0.0.0. Ключ не кладите в git.
|
||||
{result.hostError || (result.stage === "applied" ? "Пир добавлен без перезапуска WireGuard. Ожидайте handshake и поступления IPFIX в панели NetFlow." : "Выполните установщик из вкладки «Агент Docker» от root на сервере MM. Он однократно пересоздаст backend. Экспорт содержит приватный ключ — храните его как секрет.")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<FlowHostPanel backendUrl={backendUrl} />
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{result.steps.map((s) => (
|
||||
<li key={s} className="flex items-start gap-2 text-sm">
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Input } from "@/components/ui/input"
|
||||
import { CodeExportSheet, type CodeExportFormat } from "@/components/reui-kit/code-export-sheet"
|
||||
import type { TrafficFlowSettingsDto } from "@mmapp/contracts/traffic-flow"
|
||||
import { GeoipSettingsSection } from "./geoip-settings-section"
|
||||
import { FlowHostPanel } from "./flow-host-panel"
|
||||
import {
|
||||
generateTrafficFlowKeys,
|
||||
getTrafficFlowHostFiles,
|
||||
@@ -24,11 +25,11 @@ import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic
|
||||
import { KeyRoundIcon, DownloadIcon, InfoIcon, AlertCircleIcon } from "lucide-react"
|
||||
|
||||
const HOST_STEPS = [
|
||||
"На хосте Docker (не в контейнере mmapp-backend): apt install wireguard (или эквивалент).",
|
||||
"Скачайте wg-flow.conf и положите в /etc/wireguard/wg-flow.conf.",
|
||||
"wg-quick up wg-flow (или systemctl enable --now wg-quick@wg-flow).",
|
||||
"Создайте ключи хоста, откройте «Файлы хоста» и скачайте вкладку «Агент Docker».",
|
||||
"Выполните sudo bash install-netflow-agent.sh /opt/cdn-mm на хосте Docker. При установке backend будет пересоздан.",
|
||||
"Обновите статус хоста. Дальнейшие подключения автоматически применяются агентом.",
|
||||
"Firewall: разрешите UDP listen WireGuard. UDP 4739 наружу не открывайте.",
|
||||
"В docker-compose у backend раскомментируйте bind IPFIX только на адресе wg-flow.",
|
||||
"Установщик публикует IPFIX только на адресе wg-flow. Ручные файлы доступны в остальных вкладках экспорта.",
|
||||
"Проверка: wg show · ss -ulnp | grep 4739 · в этой панели — last datagram.",
|
||||
]
|
||||
|
||||
@@ -43,6 +44,7 @@ function NetflowSettingsContent({
|
||||
enabled: boolean
|
||||
}) {
|
||||
const [settings,setSettings] = useState<TrafficFlowSettingsDto|null>(null)
|
||||
const [automatic,setAutomatic] = useState(false)
|
||||
const [draft,setDraft] = useState<NetflowDraft>(emptyNetflowDraft)
|
||||
const dirty = useRef(new Set<keyof NetflowDraft>())
|
||||
const [dirtyCount,setDirtyCount] = useState(0)
|
||||
@@ -147,16 +149,16 @@ function NetflowSettingsContent({
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormField label="Collector IP" hint="Адрес в туннеле, куда JH шлёт flow">
|
||||
<Input className="font-mono" aria-label="Collector IP" disabled={disabled} value={draft.collectorIp} onChange={(e) => change("collectorIp", e.target.value)} />
|
||||
<Input className="font-mono" aria-label="Collector IP" disabled={disabled || automatic} value={draft.collectorIp} onChange={(e) => change("collectorIp", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Префикс overlay">
|
||||
<Input className="font-mono" aria-label="Префикс overlay" disabled={disabled} value={draft.prefix} onChange={(e) => change("prefix", e.target.value)} />
|
||||
<Input className="font-mono" aria-label="Префикс overlay" disabled={disabled || automatic} value={draft.prefix} onChange={(e) => change("prefix", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="UDP IPFIX">
|
||||
<Input className="font-mono" aria-label="UDP IPFIX" disabled={disabled} value={draft.flowListenPort} onChange={(e) => change("flowListenPort", e.target.value)} />
|
||||
<Input className="font-mono" aria-label="UDP IPFIX" disabled={disabled || automatic} value={draft.flowListenPort} onChange={(e) => change("flowListenPort", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="WG listen">
|
||||
<Input className="font-mono" aria-label="WG listen" disabled={disabled} value={draft.wgListenPort} onChange={(e) => change("wgListenPort", e.target.value)} />
|
||||
<Input className="font-mono" aria-label="WG listen" disabled={disabled || automatic} value={draft.wgListenPort} onChange={(e) => change("wgListenPort", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Публичный endpoint хоста MM" hint="IP или DNS, который видят JH">
|
||||
<Input className="font-mono" aria-label="Публичный endpoint хоста MM" disabled={disabled} value={draft.publicEndpoint} onChange={(e) => change("publicEndpoint", e.target.value)} placeholder="203.0.113.10" />
|
||||
@@ -201,14 +203,15 @@ function NetflowSettingsContent({
|
||||
{settings?.lastError ? ` · ${settings.lastError}` : ""}
|
||||
</p>
|
||||
|
||||
<div className="rounded-md border px-4 py-3 flex flex-col gap-2">
|
||||
<FlowHostPanel backendUrl={backendUrl} enabled={enabled} onConfigured={setAutomatic} />
|
||||
{automatic ? <p className="text-xs text-muted-foreground">Адрес, префикс и порты закреплены установкой агента. Для их изменения повторите установщик.</p> : <div className="rounded-md border px-4 py-3 flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">Туннель на сервере Docker MM</p>
|
||||
<ol className="text-xs text-muted-foreground flex flex-col gap-1.5 list-decimal pl-4">
|
||||
{HOST_STEPS.map((s) => (
|
||||
<li key={s}>{s.replaceAll("4739", settings ? String(settings.flowListenPort) : "IPFIX")}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={disabled || !dirtyCount} onClick={() => { void handleSave() }}>
|
||||
|
||||
@@ -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,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=[email protected]
|
||||
After=[email protected]
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable [email protected]
|
||||
if ! ip link show dev wg-flow >/dev/null 2>&1; then
|
||||
systemctl start [email protected]
|
||||
else
|
||||
ip link set dev wg-flow up
|
||||
fi
|
||||
cd "$COMPOSE_DIR"
|
||||
docker compose "${COMPOSE_ARGS[@]}" -f docker-compose.netflow-agent.yml up -d netflow-agent
|
||||
AGENT_READY=false
|
||||
for attempt in $(seq 1 60); do
|
||||
if [[ "$(docker inspect --format '{{.State.Health.Status}}' mmapp-netflow-agent)" == healthy ]]; then
|
||||
AGENT_READY=true
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
[[ "$AGENT_READY" == true ]] || { echo "Агент не готов. Проверьте docker logs mmapp-netflow-agent; backend не пересоздан." >&2; exit 1; }
|
||||
docker compose "${COMPOSE_ARGS[@]}" -f docker-compose.netflow-agent.yml up -d --no-deps --force-recreate backend
|
||||
echo "Агент установлен. Используйте те же -f файлы при дальнейших операциях Compose:"
|
||||
printf 'docker compose '; printf '%q ' "${COMPOSE_ARGS[@]}" -f "$COMPOSE_DIR/docker-compose.netflow-agent.yml"; echo 'up -d'
|
||||
echo "В MM обновите статус хоста и нажмите «Применить настройки хоста»."
|
||||
@@ -0,0 +1,201 @@
|
||||
import base64
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
import shutil
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import agent
|
||||
|
||||
|
||||
def key(n):
|
||||
return base64.b64encode(bytes([n]) * 32).decode()
|
||||
|
||||
|
||||
class ControllerTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Windows Python 3.13's TemporaryDirectory mode 0700 excludes the restricted sandbox token.
|
||||
base = Path.cwd() / "tmp" if os.name == "nt" else Path(tempfile.gettempdir())
|
||||
base.mkdir(exist_ok=True)
|
||||
self.root = base / ("flow-agent-test-" + uuid.uuid4().hex)
|
||||
self.root.mkdir()
|
||||
def cleanup():
|
||||
if self.root.resolve().parent != base.resolve():
|
||||
raise RuntimeError("Unexpected test directory")
|
||||
shutil.rmtree(self.root)
|
||||
self.addCleanup(cleanup)
|
||||
self.addCleanup(patch.stopall)
|
||||
patch.object(agent, "CONFIG", self.root).start()
|
||||
self.interface = dict(PrivateKey=key(1), Address="10.255.254.1/24", ListenPort="51821", MTU="1420")
|
||||
self.third = dict(PublicKey=key(3), AllowedIPs="192.0.2.5/32", PresharedKey=key(9), PersistentKeepalive="0")
|
||||
self.desired = dict(collectorIp="10.255.254.1", prefix="10.255.254.0/24", flowListenPort=4739,
|
||||
wgListenPort=51821, hostPublicKey=key(2), hostPrivateKey=key(1),
|
||||
peers=[dict(serverId=1, publicKey=key(4), allowedIps=["10.255.254.2/32"], endpoint="router.test:13232")])
|
||||
(self.root / "binding.json").write_text(json.dumps({k: self.desired[k] for k in ("collectorIp", "prefix", "flowListenPort", "wgListenPort")}))
|
||||
self.previous = agent.render(self.interface, [self.third])
|
||||
(self.root / "wg-flow.conf").write_text(self.previous)
|
||||
self.runtime = [copy.deepcopy(self.third)]
|
||||
self.calls = []
|
||||
def run(*args, input=None):
|
||||
self.calls.append(args)
|
||||
if args == ("wg", "pubkey"):
|
||||
return key(2) if input == key(1) + "\n" else key(8)
|
||||
if args == ("wg", "showconf", "wg-flow"):
|
||||
return agent.render(self.interface, self.runtime, native=True)
|
||||
raise AssertionError(args)
|
||||
patch.object(agent, "run", run).start()
|
||||
patch.object(agent, "live", self.live).start()
|
||||
patch.object(agent, "sync", self.sync).start()
|
||||
self.synced = []
|
||||
|
||||
def live(self):
|
||||
return dict(interfaceUp=True, publicKey=key(2), listenPort=51821, addresses=["10.255.254.1/24"], peers=[]), copy.deepcopy(self.runtime)
|
||||
|
||||
def sync(self, text):
|
||||
self.synced.append(text)
|
||||
# Native configs don't include wg-quick's Address; simulate applying their peers.
|
||||
_, self.runtime = agent.parse_conf(text.replace("ListenPort = 51821", "ListenPort = 51821\nAddress = 10.255.254.1/24"))
|
||||
|
||||
def test_apply_preserves_third_party_psk_and_is_idempotent(self):
|
||||
self.assertFalse(agent.check(self.desired)["configMatches"])
|
||||
self.assertTrue(agent.check(self.desired, apply=True)["configMatches"])
|
||||
text = (self.root / "wg-flow.conf").read_text()
|
||||
self.assertIn(key(3), text)
|
||||
self.assertIn(key(9), text)
|
||||
self.assertIn(key(4), text)
|
||||
self.assertNotIn("Address", self.synced[0])
|
||||
self.assertTrue(agent.check(self.desired)["configMatches"])
|
||||
agent.check(self.desired, apply=True)
|
||||
self.assertEqual((self.root / "wg-flow.conf").read_text(), text)
|
||||
if os.name != "nt":
|
||||
self.assertEqual((self.root / "wg-flow.conf").stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_runtime_only_peer_preserved(self):
|
||||
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="198.51.100.7/32", PersistentKeepalive="0"))
|
||||
agent.check(self.desired, apply=True)
|
||||
self.assertIn(key(6), (self.root / "wg-flow.conf").read_text())
|
||||
|
||||
def test_runtime_peer_without_allowed_ips_preserved(self):
|
||||
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="", PersistentKeepalive="0"))
|
||||
agent.check(self.desired, apply=True)
|
||||
self.assertTrue(agent.check(self.desired)["configMatches"])
|
||||
|
||||
def test_third_party_runtime_settings_are_not_reset(self):
|
||||
# Runtime may contain a roaming endpoint and intentionally unsaved settings.
|
||||
self.runtime[0].update(Endpoint="198.51.100.2:50000", PersistentKeepalive="10")
|
||||
agent.check(self.desired, apply=True)
|
||||
third = next(p for p in self.runtime if p["PublicKey"] == key(3))
|
||||
self.assertEqual(third["Endpoint"], "198.51.100.2:50000")
|
||||
self.assertEqual(third["PersistentKeepalive"], "10")
|
||||
_, stored = agent.parse_conf((self.root / "wg-flow.conf").read_text())
|
||||
self.assertEqual(next(p for p in stored if p["PublicKey"] == key(3))["PersistentKeepalive"], "0")
|
||||
|
||||
def test_managed_roaming_endpoint_preserved_without_changing_desired_dns(self):
|
||||
agent.check(self.desired, apply=True)
|
||||
next(p for p in self.runtime if p["PublicKey"] == key(4))["Endpoint"] = "198.51.100.2:50000"
|
||||
agent.check(self.desired, apply=True)
|
||||
_, stored = agent.parse_conf((self.root / "wg-flow.conf").read_text())
|
||||
self.assertEqual(next(p for p in stored if p["PublicKey"] == key(4))["Endpoint"], "router.test:13232")
|
||||
self.assertEqual(next(p for p in self.runtime if p["PublicKey"] == key(4))["Endpoint"], "198.51.100.2:50000")
|
||||
self.assertTrue(agent.check(self.desired)["configMatches"])
|
||||
|
||||
def test_down_interface_cannot_report_applied(self):
|
||||
status, peers = self.live()
|
||||
with patch.object(agent, "live", return_value=({**status, "interfaceUp": False}, peers)):
|
||||
with self.assertRaisesRegex(agent.AgentError, "выключен"):
|
||||
agent.check(self.desired, apply=True)
|
||||
self.assertEqual(self.synced, [])
|
||||
|
||||
def test_overlap_rejected_before_any_write(self):
|
||||
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="10.255.254.0/24"))
|
||||
with self.assertRaisesRegex(agent.AgentError, "пересекаются"):
|
||||
agent.check(self.desired, apply=True)
|
||||
self.assertEqual((self.root / "wg-flow.conf").read_text(), self.previous)
|
||||
self.assertEqual(self.synced, [])
|
||||
|
||||
def test_different_interface_identity_rejected(self):
|
||||
self.desired["hostPrivateKey"], self.desired["hostPublicKey"] = key(7), key(8)
|
||||
with self.assertRaisesRegex(agent.AgentError, "существующего"):
|
||||
agent.check(self.desired, apply=True)
|
||||
self.assertEqual(self.synced, [])
|
||||
|
||||
def test_failed_apply_restores_file_and_runtime(self):
|
||||
rollback = agent.render(self.interface, self.runtime, native=True)
|
||||
with patch.object(agent, "sync", side_effect=[agent.AgentError("secret error"), None]) as sync:
|
||||
with self.assertRaisesRegex(agent.AgentError, "предыдущая конфигурация восстановлена"):
|
||||
agent.check(self.desired, apply=True)
|
||||
self.assertEqual(sync.call_args_list[-1].args, (rollback,))
|
||||
self.assertEqual((self.root / "wg-flow.conf").read_text(), self.previous)
|
||||
|
||||
def test_failed_rollback_is_explicit(self):
|
||||
with patch.object(agent, "sync", side_effect=agent.AgentError("secret error")):
|
||||
with self.assertRaisesRegex(agent.AgentError, "восстановление требует ручной проверки"):
|
||||
agent.check(self.desired, apply=True)
|
||||
|
||||
def test_invalid_or_shell_input_rejected(self):
|
||||
for change in [dict(flowListenPort=4740), dict(prefix="10.255.254.0/28"), dict(hostPrivateKey="$(id)"), dict(command="id"), dict(peers=[dict(serverId=1, publicKey=key(4), allowedIps=["0.0.0.0/0"])]), dict(peers=[dict(serverId=1, publicKey=key(4), allowedIps=["10.255.254.2/32"], endpoint="router.test:13232\nPostUp = id")])]:
|
||||
with self.subTest(change=list(change)):
|
||||
with self.assertRaises(agent.AgentError):
|
||||
agent.check({**self.desired, **change}, apply=True)
|
||||
self.assertEqual(self.synced, [])
|
||||
|
||||
def test_hooks_and_duplicate_fields_rejected(self):
|
||||
for suffix in ("\nPostUp = id", "\nListenPort = 3", "\n[Interface]\nPrivateKey = secret"):
|
||||
with self.assertRaises(agent.AgentError):
|
||||
agent.parse_conf(agent.render(self.interface, []) + suffix)
|
||||
|
||||
def test_endpoint_change_requires_apply(self):
|
||||
agent.check(self.desired, apply=True)
|
||||
self.desired["peers"][0]["endpoint"] = "new-router.test:13232"
|
||||
self.assertFalse(agent.check(self.desired)["configMatches"])
|
||||
|
||||
def test_command_errors_do_not_expose_keys(self):
|
||||
with patch.object(agent.subprocess, "run", return_value=type("Result", (), {"returncode": 1, "stdout": key(1), "stderr": key(9)})()):
|
||||
# Use the original function, not the fixture runner.
|
||||
with self.assertRaisesRegex(agent.AgentError, "не выполнена") as error:
|
||||
ORIGINAL_RUN("wg", "show")
|
||||
self.assertNotIn(key(1), str(error.exception))
|
||||
self.assertNotIn(key(9), str(error.exception))
|
||||
|
||||
|
||||
ORIGINAL_RUN = agent.run
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("FLOW_AGENT_KERNEL_TEST") == "1", "Requires Linux kernel WireGuard and NET_ADMIN in an isolated container")
|
||||
class KernelTest(unittest.TestCase):
|
||||
def test_real_interface_apply_and_preservation(self):
|
||||
with tempfile.TemporaryDirectory() as folder, patch.object(agent, "CONFIG", Path(folder)):
|
||||
private = agent.run("wg", "genkey")
|
||||
public = agent.run("wg", "pubkey", input=private + "\n")
|
||||
other = agent.run("wg", "pubkey", input=agent.run("wg", "genkey") + "\n")
|
||||
desired_key = agent.run("wg", "pubkey", input=agent.run("wg", "genkey") + "\n")
|
||||
interface = dict(PrivateKey=private, Address="10.255.254.1/24", ListenPort="51821")
|
||||
third = dict(PublicKey=other, AllowedIPs="192.0.2.3/32")
|
||||
path = Path(folder)
|
||||
(path / "wg-flow.conf").write_text(agent.render(interface, [third]))
|
||||
snapshot = dict(collectorIp="10.255.254.1", prefix="10.255.254.0/24", flowListenPort=4739, wgListenPort=51821,
|
||||
hostPublicKey=public, hostPrivateKey=private, peers=[dict(serverId=1, publicKey=desired_key, allowedIps=["10.255.254.2/32"], endpoint="127.0.0.1:13232")])
|
||||
(path / "binding.json").write_text(json.dumps({k: snapshot[k] for k in ("collectorIp", "prefix", "flowListenPort", "wgListenPort")}))
|
||||
agent.run("ip", "link", "add", "dev", agent.IFACE, "type", "wireguard")
|
||||
try:
|
||||
agent.run("ip", "address", "add", "10.255.254.1/24", "dev", agent.IFACE)
|
||||
agent.sync(agent.render(interface, [third], native=True))
|
||||
agent.run("ip", "link", "set", "dev", agent.IFACE, "up")
|
||||
before = json.loads(agent.run("ip", "-j", "link", "show", "dev", agent.IFACE))[0]["ifindex"]
|
||||
self.assertTrue(agent.check(snapshot, apply=True)["configMatches"])
|
||||
self.assertTrue(agent.check(snapshot)["configMatches"])
|
||||
status, _ = agent.live()
|
||||
self.assertEqual({p["publicKey"] for p in status["peers"]}, {other, desired_key})
|
||||
self.assertNotIn(private, json.dumps(status))
|
||||
after = json.loads(agent.run("ip", "-j", "link", "show", "dev", agent.IFACE))[0]["ifindex"]
|
||||
self.assertEqual(before, after)
|
||||
finally:
|
||||
agent.run("ip", "link", "delete", "dev", agent.IFACE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,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).
|
||||
+74
-2
@@ -1,6 +1,12 @@
|
||||
# UX/UI: журнал реализации
|
||||
|
||||
Актуальное состояние 2026-10-15: Production266 — с отдельного разрешения создан online-индекс retention; нагрузка БД существенно снизилась в двух повторных замерах. Карта исправлена локально (265), frontend-патч ещё не развёрнут. Проверка трафика и причин остальных тайм-аутов продолжается отдельно. Лабораторная приёмка264 завершена в документированных границах стенда:1396 основных UI и221 context-сценарий;120 live default states30 экранов. Runtime стенда backend258/frontend261. Непроверенные внешние группы сохранены в итоговом акте. Production v2.0.0 подтверждён через UI; [описание релиза](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).
|
||||
|
||||
@@ -38,11 +44,77 @@
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Завершён2026-10-15 в границах предоставленного стенда:1396 UI/221 context,120 live states30 экранов, production builds/ReUI checklist и реальные интеграции. Ограничения — в [акте](ux-ui-acceptance-candidate.md); production-выпуск не выполнялся. |
|
||||
|
||||
## Production276 — SYN cookies arn-gw01 и повторная проверка (2026-10-16)
|
||||
|
||||
После прямого разрешения владельца проверены ID1/host и false;16 октября09:55:01 по Новосибирску выполнен только POST /ip/settings/set с tcp-syncookies=true. Предварительный PATCH singleton-раздела отклонён400 без применения; использована штатная команда set с тем же единственным параметром. Повторные чтения и identity подтвердили результат. Пароли, службы, firewall, маршруты, приложение, БД и контейнеры не менялись; перезапуска не было.
|
||||
|
||||
8 последовательных контрольных HTTP200: TCP21–45мс против1042–5136мс, полный HTTPS143–334мс против1162–5274мс в исходных4 замерах.45-секундный захват только target443 SYN/SYN-ACK:42 пакета, повторов SYN в полученном выводе нет, kernel dropped0, захват завершён. К09:57:30 штатные задания после применения:5 traffic/5 gre_bgp/10 resources/1 internet_path успешны, ошибок0, максимальные длительности1091/426/854/284мс. Финальный readbacktrue, CPU6%, uptime14m19s. В доступном неполном кольце логов новых SYN warning нет; старые события не удалялись.
|
||||
|
||||
В кратком сравнении улучшение подтверждено, но источник всех потерь и поведение при другой нагрузке не доказаны. [Измерения и откат](../deploy/PRODUCTION-ARN-276.md). Диагностика завершена, фоновых циклов/захвата нет. Документация сохраняется локальным коммитом без push; код не менялся, повторная сборка не требовалась.
|
||||
|
||||
## Production275 — диагностика SYN и задержек arn-gw01 (2026-10-16)
|
||||
|
||||
На ID1/arn-gw01 воспроизведён REST timeout10с. Четыре последовательных HTTP200 показали DNS2–6мс, TCP1.042–5.136с, TLS ещё99–145мс, ответ после TLS19–21мс.20-секундный захват SYN/SYN-ACK только target443 на production-хосте подтвердил повторные SYN штатного приложения и ожидание ответа2.044/4.122с; нормальный SYN-ACK18–21мс. Место потери между хостом и обработкой CHR не установлено.
|
||||
|
||||
RouterOS7.24.5, uptime5m41s/CPU6% в одном замере; оба warning80/443 через7с после первой записи запуска, позднее есть смена часов Europe/Stockholm. Массовую текущую атаку это не доказывает. tcp-syncookies=false; ограничения input80/443 доверенными источниками в прочитанных правилах нет, RAW пустой. Итоговый conntrack-снимок:4 локальных80/443 TCP,2 от приложения/2 других, не свидетельство большого потока. Порты и адреса этой версии считались отдельными полями.
|
||||
|
||||
[Протокол и подготовленный следующий шаг](../deploy/PRODUCTION-ARN-275.md). Предложено отдельно согласовать изменение только tcp-syncookies с чтением результата и коротким повторным замером; сеть провайдера оно не исправляет. Пароли, RouterOS, firewall, приложение и контейнеры не менялись. Диагностика завершена, tcpdump остановился по20с timeout; код не менялся, сборка/тесты повторно не запускались. Локальный коммит без push.
|
||||
|
||||
## Production274 — отключение FTP/Telnet и наблюдение (2026-10-16)
|
||||
|
||||
После явного разрешения владельца на ID3/nov-gw01 повторно проверены службы ftp*1/21 и telnet*0/23, затем16 октября01:33:45 по Новосибирску применено только disabled=true. Повторное чтение подтвердило результат; www/www-ssl/winbox/api/api-ssl сохранены включёнными. Владелец отдельно подтвердил оставить FTP/Telnet выключенными. Пароли, firewall, маршрутизация, задания RouterOS и контейнеры не менялись.
|
||||
|
||||
До01:54:44 наблюдался обычный опрос:42 traffic/42 gre_bgp/4 internet_path/84 uptime_resources успешны, ошибок0; максимальные длительности2278/910/809/1937мс. Финальный REST и uptime подтверждают доступность без перезапуска. На01:55:47 новые TCP-подключения21/23 получили ECONNREFUSED. Остаются6 старых динамических Telnet строк: принудительное завершение соединений не выполнялось. В выборках логов2 отказа Winbox с несохранёнными источниками;4 пропуска кольца из-за scheduler-изменений, поэтому непрерывное отсутствие атак не утверждается. Ошибки401/500 прекратились ещё до отключения; причинная связь не доказана. Наблюдение завершено, фоновых диагностических циклов не осталось.
|
||||
|
||||
По дополнительному вопросу владельца проверена production PostgreSQL только чтением: standby=false, wal_level=replica, подключённых реплик/slots/subscriptions0, synchronous_standby_names пустой. Репликация в конфигурации проекта и активном состоянии не обнаружена. [Процедура, результат и откат](../deploy/PRODUCTION-ROUTEROS-274.md). Документация сохраняется локальным коммитом, без push/deploy; исправление подсказок273 не развёрнуто.
|
||||
|
||||
## Production273 — диагностика отказов входа RouterOS (2026-10-16)
|
||||
|
||||
На nov-gw01 подтверждены соседние500 `too many concurrent logins (6)` и401; последние в01:11:40–44 по Новосибирску. Тот же сохранённый пароль работает, full/api/rest-api разрешены. На01:27 последние15 минут: traffic30/30, gre_bgp30/30, internet_path3/3 успешны. Наблюдаются внешние попытки входа через открытые FTP/Telnet и7 внешних Telnet-соединений, а также частые изменения от локального evofw-sync. Их причинная связь с отказами пока не доказана; насыщение входа нельзя приравнивать к max-sessions20 или старым user/active записям. Production, RouterOS и пароли не менялись.
|
||||
|
||||
Локально исправлены подсказки:401 не объявляет пароль неверным; точный500/detail распознаётся как лимит одновременных входов. Исходные status/path/body сохранены, повторов операций нет.12 HTTP-проверок, профиль ros-file, backend build, scoped ESLint и diff check прошли. [Протокол и процедура](../deploy/PRODUCTION-ROUTEROS-273.md). Без push/deploy. Следующий шаг — после отдельного разрешения временно отключить только FTP/Telnet на ID3 и сравнить обычный опрос минимум за два периода продления REST-логина. Это ещё не выполненное исправление первопричины.
|
||||
|
||||
## Production272 — публикация и приёмка v2.0.2 (2026-10-16)
|
||||
|
||||
По прямому разрешению владельца подготовленные коммиты268/269/270/271 отправлены в main до e6a2e400. Все7 jobs CI2468 success; v2.0.2 опубликован. Проверены OCI version/revision и digest обоих образов, затем обновлены только backend и frontend через Compose --no-deps --pull never. Backend healthy с00:41:33, frontend healthy с00:42:11 по Новосибирску; PostgreSQL container ID/StartedAt и SHA256 Compose сохранены. RouterOS-конфигурация и права не менялись; автоматического prune не было.
|
||||
|
||||
Обычный HTTP после обновления:3 JH,4/3/5 маршрутов,200/live=true/stale=false, разовые задержки1048/2575/736мс. Каталоги шлюзов200,9/7/8 вариантов за1167/3800/993мс. В браузере до обновления воспроизведена ошибка, после последовательно выбраны все3 JH и подтверждены4/3/5 endpoints без ошибки; несколько endpoints могут группироваться в одну строку. Filters выбранного московского JH:10 правил,0 ложных «нет строки в списке», /32 подписи видны. Карта:3JH/1EN, новая графическая легенда; клик даёт aria-pressed=true, outline=none и focus-ring opacity=0. DNS read-only403 mm:network:write остаётся;25GRE загружены,3 линии отрисованы, полная FQDN-связность не принята.
|
||||
|
||||
[Процедура и откат](../deploy/PRODUCTION-RELEASE-272.md), [анонимизированный протокол](ux-ui/acceptance/prod272-release.json); приватные скриншоты tmp/prod272-*.jpg вне Git. Формы записи/удаление в production не выполнялись. Протокол сохраняется локально без нового push, чтобы не запускать дополнительный релиз только документацией. Следующие отдельные задачи: read-only DNS, оценка поведения легенды рядом с открытыми деталями на узком холсте, независимая проверка трафика и остальных тайм-аутов; общая production-готовность всех внешних сценариев этим блоком не объявляется.
|
||||
|
||||
## Production271 — чтение рекурсивных маршрутов JH (2026-10-16)
|
||||
|
||||
Production GET /recursive-routes/catalog на двух московских JH возвращал503 «Не подтверждены параметры управляемого маршрута», на новосибирском наблюдался отдельный10с timeout. Проверка native RouterOS чтением: список и GET конкретной строки не содержат disabled/check-gateway; /ip/route/get подтверждает disabled=false, незаданный check-gateway возвращает[]. Строгий parser сохранён; добавлено дочитывание отсутствующих полей только для собственных маршрутов, максимум2 запроса одновременно. Только успешный пустой get check-gateway означает none; null/неверный envelope/ошибка не становятся defaults. Explicit invalid значения не заменяются.
|
||||
|
||||
Нагрузка чтения: полные таблицы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, в обязательном режиме исходная ошибка подключения выходит наружу. Полная CI-проверка сети и SQL-профилей должна подтвердиться следующим запуском; успешный тест retention на реальном тестовом PostgreSQL описан в266. Production этим блоком напрямую не изменялся.
|
||||
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)
|
||||
|
||||
|
||||
@@ -2758,3 +2758,14 @@ Runtime backend ux-regression222/frontend ux-routing218. Latest backend25/UI52
|
||||
2 roundtrip objects,12 unique API scenarios, synthetic native export/CHR contacts0. Own DB/roles/catalog/localfiles/currentobjects cleaned. Versioning Enabled:8versions+8markers removed,3 own prefixes remaining0; bucket configuration unchanged. Native backup/restore/live UI/смена storage origin/retention semantics остаются230. Private env удалены, secrets не в published files. [API](ux-ui/acceptance/s3-application229.json), [version cleanup](ux-ui/acceptance/s3-version-cleanup229.json).
|
||||
|
||||
Локальные .rsc после235: `/opt/mmapp-test/data/backups` → `/app/storage/backups`; см. [порядок переноса](../deploy/BACKUP-STORAGE.md). Две новые native copies после force-recreate проверены и удалены; десять старых catalog rows сохранены с зафиксированным отсутствием files.
|
||||
|
||||
## NetFlow host agent — 2026-10-16
|
||||
|
||||
Ветка `codex/netflow-host-agent`, исходники `db81cf0f` + исправление `0abdac82`.
|
||||
Временные backend/agent и отдельные БД: kernel WG 15/15, профиль32 и отдельные
|
||||
monthly/IPv6 UDP SQL passed. Оба CHR установили handshake и передали IPFIX в БД;
|
||||
повторное подключение, сохранение стороннего пира и рестарты agent/backend прошли.
|
||||
Найденные устаревшие счётчики API исправлены, живой сценарий повторён полностью.
|
||||
Исходные Traffic Flow настройки восстановлены, own WG/containers/DB/roles/keys
|
||||
удалены; четыре основных сервиса и их образы сохранены. Installer/systemd/cold reboot
|
||||
не выполнялись. [Отчёт и границы](ux-ui/acceptance/netflow-agent-2026-10-16.json).
|
||||
@@ -0,0 +1,402 @@
|
||||
{
|
||||
"date": "2026-10-16",
|
||||
"stand": "161.104.46.222",
|
||||
"branch": "codex/netflow-host-agent",
|
||||
"baseCommit": "db81cf0f",
|
||||
"testedSourceCommit": "0abdac82de3fd53fa0eeaae672a3681918191a9a",
|
||||
"passed": true,
|
||||
"scope": "Temporary backend/agent containers, separate PostgreSQL databases and host wg-flow; both authorized test MikroTik configured and restored.",
|
||||
"build": {
|
||||
"strategy": "Current compiled backend/contracts over existing backend runtime with matching application dependencies; test-only jose and transpiled tests added. Agent built from its Dockerfile.",
|
||||
"uploadedFilesVerified": 1976,
|
||||
"baseProductionCompiledFilesVerified": 1396,
|
||||
"patchedCompiledRouteSha256": "99a5b78f0586cbc53c35ed4bcefadd4fdd6ecea6da075f936b7768c634b03dd1",
|
||||
"patchedCompiledRouteMatchesLocal": true
|
||||
},
|
||||
"agentKernelTests": {
|
||||
"passed": 15,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"network": "Isolated container namespace",
|
||||
"coverage": [
|
||||
"Real kernel WireGuard apply",
|
||||
"Foreign persistent/runtime peers",
|
||||
"Unchanged interface identity",
|
||||
"Validation/conflicts",
|
||||
"Configuration and runtime rollback"
|
||||
]
|
||||
},
|
||||
"sql": {
|
||||
"primary": {
|
||||
"database": "mm_accept_166_1792139159368",
|
||||
"passed": true,
|
||||
"cases": [
|
||||
{
|
||||
"path": "services/traffic-flow-host-file-builders.test.js",
|
||||
"durationMs": 196,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-key-execution.test.js",
|
||||
"durationMs": 54,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "routes/traffic-flow-settings.test.js",
|
||||
"durationMs": 586,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "plugins/auth.netflow-settings.test.js",
|
||||
"durationMs": 665,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-agent-install.test.js",
|
||||
"durationMs": 174,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-agent-client.test.js",
|
||||
"durationMs": 260,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-operation-queue.test.js",
|
||||
"durationMs": 54,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "routes/traffic-flow-host.test.js",
|
||||
"durationMs": 830,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "routes/traffic-flow-query.test.js",
|
||||
"durationMs": 1816,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-month.test.js",
|
||||
"durationMs": 1146,
|
||||
"skips": [
|
||||
"Monthly SQL: skip (requires an owned isolated database)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-parse.test.js",
|
||||
"durationMs": 1174,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-ipv6-pipeline.test.js",
|
||||
"durationMs": 1203,
|
||||
"skips": [
|
||||
"IPv6 UDP/worker/SQL: skip (requires an owned isolated database)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-map-exporter.test.js",
|
||||
"durationMs": 55,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-ifaces.test.js",
|
||||
"durationMs": 64,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-ifindex.test.js",
|
||||
"durationMs": 59,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-dedup.test.js",
|
||||
"durationMs": 55,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-planes.test.js",
|
||||
"durationMs": 51,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-ip.test.js",
|
||||
"durationMs": 75,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-dest.test.js",
|
||||
"durationMs": 1836,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-classify.test.js",
|
||||
"durationMs": 903,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-ripe.test.js",
|
||||
"durationMs": 1440,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-brands.test.js",
|
||||
"durationMs": 93,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-ingest.test.js",
|
||||
"durationMs": 5340,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-analytics.test.js",
|
||||
"durationMs": 2172,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-map-hops.test.js",
|
||||
"durationMs": 1661,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-purge.test.js",
|
||||
"durationMs": 2451,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/geoip-download.test.js",
|
||||
"durationMs": 667,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-geoip.test.js",
|
||||
"durationMs": 1930,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-facts.test.js",
|
||||
"durationMs": 964,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-facts-filter.test.js",
|
||||
"durationMs": 1362,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/traffic-flow-facts-rebuild.test.js",
|
||||
"durationMs": 1833,
|
||||
"skips": []
|
||||
},
|
||||
{
|
||||
"path": "services/statistics-aggregate.test.js",
|
||||
"durationMs": 1915,
|
||||
"skips": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"dedicatedIpv6": {
|
||||
"database": "mm_accept_144_1792139159368",
|
||||
"passed": true,
|
||||
"cases": [
|
||||
{
|
||||
"path": "services/traffic-flow-ipv6-pipeline.test.js",
|
||||
"durationMs": 7879,
|
||||
"skips": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"dedicatedMonthly": {
|
||||
"database": "mm_accept_149_1792139159368",
|
||||
"passed": true,
|
||||
"cases": [
|
||||
{
|
||||
"path": "services/traffic-flow-month.test.js",
|
||||
"durationMs": 3800,
|
||||
"skips": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"note": "The two explicitly guarded SQL cases skipped in primary were then executed without skips in separate owned databases. This profile predates only the runtime-counter route fix."
|
||||
},
|
||||
"live": {
|
||||
"sourceCommit": "db81cf0f",
|
||||
"passed": true,
|
||||
"checks": [
|
||||
{
|
||||
"name": "native-baselines-and-own-database-prepared",
|
||||
"routers": 2
|
||||
},
|
||||
{
|
||||
"name": "real-mikrotik-connected-1",
|
||||
"address": "198.18.254.2"
|
||||
},
|
||||
{
|
||||
"name": "real-mikrotik-connected-2",
|
||||
"address": "198.18.254.3"
|
||||
},
|
||||
{
|
||||
"name": "first-router-preserved-after-second",
|
||||
"peerCount": 3
|
||||
},
|
||||
{
|
||||
"name": "repeat-connect-without-duplicate"
|
||||
},
|
||||
{
|
||||
"name": "synthetic-ipfix-through-real-wireguard-to-postgresql",
|
||||
"packetsReceived": 4,
|
||||
"lastExporterIp": "198.18.254.200"
|
||||
},
|
||||
{
|
||||
"name": "agent-restart-preserves-tunnels-and-ipfix"
|
||||
},
|
||||
{
|
||||
"name": "backend-real-restart-reconciled",
|
||||
"peerCount": 4
|
||||
},
|
||||
{
|
||||
"name": "ipfix-after-backend-restart"
|
||||
},
|
||||
{
|
||||
"name": "persisted-exporter-summary",
|
||||
"exporters": [
|
||||
{
|
||||
"server_id": 1,
|
||||
"buckets": 44,
|
||||
"bytes": "18451"
|
||||
},
|
||||
{
|
||||
"server_id": 2,
|
||||
"buckets": 27,
|
||||
"bytes": "10281"
|
||||
},
|
||||
{
|
||||
"server_id": 3,
|
||||
"buckets": 1,
|
||||
"bytes": "432075"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "router-baseline-restored-2"
|
||||
},
|
||||
{
|
||||
"name": "router-baseline-restored-1"
|
||||
}
|
||||
],
|
||||
"nativeCleanup": true
|
||||
},
|
||||
"cleanup": {
|
||||
"nativeAndHostCleanup": true,
|
||||
"ownedDatabaseRemoved": true,
|
||||
"exitCode": 0
|
||||
},
|
||||
"finalVerification": {
|
||||
"passed": true,
|
||||
"services": [
|
||||
{
|
||||
"name": "mmapp-test-backend",
|
||||
"image": "mmapp-test/backend:ux-certificates258",
|
||||
"imageId": "sha256:984684709e619972bae8657208e3150d12f1c4f2b5642fecf5286a5d87e9fe71",
|
||||
"running": true,
|
||||
"health": "healthy",
|
||||
"imageUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "mmapp-test-frontend",
|
||||
"image": "mmapp-test/frontend:ux-accessibility261",
|
||||
"imageId": "sha256:eec4410f1109a46e864d695cfb4311fdb9a224247549c8a9e78e44c6c0e455cb",
|
||||
"running": true,
|
||||
"health": null,
|
||||
"imageUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "mmapp-test-portal",
|
||||
"image": "git.shx.one/denozord/auth-portal@sha256:2bdf42400a1b35caa43303b9d01078b054b2a9041eba7019c1fffde71b7e4489",
|
||||
"imageId": "sha256:2bdf42400a1b35caa43303b9d01078b054b2a9041eba7019c1fffde71b7e4489",
|
||||
"running": true,
|
||||
"health": "healthy",
|
||||
"imageUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "mmapp-test-postgres",
|
||||
"image": "postgres:18-alpine",
|
||||
"imageId": "sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873",
|
||||
"running": true,
|
||||
"health": "healthy",
|
||||
"imageUnchanged": true
|
||||
}
|
||||
],
|
||||
"temporaryContainersRemaining": 0,
|
||||
"hostOwnedWireguardRemaining": false,
|
||||
"privateFixturesRemoved": true,
|
||||
"mainFlowSettings": {
|
||||
"enabled": false,
|
||||
"collector_ip": "10.255.254.1",
|
||||
"prefix": "10.255.254.0/24",
|
||||
"flow_listen_port": 4739,
|
||||
"wg_listen_port": 51821,
|
||||
"peerCount": 0
|
||||
},
|
||||
"ownedDatabasesAndRolesRemaining": 0,
|
||||
"routers": [
|
||||
{
|
||||
"id": "1",
|
||||
"host": "194.226.121.150",
|
||||
"reachable": true,
|
||||
"ownedWireguardInterfaces": 0,
|
||||
"ownedFlowTargets": 0,
|
||||
"ownedCollectorRoutes": 0,
|
||||
"trafficFlowEnabled": "false"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"host": "104.165.244.7",
|
||||
"reachable": true,
|
||||
"ownedWireguardInterfaces": 0,
|
||||
"ownedFlowTargets": 0,
|
||||
"ownedCollectorRoutes": 0,
|
||||
"trafficFlowEnabled": "false"
|
||||
}
|
||||
]
|
||||
},
|
||||
"correction": {
|
||||
"commit": "0abdac82",
|
||||
"problem": "Settings API returned stale persisted counters despite successful worker ingestion.",
|
||||
"change": "Read packet count, last exporter, error and packet time from collector runtime counters.",
|
||||
"regressionProof": "Initial live run persisted flows but timed out waiting for the settings packet counter after agent restart. Final run observed packetsReceived=4 and exporter 198.18.254.200, then passed both actual container restarts and continued reception.",
|
||||
"localValidation": [
|
||||
"Backend/contracts build",
|
||||
"8 netflow-settings test scripts",
|
||||
"ESLint for modified route"
|
||||
]
|
||||
},
|
||||
"earlierAttempts": [
|
||||
{
|
||||
"stage": "SQL runner",
|
||||
"result": "Harness FLOW_OPTIONAL truthiness corrected; primary profile rerun passed."
|
||||
},
|
||||
{
|
||||
"stage": "Live runner bootstrap",
|
||||
"result": "Production image lacks development pino-pretty; harness disabled pretty logging; resources cleaned."
|
||||
},
|
||||
{
|
||||
"stage": "Live counter verification",
|
||||
"result": "Product stale-counter defect detected and fixed; both routers and host restored before final full live rerun.",
|
||||
"failure": {
|
||||
"phase": "exercise",
|
||||
"name": "Error",
|
||||
"message": "Timed out: IPFIX continues during agent restart"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nativeExportEvidence": "Own DB recorded server_id 1 and 2 through their distinct WG source addresses: 44 buckets/18451 bytes and 27 buckets/10281 bytes. Server 3 is the separate synthetic Linux IPFIX exporter (1 bucket/432075 bytes). These are exported flow-byte totals, not captured UDP wire bytes.",
|
||||
"limits": [
|
||||
"One-time installer was not applied to the primary stand deployment; its compose file is named compose.yaml. Installer integration with wg-quick/systemd and cold host reboot remain unverified on this stand.",
|
||||
"No production deployment, registry publication, or primary frontend/backend replacement was performed.",
|
||||
"Docker images and source/log artifacts are retained under /opt/mmapp-test/artifacts/netflow-agent-db81cf0f-20261016; temporary containers, owned DBs/roles and private fixture files were removed.",
|
||||
"This is a functional acceptance run, not a load or long-duration stability test."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,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"]
|
||||
}
|
||||
@@ -9,6 +9,23 @@ export const flowHostPeerSchema = z.object({
|
||||
endpoint: z.string().optional(),
|
||||
})
|
||||
|
||||
export const trafficFlowHostStatusSchema = z.object({
|
||||
configured: z.boolean(),
|
||||
reachable: z.boolean(),
|
||||
interfaceUp: z.boolean(),
|
||||
configMatches: z.boolean().nullable(),
|
||||
publicKey: z.string(),
|
||||
listenPort: z.number().int().nonnegative(),
|
||||
addresses: z.array(z.string()),
|
||||
peers: z.array(z.object({
|
||||
publicKey: z.string(), allowedIps: z.array(z.string()),
|
||||
lastHandshakeAt: z.number().int().nonnegative().nullable(),
|
||||
receivedBytes: z.number().nonnegative(), sentBytes: z.number().nonnegative(),
|
||||
})),
|
||||
error: z.string().nullable(),
|
||||
})
|
||||
export type TrafficFlowHostStatus = z.infer<typeof trafficFlowHostStatusSchema>
|
||||
|
||||
export const trafficFlowSettingsDtoSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
collectorIp: z.string().min(1),
|
||||
@@ -77,6 +94,9 @@ export const trafficFlowOverlayResultSchema = z.object({
|
||||
trafficFlow: z.boolean(),
|
||||
steps: z.array(z.string()),
|
||||
hostFiles: z.array(trafficFlowHostFileSchema),
|
||||
hostStatus: trafficFlowHostStatusSchema.optional(),
|
||||
stage: z.enum(["manual", "host-pending", "applied"]).optional(),
|
||||
hostError: z.string().optional(),
|
||||
})
|
||||
|
||||
export const flowTalkerDtoSchema = z.object({
|
||||
|
||||
@@ -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,
|
||||
TrafficFlowSettingsDto,
|
||||
TrafficFlowSettingsPatch,
|
||||
TrafficFlowHostStatus,
|
||||
} from "@mmapp/contracts/traffic-flow"
|
||||
import { z } from "zod"
|
||||
import { trafficFlowSettingsDtoSchema, trafficFlowSettingsPatchSchema, trafficFlowHostFileSchema, flowPurgeDtoSchema, flowAnalyticsDtoSchema, flowMonthlyDtoSchema } from "@mmapp/contracts/traffic-flow"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { trafficFlowHostStatusSchema } from "@mmapp/contracts/traffic-flow"
|
||||
|
||||
export type { TrafficFlowHostFile }
|
||||
|
||||
export async function getTrafficFlowHostStatus(baseUrl: string, init?: RequestInit): Promise<TrafficFlowHostStatus> {
|
||||
return flowConfirmed(trafficFlowHostStatusSchema, await requestJson(baseUrl, "/api/traffic/flow/host-status", {
|
||||
...init, signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(65_000)]),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function reconcileTrafficFlowHost(baseUrl: string, init?: RequestInit): Promise<TrafficFlowHostStatus> {
|
||||
const response = flowConfirmed(z.object({ ok: z.literal(true), hostStatus: trafficFlowHostStatusSchema }),
|
||||
await requestJson(baseUrl, "/api/traffic/flow/host-reconcile", { ...init, method: "POST", signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(120_000)]) }))
|
||||
return response.hostStatus
|
||||
}
|
||||
|
||||
const flowSettingsSchema = trafficFlowSettingsDtoSchema.extend({ flowListenPort: z.number().int().min(1).max(65535), wgListenPort: z.number().int().min(1).max(65535) }).refine(data => (!data.hasHostPrivateKey || data.hostPublicKey.length > 0) && (!data.listenerBound || !!data.listenerAddress) && (data.lastDatagramAt === null || Number.isFinite(Date.parse(data.lastDatagramAt))) && new Set(data.peers.map(peer => peer.serverId)).size === data.peers.length, "Несогласованные настройки NetFlow")
|
||||
const flowPatchSchema = trafficFlowSettingsPatchSchema.strict()
|
||||
// Optional overlay/wire measurements may be unavailable independently of the
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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