Files
MikrotikManager/backend/src/services/internet-path-collector.ts
T
DenozordecandCursor ec43591a99
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 4m9s
Docker images / frontend-image (push) Successful in 4m28s
Docker images / updater-image (push) Successful in 58s
Docker images / backend-image (push) Successful in 2m49s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
feat(db): перевести хранилище с SQLite на PostgreSQL
При старте backend накатывает схему PostgreSQL 18 и, если база пустая, один раз импортирует mikrotik.db с тома. Повторный старт не копирует данные. Бэкап в UI идёт через pg_dump.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 01:36:48 +07:00

314 lines
12 KiB
TypeScript

import { and, asc, desc, eq, lt } from "drizzle-orm"
import { db } from "../db/index.js"
import {
filterRules,
internetPathSettings,
internetPathSnapshots,
servers,
uptimeSpeedProbes,
} from "../db/schema.js"
import { listServersRead } from "../modules/servers/service/servers-service.js"
import { MikrotikClient } from "./mikrotik.js"
import type { InternetPathRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
const INTERNET_TARGET = "1.1.1.1"
let collecting = false
function isTrue(v: unknown): boolean {
const s = String(v ?? "").trim().toLowerCase()
return s === "true" || s === "yes"
}
function toIp(raw: string | null | undefined): string | null {
const v = String(raw ?? "").trim()
if (!v) return null
return v.split("/")[0]?.trim() ?? null
}
function norm(v: string | null | undefined): string {
return String(v ?? "").trim().toLowerCase()
}
async function getSettingsRow() {
const row = (await db.select().from(internetPathSettings).where(eq(internetPathSettings.id, 1)).limit(1))[0]
if (row) return row
const now = new Date().toISOString()
await db.insert(internetPathSettings).values({
id: 1,
enabled: true,
intervalSec: 300,
retentionDays: 14,
createdAt: now,
updatedAt: now,
})
return (await db.select().from(internetPathSettings).where(eq(internetPathSettings.id, 1)).limit(1))[0]
}
async function cleanupSnapshots(retentionDays: number) {
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString()
await db.delete(internetPathSnapshots).where(lt(internetPathSnapshots.sampledAt, cutoff))
}
async function buildRulesets() {
const enabled = await db.select().from(servers).where(eq(servers.enabled, true))
const rules = await db.select().from(filterRules).orderBy(asc(filterRules.serverId), asc(filterRules.sortOrder))
return enabled.map((s) => ({
serverId: String(s.id),
rules: rules
.filter((r) => r.serverId === s.id)
.map((r) => ({
id: String(r.id),
community: r.community,
communityName: r.communityName ?? undefined,
action: r.action,
gateway: r.gateway,
gatewayTunnelId: r.gatewayTunnelId,
description: r.description,
})),
}))
}
async function readRouteLookup(serverId: number): Promise<{ gateway: string | null; routingMark: string | null }> {
const row = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!row) return { gateway: null, routingMark: null }
const client = MikrotikClient.fromServer(row)
const routes = await client.get<Array<Record<string, string>>>("/ip/route").catch(() => [])
const best = routes
.filter((r) => String(r["dst-address"] ?? "").trim() === "0.0.0.0/0")
.filter((r) => isTrue(r.active))
.filter((r) => {
const rt = String(r["routing-table"] ?? "").trim().toLowerCase()
return rt === "" || rt === "main"
})
.filter((r) => String(r.disabled ?? "false").toLowerCase() !== "true")
.filter((r) => String(r.inactive ?? "false").toLowerCase() !== "true")
.sort((a, b) => Number(a.distance ?? 255) - Number(b.distance ?? 255))[0]
return {
gateway: String(best?.gateway ?? "").trim() || null,
routingMark: String(best?.["routing-mark"] ?? "").trim() || null,
}
}
async function readWanRuntime(serverId: number) {
const server = (await listServersRead()).find((s) => Number(s.id) === serverId)
const row = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!server || !row) return null
const client = MikrotikClient.fromServer(row)
const [dhcpRaw, ipAddrs, routes] = await Promise.all([
client.get<Array<Record<string, string>>>("/ip/dhcp-client").catch(() => []),
client.get<Array<Record<string, string>>>("/ip/address").catch(() => []),
client.get<Array<Record<string, string>>>("/ip/route").catch(() => []),
])
const defaultRoute = routes
.filter((r) => String(r["dst-address"] ?? "").trim() === "0.0.0.0/0")
.filter((r) => isTrue(r.active))
.filter((r) => {
const rt = String(r["routing-table"] ?? "").trim().toLowerCase()
return rt === "" || rt === "main"
})
.filter((r) => String(r.disabled ?? "false").toLowerCase() !== "true")
.filter((r) => String(r.inactive ?? "false").toLowerCase() !== "true")
.sort((a, b) => Number(a.distance ?? 255) - Number(b.distance ?? 255))[0]
const defaultGateway = String(defaultRoute?.gateway ?? "").trim() || null
const immediateGw = String(defaultRoute?.["immediate-gw"] ?? "").trim() || null
const defaultInterface =
((immediateGw?.includes("%") ? immediateGw.split("%")[1]?.trim() : ""))
|| String(defaultRoute?.interface ?? "").trim()
|| String(
dhcpRaw.find((d) => toIp(d.gateway) != null && toIp(d.gateway) === toIp(defaultGateway))?.interface ?? "",
).trim()
|| null
const uplinks = (server.wanUplinks ?? []).map((w) => {
const iface = String(w.iface ?? "").trim()
const dhcp = dhcpRaw.find((d) => norm(d.interface) === norm(iface))
const leasedIp =
toIp(dhcp?.address)
?? toIp(ipAddrs.find((a) => norm(a.interface) === norm(iface))?.address)
?? null
return {
id: w.id,
iface,
name: w.name,
isp: w.isp,
configuredIp: w.ip,
leasedIp,
dhcpStatus: String(dhcp?.status ?? "").trim() || null,
isDefault: defaultInterface != null && norm(defaultInterface) === norm(iface),
}
})
return {
defaultGateway,
defaultInterface,
uplinks,
}
}
async function mapSpeedProbes() {
const rows = await db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder))
return rows.map((r) => ({
id: r.id,
srcServerId: String(r.srcServerId),
dstServerId: String(r.dstServerId),
srcInterface: r.srcInterface || "",
dstInterface: r.dstInterface || "",
protocol: r.protocol === "udp" ? "udp" : "tcp",
direction: r.direction === "transmit" || r.direction === "receive" ? r.direction : "both",
durationSec: String(Math.max(3, r.durationSec || 10)),
enabled: r.enabled !== false,
lastRunAt: r.lastRunAt ?? null,
lastTxAvgMbps: r.lastTxAvgMbps ?? null,
lastRxAvgMbps: r.lastRxAvgMbps ?? null,
lastStatus: r.lastStatus ?? null,
lastError: r.lastError ?? null,
lastPingRttMs: r.lastPingRttMs ?? null,
lastPingLossPct: r.lastPingLossPct ?? null,
lastPingAt: r.lastPingAt ?? null,
lastPingError: r.lastPingError ?? null,
}))
}
function parseInnerIps(comment: string): { localInnerIp: string; remoteInnerIp: string } {
const local = comment.match(/address\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? ""
const remote = comment.match(/(?:network|gateway)\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? ""
return { localInnerIp: local, remoteInnerIp: remote }
}
async function collectGreTunnels() {
const enabled = await db.select().from(servers).where(eq(servers.enabled, true))
const all = await Promise.all(enabled.map(async (srv) => {
try {
const client = MikrotikClient.fromServer(srv)
const rows = await client.get<Array<Record<string, string>>>("/interface/gre")
return rows.map((g, idx) => {
const keepalive = String(g.keepalive ?? "0,0").split(",")
const inner = parseInnerIps(String(g.comment ?? ""))
return {
id: String(g.name ?? g[".id"] ?? `gre-${srv.id}-${idx}`),
name: String(g.name ?? `gre-${idx + 1}`),
serverId: String(srv.id),
localAddress: String(g["local-address"] ?? ""),
remoteAddress: String(g["remote-address"] ?? ""),
localInnerIp: inner.localInnerIp,
remoteInnerIp: inner.remoteInnerIp,
poolId: "live",
ipsec: null,
mtu: Number.parseInt(String(g.mtu ?? "1476"), 10) || 1476,
keepaliveInterval: Number.parseInt(String(keepalive[0] ?? "0"), 10) || 0,
keepaliveRetries: Number.parseInt(String(keepalive[1] ?? "0"), 10) || 0,
dscp: "inherit" as const,
clampTcpMss: String(g["clamp-tcp-mss"] ?? "true") !== "false",
allowFastPath: String(g["allow-fast-path"] ?? "true") !== "false",
comment: String(g.comment ?? ""),
enabled: String(g.disabled ?? "false") !== "true",
status:
String(g.disabled ?? "false") === "true"
? "down" as const
: (String(g.running ?? "false") === "true" ? "up" as const : "degraded" as const),
}
})
} catch {
return []
}
}))
return all.flat()
}
export async function getInternetPathSettings() {
return await getSettingsRow()
}
export async function updateInternetPathSettings(patch: { enabled?: boolean; intervalSec?: number; retentionDays?: number }) {
const prev = await getSettingsRow()
await db.update(internetPathSettings).set({
enabled: patch.enabled ?? prev.enabled,
intervalSec: patch.intervalSec ?? prev.intervalSec,
retentionDays: patch.retentionDays ?? prev.retentionDays,
updatedAt: new Date().toISOString(),
}).where(eq(internetPathSettings.id, 1))
return await getSettingsRow()
}
export async function getLatestInternetPathSnapshot() {
const rows = await db.select().from(internetPathSnapshots).orderBy(desc(internetPathSnapshots.id)).limit(1)
return rows[0] ?? null
}
export async function collectInternetPathSnapshotOnce(): Promise<InternetPathRunSnapshot> {
const sampledAt = new Date().toISOString()
if (collecting) {
return {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "internet_path",
sampledAt,
homes: 0,
snapshotSaved: false,
}
}
collecting = true
const started = Date.now()
const settings = await getSettingsRow()
try {
const serversRead = await listServersRead()
const homes = serversRead.filter((s) => s.type === "home-router")
const [greTunnels, rulesets] = await Promise.all([await collectGreTunnels(), Promise.resolve(await buildRulesets())])
const speedProbes = await mapSpeedProbes()
const routeLookupByServerId: Record<string, { gateway: string | null; routingMark: string | null }> = {}
const wanRuntimeByHomeId: Record<string, unknown> = {}
for (const h of homes) {
routeLookupByServerId[String(h.id)] = await readRouteLookup(Number(h.id)).catch(() => ({ gateway: null, routingMark: null }))
wanRuntimeByHomeId[String(h.id)] = await readWanRuntime(Number(h.id)).catch(() => null)
}
const payload = {
sampledAt,
internetTarget: INTERNET_TARGET,
servers: serversRead,
greTunnels,
filtersRulesets: rulesets,
speedProbes,
routeLookupByServerId,
wanRuntimeByHomeId,
}
await db.insert(internetPathSnapshots).values({
sampledAt,
payloadJson: payload,
})
await cleanupSnapshots(Math.max(1, settings.retentionDays))
await db.update(internetPathSettings).set({
lastCollectedAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: "",
updatedAt: sampledAt,
}).where(eq(internetPathSettings.id, 1))
return {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "internet_path",
sampledAt,
homes: homes.length,
snapshotSaved: true,
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
await db.update(internetPathSettings).set({
lastCollectedAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: msg,
updatedAt: sampledAt,
}).where(eq(internetPathSettings.id, 1))
return {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "internet_path",
sampledAt,
homes: 0,
snapshotSaved: false,
fatalError: msg,
}
} finally {
collecting = false
}
}
export function isInternetPathCollecting(): boolean {
return collecting
}