Init 2
This commit is contained in:
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
@@ -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}`,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user