This commit is contained in:
Denozordec
2026-05-03 11:16:07 +07:00
parent ce00c4c671
commit bdb9b72fac
66 changed files with 9553 additions and 1547 deletions
+105
View File
@@ -81,6 +81,8 @@ CREATE TABLE IF NOT EXISTS uptime_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
interval_sec INTEGER NOT NULL DEFAULT 15,
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TEXT,
last_duration_ms INTEGER,
@@ -97,6 +99,7 @@ CREATE TABLE IF NOT EXISTS uptime_probes (
target TEXT NOT NULL,
probe_filter TEXT NOT NULL DEFAULT '—',
enabled INTEGER NOT NULL DEFAULT 1,
show_on_dashboard INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
@@ -134,6 +137,66 @@ CREATE TABLE IF NOT EXISTS uptime_resource_samples (
);
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_server_time
ON uptime_resource_samples(server_id, sampled_at);
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
id TEXT PRIMARY KEY,
src_server_id INTEGER NOT NULL,
dst_server_id INTEGER NOT NULL,
src_interface TEXT NOT NULL DEFAULT '',
dst_interface TEXT NOT NULL DEFAULT '',
protocol TEXT NOT NULL DEFAULT 'tcp',
direction TEXT NOT NULL DEFAULT 'both',
duration_sec INTEGER NOT NULL DEFAULT 10,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at TEXT,
last_tx_avg_mbps REAL,
last_rx_avg_mbps REAL,
last_status TEXT,
last_error TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (src_server_id) REFERENCES servers(id) ON DELETE CASCADE,
FOREIGN KEY (dst_server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort
ON uptime_speed_probes(src_server_id, sort_order);
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
id TEXT PRIMARY KEY,
probe_id TEXT,
src_server_id INTEGER NOT NULL,
dst_server_id INTEGER NOT NULL,
src_interface TEXT NOT NULL DEFAULT '',
dst_interface TEXT NOT NULL DEFAULT '',
src_address TEXT,
dst_address TEXT,
src_interface_address TEXT,
dst_interface_address TEXT,
protocol TEXT NOT NULL DEFAULT 'tcp',
direction TEXT NOT NULL DEFAULT 'both',
duration_sec INTEGER NOT NULL DEFAULT 10,
tx_avg_mbps REAL,
rx_avg_mbps REAL,
ping_rtt_ms INTEGER,
ping_loss_pct INTEGER,
ping_error TEXT,
status TEXT NOT NULL DEFAULT 'done',
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (src_server_id) REFERENCES servers(id) ON DELETE CASCADE,
FOREIGN KEY (dst_server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at
ON uptime_speed_test_runs(created_at);
CREATE TABLE IF NOT EXISTS evobgp_settings (
id INTEGER PRIMARY KEY,
base_url TEXT NOT NULL DEFAULT '',
api_key TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`)
// Lightweight schema evolution for existing databases without migrations
@@ -148,6 +211,42 @@ const hasSrcInterfaceColumn = uptimeProbeCols.some((c) => c.name === "src_interf
if (!hasSrcInterfaceColumn) {
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN src_interface TEXT NOT NULL DEFAULT ''`)
}
const hasShowOnDashboardColumn = uptimeProbeCols.some((c) => c.name === "show_on_dashboard")
if (!hasShowOnDashboardColumn) {
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN show_on_dashboard INTEGER NOT NULL DEFAULT 0`)
}
const uptimeSettingsCols = sqlite.prepare(`PRAGMA table_info('uptime_settings')`).all() as Array<{ name?: string }>
const hasProbeIntervalColumn = uptimeSettingsCols.some((c) => c.name === "probe_interval_sec")
if (!hasProbeIntervalColumn) {
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN probe_interval_sec INTEGER NOT NULL DEFAULT 15`)
}
const hasSpeedIntervalColumn = uptimeSettingsCols.some((c) => c.name === "speed_interval_sec")
if (!hasSpeedIntervalColumn) {
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN speed_interval_sec INTEGER NOT NULL DEFAULT 60`)
}
const uptimeSpeedProbeCols = sqlite.prepare(`PRAGMA table_info('uptime_speed_probes')`).all() as Array<{ name?: string }>
const ensureSpeedProbeCol = (name: string, ddl: string) => {
if (!uptimeSpeedProbeCols.some((c) => c.name === name)) sqlite.exec(ddl)
}
ensureSpeedProbeCol("last_run_at", `ALTER TABLE uptime_speed_probes ADD COLUMN last_run_at TEXT`)
ensureSpeedProbeCol("last_tx_avg_mbps", `ALTER TABLE uptime_speed_probes ADD COLUMN last_tx_avg_mbps REAL`)
ensureSpeedProbeCol("last_rx_avg_mbps", `ALTER TABLE uptime_speed_probes ADD COLUMN last_rx_avg_mbps REAL`)
ensureSpeedProbeCol("last_status", `ALTER TABLE uptime_speed_probes ADD COLUMN last_status TEXT`)
ensureSpeedProbeCol("last_error", `ALTER TABLE uptime_speed_probes ADD COLUMN last_error TEXT`)
ensureSpeedProbeCol("last_ping_rtt_ms", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_rtt_ms INTEGER`)
ensureSpeedProbeCol("last_ping_loss_pct", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_loss_pct INTEGER`)
ensureSpeedProbeCol("last_ping_at", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_at TEXT`)
ensureSpeedProbeCol("last_ping_error", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_error TEXT`)
const serverCols = sqlite.prepare(`PRAGMA table_info('servers')`).all() as Array<{ name?: string }>
if (!serverCols.some((c) => c.name === "lan_subnet")) {
sqlite.exec(`ALTER TABLE servers ADD COLUMN lan_subnet TEXT NOT NULL DEFAULT ''`)
}
if (!serverCols.some((c) => c.name === "wan_uplinks")) {
sqlite.exec(`ALTER TABLE servers ADD COLUMN wan_uplinks TEXT NOT NULL DEFAULT '[]'`)
}
sqlite.exec(`
INSERT INTO traffic_settings (id, enabled, interval_sec, retention_days)
@@ -161,4 +260,10 @@ SELECT 1, 1, 15, 14
WHERE NOT EXISTS (SELECT 1 FROM uptime_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO evobgp_settings (id, base_url, api_key, enabled)
SELECT 1, '', '', 0
WHERE NOT EXISTS (SELECT 1 FROM evobgp_settings WHERE id = 1);
`)
export const db = drizzle(sqlite, { schema })
+74
View File
@@ -27,6 +27,11 @@ export const servers = sqliteTable("servers", {
comment: text("comment").notNull().default(""),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
/** Подсеть LAN (home-router), текст из формы */
lanSubnet: text("lan_subnet").notNull().default(""),
/** JSON-массив WAN-аплинков [{ id, name, isp, iface, ip, maxDl, maxUl }, …] */
wanUplinks: text("wan_uplinks").notNull().default("[]"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
@@ -133,6 +138,8 @@ export const uptimeSettings = sqliteTable("uptime_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
intervalSec: integer("interval_sec").notNull().default(15),
probeIntervalSec: integer("probe_interval_sec").notNull().default(15),
speedIntervalSec: integer("speed_interval_sec").notNull().default(60),
retentionDays: integer("retention_days").notNull().default(14),
lastCollectedAt: text("last_collected_at"),
lastDurationMs: integer("last_duration_ms"),
@@ -151,6 +158,8 @@ export const uptimeProbes = sqliteTable("uptime_probes", {
target: text("target").notNull(),
probeFilter: text("probe_filter").notNull().default("—"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
/** Выводить пробу в блоке «Активные пробы» на дашборде */
showOnDashboard: integer("show_on_dashboard", { mode: "boolean" }).notNull().default(false),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
@@ -184,6 +193,68 @@ export const uptimeResourceSamples = sqliteTable("uptime_resource_samples", {
rosVersion: text("ros_version").notNull().default(""),
})
export const uptimeSpeedProbes = sqliteTable("uptime_speed_probes", {
id: text("id").primaryKey(),
srcServerId: integer("src_server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
dstServerId: integer("dst_server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
srcInterface: text("src_interface").notNull().default(""),
dstInterface: text("dst_interface").notNull().default(""),
protocol: text("protocol", { enum: ["tcp", "udp"] }).notNull().default("tcp"),
direction: text("direction", { enum: ["transmit", "receive", "both"] }).notNull().default("both"),
durationSec: integer("duration_sec").notNull().default(10),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
lastRunAt: text("last_run_at"),
lastTxAvgMbps: real("last_tx_avg_mbps"),
lastRxAvgMbps: real("last_rx_avg_mbps"),
lastStatus: text("last_status", { enum: ["done", "error"] }),
lastError: text("last_error"),
lastPingRttMs: integer("last_ping_rtt_ms"),
lastPingLossPct: integer("last_ping_loss_pct"),
lastPingAt: text("last_ping_at"),
lastPingError: text("last_ping_error"),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
// ── EvoBGP integration (URL + API key на сервере) ─────────────────────────────
export const evobgpSettings = sqliteTable("evobgp_settings", {
id: integer("id").primaryKey(),
baseUrl: text("base_url").notNull().default(""),
apiKey: text("api_key").notNull().default(""),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
export const uptimeSpeedTestRuns = sqliteTable("uptime_speed_test_runs", {
id: text("id").primaryKey(),
probeId: text("probe_id"),
srcServerId: integer("src_server_id").notNull().references(() => servers.id, { onDelete: "cascade" }),
dstServerId: integer("dst_server_id").notNull().references(() => servers.id, { onDelete: "cascade" }),
srcInterface: text("src_interface").notNull().default(""),
dstInterface: text("dst_interface").notNull().default(""),
srcAddress: text("src_address"),
dstAddress: text("dst_address"),
srcInterfaceAddress:text("src_interface_address"),
dstInterfaceAddress:text("dst_interface_address"),
protocol: text("protocol", { enum: ["tcp", "udp"] }).notNull().default("tcp"),
direction: text("direction", { enum: ["transmit", "receive", "both"] }).notNull().default("both"),
durationSec: integer("duration_sec").notNull().default(10),
txAvgMbps: real("tx_avg_mbps"),
rxAvgMbps: real("rx_avg_mbps"),
pingRttMs: integer("ping_rtt_ms"),
pingLossPct: integer("ping_loss_pct"),
pingError: text("ping_error"),
status: text("status", { enum: ["done", "error"] }).notNull().default("done"),
error: text("error"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
})
// ── inferred types ─────────────────────────────────────────────────────────────
export type Server = typeof servers.$inferSelect
@@ -198,3 +269,6 @@ export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
export type UptimeProbeRow = typeof uptimeProbes.$inferSelect
export type UptimeProbeSampleRow = typeof uptimeProbeSamples.$inferSelect
export type UptimeResourceSampleRow = typeof uptimeResourceSamples.$inferSelect
export type UptimeSpeedProbeRow = typeof uptimeSpeedProbes.$inferSelect
export type UptimeSpeedTestRunRow = typeof uptimeSpeedTestRuns.$inferSelect
export type EvobgpSettingsRow = typeof evobgpSettings.$inferSelect
+8 -1
View File
@@ -10,6 +10,9 @@ import filtersRoutes from "./routes/filters.js"
import recursiveRoutes from "./routes/recursive-routes.js"
import trafficRoutes from "./routes/traffic.js"
import uptimeRoutes from "./routes/uptime.js"
import networkRoutes from "./routes/network.js"
import evobgpRoutes from "./routes/evobgp.js"
import probesRoutes from "./routes/probes.js"
import { collectTrafficOnce, restartTrafficCollector, stopTrafficCollector } from "./services/traffic-collector.js"
import { collectUptimeOnce, restartUptimeCollector, stopUptimeCollector } from "./services/uptime-collector.js"
@@ -31,7 +34,8 @@ app.setSerializerCompiler(serializerCompiler)
// CORS — allow Next.js frontend
await app.register(cors, {
origin: env.CORS_ORIGIN,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
/** PATCH — для /api/uptime/probes/:id (звезда на дашборде); без этого браузер режет preflight */
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
})
// ── routes ─────────────────────────────────────────────────────────────────────
@@ -46,6 +50,9 @@ await app.register(filtersRoutes, { prefix: "/api" })
await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
await app.register(networkRoutes, { prefix: "/api" })
await app.register(evobgpRoutes, { prefix: "/api" })
await app.register(probesRoutes, { prefix: "/api" })
restartTrafficCollector()
void collectTrafficOnce()
+1 -1
View File
@@ -107,7 +107,7 @@ function parseSessions(server: ServerRow, raw: RosBgpSession[]): BgpSessionRead[
if (s["add-path-capability"] === "true") legacyCaps.push("ADD-PATH")
if (s["graceful-restart-capability"] === "true") legacyCaps.push("Graceful Restart")
if (s["extended-message-capability"] === "true") legacyCaps.push("Extended Messages")
const caps = [...new Set([...localCaps, ...legacyCaps])]
const caps = [...new Set([...localCaps, ...remoteCaps, ...legacyCaps])]
// Hold time: RouterOS 7 uses "hold-time" ("1m30s"), RouterOS 6 uses "active-holdtime" (seconds)
const holdTime = parseDuration(s["hold-time"])
+281
View File
@@ -0,0 +1,281 @@
import { eq, sql } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { z } from "zod"
import { db } from "../db/index.js"
import { evobgpSettings } from "../db/schema.js"
const PutEvobgpSchema = z.object({
baseUrl: z.string().optional(),
enabled: z.boolean().optional(),
/** undefined — не менять; null или "" — очистить ключ; строка — новое значение */
apiKey: z.union([z.string(), z.null()]).optional(),
})
/** Опционально: проверить черновики до сохранения; поле отсутствует — взять из БД */
const TestEvobgpSchema = z.object({
baseUrl: z.string().optional(),
apiKey: z.string().optional(),
})
function ensureEvobgpRow() {
let row = db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1).all()[0]
if (!row) {
db.insert(evobgpSettings).values({ id: 1 }).run()
row = db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1).all()[0]
}
return row!
}
function normalizeBaseUrl(raw: string): string {
const trimmed = raw.trim().replace(/\/$/, "")
if (!trimmed) return ""
try {
const u = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`)
return `${u.protocol}//${u.host}`
} catch {
return trimmed
}
}
interface EvoCatalogRaw {
modules: { items: Array<{ id: string; name: string; type: string }> }
domains: {
items: Array<{
module_id: string
entry: { id: string; fqdn: string; community_id?: string | null }
}>
}
asns: {
items: Array<{
module_id: string
entry: { id: string; asn: number; community_id?: string | null }
}>
}
ip_ranges: {
items: Array<{
module_id: string
entry: { id: string; prefix: string; community_id: string }
}>
}
communities: {
items: Array<{ id: string; community?: string; title?: string }>
}
}
function problemMessage(text: string, status: number): string {
try {
const j = JSON.parse(text) as { detail?: string; title?: string }
return j.detail ?? j.title ?? `HTTP ${status}`
} catch {
return text || `HTTP ${status}`
}
}
async function fetchEvoJson<T>(root: string, path: string, token: string): Promise<T> {
const url = `${root}${path.startsWith("/") ? "" : "/"}${path}`
const ac = new AbortController()
const t = setTimeout(() => ac.abort(), 90_000)
try {
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
signal: ac.signal,
})
const text = await res.text()
if (!res.ok) {
throw new Error(problemMessage(text, res.status))
}
return JSON.parse(text) as T
} finally {
clearTimeout(t)
}
}
function credentialsFromDb(): { root: string; apiKey: string } | null {
const row = ensureEvobgpRow()
const root = normalizeBaseUrl(row.baseUrl)
const apiKey = row.apiKey.trim()
if (!root || !apiKey) return null
return { root, apiKey }
}
const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/evobgp/settings", async (_req, reply) => {
const row = ensureEvobgpRow()
return reply.send({
baseUrl: row.baseUrl ?? "",
enabled: row.enabled ?? false,
secretConfigured: Boolean(row.apiKey?.trim()),
})
})
app.put("/evobgp/settings", async (req, reply) => {
const parsed = PutEvobgpSchema.safeParse(req.body)
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const cur = ensureEvobgpRow()
let nextBase = cur.baseUrl
let nextEnabled = cur.enabled
let nextKey = cur.apiKey
if (parsed.data.baseUrl !== undefined) nextBase = parsed.data.baseUrl.trim()
if (parsed.data.enabled !== undefined) nextEnabled = parsed.data.enabled
if (parsed.data.apiKey !== undefined) {
nextKey = parsed.data.apiKey === null || parsed.data.apiKey === "" ? "" : parsed.data.apiKey.trim()
}
db.update(evobgpSettings)
.set({
baseUrl: nextBase,
enabled: nextEnabled,
apiKey: nextKey,
updatedAt: sql`(datetime('now'))`,
})
.where(eq(evobgpSettings.id, 1))
.run()
const row = ensureEvobgpRow()
return reply.send({
baseUrl: row.baseUrl ?? "",
enabled: row.enabled ?? false,
secretConfigured: Boolean(row.apiKey?.trim()),
})
})
/**
* Проверка Bearer: тело опционально — можно передать черновики URL/ключа с формы.
* Для каждого поля: если в JSON ключ отсутствует — используется значение из БД;
* если `apiKey` передана пустой строкой — ключ берётся из БД (проверка только нового URL).
*/
app.post("/evobgp/test", async (req, reply) => {
const parsed = TestEvobgpSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const row = ensureEvobgpRow()
const d = parsed.data
const urlRaw = d.baseUrl !== undefined ? d.baseUrl : row.baseUrl
const keyRaw =
d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey
const root = normalizeBaseUrl(urlRaw.trim())
const token = keyRaw.trim()
if (!root || !token) {
return reply.status(400).send({
error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)",
})
}
try {
await fetchEvoJson<{ items: unknown[] }>(root, "/v1/modules?limit=1", token)
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : "Ошибка EvoBGP"
return reply.status(502).send({ error: msg })
}
})
/**
* Прокси к GET /v1/router-lists/catalog — учётные данные только из БД.
*/
app.post("/evobgp/catalog", async (_req, reply) => {
const row = ensureEvobgpRow()
if (!row.enabled) {
return reply.status(400).send({ error: "Интеграция EvoBGP выключена в настройках" })
}
const cred = credentialsFromDb()
if (!cred) {
return reply.status(400).send({ error: "Не заданы базовый URL или API-ключ в БД" })
}
try {
const catalog = await fetchEvoJson<EvoCatalogRaw>(cred.root, "/v1/router-lists/catalog", cred.apiKey)
const modules = catalog.modules?.items ?? []
const modMap = new Map(modules.map((m) => [m.id, m]))
const commItems = catalog.communities?.items ?? []
const commLabel = new Map<string, string>()
for (const c of commItems) {
const label = (c.community && c.title)
? `${c.title} (${c.community})`
: (c.title ?? c.community ?? c.id)
commLabel.set(c.id, label)
}
const fmtTime = new Date().toLocaleString("ru-RU")
const fetchedAt = new Date().toISOString()
const domains = (catalog.domains?.items ?? []).map((rowItem) => {
const mod = modMap.get(rowItem.module_id)
const cid = rowItem.entry.community_id
return {
id: `${rowItem.module_id}:${rowItem.entry.id}`,
domain: rowItem.entry.fqdn,
resolvedIp: "—",
asn: "—",
purpose: mod?.name ?? "—",
filter: cid ? (commLabel.get(cid) ?? cid) : "—",
updated: fmtTime,
enabled: true,
}
})
const ipRanges = (catalog.ip_ranges?.items ?? []).map((rowItem) => {
const mod = modMap.get(rowItem.module_id)
const cid = rowItem.entry.community_id
return {
id: `${rowItem.module_id}:${rowItem.entry.id}`,
cidr: rowItem.entry.prefix,
asn: "—",
country: "—",
purpose: mod?.name ?? "—",
filter: commLabel.get(cid) ?? cid,
updated: fmtTime,
enabled: true,
}
})
const asns = (catalog.asns?.items ?? []).map((rowItem) => {
const mod = modMap.get(rowItem.module_id)
const cid = rowItem.entry.community_id
return {
id: `${rowItem.module_id}:${rowItem.entry.id}`,
asn: `AS${rowItem.entry.asn}`,
org: "—",
country: "—",
prefixes: 0,
filter: cid ? (commLabel.get(cid) ?? cid) : "—",
updated: fmtTime,
enabled: true,
}
})
const communities = commItems.map((c) => ({
id: c.id,
value: c.community ?? c.id,
name: c.title ?? c.community ?? c.id,
description: "",
type: "custom" as const,
filterIds: [] as string[],
serverCount: 0,
prefixCount: 0,
action: "permit" as const,
enabled: true,
}))
return reply.send({
fetchedAt,
modules: modules.map((m) => ({ id: m.id, name: m.name, type: m.type })),
domains,
ipRanges,
asns,
communities,
})
} catch (e) {
const msg = e instanceof Error ? e.message : "Ошибка EvoBGP"
return reply.status(502).send({ error: msg })
}
})
}
export default evobgpRoutes
+177 -18
View File
@@ -1,7 +1,7 @@
import { and, asc, eq, inArray } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { filterRules, servers } from "../db/schema.js"
import { filterRules, recursiveRoutes, servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
type ServerRow = typeof servers.$inferSelect
@@ -121,6 +121,39 @@ function parseFilterRule(raw: RosFilterRule): ApiFilterRule[] {
}))
}
/** Хоп для BGP filter из префикса рекурсивного статического маршрута (напр. 10.9.9.2/32 → 10.9.9.2) */
function gatewayFromRecursiveDst(dstAddress: string): string {
return (dstAddress ?? "").trim().split("/")[0]?.trim() ?? ""
}
/**
* Импорт с роутера даёт только `set gateway` без `set out-interface` — в таком виде не отличить от «поломанного» GRE.
* Сопоставляем hop с локальной таблицей recursive_routes и восстанавливаем gatewayTunnelId = rec:<id>.
*/
function enrichRulesWithRecursiveGateway(serverId: number, rules: ApiFilterRule[]): ApiFilterRule[] {
const rows = db
.select()
.from(recursiveRoutes)
.where(and(eq(recursiveRoutes.serverId, serverId), eq(recursiveRoutes.disabled, false)))
.all()
return rules.map(rule => {
if (rule.action === "blackhole") return rule
const tid = (rule.gatewayTunnelId ?? "").trim()
if (tid) return rule
const gw = (rule.gateway ?? "").trim()
if (!gw) return rule
const candidates = rows.filter(r => gatewayFromRecursiveDst(r.dstAddress) === gw)
if (candidates.length === 0) return rule
const best = candidates.reduce((a, b) => (a.distance <= b.distance ? a : b))
return {
...rule,
gatewayTunnelId: `rec:${best.id}`,
gateway: gatewayFromRecursiveDst(best.dstAddress) || gw,
}
})
}
async function fetchServerFilters(server: ServerRow) {
const client = MikrotikClient.fromServer(server)
const [greRaw, filterRaw] = await Promise.all([
@@ -156,10 +189,12 @@ async function fetchServerFilters(server: ServerRow) {
}
})
const rules = filterRaw
const rulesRaw = filterRaw
.filter(r => (r.chain ?? "").trim().toLowerCase() === "bgp-in")
.flatMap(parseFilterRule)
const rules = enrichRulesWithRecursiveGateway(server.id, rulesRaw)
return {
serverId: String(server.id),
rules,
@@ -185,7 +220,70 @@ function toApiRulesets(serverRows: ServerRow[]) {
}))
}
function toRouterRuleBody(rules: ApiFilterRule[]): string {
/** GRE: gatewayTunnelId = имя интерфейса; рекурсивный: rec:<id строки recursive_routes */
function resolveRouteTargets(serverId: number, rule: ApiFilterRule): { gateway: string; outIface: string } {
if (rule.action === "blackhole") return { gateway: "", outIface: "" }
const tid = (rule.gatewayTunnelId ?? "").trim()
if (tid.startsWith("rec:")) {
const rid = Number.parseInt(tid.slice(4), 10)
if (!Number.isFinite(rid)) {
return { gateway: rule.gateway, outIface: "" }
}
const row = db
.select()
.from(recursiveRoutes)
.where(and(eq(recursiveRoutes.serverId, serverId), eq(recursiveRoutes.id, rid)))
.limit(1)
.all()[0]
if (!row) return { gateway: rule.gateway, outIface: "" }
const gw = gatewayFromRecursiveDst(row.dstAddress)
return { gateway: gw || rule.gateway, outIface: "" }
}
return { gateway: rule.gateway, outIface: tid }
}
function normalizeCommunity(c: string): string {
return (c ?? "").trim()
}
/** Одинаковый эффект на роутере при одинаковой community (blackhole vs gateway + out-interface) */
function ruleEffectSignature(serverId: number, r: ApiFilterRule): string {
if (r.action === "blackhole") return `bh:${normalizeCommunity(r.community)}`
const { gateway, outIface } = resolveRouteTargets(serverId, r)
return `rt:${normalizeCommunity(r.community)}:${gateway}:${outIface}`
}
export type FilterRouterCompareStatus = "synced" | "drift" | "missing"
function compareDbRulesWithRouter(
serverId: number,
dbRules: ApiFilterRule[],
remoteRules: ApiFilterRule[],
): Record<string, FilterRouterCompareStatus> {
const remoteSigByComm = new Map<string, string>()
for (const rr of remoteRules) {
const c = normalizeCommunity(rr.community)
if (!remoteSigByComm.has(c)) {
remoteSigByComm.set(c, ruleEffectSignature(serverId, rr))
}
}
const out: Record<string, FilterRouterCompareStatus> = {}
for (const dr of dbRules) {
const c = normalizeCommunity(dr.community)
const sigD = ruleEffectSignature(serverId, dr)
const sigR = remoteSigByComm.get(c)
if (sigR === undefined) {
out[c] = "missing"
} else if (sigR !== sigD) {
out[c] = "drift"
} else {
out[c] = "synced"
}
}
return out
}
function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): string {
return rules.map((rule, i) => {
const kw = i === 0 ? "if" : "} else if"
const comment = rule.description ? ` # ${rule.description}` : ""
@@ -197,13 +295,15 @@ function toRouterRuleBody(rules: ApiFilterRule[]): string {
" accept;",
].filter(Boolean).join("\n")
}
return [
const { gateway, outIface } = resolveRouteTargets(serverId, rule)
const lines = [
` ${kw} (bgp-communities.has("${rule.community}")) {`,
comment,
` set gateway ${rule.gateway};`,
` set out-interface ${rule.gatewayTunnelId};`,
" accept;",
].filter(Boolean).join("\n")
` set gateway ${gateway};`,
]
if (outIface) lines.push(` set out-interface ${outIface};`)
lines.push(" accept;")
return lines.filter(Boolean).join("\n")
}).join("\n")
}
@@ -228,21 +328,80 @@ async function replaceDbRules(serverId: number, rules: ApiFilterRule[]) {
}
const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
/** Сравнение правил в БД с живым bgp-in на MikroTik (один запрос API к роутеру) */
app.get("/filters/router-compare", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseServerId(q.serverId)
if (serverId === null) {
return reply.status(400).send({ error: "serverId is required" })
}
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const remote = await fetchServerFilters(server)
const rows = db
.select()
.from(filterRules)
.where(eq(filterRules.serverId, serverId))
.orderBy(asc(filterRules.sortOrder))
.all()
const dbRules: ApiFilterRule[] = rows.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,
}))
const byCommunity = compareDbRulesWithRouter(serverId, dbRules, remote.rules)
return reply.send({ byCommunity })
} catch (err) {
app.log.error({ serverId, err: String(err) }, "filters router-compare failed")
return reply.status(500).send({ error: String(err) })
}
})
/** GRE с роутеров: один сервер (?serverId) или все включённые (без query) — для /gre, карты сети */
app.get("/filters/gre-tunnels", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const sid = parseServerId(q.serverId)
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
if (sid !== null) {
const server = enabledServers.find(s => s.id === sid)
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const remote = await fetchServerFilters(server)
return reply.send({ tunnels: remote.tunnels })
} catch {
return reply.send({ tunnels: [] as LiveGreTunnel[] })
}
}
const results = await Promise.all(enabledServers.map(async (server) => {
try {
const remote = await fetchServerFilters(server)
return remote.tunnels
} catch {
return [] as LiveGreTunnel[]
}
}))
return reply.send({ tunnels: results.flat() })
})
/** Только правила фильтров из БД (без опроса MikroTik за GRE) */
app.get("/filters/rules", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const dbRulesets = toApiRulesets(allServers)
const results = await Promise.all(allServers.map(async (server) => {
try {
const remote = await fetchServerFilters(server)
return { serverId: String(server.id), tunnels: remote.tunnels }
} catch {
return { serverId: String(server.id), tunnels: [] as LiveGreTunnel[] }
}
}))
return reply.send({
rulesets: dbRulesets,
greTunnels: results.flatMap(r => r.tunnels),
greTunnels: [] as LiveGreTunnel[],
})
})
@@ -315,7 +474,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
}))
if (rules.length > 0) {
const ruleBody = toRouterRuleBody(rules)
const ruleBody = toRouterRuleBody(server.id, rules)
await client.post("/routing/filter/rule", {
chain: "bgp-in",
comment: `RouterLists: ${server.name || server.host}`,
+67
View File
@@ -0,0 +1,67 @@
import { promises as dns } from "node:dns"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { z } from "zod"
const IPV4 =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
function isIpv4(s: string): boolean {
return IPV4.test(s.trim())
}
/** Кэш DNS на процесс (GRE outer меняется редко). */
const resolveCache = new Map<string, { ip: string | null; expires: number }>()
const TTL_MS = 5 * 60 * 1000
async function resolveHostToIpv4(hostname: string): Promise<string | null> {
const key = hostname.trim().toLowerCase()
if (!key || isIpv4(key)) return isIpv4(key) ? key.trim() : null
const hit = resolveCache.get(key)
if (hit && hit.expires > Date.now()) return hit.ip
try {
const { address } = await dns.lookup(key, { family: 4 })
const ip = isIpv4(address) ? address : null
resolveCache.set(key, { ip, expires: Date.now() + TTL_MS })
return ip
} catch {
resolveCache.set(key, { ip: null, expires: Date.now() + TTL_MS })
return null
}
}
const ResolveHostsBodySchema = z.object({
hosts: z.array(z.string().max(253)).max(32),
})
const networkRoutes: FastifyPluginAsyncZod = async (app) => {
/**
* Резолв FQDN из GRE outer (remote-address / local-address) в IPv4 для сопоставления с WAN в каталоге.
*/
app.post("/network/resolve-hosts", async (req, reply) => {
const parsed = ResolveHostsBodySchema.safeParse(req.body)
if (!parsed.success) {
return reply.status(400).send({ error: "Invalid body", details: parsed.error.flatten() })
}
const seen = new Set<string>()
const unique: string[] = []
for (const h of parsed.data.hosts) {
const k = h.trim().toLowerCase()
if (!k || seen.has(k)) continue
seen.add(k)
unique.push(h.trim())
}
const results: Record<string, string | null> = {}
for (const host of unique) {
const key = host.toLowerCase()
results[key] = await resolveHostToIpv4(host)
}
return reply.send({ results })
})
}
export default networkRoutes
+412
View File
@@ -0,0 +1,412 @@
import dns from "node:dns/promises"
import { eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import type { RosIpRoute, RosPingResult } from "../types/server.js"
import { resolveRosSrcIpv4 } from "../utils/ros-src-address.js"
import { abortAfterMs, mergeAbortSignals } from "../utils/abort-signals.js"
/**
* Документация REST API RouterOS: если команда «бесконечна», сессия всё равно закрывается
* через ~60 с; параметры команды **не** продлевают этот лимит.
* @see https://help.mikrotik.com/docs/display/ROS/REST+API — раздел «Timeout»
*/
const ROS_REST_SESSION_MAX_MS = 58_000
/** Парсинг ввода пользователя (800ms, 1s, 00:00:01) → миллисекунды (10–3000). */
function parseTraceHopInputToMs(raw: unknown): number {
const s = String(raw ?? "").trim().toLowerCase()
if (!s) return 1000
const msM = /^(\d+)ms$/.exec(s)
if (msM) return Math.min(3000, Math.max(10, Number(msM[1])))
const secM = /^(\d+(?:\.\d+)?)s$/.exec(s)
if (secM) return Math.min(3000, Math.max(10, Number(secM[1]) * 1000))
const hm = /^(\d{1,2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/.exec(String(raw ?? "").trim())
if (hm) {
const h = Number(hm[1]), m = Number(hm[2]), sec = Number(hm[3])
const frac = hm[4] ? Number(hm[4].padEnd(3, "0").slice(0, 3)) : 0
const t = ((h * 60 + m) * 60 + sec) * 1000 + frac
return Math.min(3000, Math.max(10, t))
}
return 1000
}
/** В REST JSON для /tool/traceroute нужен формат времени HH:MM:SS (не «1s»). */
function formatMsAsRosTracerouteTimeout(ms: number): string {
const capped = Math.min(3000, Math.max(10, Math.round(ms)))
const totalSeconds = Math.floor(capped / 1000)
const milli = capped % 1000
const ss = totalSeconds % 60
const mmTotal = Math.floor(totalSeconds / 60)
const mm = mmTotal % 60
const hh = Math.floor(mmTotal / 60)
const pad = (n: number) => String(n).padStart(2, "0")
if (milli === 0) return `${pad(hh)}:${pad(mm)}:${pad(ss)}`
return `${pad(hh)}:${pad(mm)}:${pad(ss)}.${String(milli).padStart(3, "0")}`
}
function traceHopTimeoutForApi(raw: unknown): string {
return formatMsAsRosTracerouteTimeout(parseTraceHopInputToMs(raw))
}
function parseServerId(raw: string): number | null {
const n = Number.parseInt(raw, 10)
return Number.isFinite(n) ? n : null
}
function ipv4ToUint(ip: string): number | null {
const p = ip.split(".").map((x) => Number.parseInt(x, 10))
if (p.length !== 4 || p.some((x) => !Number.isFinite(x) || x < 0 || x > 255)) return null
return (((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]) >>> 0)
}
function maskFromLen(len: number): number {
if (len <= 0) return 0
if (len >= 32) return 0xffffffff
return (~((1 << (32 - len)) - 1)) >>> 0
}
function parseDstRoute(dst: string): { net: number; maskBits: number } | null {
const t = dst.trim()
if (!t) return null
if (!t.includes("/")) {
const ip = ipv4ToUint(t)
return ip === null ? null : { net: ip, maskBits: 32 }
}
const [addr, mb] = t.split("/")
const ip = ipv4ToUint(addr.trim())
const maskBits = Number.parseInt((mb ?? "").trim(), 10)
if (ip === null || !Number.isFinite(maskBits) || maskBits < 0 || maskBits > 32) return null
const mask = maskFromLen(maskBits)
return { net: ip & mask, maskBits }
}
function ipMatchesRoute(destIp: string, dstAddressField: string): boolean {
const ip = ipv4ToUint(destIp.trim())
if (ip === null) return false
const cidr = parseDstRoute(dstAddressField)
if (!cidr) return false
const mask = maskFromLen(cidr.maskBits)
return (ip & mask) === (cidr.net & mask)
}
function fmtPingRouterOs(results: RosPingResult[], host: string): string {
const lines = [`PING ${host}`]
for (const r of results) {
if (!r.seq && !r.sent && r.status !== "timeout") continue
if (r.status === "timeout") {
lines.push(` seq=${r.seq ?? "?"} timeout`)
} else if (r.seq && r.time !== undefined) {
lines.push(` seq=${r.seq} ttl=${r.ttl ?? "?"} time=${r.time}`)
}
}
const sum = [...results].reverse().find((r) => r.sent)
if (sum) {
lines.push(` sent=${sum.sent} received=${sum.received ?? "?"} packet-loss=${sum["packet-loss"] ?? "?"}`)
if (sum.time && sum.sent) lines.push(` avg-rtt=${sum.time}`)
}
return lines.join("\n")
}
function fmtTraceroute(rows: unknown): string {
if (!Array.isArray(rows)) return typeof rows === "string" ? rows : JSON.stringify(rows, null, 2)
const hdr = " # ADDRESS LOSS LAST AVG"
const lines = [hdr]
rows.forEach((row, i) => {
const r = row as Record<string, string | undefined>
const addr = String(r.address ?? r.host ?? r["from-address"] ?? "?")
const loss = String(r.loss ?? r["packet-loss"] ?? "—")
const last = String(r["last"] ?? r.time ?? "—")
const avg = String(r.avg ?? r["avg-rtt"] ?? "—")
lines.push(
` ${String(i + 1).padStart(2)} ${addr.padEnd(40)} ${loss.padEnd(6)} ${last.padStart(8)} ${avg.padStart(8)}`,
)
})
return lines.join("\n")
}
function fmtBandwidth(rows: Array<Record<string, string>>): string {
if (rows.length === 0) return "(no bandwidth-test output)"
const lines = rows.map((r) => {
const tx = r["tx-current"] ?? r["tx-total-average"] ?? ""
const rx = r["rx-current"] ?? r["rx-total-average"] ?? ""
const sec = r["test-duration"] ?? ""
const parts = [`tx=${tx}`, `rx=${rx}`]
if (sec) parts.push(`t=${sec}`)
return ` ${parts.join(" ")}`
})
return ["bandwidth-test:", ...lines].join("\n")
}
function fmtRouteLookup(destIp: string, routes: RosIpRoute[]): string {
const matches = routes.filter((r) => {
const dst = String(r["dst-address"] ?? "").trim()
if (!dst) return false
if (String(r.active ?? "true").toLowerCase() === "false") return false
return ipMatchesRoute(destIp, dst)
})
if (matches.length === 0) {
return `no route for ${destIp} (active routes checked: ${routes.length})`
}
matches.sort((a, b) => {
const da = parseDstRoute(String(a["dst-address"] ?? ""))
const db = parseDstRoute(String(b["dst-address"] ?? ""))
return (db?.maskBits ?? 0) - (da?.maskBits ?? 0)
})
const best = matches[0]!
const lines = [
`lookup ${destIp} → best match:`,
` dst-address: ${best["dst-address"] ?? "—"}`,
` gateway: ${best.gateway ?? best.interface ?? "—"}`,
` distance: ${best.distance ?? "—"}`,
` routing-mark: ${best["routing-mark"] ?? "main"}`,
]
return lines.join("\n")
}
async function dnsLookupText(name: string, type: string): Promise<string> {
const t = type.toUpperCase()
const lines: string[] = [`;; QUESTION: ${name} ${t}`, ""]
try {
if (t === "A") {
const addrs = await dns.resolve4(name)
addrs.forEach((a) => lines.push(`${name}. IN A ${a}`))
} else if (t === "AAAA") {
const addrs = await dns.resolve6(name)
addrs.forEach((a) => lines.push(`${name}. IN AAAA ${a}`))
} else if (t === "MX") {
const mx = await dns.resolveMx(name)
mx.sort((a, b) => a.priority - b.priority)
mx.forEach((m) => lines.push(`${name}. IN MX ${m.priority} ${m.exchange}`))
} else if (t === "NS") {
const ns = await dns.resolveNs(name)
ns.forEach((n) => lines.push(`${name}. IN NS ${n}`))
} else if (t === "TXT") {
const tx = await dns.resolveTxt(name)
tx.forEach((chunks) => lines.push(`${name}. IN TXT "${chunks.join("")}"`))
} else if (t === "CNAME") {
const c = await dns.resolveCname(name)
lines.push(`${name}. IN CNAME ${c}`)
} else if (t === "PTR") {
const raw = name.trim()
if (/^[\d.]+$/.test(raw)) {
const hosts = await dns.reverse(raw)
hosts.forEach((h) => lines.push(`${raw}.in-addr.arpa. IN PTR ${h}`))
} else {
const ptr = await dns.resolvePtr(raw.includes(".arpa") ? raw : `${raw}.in-addr.arpa`)
ptr.forEach((p) => lines.push(`${raw}. IN PTR ${p}`))
}
} else {
lines.push(`;; тип «${t}» не поддержан в живом режиме (используйте A, AAAA, MX, NS, TXT, CNAME, PTR)`)
}
} catch (e) {
lines.push(`;; ERROR: ${e instanceof Error ? e.message : String(e)}`)
lines.push(";; (резолв выполняется на хосте бекенда, не на MikroTik)")
}
return lines.join("\n")
}
async function mtuDiscover(client: MikrotikClient, address: string, srcIpv4: string | null): Promise<string> {
const sizes = [1500, 1492, 1480, 1476, 1472, 1468, 1400, 1280, 1024, 576]
const lines: string[] = [`MTU discovery (do-not-fragment ping) → ${address}`, ""]
let mtuFound = 0
for (const size of sizes) {
try {
const body: Record<string, string> = {
address,
count: "1",
size: String(size),
interval: "0.2s",
/** REST /tool/ping: как в CLI — `do-not-fragment=yes`, не `dont-fragment` */
"do-not-fragment": "true",
}
if (srcIpv4) body["src-address"] = srcIpv4
const rows = await client.post<RosPingResult[]>("/tool/ping", body, 25_000)
const timeout = rows.some((r) => r.status === "timeout")
if (!timeout) {
lines.push(` ${String(size).padStart(4)} ✓ ok`)
mtuFound = size
break
}
lines.push(` ${String(size).padStart(4)} ✗ fragment needed / timeout`)
} catch (e) {
lines.push(` ${String(size).padStart(4)}${e instanceof Error ? e.message : String(e)}`)
}
}
lines.push("")
lines.push(mtuFound > 0 ? `MTU (DF): ~${mtuFound} bytes` : "MTU: could not determine")
return lines.join("\n")
}
function resolveBtestPeer(remoteHost: string, explicitDstId?: number) {
if (explicitDstId != null && Number.isFinite(explicitDstId)) {
const s = db.select().from(servers).where(eq(servers.id, explicitDstId)).limit(1).all()[0]
if (s) return s
}
const norm = remoteHost.trim().toLowerCase()
return db.select().from(servers).where(eq(servers.enabled, true)).all()
.find((x) => x.host.trim().toLowerCase() === norm)
}
const probesRoutes: FastifyPluginAsyncZod = async (app) => {
app.post("/servers/:id/probes/run", async (req, reply) => {
const sid = parseServerId(String((req.params as { id?: string }).id ?? ""))
if (sid === null) return reply.status(400).send({ error: "Invalid server id" })
const server = db.select().from(servers).where(eq(servers.id, sid)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const body = req.body as {
tool?: string
target?: string
pingCount?: number
pingSize?: number
pingTtl?: number
traceProto?: string
traceMaxHops?: number
/** Таймаут одной пробы: 800ms, 1s или 00:00:01 (в API уходит как HH:MM:SS) */
traceHopTimeout?: string
/** Число проб на хоп (1–3). Для REST рекомендуется 1 из‑за лимита сессии ~60 с */
traceProbeCount?: number
/** Резолвить адреса хопов в имена (RouterOS: use-dns yes|no → REST true|false) */
traceUseDns?: boolean
dnsType?: string
bwRemoteAddress?: string
dstServerId?: number
bwProto?: string
bwDuration?: number
}
const tool = String(body.tool ?? "").trim() as
| "ping"
| "traceroute"
| "bandwidth"
| "dns"
| "route"
| "mtu"
const client = MikrotikClient.fromServer(server)
const srcIpv4 = await resolveRosSrcIpv4(server.host)
try {
let output = ""
switch (tool) {
case "ping": {
const target = String(body.target ?? "").trim()
if (!target) return reply.status(400).send({ error: "target required" })
const count = Math.min(100, Math.max(1, Number(body.pingCount) || 5))
const size = Math.min(8192, Math.max(28, Number(body.pingSize) || 64))
const ttl = Math.min(255, Math.max(1, Number(body.pingTtl) || 64))
const pingBody: Record<string, string> = {
address: target,
count: String(count),
size: String(size),
ttl: String(ttl),
interval: "0.2s",
}
if (srcIpv4) pingBody["src-address"] = srcIpv4
const rows = await client.post<RosPingResult[]>("/tool/ping", pingBody, 120_000)
output = fmtPingRouterOs(rows, target)
break
}
case "traceroute": {
const target = String(body.target ?? "").trim()
if (!target) return reply.status(400).send({ error: "target required" })
const proto = body.traceProto === "udp" || body.traceProto === "tcp" ? body.traceProto : "icmp"
const maxHopsRequested = Math.min(64, Math.max(1, Number(body.traceMaxHops) || 30))
const probeCount = Math.min(3, Math.max(1, Number(body.traceProbeCount) || 1))
const hopMs = parseTraceHopInputToMs(body.traceHopTimeout)
const hopTimeoutRos = traceHopTimeoutForApi(body.traceHopTimeout)
const budgetMs = ROS_REST_SESSION_MAX_MS - 5000
const worstMsPerHop = probeCount * hopMs
const maxFeasibleHops = Math.max(1, Math.floor(budgetMs / worstMsPerHop))
const effectiveMaxHops = Math.min(maxHopsRequested, maxFeasibleHops)
const traceWallMs = ROS_REST_SESSION_MAX_MS
const useDns = Boolean(body.traceUseDns)
const traceBody: Record<string, string> = {
address: target,
protocol: proto,
"max-hops": String(effectiveMaxHops),
timeout: hopTimeoutRos,
count: String(probeCount),
"use-dns": useDns ? "true" : "false",
}
if (srcIpv4) traceBody["src-address"] = srcIpv4
const disconnectAbort = new AbortController()
const onClientClose = () => disconnectAbort.abort()
req.raw.once("close", onClientClose)
try {
const sig = mergeAbortSignals(disconnectAbort.signal, abortAfterMs(traceWallMs))
const rows = await client.post<unknown>("/tool/traceroute", traceBody, traceWallMs, sig)
const note =
effectiveMaxHops < maxHopsRequested
? `;; REST API RouterOS: сессия ~60 с (параметры traceroute не продлевают лимит). max-hops снижен с ${maxHopsRequested} до ${effectiveMaxHops} (count=${probeCount}, timeout=${hopTimeoutRos}).\n`
: ""
output = note + fmtTraceroute(rows)
} finally {
req.raw.off("close", onClientClose)
}
break
}
case "bandwidth": {
const remote = String(body.bwRemoteAddress ?? "").trim()
if (!remote) return reply.status(400).send({ error: "bwRemoteAddress required" })
const dst = resolveBtestPeer(remote, body.dstServerId)
if (!dst) {
return reply.status(400).send({
error: "Не найден сервер назначения для bandwidth-test: добавьте узел с host = GRE remote или укажите dstServerId",
})
}
const protocol = body.bwProto === "udp" ? "udp" : "tcp"
const durationSec = Math.max(3, Math.min(120, Number(body.bwDuration) || 10))
const rows = await client.bandwidthTest({
address: remote,
user: dst.username,
password: dst.password,
protocol,
direction: "both",
durationSec,
})
output = fmtBandwidth(rows)
break
}
case "dns": {
const target = String(body.target ?? "").trim()
if (!target) return reply.status(400).send({ error: "target required" })
const dtype = String(body.dnsType ?? "A").trim() || "A"
output = await dnsLookupText(target, dtype)
break
}
case "route": {
const target = String(body.target ?? "").trim()
if (!target) return reply.status(400).send({ error: "target required" })
const routes = await client.getIpRoutes()
output = fmtRouteLookup(target, routes)
break
}
case "mtu": {
const target = String(body.target ?? "").trim()
if (!target) return reply.status(400).send({ error: "target required" })
output = await mtuDiscover(client, target, srcIpv4)
break
}
default:
return reply.status(400).send({ error: `unknown tool: ${tool}` })
}
return reply.send({ output })
} catch (err) {
const msg =
err instanceof DOMException && err.name === "AbortError"
? "Запрос отменён (клиент закрыл соединение или истёк лимит времени для traceroute)."
: err instanceof Error
? err.message
: String(err)
return reply.send({ output: `error: ${msg}` })
}
})
}
export default probesRoutes
+59 -3
View File
@@ -2,7 +2,7 @@ import { desc, eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { servers, serverSnapshots } from "../db/schema.js"
import type { ServerRead } from "../types/server.js"
import type { ServerRead, WanUplinkRead } from "../types/server.js"
import {
ServerCreateSchema,
ServerUpdateSchema,
@@ -12,12 +12,38 @@ import {
} from "../types/server.js"
import { pollServer, toSnapshotRead } from "../services/poller.js"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import { resolveRosSrcIpv4 } from "../utils/ros-src-address.js"
// ── helpers ────────────────────────────────────────────────────────────────────
type ServerRow = typeof servers.$inferSelect
type SnapshotRow = typeof serverSnapshots.$inferSelect
function parseWanUplinksJson(raw: string | null | undefined): WanUplinkRead[] {
if (raw == null || raw === "") return []
try {
const data = JSON.parse(raw) as unknown
if (!Array.isArray(data)) return []
const out: WanUplinkRead[] = []
for (const row of data) {
if (typeof row !== "object" || row === null) continue
const r = row as Record<string, unknown>
out.push({
id: typeof r.id === "string" ? r.id : "",
name: typeof r.name === "string" ? r.name : "",
isp: typeof r.isp === "string" ? r.isp : "",
iface: typeof r.iface === "string" ? r.iface : "",
ip: typeof r.ip === "string" ? r.ip : "",
maxDl: typeof r.maxDl === "number" && Number.isFinite(r.maxDl) ? r.maxDl : 0,
maxUl: typeof r.maxUl === "number" && Number.isFinite(r.maxUl) ? r.maxUl : 0,
})
}
return out
} catch {
return []
}
}
/** Merge a server row with its latest snapshot into the frontend-compatible shape */
function toServerRead(server: ServerRow, snap: SnapshotRow | undefined): ServerRead {
return {
@@ -35,6 +61,8 @@ function toServerRead(server: ServerRow, snap: SnapshotRow | undefined): ServerR
asn: server.asn,
comment: server.comment,
enabled: server.enabled,
lanSubnet: server.lanSubnet ?? "",
wanUplinks: parseWanUplinksJson(server.wanUplinks),
createdAt: server.createdAt,
updatedAt: server.updatedAt,
// snapshot fields (null if never polled)
@@ -121,12 +149,30 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.send(result)
})
// GET /api/servers/:id/ros-src-address — IPv4 для src-address в RouterOS (не FQDN)
app.get("/:id/ros-src-address", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const server = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const ipv4 = await resolveRosSrcIpv4(server.host)
return reply.send({ host: server.host, ipv4 })
})
// POST /api/servers
app.post("/", { schema: { body: ServerCreateSchema } }, async (req, reply) => {
const now = new Date().toISOString()
const { wanUplinks, ...rest } = req.body
const [inserted] = db
.insert(servers)
.values({ ...req.body, createdAt: now, updatedAt: now })
.values({
...rest,
wanUplinks: JSON.stringify(wanUplinks ?? []),
createdAt: now,
updatedAt: now,
})
.returning()
.all()
return reply.status(201).send(toServerRead(inserted, undefined))
@@ -155,9 +201,19 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
if (!existing) return reply.status(404).send({ error: "Server not found" })
const body = req.body
const { wanUplinks, ...rest } = body
const setPayload: Record<string, unknown> = { updatedAt: new Date().toISOString() }
for (const [k, v] of Object.entries(rest)) {
if (v !== undefined) setPayload[k] = v
}
if (wanUplinks !== undefined) {
setPayload.wanUplinks = JSON.stringify(wanUplinks)
}
const [updated] = db
.update(servers)
.set({ ...req.body, updatedAt: new Date().toISOString() })
.set(setPayload as Partial<ServerRow>)
.where(eq(servers.id, req.params.id))
.returning()
.all()
+381 -50
View File
@@ -1,15 +1,18 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { eq } from "drizzle-orm"
import { asc, desc, eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { servers, uptimeSpeedProbes, uptimeSpeedTestRuns } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import type { RosIpAddress } from "../types/server.js"
import {
collectUptimeOnce,
parsePing,
readProbeRows,
readProbeSamplesSince,
readResourceSamplesSince,
readUptimeSettings,
replaceProbes,
updateProbeShowOnDashboard,
updateUptimeSettings,
} from "../services/uptime-collector.js"
@@ -31,6 +34,28 @@ function toSeries(values: number[], target = 40): number[] {
return [...Array(target - values.length).fill(values[0] ?? 0), ...values]
}
/**
* Один «рабочий» IPv4/IPv6 для интерфейса: пропускаем записи /ip/address с disabled/invalid,
* среди оставшихся предпочитаем статические адреса динамическим (иначе БТ может цепляться к отключённому IP).
*/
function firstUsableIpOnInterface(rows: RosIpAddress[], iface: string): string {
const name = iface.trim()
const candidates = rows.filter((a) => {
if (String(a.interface ?? "").trim() !== name) return false
if (String(a.disabled ?? "false").toLowerCase() === "true") return false
if (String(a.invalid ?? "false").toLowerCase() === "true") return false
const addr = String(a.address ?? "").trim()
return addr.includes("/")
})
candidates.sort((a, b) => {
const ad = String(a.dynamic ?? "false").toLowerCase() === "true" ? 1 : 0
const bd = String(b.dynamic ?? "false").toLowerCase() === "true" ? 1 : 0
return ad - bd
})
const raw = candidates[0]?.address
return raw ? String(raw).split("/")[0]?.trim() ?? "" : ""
}
function parseRateToMbps(raw: unknown): number {
const txt = String(raw ?? "").trim().toLowerCase()
if (!txt) return 0
@@ -49,12 +74,39 @@ function parseRateToMbps(raw: unknown): number {
return n > 10000 ? n / 1_000_000 : n
}
const btestNodeLocks = new Map<number, Promise<void>>()
async function withBtestNodeLocks<T>(nodeIds: number[], fn: () => Promise<T>): Promise<T> {
const ids = [...new Set(nodeIds.filter((id) => Number.isFinite(id)))].sort((a, b) => a - b)
const releases: Array<() => void> = []
try {
for (const id of ids) {
const prev = btestNodeLocks.get(id)
let resolveCurrent!: () => void
const current = new Promise<void>((resolve) => {
resolveCurrent = resolve
})
btestNodeLocks.set(id, current)
if (prev) await prev
releases.push(() => {
if (btestNodeLocks.get(id) === current) btestNodeLocks.delete(id)
resolveCurrent()
})
}
return await fn()
} finally {
for (let i = releases.length - 1; i >= 0; i -= 1) releases[i]()
}
}
const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/uptime/settings", async (_req, reply) => {
const s = readUptimeSettings()
return reply.send({
enabled: s.enabled,
intervalSec: s.intervalSec,
probeIntervalSec: s.probeIntervalSec ?? s.intervalSec,
speedIntervalSec: s.speedIntervalSec ?? 60,
retentionDays: s.retentionDays,
lastCollectedAt: s.lastCollectedAt ?? null,
lastDurationMs: s.lastDurationMs ?? null,
@@ -63,10 +115,18 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
})
app.put("/uptime/settings", async (req, reply) => {
const body = req.body as { enabled?: boolean; intervalSec?: number | string; retentionDays?: number | string }
const body = req.body as {
enabled?: boolean
intervalSec?: number | string
probeIntervalSec?: number | string
speedIntervalSec?: number | string
retentionDays?: number | string
}
const updated = updateUptimeSettings({
enabled: body.enabled,
intervalSec: body.intervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.intervalSec), 10) || 15),
probeIntervalSec: body.probeIntervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.probeIntervalSec), 10) || 15),
speedIntervalSec: body.speedIntervalSec == null ? undefined : Math.max(10, Number.parseInt(String(body.speedIntervalSec), 10) || 60),
retentionDays: body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14),
})
return reply.send({
@@ -74,6 +134,8 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
settings: {
enabled: updated.enabled,
intervalSec: updated.intervalSec,
probeIntervalSec: updated.probeIntervalSec ?? updated.intervalSec,
speedIntervalSec: updated.speedIntervalSec ?? 60,
retentionDays: updated.retentionDays,
lastCollectedAt: updated.lastCollectedAt ?? null,
lastDurationMs: updated.lastDurationMs ?? null,
@@ -97,6 +159,125 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.send({ probes: readProbeRows() })
})
app.get("/uptime/speed-probes", async (_req, reply) => {
const rows = db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder)).all()
return reply.send({
probes: 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,
})),
})
})
app.get("/uptime/speed-test/runs", async (_req, reply) => {
const rows = db.select().from(uptimeSpeedTestRuns).orderBy(desc(uptimeSpeedTestRuns.createdAt)).limit(200).all()
return reply.send({
runs: rows.map((r) => ({
id: r.id,
probeId: r.probeId ?? null,
srcServerId: String(r.srcServerId),
dstServerId: String(r.dstServerId),
srcInterface: r.srcInterface || "",
dstInterface: r.dstInterface || "",
srcAddress: r.srcAddress ?? null,
dstAddress: r.dstAddress ?? null,
srcInterfaceAddress: r.srcInterfaceAddress ?? null,
dstInterfaceAddress: r.dstInterfaceAddress ?? null,
protocol: r.protocol === "udp" ? "udp" : "tcp",
direction: r.direction === "transmit" || r.direction === "receive" ? r.direction : "both",
durationSec: Math.max(3, r.durationSec || 10),
txAvgMbps: r.txAvgMbps ?? 0,
rxAvgMbps: r.rxAvgMbps ?? 0,
afterBtPing: {
rttMs: r.pingRttMs ?? null,
lossPct: r.pingLossPct ?? null,
error: r.pingError ?? null,
},
status: r.status === "error" ? "error" : "done",
error: r.error ?? null,
createdAt: r.createdAt,
})),
})
})
app.put("/uptime/speed-probes", async (req, reply) => {
const body = req.body as {
probes?: Array<{
id?: string
srcServerId?: string | number
dstServerId?: string | number
srcInterface?: string
dstInterface?: string
protocol?: "tcp" | "udp"
direction?: "transmit" | "receive" | "both"
durationSec?: string | number
enabled?: boolean
}>
}
const normalized = (body.probes ?? [])
.map((p, i) => ({
id: p.id || `sp-${Date.now()}-${i}`,
srcServerId: Number.parseInt(String(p.srcServerId ?? ""), 10),
dstServerId: Number.parseInt(String(p.dstServerId ?? ""), 10),
srcInterface: String(p.srcInterface ?? "").trim(),
dstInterface: String(p.dstInterface ?? "").trim(),
protocol: p.protocol === "udp" ? "udp" : "tcp",
direction: p.direction === "transmit" || p.direction === "receive" ? p.direction : "both",
durationSec: Math.max(3, Number.parseInt(String(p.durationSec ?? ""), 10) || 10),
enabled: p.enabled !== false,
}))
.filter((p) => Number.isFinite(p.srcServerId) && Number.isFinite(p.dstServerId) && p.srcServerId !== p.dstServerId)
const now = new Date().toISOString()
const existing = db.select({ id: uptimeSpeedProbes.id }).from(uptimeSpeedProbes).all()
const nextIds = new Set(normalized.map((p) => p.id))
for (const row of existing) {
if (nextIds.has(row.id)) continue
db.delete(uptimeSpeedProbes).where(eq(uptimeSpeedProbes.id, row.id)).run()
}
for (let i = 0; i < normalized.length; i += 1) {
const p = normalized[i]
const patch = {
srcServerId: p.srcServerId,
dstServerId: p.dstServerId,
srcInterface: p.srcInterface,
dstInterface: p.dstInterface,
protocol: p.protocol as "tcp" | "udp",
direction: p.direction as "transmit" | "receive" | "both",
durationSec: p.durationSec,
enabled: p.enabled,
sortOrder: i,
updatedAt: now,
}
const updated = db.update(uptimeSpeedProbes).set(patch).where(eq(uptimeSpeedProbes.id, p.id)).run()
if ((updated.changes ?? 0) > 0) continue
db.insert(uptimeSpeedProbes).values({
id: p.id,
...patch,
createdAt: now,
}).run()
}
return reply.send({ ok: true })
})
app.put("/uptime/probes", async (req, reply) => {
const body = req.body as {
probes?: Array<{
@@ -107,6 +288,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
target?: string
filter?: string
enabled?: boolean
showOnDashboard?: boolean
}>
}
const normalized = (body.probes ?? [])
@@ -118,12 +300,26 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
target: String(p.target ?? "").trim(),
probeFilter: String(p.filter ?? "—"),
enabled: p.enabled !== false,
showOnDashboard: p.showOnDashboard === true,
}))
.filter((p) => Number.isFinite(p.srcServerId) && p.name && p.target)
replaceProbes(normalized)
return reply.send({ ok: true })
})
app.patch("/uptime/probes/:id", async (req, reply) => {
const params = req.params as { id?: string }
const probeId = String(params.id ?? "").trim()
if (!probeId) return reply.status(400).send({ error: "Invalid probe id" })
const body = req.body as { showOnDashboard?: boolean }
if (typeof body.showOnDashboard !== "boolean") {
return reply.status(400).send({ error: "showOnDashboard boolean required" })
}
const ok = updateProbeShowOnDashboard(probeId, body.showOnDashboard)
if (!ok) return reply.status(404).send({ error: "Probe not found" })
return reply.send({ ok: true })
})
app.get("/uptime/sources/:id/interfaces", async (req, reply) => {
const params = req.params as { id?: string | number }
const serverId = Number.parseInt(String(params.id ?? ""), 10)
@@ -139,6 +335,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
disabled: String(r.disabled ?? "false").toLowerCase() === "true",
}))
.filter((r) => r.name.length > 0)
.filter((r) => r.running && !r.disabled)
.sort((a, b) => a.name.localeCompare(b.name))
return reply.send({ interfaces })
} catch (e) {
@@ -149,6 +346,8 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
app.post("/uptime/speed-test", async (req, reply) => {
const body = req.body as {
runId?: string
probeId?: string
srcServerId?: string | number
dstServerId?: string | number
srcInterface?: string
@@ -167,64 +366,187 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
const dst = db.select().from(servers).where(eq(servers.id, dstId)).limit(1).all()[0]
if (!src || !dst) return reply.status(404).send({ error: "Server not found" })
try {
const protocol = body.protocol === "udp" ? "udp" : "tcp"
const direction = body.direction === "transmit" || body.direction === "receive" ? body.direction : "both"
const durationSec = Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10)
const srcInterface = String(body.srcInterface ?? "").trim()
const dstInterface = String(body.dstInterface ?? "").trim()
const srcClient = MikrotikClient.fromServer(src)
const dstAddressRows = dstInterface
? await MikrotikClient.fromServer(dst).getIpAddresses().catch(() => [])
: []
const dstAddress = dstInterface
? String(
dstAddressRows.find((a) => String(a.interface ?? "") === dstInterface && String(a.address ?? "").includes("/"))?.address ??
"",
).split("/")[0]
: dst.host
if (dstInterface && !dstAddress) {
return reply.status(400).send({ error: `На интерфейсе назначения '${dstInterface}' нет IP-адреса` })
}
const initialAddress = dstAddress || dst.host
return await withBtestNodeLocks([srcId, dstId], async () => {
const protocol = body.protocol === "udp" ? "udp" : "tcp"
const direction = body.direction === "transmit" || body.direction === "receive" ? body.direction : "both"
const durationSec = Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10)
const srcInterface = String(body.srcInterface ?? "").trim()
const dstInterface = String(body.dstInterface ?? "").trim()
const srcClient = MikrotikClient.fromServer(src)
const dstAddressRows = dstInterface
? await MikrotikClient.fromServer(dst).getIpAddresses().catch(() => [])
: []
const srcAddressRows = srcInterface
? await srcClient.getIpAddresses().catch(() => [])
: []
const dstAddress = dstInterface ? firstUsableIpOnInterface(dstAddressRows, dstInterface) : dst.host
if (dstInterface && !dstAddress) {
return reply.status(400).send({
error: `На интерфейсе назначения '${dstInterface}' нет активного IP (проверьте /ip/address — не disabled).`,
})
}
const initialAddress = dstAddress || dst.host
const srcAddress = srcInterface ? firstUsableIpOnInterface(srcAddressRows, srcInterface) : src.host
const srcInterfaceAddress = srcInterface ? (srcAddress || null) : null
const dstInterfaceAddress = dstInterface ? (dstAddress || null) : null
const rows = await srcClient.bandwidthTest({
address: initialAddress,
user: dst.username,
password: dst.password,
protocol,
direction,
durationSec,
})
const txSeries = rows
.map((r) => parseRateToMbps(r["tx-current"] ?? r["tx-10-second-average"] ?? r["tx-total-average"]))
.filter((v) => Number.isFinite(v) && v >= 0)
const rxSeries = rows
.map((r) => parseRateToMbps(r["rx-current"] ?? r["rx-10-second-average"] ?? r["rx-total-average"]))
.filter((v) => Number.isFinite(v) && v >= 0)
const last = rows[rows.length - 1] ?? {}
const txAvg = parseRateToMbps(last["tx-total-average"] ?? last["tx-10-second-average"])
const rxAvg = parseRateToMbps(last["rx-total-average"] ?? last["rx-10-second-average"])
return reply.send({
ok: true,
result: {
srcInterface: srcInterface || null,
dstInterface: dstInterface || null,
const rows = await srcClient.bandwidthTest({
address: initialAddress,
user: dst.username,
password: dst.password,
protocol,
direction,
durationSec,
txAvgMbps: txAvg || (txSeries.length ? txSeries.reduce((a, b) => a + b, 0) / txSeries.length : 0),
rxAvgMbps: rxAvg || (rxSeries.length ? rxSeries.reduce((a, b) => a + b, 0) / rxSeries.length : 0),
txSeries,
rxSeries,
raw: rows,
},
})
const txSeries = rows
.map((r) => parseRateToMbps(r["tx-current"] ?? r["tx-10-second-average"] ?? r["tx-total-average"]))
.filter((v) => Number.isFinite(v) && v >= 0)
const rxSeries = rows
.map((r) => parseRateToMbps(r["rx-current"] ?? r["rx-10-second-average"] ?? r["rx-total-average"]))
.filter((v) => Number.isFinite(v) && v >= 0)
const last = rows[rows.length - 1] ?? {}
const txAvg = parseRateToMbps(last["tx-total-average"] ?? last["tx-10-second-average"])
const rxAvg = parseRateToMbps(last["rx-total-average"] ?? last["rx-10-second-average"])
const txFinal = txAvg || (txSeries.length ? txSeries.reduce((a, b) => a + b, 0) / txSeries.length : 0)
const rxFinal = rxAvg || (rxSeries.length ? rxSeries.reduce((a, b) => a + b, 0) / rxSeries.length : 0)
await new Promise<void>((resolve) => setTimeout(resolve, 3000))
const pingNow = new Date().toISOString()
let afterBtPing: {
rttMs: number | null
lossPct: number | null
error: string | null
} = { rttMs: null, lossPct: null, error: null }
try {
const pingRows = await srcClient.ping(initialAddress, 4, srcInterface || undefined, { interval: "1s" })
const pr = parsePing(pingRows as Parameters<typeof parsePing>[0])
afterBtPing = {
rttMs: pr.avgRtt,
lossPct: pr.loss,
error: null,
}
} catch (pe) {
afterBtPing = {
rttMs: null,
lossPct: null,
error: pe instanceof Error ? pe.message : String(pe),
}
}
const probeId = String(body.probeId ?? "").trim()
const runId = String(body.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
if (probeId) {
db.update(uptimeSpeedProbes).set({
lastRunAt: pingNow,
lastTxAvgMbps: txFinal,
lastRxAvgMbps: rxFinal,
lastStatus: "done",
lastError: "",
lastPingRttMs: afterBtPing.rttMs,
lastPingLossPct: afterBtPing.lossPct,
lastPingAt: pingNow,
lastPingError: afterBtPing.error ?? "",
updatedAt: pingNow,
}).where(eq(uptimeSpeedProbes.id, probeId)).run()
}
db.insert(uptimeSpeedTestRuns).values({
id: runId,
probeId: probeId || null,
srcServerId: srcId,
dstServerId: dstId,
srcInterface: srcInterface || "",
dstInterface: dstInterface || "",
srcAddress: srcAddress || src.host,
dstAddress: initialAddress,
srcInterfaceAddress,
dstInterfaceAddress,
protocol,
direction,
durationSec,
txAvgMbps: txFinal,
rxAvgMbps: rxFinal,
pingRttMs: afterBtPing.rttMs,
pingLossPct: afterBtPing.lossPct,
pingError: afterBtPing.error ?? null,
status: "done",
error: null,
createdAt: pingNow,
}).run()
return reply.send({
ok: true,
result: {
runId,
srcInterface: srcInterface || null,
dstInterface: dstInterface || null,
address: initialAddress,
srcAddress: srcAddress || src.host,
dstAddress: initialAddress,
srcInterfaceAddress,
dstInterfaceAddress,
protocol,
direction,
durationSec,
txAvgMbps: txFinal,
rxAvgMbps: rxFinal,
txSeries,
rxSeries,
raw: rows,
afterBtPing,
},
})
})
} catch (e) {
const raw = e instanceof Error ? e.message : "Bandwidth test failed"
const message = /timed out|socket hang up|ECONNRESET/i.test(raw)
? "BTTest timeout/socket hang up: проверь доступность destination, /tool bandwidth-server, firewall и корректность интерфейсов"
: raw
const probeId = String((req.body as { probeId?: string }).probeId ?? "").trim()
if (probeId) {
db.update(uptimeSpeedProbes).set({
lastRunAt: new Date().toISOString(),
lastStatus: "error",
lastError: message,
updatedAt: new Date().toISOString(),
}).where(eq(uptimeSpeedProbes.id, probeId)).run()
}
const body = req.body as {
runId?: string
srcServerId?: string | number
dstServerId?: string | number
srcInterface?: string
dstInterface?: string
protocol?: "tcp" | "udp"
direction?: "transmit" | "receive" | "both"
durationSec?: string | number
}
const srcId = Number.parseInt(String(body.srcServerId ?? ""), 10)
const dstId = Number.parseInt(String(body.dstServerId ?? ""), 10)
if (Number.isFinite(srcId) && Number.isFinite(dstId)) {
db.insert(uptimeSpeedTestRuns).values({
id: String(body.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
probeId: probeId || null,
srcServerId: srcId,
dstServerId: dstId,
srcInterface: String(body.srcInterface ?? "").trim(),
dstInterface: String(body.dstInterface ?? "").trim(),
srcAddress: null,
dstAddress: null,
srcInterfaceAddress: null,
dstInterfaceAddress: null,
protocol: body.protocol === "udp" ? "udp" : "tcp",
direction: body.direction === "transmit" || body.direction === "receive" ? body.direction : "both",
durationSec: Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10),
txAvgMbps: null,
rxAvgMbps: null,
pingRttMs: null,
pingLossPct: null,
pingError: null,
status: "error",
error: message,
createdAt: new Date().toISOString(),
}).run()
}
return reply.status(502).send({ error: message })
}
})
@@ -246,6 +568,13 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
const name = String(i.name ?? "").trim()
const addresses = ips
.filter((a) => String(a.interface ?? "") === name)
.filter((a) => String(a.disabled ?? "false").toLowerCase() !== "true")
.filter((a) => String(a.invalid ?? "false").toLowerCase() !== "true")
.sort((a, b) => {
const ad = String(a.dynamic ?? "false").toLowerCase() === "true" ? 1 : 0
const bd = String(b.dynamic ?? "false").toLowerCase() === "true" ? 1 : 0
return ad - bd
})
.map((a) => String(a.address ?? "").split("/")[0])
.filter((x) => x.length > 0)
return {
@@ -256,6 +585,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
}
})
.filter((i) => i.name.length > 0)
.filter((i) => i.running && !i.disabled)
return reply.send({ interfaces })
} catch (e) {
const message = e instanceof Error ? e.message : "Failed to fetch speed-test interfaces"
@@ -288,6 +618,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
status: (last?.status ?? "down") as "up" | "warn" | "down",
series,
enabled: p.enabled,
showOnDashboard: p.showOnDashboard,
}
})
+53 -16
View File
@@ -79,6 +79,7 @@ function rosPost(
path: string,
body: Record<string, string>,
timeoutMs: number,
externalSignal?: AbortSignal,
): Promise<unknown> {
return new Promise((resolve, reject) => {
const basePath = params.apiPath ?? "/rest"
@@ -100,25 +101,55 @@ function rosPost(
const lib = params.useSsl ? https : http
const timer = setTimeout(() => {
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
}, timeoutMs)
let req: ReturnType<typeof lib.request> | undefined
const req = lib.request(options, (res) => {
let settled = false
const settle = (fn: () => void) => {
if (settled) return
settled = true
if (req) {
req.setTimeout(0)
req.removeListener("timeout", onSocketTimeout)
}
externalSignal?.removeEventListener("abort", onExternalAbort)
fn()
}
const onExternalAbort = () => {
req?.destroy(new DOMException("Aborted", "AbortError"))
}
const onSocketTimeout = () => {
req?.destroy()
settle(() => reject(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`)))
}
if (externalSignal?.aborted) {
settle(() => reject(new DOMException("Aborted", "AbortError")))
return
}
externalSignal?.addEventListener("abort", onExternalAbort)
req = lib.request(options, (res) => {
let buf = ""
res.setEncoding("utf8")
res.on("data", (chunk: string) => { buf += chunk })
res.on("end", () => {
clearTimeout(timer)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, buf)); return
}
try { resolve(JSON.parse(buf)) } catch {
reject(new Error(`Invalid JSON from RouterOS: ${buf.slice(0, 200)}`))
}
settle(() => {
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, buf)); return
}
try { resolve(JSON.parse(buf)) } catch {
reject(new Error(`Invalid JSON from RouterOS: ${buf.slice(0, 200)}`))
}
})
})
})
req.on("error", (err) => { clearTimeout(timer); reject(err) })
req.setTimeout(timeoutMs)
req.on("timeout", onSocketTimeout)
req.on("error", (err) => {
settle(() => reject(err))
})
req.write(payload)
req.end()
})
@@ -189,8 +220,8 @@ export class MikrotikClient {
return rosRequest(this.params, path, timeoutMs) as Promise<T>
}
async post<T>(path: string, body: Record<string, string>, timeoutMs = 15_000): Promise<T> {
return rosPost(this.params, path, body, timeoutMs) as Promise<T>
async post<T>(path: string, body: Record<string, string>, timeoutMs = 15_000, signal?: AbortSignal): Promise<T> {
return rosPost(this.params, path, body, timeoutMs, signal) as Promise<T>
}
async delete(path: string, timeoutMs = 10_000): Promise<void> {
@@ -260,12 +291,18 @@ export class MikrotikClient {
return this.get<RosLogEntry[]>(`/log?limit=${limit}`)
}
async ping(address: string, count = 4, interfaceName?: string): Promise<RosPingResult[]> {
async ping(
address: string,
count = 4,
interfaceName?: string,
options?: { interval?: string },
): Promise<RosPingResult[]> {
const body: Record<string, string> = {
address,
count: String(count),
interval: "0.2s",
}
if (options?.interval && options.interval.trim()) body.interval = options.interval.trim()
else body.interval = "0.2s"
if (interfaceName && interfaceName.trim()) body.interface = interfaceName.trim()
return this.post<RosPingResult[]>("/tool/ping", body, 20_000)
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { desc, eq } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers, serverSnapshots } from "../db/schema.js"
import type { SnapshotInsert } from "../db/schema.js"
+70 -23
View File
@@ -65,6 +65,8 @@ function getSettings() {
id: 1,
enabled: true,
intervalSec: 15,
probeIntervalSec: 15,
speedIntervalSec: 60,
retentionDays: 14,
createdAt: now,
updatedAt: now,
@@ -78,18 +80,31 @@ function cleanup(retentionDays: number) {
db.delete(uptimeResourceSamples).where(lt(uptimeResourceSamples.sampledAt, cutoff)).run()
}
function parsePing(results: Array<{ time?: string; status?: string; sent?: string; received?: string; "packet-loss"?: string }>) {
const sum = [...results].reverse().find((r) => r.sent || r.received || r["packet-loss"])
const lossRaw = sum?.["packet-loss"] ?? "100%"
const lossPct = Number.parseInt(String(lossRaw).replace("%", ""), 10)
const okReplies = results.filter((r) => r.time && r.status !== "timeout")
export function parsePing(results: Array<{ time?: string; status?: string; sent?: string; received?: string; "packet-loss"?: string; "avg-rtt"?: string }>) {
const sum = [...results].reverse().find((r) =>
r.sent != null || r.received != null || r["packet-loss"] != null || r["avg-rtt"] != null,
)
const okReplies = results.filter((r) => r.time && String(r.status ?? "").toLowerCase() !== "timeout")
const timedOutReplies = results.filter((r) => String(r.status ?? "").toLowerCase() === "timeout")
const rtts = okReplies
.map((r) => parsePingTimeMs(r.time))
.filter((v): v is number => v != null && Number.isFinite(v))
const avgFromReplies = rtts.length ? Math.round(rtts.reduce((a, b) => a + b, 0) / rtts.length) : null
const avgFromSummary = parsePingTimeMs(sum?.time)
const avgFromSummary = parsePingTimeMs(sum?.time) ?? parsePingTimeMs(sum?.["avg-rtt"])
const avgRtt = avgFromReplies ?? (avgFromSummary != null ? Math.round(avgFromSummary) : null)
const loss = Number.isFinite(lossPct) ? Math.max(0, Math.min(100, lossPct)) : 100
const sent = Number.parseInt(String(sum?.sent ?? ""), 10)
const received = Number.parseInt(String(sum?.received ?? ""), 10)
const sentFallback = okReplies.length + timedOutReplies.length
const safeSent = Number.isFinite(sent) && sent > 0 ? sent : sentFallback
const safeReceived = Number.isFinite(received) && received >= 0 ? received : okReplies.length
const computedLoss = safeSent > 0 ? Math.round(((safeSent - safeReceived) / safeSent) * 100) : null
const lossRaw = String(sum?.["packet-loss"] ?? "")
const parsedLoss = Number.parseInt(lossRaw.replace("%", ""), 10)
const lossSource = Number.isFinite(parsedLoss) ? parsedLoss : computedLoss
const loss =
typeof lossSource === "number" && Number.isFinite(lossSource)
? Math.max(0, Math.min(100, lossSource))
: 100
const status: "up" | "warn" | "down" = loss >= 100 ? "down" : (loss > 1 || (avgRtt ?? 0) > 60 ? "warn" : "up")
return { avgRtt, loss, status }
}
@@ -188,7 +203,7 @@ export function restartUptimeCollector() {
if (timer) clearInterval(timer)
timer = null
if (!s.enabled) return
const intervalMs = Math.max(5, s.intervalSec) * 1000
const intervalMs = Math.max(5, s.probeIntervalSec || s.intervalSec) * 1000
timer = setInterval(() => { void collectUptimeOnce() }, intervalMs)
}
@@ -204,12 +219,17 @@ export function readUptimeSettings() {
export function updateUptimeSettings(patch: {
enabled?: boolean
intervalSec?: number
probeIntervalSec?: number
speedIntervalSec?: number
retentionDays?: number
}) {
const prev = getSettings()
const nextProbeInterval = patch.probeIntervalSec ?? patch.intervalSec ?? prev.probeIntervalSec ?? prev.intervalSec
const next = {
enabled: patch.enabled ?? prev.enabled,
intervalSec: patch.intervalSec ?? prev.intervalSec,
intervalSec: nextProbeInterval,
probeIntervalSec: nextProbeInterval,
speedIntervalSec: patch.speedIntervalSec ?? prev.speedIntervalSec ?? 60,
retentionDays: patch.retentionDays ?? prev.retentionDays,
updatedAt: new Date().toISOString(),
}
@@ -222,6 +242,15 @@ export function readProbeRows() {
return db.select().from(uptimeProbes).orderBy(asc(uptimeProbes.sortOrder)).all()
}
export function updateProbeShowOnDashboard(probeId: string, showOnDashboard: boolean): boolean {
const now = new Date().toISOString()
const r = db.update(uptimeProbes)
.set({ showOnDashboard, updatedAt: now })
.where(eq(uptimeProbes.id, probeId))
.run()
return (r.changes ?? 0) > 0
}
export function replaceProbes(rows: Array<{
id: string
srcServerId: number
@@ -230,22 +259,40 @@ export function replaceProbes(rows: Array<{
target: string
probeFilter: string
enabled: boolean
showOnDashboard?: boolean
}>) {
db.delete(uptimeProbes).run()
if (rows.length === 0) return
const now = new Date().toISOString()
db.insert(uptimeProbes).values(rows.map((r, i) => ({
id: r.id,
srcServerId: r.srcServerId,
srcInterface: r.srcInterface || "",
name: r.name,
target: r.target,
probeFilter: r.probeFilter || "—",
enabled: r.enabled,
sortOrder: i,
createdAt: now,
updatedAt: now,
}))).run()
const existing = db.select({ id: uptimeProbes.id }).from(uptimeProbes).all()
const nextIds = new Set(rows.map((r) => r.id))
// Remove only probes that user actually deleted (their samples are removed by FK cascade).
for (const p of existing) {
if (nextIds.has(p.id)) continue
db.delete(uptimeProbes).where(eq(uptimeProbes.id, p.id)).run()
}
// Upsert rows in-place to preserve sample history for unchanged probe ids.
for (let i = 0; i < rows.length; i += 1) {
const r = rows[i]
const patch = {
srcServerId: r.srcServerId,
srcInterface: r.srcInterface || "",
name: r.name,
target: r.target,
probeFilter: r.probeFilter || "—",
enabled: r.enabled,
showOnDashboard: r.showOnDashboard ?? false,
sortOrder: i,
updatedAt: now,
}
const updated = db.update(uptimeProbes).set(patch).where(eq(uptimeProbes.id, r.id)).run()
if ((updated.changes ?? 0) > 0) continue
db.insert(uptimeProbes).values({
id: r.id,
...patch,
createdAt: now,
}).run()
}
}
export function readProbeSamplesSince(sinceIso: string) {
+24
View File
@@ -49,6 +49,16 @@ export interface RosIpAddress {
// ── Zod schemas for API validation ────────────────────────────────────────────
export const WanUplinkSchema = z.object({
id: z.string(),
name: z.string(),
isp: z.string(),
iface: z.string(),
ip: z.string(),
maxDl: z.number(),
maxUl: z.number(),
})
export const ServerCreateSchema = z.object({
host: z.string().min(1, "Host is required"),
port: z.number().int().positive().default(443),
@@ -63,6 +73,8 @@ export const ServerCreateSchema = z.object({
asn: z.string().default(""),
comment: z.string().default(""),
enabled: z.boolean().default(true),
lanSubnet: z.string().default(""),
wanUplinks: z.array(WanUplinkSchema).default([]),
})
export const ServerUpdateSchema = ServerCreateSchema.partial().omit({ host: true }).extend({
@@ -90,6 +102,16 @@ export const SnapshotsQuerySchema = z.object({
// ── Response types (what the API returns) ─────────────────────────────────────
/** Flat server response — mirrors the frontend's Server interface from lib/data.ts */
export interface WanUplinkRead {
id: string
name: string
isp: string
iface: string
ip: string
maxDl: number
maxUl: number
}
export interface ServerRead {
id: number
name: string
@@ -105,6 +127,8 @@ export interface ServerRead {
asn: string
comment: string
enabled: boolean
lanSubnet: string
wanUplinks: WanUplinkRead[]
createdAt: string
updatedAt: string
// from latest snapshot (null if never polled)
+22
View File
@@ -0,0 +1,22 @@
/** Объединяет несколько AbortSignal: один abort отменяет общий контроллер. */
export function mergeAbortSignals(...signals: AbortSignal[]): AbortSignal {
const c = new AbortController()
const forward = () => {
c.abort()
}
for (const s of signals) {
if (s.aborted) {
forward()
return c.signal
}
s.addEventListener("abort", forward, { once: true })
}
return c.signal
}
/** Отмена через `ms` миллисекунд (таймер не снимается при другой отмене — ок для короткоживущих запросов). */
export function abortAfterMs(ms: number): AbortSignal {
const c = new AbortController()
setTimeout(() => c.abort(), ms)
return c.signal
}
+24
View File
@@ -0,0 +1,24 @@
import dns from "node:dns/promises"
/** RouterOS «src-address» для /tool/ping и /tool/traceroute ожидает IPv4, не FQDN. */
export function isIpv4Literal(host: string): boolean {
const s = host.trim()
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(s)) return false
return s.split(".").every((p) => {
const n = Number.parseInt(p, 10)
return Number.isFinite(n) && n >= 0 && n <= 255
})
}
/** Возвращает первый A-запись IPv4 для управления; если host уже IPv4 — как есть. */
export async function resolveRosSrcIpv4(host: string): Promise<string | null> {
const h = host.trim()
if (!h) return null
if (isIpv4Literal(h)) return h
try {
const addrs = await dns.resolve4(h)
return addrs[0] ?? null
} catch {
return null
}
}