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
При старте backend накатывает схему PostgreSQL 18 и, если база пустая, один раз импортирует mikrotik.db с тома. Повторный старт не копирует данные. Бэкап в UI идёт через pg_dump. Co-authored-by: Cursor <cursoragent@cursor.com>
314 lines
12 KiB
TypeScript
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
|
|
}
|