This commit is contained in:
Denozordec committed 2026-05-03 11:16:07 +07:00
1 parent ce00c4c671
commit bdb9b72fac
66 files changed
+9553 -1547

No files matched your search

+1 -1
View File
@@ -107,7 +107,7 @@ function parseSessions(server: ServerRow, raw: RosBgpSession[]): BgpSessionRead[
if (s["add-path-capability"] === "true") legacyCaps.push("ADD-PATH")
if (s["graceful-restart-capability"] === "true") legacyCaps.push("Graceful Restart")
if (s["extended-message-capability"] === "true") legacyCaps.push("Extended Messages")
const caps = [...new Set([...localCaps, ...legacyCaps])]
const caps = [...new Set([...localCaps, ...remoteCaps, ...legacyCaps])]
// Hold time: RouterOS 7 uses "hold-time" ("1m30s"), RouterOS 6 uses "active-holdtime" (seconds)
const holdTime = parseDuration(s["hold-time"])
+281
View File
@@ -0,0 +1,281 @@
import { eq, sql } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { z } from "zod"
import { db } from "../db/index.js"
import { evobgpSettings } from "../db/schema.js"
const PutEvobgpSchema = z.object({
baseUrl: z.string().optional(),
enabled: z.boolean().optional(),
/** undefined — не менять; null или "" — очистить ключ; строка — новое значение */
apiKey: z.union([z.string(), z.null()]).optional(),
})
/** Опционально: проверить черновики до сохранения; поле отсутствует — взять из БД */
const TestEvobgpSchema = z.object({
baseUrl: z.string().optional(),
apiKey: z.string().optional(),
})
function ensureEvobgpRow() {
let row = db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1).all()[0]
if (!row) {
db.insert(evobgpSettings).values({ id: 1 }).run()
row = db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1).all()[0]
}
return row!
}
function normalizeBaseUrl(raw: string): string {
const trimmed = raw.trim().replace(/\/$/, "")
if (!trimmed) return ""
try {
const u = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`)
return `${u.protocol}//${u.host}`
} catch {
return trimmed
}
}
interface EvoCatalogRaw {
modules: { items: Array<{ id: string; name: string; type: string }> }
domains: {
items: Array<{
module_id: string
entry: { id: string; fqdn: string; community_id?: string | null }
}>
}
asns: {
items: Array<{
module_id: string
entry: { id: string; asn: number; community_id?: string | null }
}>
}
ip_ranges: {
items: Array<{
module_id: string
entry: { id: string; prefix: string; community_id: string }
}>
}
communities: {
items: Array<{ id: string; community?: string; title?: string }>
}
}
function problemMessage(text: string, status: number): string {
try {
const j = JSON.parse(text) as { detail?: string; title?: string }
return j.detail ?? j.title ?? `HTTP ${status}`
} catch {
return text || `HTTP ${status}`
}
}
async function fetchEvoJson<T>(root: string, path: string, token: string): Promise<T> {
const url = `${root}${path.startsWith("/") ? "" : "/"}${path}`
const ac = new AbortController()
const t = setTimeout(() => ac.abort(), 90_000)
try {
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
signal: ac.signal,
})
const text = await res.text()
if (!res.ok) {
throw new Error(problemMessage(text, res.status))
}
return JSON.parse(text) as T
} finally {
clearTimeout(t)
}
}
function credentialsFromDb(): { root: string; apiKey: string } | null {
const row = ensureEvobgpRow()
const root = normalizeBaseUrl(row.baseUrl)
const apiKey = row.apiKey.trim()
if (!root || !apiKey) return null
return { root, apiKey }
}
const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/evobgp/settings", async (_req, reply) => {
const row = ensureEvobgpRow()
return reply.send({
baseUrl: row.baseUrl ?? "",
enabled: row.enabled ?? false,
secretConfigured: Boolean(row.apiKey?.trim()),
})
})
app.put("/evobgp/settings", async (req, reply) => {
const parsed = PutEvobgpSchema.safeParse(req.body)
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const cur = ensureEvobgpRow()
let nextBase = cur.baseUrl
let nextEnabled = cur.enabled
let nextKey = cur.apiKey
if (parsed.data.baseUrl !== undefined) nextBase = parsed.data.baseUrl.trim()
if (parsed.data.enabled !== undefined) nextEnabled = parsed.data.enabled
if (parsed.data.apiKey !== undefined) {
nextKey = parsed.data.apiKey === null || parsed.data.apiKey === "" ? "" : parsed.data.apiKey.trim()
}
db.update(evobgpSettings)
.set({
baseUrl: nextBase,
enabled: nextEnabled,
apiKey: nextKey,
updatedAt: sql`(datetime('now'))`,
})
.where(eq(evobgpSettings.id, 1))
.run()
const row = ensureEvobgpRow()
return reply.send({
baseUrl: row.baseUrl ?? "",
enabled: row.enabled ?? false,
secretConfigured: Boolean(row.apiKey?.trim()),
})
})
/**
* Проверка Bearer: тело опционально — можно передать черновики URL/ключа с формы.
* Для каждого поля: если в JSON ключ отсутствует — используется значение из БД;
* если `apiKey` передана пустой строкой — ключ берётся из БД (проверка только нового URL).
*/
app.post("/evobgp/test", async (req, reply) => {
const parsed = TestEvobgpSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const row = ensureEvobgpRow()
const d = parsed.data
const urlRaw = d.baseUrl !== undefined ? d.baseUrl : row.baseUrl
const keyRaw =
d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey
const root = normalizeBaseUrl(urlRaw.trim())
const token = keyRaw.trim()
if (!root || !token) {
return reply.status(400).send({
error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)",
})
}
try {
await fetchEvoJson<{ items: unknown[] }>(root, "/v1/modules?limit=1", token)
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : "Ошибка EvoBGP"
return reply.status(502).send({ error: msg })
}
})
/**
* Прокси к GET /v1/router-lists/catalog — учётные данные только из БД.
*/
app.post("/evobgp/catalog", async (_req, reply) => {
const row = ensureEvobgpRow()
if (!row.enabled) {
return reply.status(400).send({ error: "Интеграция EvoBGP выключена в настройках" })
}
const cred = credentialsFromDb()
if (!cred) {
return reply.status(400).send({ error: "Не заданы базовый URL или API-ключ в БД" })
}
try {
const catalog = await fetchEvoJson<EvoCatalogRaw>(cred.root, "/v1/router-lists/catalog", cred.apiKey)
const modules = catalog.modules?.items ?? []
const modMap = new Map(modules.map((m) => [m.id, m]))
const commItems = catalog.communities?.items ?? []
const commLabel = new Map<string, string>()
for (const c of commItems) {
const label = (c.community && c.title)
? `${c.title} (${c.community})`
: (c.title ?? c.community ?? c.id)
commLabel.set(c.id, label)
}
const fmtTime = new Date().toLocaleString("ru-RU")
const fetchedAt = new Date().toISOString()
const domains = (catalog.domains?.items ?? []).map((rowItem) => {
const mod = modMap.get(rowItem.module_id)
const cid = rowItem.entry.community_id
return {
id: `${rowItem.module_id}:${rowItem.entry.id}`,
domain: rowItem.entry.fqdn,
resolvedIp: "—",
asn: "—",
purpose: mod?.name ?? "—",
filter: cid ? (commLabel.get(cid) ?? cid) : "—",
updated: fmtTime,
enabled: true,
}
})
const ipRanges = (catalog.ip_ranges?.items ?? []).map((rowItem) => {
const mod = modMap.get(rowItem.module_id)
const cid = rowItem.entry.community_id
return {
id: `${rowItem.module_id}:${rowItem.entry.id}`,
cidr: rowItem.entry.prefix,
asn: "—",
country: "—",
purpose: mod?.name ?? "—",
filter: commLabel.get(cid) ?? cid,
updated: fmtTime,
enabled: true,
}
})
const asns = (catalog.asns?.items ?? []).map((rowItem) => {
const mod = modMap.get(rowItem.module_id)
const cid = rowItem.entry.community_id
return {
id: `${rowItem.module_id}:${rowItem.entry.id}`,
asn: `AS${rowItem.entry.asn}`,
org: "—",
country: "—",
prefixes: 0,
filter: cid ? (commLabel.get(cid) ?? cid) : "—",
updated: fmtTime,
enabled: true,
}
})
const communities = commItems.map((c) => ({
id: c.id,
value: c.community ?? c.id,
name: c.title ?? c.community ?? c.id,
description: "",
type: "custom" as const,
filterIds: [] as string[],
serverCount: 0,
prefixCount: 0,
action: "permit" as const,
enabled: true,
}))
return reply.send({
fetchedAt,
modules: modules.map((m) => ({ id: m.id, name: m.name, type: m.type })),
domains,
ipRanges,
asns,
communities,
})
} catch (e) {
const msg = e instanceof Error ? e.message : "Ошибка EvoBGP"
return reply.status(502).send({ error: msg })
}
})
}
export default evobgpRoutes
+177 -18
View File
@@ -1,7 +1,7 @@
import { and, asc, eq, inArray } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { filterRules, servers } from "../db/schema.js"
import { filterRules, recursiveRoutes, servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
type ServerRow = typeof servers.$inferSelect
@@ -121,6 +121,39 @@ function parseFilterRule(raw: RosFilterRule): ApiFilterRule[] {
}))
}
/** Хоп для BGP filter из префикса рекурсивного статического маршрута (напр. 10.9.9.2/32 → 10.9.9.2) */
function gatewayFromRecursiveDst(dstAddress: string): string {
return (dstAddress ?? "").trim().split("/")[0]?.trim() ?? ""
}
/**
* Импорт с роутера даёт только `set gateway` без `set out-interface` — в таком виде не отличить от «поломанного» GRE.
* Сопоставляем hop с локальной таблицей recursive_routes и восстанавливаем gatewayTunnelId = rec:<id>.
*/
function enrichRulesWithRecursiveGateway(serverId: number, rules: ApiFilterRule[]): ApiFilterRule[] {
const rows = db
.select()
.from(recursiveRoutes)
.where(and(eq(recursiveRoutes.serverId, serverId), eq(recursiveRoutes.disabled, false)))
.all()
return rules.map(rule => {
if (rule.action === "blackhole") return rule
const tid = (rule.gatewayTunnelId ?? "").trim()
if (tid) return rule
const gw = (rule.gateway ?? "").trim()
if (!gw) return rule
const candidates = rows.filter(r => gatewayFromRecursiveDst(r.dstAddress) === gw)
if (candidates.length === 0) return rule
const best = candidates.reduce((a, b) => (a.distance <= b.distance ? a : b))
return {
...rule,
gatewayTunnelId: `rec:${best.id}`,
gateway: gatewayFromRecursiveDst(best.dstAddress) || gw,
}
})
}
async function fetchServerFilters(server: ServerRow) {
const client = MikrotikClient.fromServer(server)
const [greRaw, filterRaw] = await Promise.all([
@@ -156,10 +189,12 @@ async function fetchServerFilters(server: ServerRow) {
}
})
const rules = filterRaw
const rulesRaw = filterRaw
.filter(r => (r.chain ?? "").trim().toLowerCase() === "bgp-in")
.flatMap(parseFilterRule)
const rules = enrichRulesWithRecursiveGateway(server.id, rulesRaw)
return {
serverId: String(server.id),
rules,
@@ -185,7 +220,70 @@ function toApiRulesets(serverRows: ServerRow[]) {
}))
}
function toRouterRuleBody(rules: ApiFilterRule[]): string {
/** GRE: gatewayTunnelId = имя интерфейса; рекурсивный: rec:<id строки recursive_routes */
function resolveRouteTargets(serverId: number, rule: ApiFilterRule): { gateway: string; outIface: string } {
if (rule.action === "blackhole") return { gateway: "", outIface: "" }
const tid = (rule.gatewayTunnelId ?? "").trim()
if (tid.startsWith("rec:")) {
const rid = Number.parseInt(tid.slice(4), 10)
if (!Number.isFinite(rid)) {
return { gateway: rule.gateway, outIface: "" }
}
const row = db
.select()
.from(recursiveRoutes)
.where(and(eq(recursiveRoutes.serverId, serverId), eq(recursiveRoutes.id, rid)))
.limit(1)
.all()[0]
if (!row) return { gateway: rule.gateway, outIface: "" }
const gw = gatewayFromRecursiveDst(row.dstAddress)
return { gateway: gw || rule.gateway, outIface: "" }
}
return { gateway: rule.gateway, outIface: tid }
}
function normalizeCommunity(c: string): string {
return (c ?? "").trim()
}
/** Одинаковый эффект на роутере при одинаковой community (blackhole vs gateway + out-interface) */
function ruleEffectSignature(serverId: number, r: ApiFilterRule): string {
if (r.action === "blackhole") return `bh:${normalizeCommunity(r.community)}`
const { gateway, outIface } = resolveRouteTargets(serverId, r)
return `rt:${normalizeCommunity(r.community)}:${gateway}:${outIface}`
}
export type FilterRouterCompareStatus = "synced" | "drift" | "missing"
function compareDbRulesWithRouter(
serverId: number,
dbRules: ApiFilterRule[],
remoteRules: ApiFilterRule[],
): Record<string, FilterRouterCompareStatus> {
const remoteSigByComm = new Map<string, string>()
for (const rr of remoteRules) {
const c = normalizeCommunity(rr.community)
if (!remoteSigByComm.has(c)) {
remoteSigByComm.set(c, ruleEffectSignature(serverId, rr))
}
}
const out: Record<string, FilterRouterCompareStatus> = {}
for (const dr of dbRules) {
const c = normalizeCommunity(dr.community)
const sigD = ruleEffectSignature(serverId, dr)
const sigR = remoteSigByComm.get(c)
if (sigR === undefined) {
out[c] = "missing"
} else if (sigR !== sigD) {
out[c] = "drift"
} else {
out[c] = "synced"
}
}
return out
}
function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): string {
return rules.map((rule, i) => {
const kw = i === 0 ? "if" : "} else if"
const comment = rule.description ? ` # ${rule.description}` : ""
@@ -197,13 +295,15 @@ function toRouterRuleBody(rules: ApiFilterRule[]): string {
" accept;",
].filter(Boolean).join("\n")
}
return [
const { gateway, outIface } = resolveRouteTargets(serverId, rule)
const lines = [
` ${kw} (bgp-communities.has("${rule.community}")) {`,
comment,
` set gateway ${rule.gateway};`,
` set out-interface ${rule.gatewayTunnelId};`,
" accept;",
].filter(Boolean).join("\n")
` set gateway ${gateway};`,
]
if (outIface) lines.push(` set out-interface ${outIface};`)
lines.push(" accept;")
return lines.filter(Boolean).join("\n")
}).join("\n")
}
@@ -228,21 +328,80 @@ async function replaceDbRules(serverId: number, rules: ApiFilterRule[]) {
}
const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
/** Сравнение правил в БД с живым bgp-in на MikroTik (один запрос API к роутеру) */
app.get("/filters/router-compare", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseServerId(q.serverId)
if (serverId === null) {
return reply.status(400).send({ error: "serverId is required" })
}
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const remote = await fetchServerFilters(server)
const rows = db
.select()
.from(filterRules)
.where(eq(filterRules.serverId, serverId))
.orderBy(asc(filterRules.sortOrder))
.all()
const dbRules: ApiFilterRule[] = rows.map(r => ({
id: String(r.id),
community: r.community,
communityName: r.communityName ?? undefined,
action: r.action,
gateway: r.gateway,
gatewayTunnelId: r.gatewayTunnelId,
description: r.description,
}))
const byCommunity = compareDbRulesWithRouter(serverId, dbRules, remote.rules)
return reply.send({ byCommunity })
} catch (err) {
app.log.error({ serverId, err: String(err) }, "filters router-compare failed")
return reply.status(500).send({ error: String(err) })
}
})
/** GRE с роутеров: один сервер (?serverId) или все включённые (без query) — для /gre, карты сети */
app.get("/filters/gre-tunnels", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const sid = parseServerId(q.serverId)
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
if (sid !== null) {
const server = enabledServers.find(s => s.id === sid)
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const remote = await fetchServerFilters(server)
return reply.send({ tunnels: remote.tunnels })
} catch {
return reply.send({ tunnels: [] as LiveGreTunnel[] })
}
}
const results = await Promise.all(enabledServers.map(async (server) => {
try {
const remote = await fetchServerFilters(server)
return remote.tunnels
} catch {
return [] as LiveGreTunnel[]
}
}))
return reply.send({ tunnels: results.flat() })
})
/** Только правила фильтров из БД (без опроса MikroTik за GRE) */
app.get("/filters/rules", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const dbRulesets = toApiRulesets(allServers)
const results = await Promise.all(allServers.map(async (server) => {
try {
const remote = await fetchServerFilters(server)
return { serverId: String(server.id), tunnels: remote.tunnels }
} catch {
return { serverId: String(server.id), tunnels: [] as LiveGreTunnel[] }
}
}))
return reply.send({
rulesets: dbRulesets,
greTunnels: results.flatMap(r => r.tunnels),
greTunnels: [] as LiveGreTunnel[],
})
})
@@ -315,7 +474,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
}))
if (rules.length > 0) {
const ruleBody = toRouterRuleBody(rules)
const ruleBody = toRouterRuleBody(server.id, rules)
await client.post("/routing/filter/rule", {
chain: "bgp-in",
comment: `RouterLists: ${server.name || server.host}`,
+67
View File
@@ -0,0 +1,67 @@
import { promises as dns } from "node:dns"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { z } from "zod"
const IPV4 =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
function isIpv4(s: string): boolean {
return IPV4.test(s.trim())
}
/** Кэш DNS на процесс (GRE outer меняется редко). */
const resolveCache = new Map<string, { ip: string | null; expires: number }>()
const TTL_MS = 5 * 60 * 1000
async function resolveHostToIpv4(hostname: string): Promise<string | null> {
const key = hostname.trim().toLowerCase()
if (!key || isIpv4(key)) return isIpv4(key) ? key.trim() : null
const hit = resolveCache.get(key)
if (hit && hit.expires > Date.now()) return hit.ip
try {
const { address } = await dns.lookup(key, { family: 4 })
const ip = isIpv4(address) ? address : null
resolveCache.set(key, { ip, expires: Date.now() + TTL_MS })
return ip
} catch {
resolveCache.set(key, { ip: null, expires: Date.now() + TTL_MS })
return null
}
}
const ResolveHostsBodySchema = z.object({
hosts: z.array(z.string().max(253)).max(32),
})
const networkRoutes: FastifyPluginAsyncZod = async (app) => {
/**
* Резолв FQDN из GRE outer (remote-address / local-address) в IPv4 для сопоставления с WAN в каталоге.
*/
app.post("/network/resolve-hosts", async (req, reply) => {
const parsed = ResolveHostsBodySchema.safeParse(req.body)
if (!parsed.success) {
return reply.status(400).send({ error: "Invalid body", details: parsed.error.flatten() })
}
const seen = new Set<string>()
const unique: string[] = []
for (const h of parsed.data.hosts) {
const k = h.trim().toLowerCase()
if (!k || seen.has(k)) continue
seen.add(k)
unique.push(h.trim())
}
const results: Record<string, string | null> = {}
for (const host of unique) {
const key = host.toLowerCase()
results[key] = await resolveHostToIpv4(host)
}
return reply.send({ results })
})
}
export default networkRoutes
+412
View File
@@ -0,0 +1,412 @@
import dns from "node:dns/promises"
import { eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import type { RosIpRoute, RosPingResult } from "../types/server.js"
import { resolveRosSrcIpv4 } from "../utils/ros-src-address.js"
import { abortAfterMs, mergeAbortSignals } from "../utils/abort-signals.js"
/**
* Документация REST API RouterOS: если команда «бесконечна», сессия всё равно закрывается
* через ~60 с; параметры команды **не** продлевают этот лимит.
* @see https://help.mikrotik.com/docs/display/ROS/REST+API — раздел «Timeout»
*/
const ROS_REST_SESSION_MAX_MS = 58_000
/** Парсинг ввода пользователя (800ms, 1s, 00:00:01) → миллисекунды (10–3000). */
function parseTraceHopInputToMs(raw: unknown): number {
const s = String(raw ?? "").trim().toLowerCase()
if (!s) return 1000
const msM = /^(\d+)ms$/.exec(s)
if (msM) return Math.min(3000, Math.max(10, Number(msM[1])))
const secM = /^(\d+(?:\.\d+)?)s$/.exec(s)
if (secM) return Math.min(3000, Math.max(10, Number(secM[1]) * 1000))
const hm = /^(\d{1,2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/.exec(String(raw ?? "").trim())
if (hm) {
const h = Number(hm[1]), m = Number(hm[2]), sec = Number(hm[3])
const frac = hm[4] ? Number(hm[4].padEnd(3, "0").slice(0, 3)) : 0
const t = ((h * 60 + m) * 60 + sec) * 1000 + frac
return Math.min(3000, Math.max(10, t))
}
return 1000
}
/** В REST JSON для /tool/traceroute нужен формат времени HH:MM:SS (не «1s»). */
function formatMsAsRosTracerouteTimeout(ms: number): string {
const capped = Math.min(3000, Math.max(10, Math.round(ms)))
const totalSeconds = Math.floor(capped / 1000)
const milli = capped % 1000
const ss = totalSeconds % 60
const mmTotal = Math.floor(totalSeconds / 60)
const mm = mmTotal % 60
const hh = Math.floor(mmTotal / 60)
const pad = (n: number) => String(n).padStart(2, "0")
if (milli === 0) return `${pad(hh)}:${pad(mm)}:${pad(ss)}`
return `${pad(hh)}:${pad(mm)}:${pad(ss)}.${String(milli).padStart(3, "0")}`
}
function traceHopTimeoutForApi(raw: unknown): string {
return formatMsAsRosTracerouteTimeout(parseTraceHopInputToMs(raw))
}
function parseServerId(raw: string): number | null {
const n = Number.parseInt(raw, 10)
return Number.isFinite(n) ? n : null
}
function ipv4ToUint(ip: string): number | null {
const p = ip.split(".").map((x) => Number.parseInt(x, 10))
if (p.length !== 4 || p.some((x) => !Number.isFinite(x) || x < 0 || x > 255)) return null
return (((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]) >>> 0)
}
function maskFromLen(len: number): number {
if (len <= 0) return 0
if (len >= 32) return 0xffffffff
return (~((1 << (32 - len)) - 1)) >>> 0
}
function parseDstRoute(dst: string): { net: number; maskBits: number } | null {
const t = dst.trim()
if (!t) return null
if (!t.includes("/")) {
const ip = ipv4ToUint(t)
return ip === null ? null : { net: ip, maskBits: 32 }
}
const [addr, mb] = t.split("/")
const ip = ipv4ToUint(addr.trim())
const maskBits = Number.parseInt((mb ?? "").trim(), 10)
if (ip === null || !Number.isFinite(maskBits) || maskBits < 0 || maskBits > 32) return null
const mask = maskFromLen(maskBits)
return { net: ip & mask, maskBits }
}
function ipMatchesRoute(destIp: string, dstAddressField: string): boolean {
const ip = ipv4ToUint(destIp.trim())
if (ip === null) return false
const cidr = parseDstRoute(dstAddressField)
if (!cidr) return false
const mask = maskFromLen(cidr.maskBits)
return (ip & mask) === (cidr.net & mask)
}
function fmtPingRouterOs(results: RosPingResult[], host: string): string {
const lines = [`PING ${host}`]
for (const r of results) {
if (!r.seq && !r.sent && r.status !== "timeout") continue
if (r.status === "timeout") {
lines.push(` seq=${r.seq ?? "?"} timeout`)
} else if (r.seq && r.time !== undefined) {
lines.push(` seq=${r.seq} ttl=${r.ttl ?? "?"} time=${r.time}`)
}
}
const sum = [...results].reverse().find((r) => r.sent)
if (sum) {
lines.push(` sent=${sum.sent} received=${sum.received ?? "?"} packet-loss=${sum["packet-loss"] ?? "?"}`)
if (sum.time && sum.sent) lines.push(` avg-rtt=${sum.time}`)
}
return lines.join("\n")
}
function fmtTraceroute(rows: unknown): string {
if (!Array.isArray(rows)) return typeof rows === "string" ? rows : JSON.stringify(rows, null, 2)
const hdr = " # ADDRESS LOSS LAST AVG"
const lines = [hdr]
rows.forEach((row, i) => {
const r = row as Record<string, string | undefined>
const addr = String(r.address ?? r.host ?? r["from-address"] ?? "?")
const loss = String(r.loss ?? r["packet-loss"] ?? "—")
const last = String(r["last"] ?? r.time ?? "—")
const avg = String(r.avg ?? r["avg-rtt"] ?? "—")
lines.push(
` ${String(i + 1).padStart(2)} ${addr.padEnd(40)} ${loss.padEnd(6)} ${last.padStart(8)} ${avg.padStart(8)}`,
)
})
return lines.join("\n")
}
function fmtBandwidth(rows: Array<Record<string, string>>): string {
if (rows.length === 0) return "(no bandwidth-test output)"
const lines = rows.map((r) => {
const tx = r["tx-current"] ?? r["tx-total-average"] ?? ""
const rx = r["rx-current"] ?? r["rx-total-average"] ?? ""
const sec = r["test-duration"] ?? ""
const parts = [`tx=${tx}`, `rx=${rx}`]
if (sec) parts.push(`t=${sec}`)
return ` ${parts.join(" ")}`
})
return ["bandwidth-test:", ...lines].join("\n")
}
function fmtRouteLookup(destIp: string, routes: RosIpRoute[]): string {
const matches = routes.filter((r) => {
const dst = String(r["dst-address"] ?? "").trim()
if (!dst) return false
if (String(r.active ?? "true").toLowerCase() === "false") return false
return ipMatchesRoute(destIp, dst)
})
if (matches.length === 0) {
return `no route for ${destIp} (active routes checked: ${routes.length})`
}
matches.sort((a, b) => {
const da = parseDstRoute(String(a["dst-address"] ?? ""))
const db = parseDstRoute(String(b["dst-address"] ?? ""))
return (db?.maskBits ?? 0) - (da?.maskBits ?? 0)
})
const best = matches[0]!
const lines = [
`lookup ${destIp} → best match:`,
` dst-address: ${best["dst-address"] ?? "—"}`,
` gateway: ${best.gateway ?? best.interface ?? "—"}`,
` distance: ${best.distance ?? "—"}`,
` routing-mark: ${best["routing-mark"] ?? "main"}`,
]
return lines.join("\n")
}
async function dnsLookupText(name: string, type: string): Promise<string> {
const t = type.toUpperCase()
const lines: string[] = [`;; QUESTION: ${name} ${t}`, ""]
try {
if (t === "A") {
const addrs = await dns.resolve4(name)
addrs.forEach((a) => lines.push(`${name}. IN A ${a}`))
} else if (t === "AAAA") {
const addrs = await dns.resolve6(name)
addrs.forEach((a) => lines.push(`${name}. IN AAAA ${a}`))
} else if (t === "MX") {
const mx = await dns.resolveMx(name)
mx.sort((a, b) => a.priority - b.priority)
mx.forEach((m) => lines.push(`${name}. IN MX ${m.priority} ${m.exchange}`))
} else if (t === "NS") {
const ns = await dns.resolveNs(name)
ns.forEach((n) => lines.push(`${name}. IN NS ${n}`))
} else if (t === "TXT") {
const tx = await dns.resolveTxt(name)
tx.forEach((chunks) => lines.push(`${name}. IN TXT "${chunks.join("")}"`))
} else if (t === "CNAME") {
const c = await dns.resolveCname(name)
lines.push(`${name}. IN CNAME ${c}`)
} else if (t === "PTR") {
const raw = name.trim()
if (/^[\d.]+$/.test(raw)) {
const hosts = await dns.reverse(raw)
hosts.forEach((h) => lines.push(`${raw}.in-addr.arpa. IN PTR ${h}`))
} else {
const ptr = await dns.resolvePtr(raw.includes(".arpa") ? raw : `${raw}.in-addr.arpa`)
ptr.forEach((p) => lines.push(`${raw}. IN PTR ${p}`))
}
} else {
lines.push(`;; тип «${t}» не поддержан в живом режиме (используйте A, AAAA, MX, NS, TXT, CNAME, PTR)`)
}
} catch (e) {
lines.push(`;; ERROR: ${e instanceof Error ? e.message : String(e)}`)
lines.push(";; (резолв выполняется на хосте бекенда, не на MikroTik)")
}
return lines.join("\n")
}
async function mtuDiscover(client: MikrotikClient, address: string, srcIpv4: string | null): Promise<string> {
const sizes = [1500, 1492, 1480, 1476, 1472, 1468, 1400, 1280, 1024, 576]
const lines: string[] = [`MTU discovery (do-not-fragment ping) → ${address}`, ""]
let mtuFound = 0
for (const size of sizes) {
try {
const body: Record<string, string> = {
address,
count: "1",
size: String(size),
interval: "0.2s",
/** REST /tool/ping: как в CLI — `do-not-fragment=yes`, не `dont-fragment` */
"do-not-fragment": "true",
}
if (srcIpv4) body["src-address"] = srcIpv4
const rows = await client.post<RosPingResult[]>("/tool/ping", body, 25_000)
const timeout = rows.some((r) => r.status === "timeout")
if (!timeout) {
lines.push(` ${String(size).padStart(4)} ✓ ok`)
mtuFound = size
break
}
lines.push(` ${String(size).padStart(4)} ✗ fragment needed / timeout`)
} catch (e) {
lines.push(` ${String(size).padStart(4)} ✗ ${e instanceof Error ? e.message : String(e)}`)
}
}
lines.push("")
lines.push(mtuFound > 0 ? `MTU (DF): ~${mtuFound} bytes` : "MTU: could not determine")
return lines.join("\n")
}
function resolveBtestPeer(remoteHost: string, explicitDstId?: number) {
if (explicitDstId != null && Number.isFinite(explicitDstId)) {
const s = db.select().from(servers).where(eq(servers.id, explicitDstId)).limit(1).all()[0]
if (s) return s
}
const norm = remoteHost.trim().toLowerCase()
return db.select().from(servers).where(eq(servers.enabled, true)).all()
.find((x) => x.host.trim().toLowerCase() === norm)
}
const probesRoutes: FastifyPluginAsyncZod = async (app) => {
app.post("/servers/:id/probes/run", async (req, reply) => {
const sid = parseServerId(String((req.params as { id?: string }).id ?? ""))
if (sid === null) return reply.status(400).send({ error: "Invalid server id" })
const server = db.select().from(servers).where(eq(servers.id, sid)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const body = req.body as {
tool?: string
target?: string
pingCount?: number
pingSize?: number
pingTtl?: number
traceProto?: string
traceMaxHops?: number
/** Таймаут одной пробы: 800ms, 1s или 00:00:01 (в API уходит как HH:MM:SS) */
traceHopTimeout?: string
/** Число проб на хоп (1–3). Для REST рекомендуется 1 из‑за лимита сессии ~60 с */
traceProbeCount?: number
/** Резолвить адреса хопов в имена (RouterOS: use-dns yes|no → REST true|false) */
traceUseDns?: boolean
dnsType?: string
bwRemoteAddress?: string
dstServerId?: number
bwProto?: string
bwDuration?: number
}
const tool = String(body.tool ?? "").trim() as
| "ping"
| "traceroute"
| "bandwidth"
| "dns"
| "route"
| "mtu"
const client = MikrotikClient.fromServer(server)
const srcIpv4 = await resolveRosSrcIpv4(server.host)
try {
let output = ""
switch (tool) {
case "ping": {
const target = String(body.target ?? "").trim()
if (!target) return reply.status(400).send({ error: "target required" })
const count = Math.min(100, Math.max(1, Number(body.pingCount) || 5))
const size = Math.min(8192, Math.max(28, Number(body.pingSize) || 64))
const ttl = Math.min(255, Math.max(1, Number(body.pingTtl) || 64))
const pingBody: Record<string, string> = {
address: target,
count: String(count),
size: String(size),
ttl: String(ttl),
interval: "0.2s",
}
if (srcIpv4) pingBody["src-address"] = srcIpv4
const rows = await client.post<RosPingResult[]>("/tool/ping", pingBody, 120_000)
output = fmtPingRouterOs(rows, target)
break
}
case "traceroute": {
const target = String(body.target ?? "").trim()
if (!target) return reply.status(400).send({ error: "target required" })
const proto = body.traceProto === "udp" || body.traceProto === "tcp" ? body.traceProto : "icmp"
const maxHopsRequested = Math.min(64, Math.max(1, Number(body.traceMaxHops) || 30))
const probeCount = Math.min(3, Math.max(1, Number(body.traceProbeCount) || 1))
const hopMs = parseTraceHopInputToMs(body.traceHopTimeout)
const hopTimeoutRos = traceHopTimeoutForApi(body.traceHopTimeout)
const budgetMs = ROS_REST_SESSION_MAX_MS - 5000
const worstMsPerHop = probeCount * hopMs
const maxFeasibleHops = Math.max(1, Math.floor(budgetMs / worstMsPerHop))
const effectiveMaxHops = Math.min(maxHopsRequested, maxFeasibleHops)
const traceWallMs = ROS_REST_SESSION_MAX_MS
const useDns = Boolean(body.traceUseDns)
const traceBody: Record<string, string> = {
address: target,
protocol: proto,
"max-hops": String(effectiveMaxHops),
timeout: hopTimeoutRos,
count: String(probeCount),
"use-dns": useDns ? "true" : "false",
}
if (srcIpv4) traceBody["src-address"] = srcIpv4
const disconnectAbort = new AbortController()
const onClientClose = () => disconnectAbort.abort()
req.raw.once("close", onClientClose)
try {
const sig = mergeAbortSignals(disconnectAbort.signal, abortAfterMs(traceWallMs))
const rows = await client.post<unknown>("/tool/traceroute", traceBody, traceWallMs, sig)
const note =
effectiveMaxHops < maxHopsRequested
? `;; REST API RouterOS: сессия ~60 с (параметры traceroute не продлевают лимит). max-hops снижен с ${maxHopsRequested} до ${effectiveMaxHops} (count=${probeCount}, timeout=${hopTimeoutRos}).\n`
: ""
output = note + fmtTraceroute(rows)
} finally {
req.raw.off("close", onClientClose)
}
break
}
case "bandwidth": {
const remote = String(body.bwRemoteAddress ?? "").trim()
if (!remote) return reply.status(400).send({ error: "bwRemoteAddress required" })
const dst = resolveBtestPeer(remote, body.dstServerId)
if (!dst) {
return reply.status(400).send({
error: "Не найден сервер назначения для bandwidth-test: добавьте узел с host = GRE remote или укажите dstServerId",
})
}
const protocol = body.bwProto === "udp" ? "udp" : "tcp"
const durationSec = Math.max(3, Math.min(120, Number(body.bwDuration) || 10))
const rows = await client.bandwidthTest({
address: remote,
user: dst.username,
password: dst.password,
protocol,
direction: "both",
durationSec,
})
output = fmtBandwidth(rows)
break
}
case "dns": {
const target = String(body.target ?? "").trim()
if (!target) return reply.status(400).send({ error: "target required" })
const dtype = String(body.dnsType ?? "A").trim() || "A"
output = await dnsLookupText(target, dtype)
break
}
case "route": {
const target = String(body.target ?? "").trim()
if (!target) return reply.status(400).send({ error: "target required" })
const routes = await client.getIpRoutes()
output = fmtRouteLookup(target, routes)
break
}
case "mtu": {
const target = String(body.target ?? "").trim()
if (!target) return reply.status(400).send({ error: "target required" })
output = await mtuDiscover(client, target, srcIpv4)
break
}
default:
return reply.status(400).send({ error: `unknown tool: ${tool}` })
}
return reply.send({ output })
} catch (err) {
const msg =
err instanceof DOMException && err.name === "AbortError"
? "Запрос отменён (клиент закрыл соединение или истёк лимит времени для traceroute)."
: err instanceof Error
? err.message
: String(err)
return reply.send({ output: `error: ${msg}` })
}
})
}
export default probesRoutes
+59 -3
View File
@@ -2,7 +2,7 @@ import { desc, eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { servers, serverSnapshots } from "../db/schema.js"
import type { ServerRead } from "../types/server.js"
import type { ServerRead, WanUplinkRead } from "../types/server.js"
import {
ServerCreateSchema,
ServerUpdateSchema,
@@ -12,12 +12,38 @@ import {
} from "../types/server.js"
import { pollServer, toSnapshotRead } from "../services/poller.js"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import { resolveRosSrcIpv4 } from "../utils/ros-src-address.js"
// ── helpers ────────────────────────────────────────────────────────────────────
type ServerRow = typeof servers.$inferSelect
type SnapshotRow = typeof serverSnapshots.$inferSelect
function parseWanUplinksJson(raw: string | null | undefined): WanUplinkRead[] {
if (raw == null || raw === "") return []
try {
const data = JSON.parse(raw) as unknown
if (!Array.isArray(data)) return []
const out: WanUplinkRead[] = []
for (const row of data) {
if (typeof row !== "object" || row === null) continue
const r = row as Record<string, unknown>
out.push({
id: typeof r.id === "string" ? r.id : "",
name: typeof r.name === "string" ? r.name : "",
isp: typeof r.isp === "string" ? r.isp : "",
iface: typeof r.iface === "string" ? r.iface : "",
ip: typeof r.ip === "string" ? r.ip : "",
maxDl: typeof r.maxDl === "number" && Number.isFinite(r.maxDl) ? r.maxDl : 0,
maxUl: typeof r.maxUl === "number" && Number.isFinite(r.maxUl) ? r.maxUl : 0,
})
}
return out
} catch {
return []
}
}
/** Merge a server row with its latest snapshot into the frontend-compatible shape */
function toServerRead(server: ServerRow, snap: SnapshotRow | undefined): ServerRead {
return {
@@ -35,6 +61,8 @@ function toServerRead(server: ServerRow, snap: SnapshotRow | undefined): ServerR
asn: server.asn,
comment: server.comment,
enabled: server.enabled,
lanSubnet: server.lanSubnet ?? "",
wanUplinks: parseWanUplinksJson(server.wanUplinks),
createdAt: server.createdAt,
updatedAt: server.updatedAt,
// snapshot fields (null if never polled)
@@ -121,12 +149,30 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.send(result)
})
// GET /api/servers/:id/ros-src-address — IPv4 для src-address в RouterOS (не FQDN)
app.get("/:id/ros-src-address", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const server = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const ipv4 = await resolveRosSrcIpv4(server.host)
return reply.send({ host: server.host, ipv4 })
})
// POST /api/servers
app.post("/", { schema: { body: ServerCreateSchema } }, async (req, reply) => {
const now = new Date().toISOString()
const { wanUplinks, ...rest } = req.body
const [inserted] = db
.insert(servers)
.values({ ...req.body, createdAt: now, updatedAt: now })
.values({
...rest,
wanUplinks: JSON.stringify(wanUplinks ?? []),
createdAt: now,
updatedAt: now,
})
.returning()
.all()
return reply.status(201).send(toServerRead(inserted, undefined))
@@ -155,9 +201,19 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
if (!existing) return reply.status(404).send({ error: "Server not found" })
const body = req.body
const { wanUplinks, ...rest } = body
const setPayload: Record<string, unknown> = { updatedAt: new Date().toISOString() }
for (const [k, v] of Object.entries(rest)) {
if (v !== undefined) setPayload[k] = v
}
if (wanUplinks !== undefined) {
setPayload.wanUplinks = JSON.stringify(wanUplinks)
}
const [updated] = db
.update(servers)
.set({ ...req.body, updatedAt: new Date().toISOString() })
.set(setPayload as Partial<ServerRow>)
.where(eq(servers.id, req.params.id))
.returning()
.all()
+381 -50
View File
@@ -1,15 +1,18 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { eq } from "drizzle-orm"
import { asc, desc, eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { servers, uptimeSpeedProbes, uptimeSpeedTestRuns } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import type { RosIpAddress } from "../types/server.js"
import {
collectUptimeOnce,
parsePing,
readProbeRows,
readProbeSamplesSince,
readResourceSamplesSince,
readUptimeSettings,
replaceProbes,
updateProbeShowOnDashboard,
updateUptimeSettings,
} from "../services/uptime-collector.js"
@@ -31,6 +34,28 @@ function toSeries(values: number[], target = 40): number[] {
return [...Array(target - values.length).fill(values[0] ?? 0), ...values]
}
/**
* Один «рабочий» IPv4/IPv6 для интерфейса: пропускаем записи /ip/address с disabled/invalid,
* среди оставшихся предпочитаем статические адреса динамическим (иначе БТ может цепляться к отключённому IP).
*/
function firstUsableIpOnInterface(rows: RosIpAddress[], iface: string): string {
const name = iface.trim()
const candidates = rows.filter((a) => {
if (String(a.interface ?? "").trim() !== name) return false
if (String(a.disabled ?? "false").toLowerCase() === "true") return false
if (String(a.invalid ?? "false").toLowerCase() === "true") return false
const addr = String(a.address ?? "").trim()
return addr.includes("/")
})
candidates.sort((a, b) => {
const ad = String(a.dynamic ?? "false").toLowerCase() === "true" ? 1 : 0
const bd = String(b.dynamic ?? "false").toLowerCase() === "true" ? 1 : 0
return ad - bd
})
const raw = candidates[0]?.address
return raw ? String(raw).split("/")[0]?.trim() ?? "" : ""
}
function parseRateToMbps(raw: unknown): number {
const txt = String(raw ?? "").trim().toLowerCase()
if (!txt) return 0
@@ -49,12 +74,39 @@ function parseRateToMbps(raw: unknown): number {
return n > 10000 ? n / 1_000_000 : n
}
const btestNodeLocks = new Map<number, Promise<void>>()
async function withBtestNodeLocks<T>(nodeIds: number[], fn: () => Promise<T>): Promise<T> {
const ids = [...new Set(nodeIds.filter((id) => Number.isFinite(id)))].sort((a, b) => a - b)
const releases: Array<() => void> = []
try {
for (const id of ids) {
const prev = btestNodeLocks.get(id)
let resolveCurrent!: () => void
const current = new Promise<void>((resolve) => {
resolveCurrent = resolve
})
btestNodeLocks.set(id, current)
if (prev) await prev
releases.push(() => {
if (btestNodeLocks.get(id) === current) btestNodeLocks.delete(id)
resolveCurrent()
})
}
return await fn()
} finally {
for (let i = releases.length - 1; i >= 0; i -= 1) releases[i]()
}
}
const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/uptime/settings", async (_req, reply) => {
const s = readUptimeSettings()
return reply.send({
enabled: s.enabled,
intervalSec: s.intervalSec,
probeIntervalSec: s.probeIntervalSec ?? s.intervalSec,
speedIntervalSec: s.speedIntervalSec ?? 60,
retentionDays: s.retentionDays,
lastCollectedAt: s.lastCollectedAt ?? null,
lastDurationMs: s.lastDurationMs ?? null,
@@ -63,10 +115,18 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
})
app.put("/uptime/settings", async (req, reply) => {
const body = req.body as { enabled?: boolean; intervalSec?: number | string; retentionDays?: number | string }
const body = req.body as {
enabled?: boolean
intervalSec?: number | string
probeIntervalSec?: number | string
speedIntervalSec?: number | string
retentionDays?: number | string
}
const updated = updateUptimeSettings({
enabled: body.enabled,
intervalSec: body.intervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.intervalSec), 10) || 15),
probeIntervalSec: body.probeIntervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.probeIntervalSec), 10) || 15),
speedIntervalSec: body.speedIntervalSec == null ? undefined : Math.max(10, Number.parseInt(String(body.speedIntervalSec), 10) || 60),
retentionDays: body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14),
})
return reply.send({
@@ -74,6 +134,8 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
settings: {
enabled: updated.enabled,
intervalSec: updated.intervalSec,
probeIntervalSec: updated.probeIntervalSec ?? updated.intervalSec,
speedIntervalSec: updated.speedIntervalSec ?? 60,
retentionDays: updated.retentionDays,
lastCollectedAt: updated.lastCollectedAt ?? null,
lastDurationMs: updated.lastDurationMs ?? null,
@@ -97,6 +159,125 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.send({ probes: readProbeRows() })
})
app.get("/uptime/speed-probes", async (_req, reply) => {
const rows = db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder)).all()
return reply.send({
probes: rows.map((r) => ({
id: r.id,
srcServerId: String(r.srcServerId),
dstServerId: String(r.dstServerId),
srcInterface: r.srcInterface || "",
dstInterface: r.dstInterface || "",
protocol: r.protocol === "udp" ? "udp" : "tcp",
direction: r.direction === "transmit" || r.direction === "receive" ? r.direction : "both",
durationSec: String(Math.max(3, r.durationSec || 10)),
enabled: r.enabled !== false,
lastRunAt: r.lastRunAt ?? null,
lastTxAvgMbps: r.lastTxAvgMbps ?? null,
lastRxAvgMbps: r.lastRxAvgMbps ?? null,
lastStatus: r.lastStatus ?? null,
lastError: r.lastError ?? null,
lastPingRttMs: r.lastPingRttMs ?? null,
lastPingLossPct: r.lastPingLossPct ?? null,
lastPingAt: r.lastPingAt ?? null,
lastPingError: r.lastPingError ?? null,
})),
})
})
app.get("/uptime/speed-test/runs", async (_req, reply) => {
const rows = db.select().from(uptimeSpeedTestRuns).orderBy(desc(uptimeSpeedTestRuns.createdAt)).limit(200).all()
return reply.send({
runs: rows.map((r) => ({
id: r.id,
probeId: r.probeId ?? null,
srcServerId: String(r.srcServerId),
dstServerId: String(r.dstServerId),
srcInterface: r.srcInterface || "",
dstInterface: r.dstInterface || "",
srcAddress: r.srcAddress ?? null,
dstAddress: r.dstAddress ?? null,
srcInterfaceAddress: r.srcInterfaceAddress ?? null,
dstInterfaceAddress: r.dstInterfaceAddress ?? null,
protocol: r.protocol === "udp" ? "udp" : "tcp",
direction: r.direction === "transmit" || r.direction === "receive" ? r.direction : "both",
durationSec: Math.max(3, r.durationSec || 10),
txAvgMbps: r.txAvgMbps ?? 0,
rxAvgMbps: r.rxAvgMbps ?? 0,
afterBtPing: {
rttMs: r.pingRttMs ?? null,
lossPct: r.pingLossPct ?? null,
error: r.pingError ?? null,
},
status: r.status === "error" ? "error" : "done",
error: r.error ?? null,
createdAt: r.createdAt,
})),
})
})
app.put("/uptime/speed-probes", async (req, reply) => {
const body = req.body as {
probes?: Array<{
id?: string
srcServerId?: string | number
dstServerId?: string | number
srcInterface?: string
dstInterface?: string
protocol?: "tcp" | "udp"
direction?: "transmit" | "receive" | "both"
durationSec?: string | number
enabled?: boolean
}>
}
const normalized = (body.probes ?? [])
.map((p, i) => ({
id: p.id || `sp-${Date.now()}-${i}`,
srcServerId: Number.parseInt(String(p.srcServerId ?? ""), 10),
dstServerId: Number.parseInt(String(p.dstServerId ?? ""), 10),
srcInterface: String(p.srcInterface ?? "").trim(),
dstInterface: String(p.dstInterface ?? "").trim(),
protocol: p.protocol === "udp" ? "udp" : "tcp",
direction: p.direction === "transmit" || p.direction === "receive" ? p.direction : "both",
durationSec: Math.max(3, Number.parseInt(String(p.durationSec ?? ""), 10) || 10),
enabled: p.enabled !== false,
}))
.filter((p) => Number.isFinite(p.srcServerId) && Number.isFinite(p.dstServerId) && p.srcServerId !== p.dstServerId)
const now = new Date().toISOString()
const existing = db.select({ id: uptimeSpeedProbes.id }).from(uptimeSpeedProbes).all()
const nextIds = new Set(normalized.map((p) => p.id))
for (const row of existing) {
if (nextIds.has(row.id)) continue
db.delete(uptimeSpeedProbes).where(eq(uptimeSpeedProbes.id, row.id)).run()
}
for (let i = 0; i < normalized.length; i += 1) {
const p = normalized[i]
const patch = {
srcServerId: p.srcServerId,
dstServerId: p.dstServerId,
srcInterface: p.srcInterface,
dstInterface: p.dstInterface,
protocol: p.protocol as "tcp" | "udp",
direction: p.direction as "transmit" | "receive" | "both",
durationSec: p.durationSec,
enabled: p.enabled,
sortOrder: i,
updatedAt: now,
}
const updated = db.update(uptimeSpeedProbes).set(patch).where(eq(uptimeSpeedProbes.id, p.id)).run()
if ((updated.changes ?? 0) > 0) continue
db.insert(uptimeSpeedProbes).values({
id: p.id,
...patch,
createdAt: now,
}).run()
}
return reply.send({ ok: true })
})
app.put("/uptime/probes", async (req, reply) => {
const body = req.body as {
probes?: Array<{
@@ -107,6 +288,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
target?: string
filter?: string
enabled?: boolean
showOnDashboard?: boolean
}>
}
const normalized = (body.probes ?? [])
@@ -118,12 +300,26 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
target: String(p.target ?? "").trim(),
probeFilter: String(p.filter ?? "—"),
enabled: p.enabled !== false,
showOnDashboard: p.showOnDashboard === true,
}))
.filter((p) => Number.isFinite(p.srcServerId) && p.name && p.target)
replaceProbes(normalized)
return reply.send({ ok: true })
})
app.patch("/uptime/probes/:id", async (req, reply) => {
const params = req.params as { id?: string }
const probeId = String(params.id ?? "").trim()
if (!probeId) return reply.status(400).send({ error: "Invalid probe id" })
const body = req.body as { showOnDashboard?: boolean }
if (typeof body.showOnDashboard !== "boolean") {
return reply.status(400).send({ error: "showOnDashboard boolean required" })
}
const ok = updateProbeShowOnDashboard(probeId, body.showOnDashboard)
if (!ok) return reply.status(404).send({ error: "Probe not found" })
return reply.send({ ok: true })
})
app.get("/uptime/sources/:id/interfaces", async (req, reply) => {
const params = req.params as { id?: string | number }
const serverId = Number.parseInt(String(params.id ?? ""), 10)
@@ -139,6 +335,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
disabled: String(r.disabled ?? "false").toLowerCase() === "true",
}))
.filter((r) => r.name.length > 0)
.filter((r) => r.running && !r.disabled)
.sort((a, b) => a.name.localeCompare(b.name))
return reply.send({ interfaces })
} catch (e) {
@@ -149,6 +346,8 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
app.post("/uptime/speed-test", async (req, reply) => {
const body = req.body as {
runId?: string
probeId?: string
srcServerId?: string | number
dstServerId?: string | number
srcInterface?: string
@@ -167,64 +366,187 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
const dst = db.select().from(servers).where(eq(servers.id, dstId)).limit(1).all()[0]
if (!src || !dst) return reply.status(404).send({ error: "Server not found" })
try {
const protocol = body.protocol === "udp" ? "udp" : "tcp"
const direction = body.direction === "transmit" || body.direction === "receive" ? body.direction : "both"
const durationSec = Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10)
const srcInterface = String(body.srcInterface ?? "").trim()
const dstInterface = String(body.dstInterface ?? "").trim()
const srcClient = MikrotikClient.fromServer(src)
const dstAddressRows = dstInterface
? await MikrotikClient.fromServer(dst).getIpAddresses().catch(() => [])
: []
const dstAddress = dstInterface
? String(
dstAddressRows.find((a) => String(a.interface ?? "") === dstInterface && String(a.address ?? "").includes("/"))?.address ??
"",
).split("/")[0]
: dst.host
if (dstInterface && !dstAddress) {
return reply.status(400).send({ error: `На интерфейсе назначения '${dstInterface}' нет IP-адреса` })
}
const initialAddress = dstAddress || dst.host
return await withBtestNodeLocks([srcId, dstId], async () => {
const protocol = body.protocol === "udp" ? "udp" : "tcp"
const direction = body.direction === "transmit" || body.direction === "receive" ? body.direction : "both"
const durationSec = Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10)
const srcInterface = String(body.srcInterface ?? "").trim()
const dstInterface = String(body.dstInterface ?? "").trim()
const srcClient = MikrotikClient.fromServer(src)
const dstAddressRows = dstInterface
? await MikrotikClient.fromServer(dst).getIpAddresses().catch(() => [])
: []
const srcAddressRows = srcInterface
? await srcClient.getIpAddresses().catch(() => [])
: []
const dstAddress = dstInterface ? firstUsableIpOnInterface(dstAddressRows, dstInterface) : dst.host
if (dstInterface && !dstAddress) {
return reply.status(400).send({
error: `На интерфейсе назначения '${dstInterface}' нет активного IP (проверьте /ip/address — не disabled).`,
})
}
const initialAddress = dstAddress || dst.host
const srcAddress = srcInterface ? firstUsableIpOnInterface(srcAddressRows, srcInterface) : src.host
const srcInterfaceAddress = srcInterface ? (srcAddress || null) : null
const dstInterfaceAddress = dstInterface ? (dstAddress || null) : null
const rows = await srcClient.bandwidthTest({
address: initialAddress,
user: dst.username,
password: dst.password,
protocol,
direction,
durationSec,
})
const txSeries = rows
.map((r) => parseRateToMbps(r["tx-current"] ?? r["tx-10-second-average"] ?? r["tx-total-average"]))
.filter((v) => Number.isFinite(v) && v >= 0)
const rxSeries = rows
.map((r) => parseRateToMbps(r["rx-current"] ?? r["rx-10-second-average"] ?? r["rx-total-average"]))
.filter((v) => Number.isFinite(v) && v >= 0)
const last = rows[rows.length - 1] ?? {}
const txAvg = parseRateToMbps(last["tx-total-average"] ?? last["tx-10-second-average"])
const rxAvg = parseRateToMbps(last["rx-total-average"] ?? last["rx-10-second-average"])
return reply.send({
ok: true,
result: {
srcInterface: srcInterface || null,
dstInterface: dstInterface || null,
const rows = await srcClient.bandwidthTest({
address: initialAddress,
user: dst.username,
password: dst.password,
protocol,
direction,
durationSec,
txAvgMbps: txAvg || (txSeries.length ? txSeries.reduce((a, b) => a + b, 0) / txSeries.length : 0),
rxAvgMbps: rxAvg || (rxSeries.length ? rxSeries.reduce((a, b) => a + b, 0) / rxSeries.length : 0),
txSeries,
rxSeries,
raw: rows,
},
})
const txSeries = rows
.map((r) => parseRateToMbps(r["tx-current"] ?? r["tx-10-second-average"] ?? r["tx-total-average"]))
.filter((v) => Number.isFinite(v) && v >= 0)
const rxSeries = rows
.map((r) => parseRateToMbps(r["rx-current"] ?? r["rx-10-second-average"] ?? r["rx-total-average"]))
.filter((v) => Number.isFinite(v) && v >= 0)
const last = rows[rows.length - 1] ?? {}
const txAvg = parseRateToMbps(last["tx-total-average"] ?? last["tx-10-second-average"])
const rxAvg = parseRateToMbps(last["rx-total-average"] ?? last["rx-10-second-average"])
const txFinal = txAvg || (txSeries.length ? txSeries.reduce((a, b) => a + b, 0) / txSeries.length : 0)
const rxFinal = rxAvg || (rxSeries.length ? rxSeries.reduce((a, b) => a + b, 0) / rxSeries.length : 0)
await new Promise<void>((resolve) => setTimeout(resolve, 3000))
const pingNow = new Date().toISOString()
let afterBtPing: {
rttMs: number | null
lossPct: number | null
error: string | null
} = { rttMs: null, lossPct: null, error: null }
try {
const pingRows = await srcClient.ping(initialAddress, 4, srcInterface || undefined, { interval: "1s" })
const pr = parsePing(pingRows as Parameters<typeof parsePing>[0])
afterBtPing = {
rttMs: pr.avgRtt,
lossPct: pr.loss,
error: null,
}
} catch (pe) {
afterBtPing = {
rttMs: null,
lossPct: null,
error: pe instanceof Error ? pe.message : String(pe),
}
}
const probeId = String(body.probeId ?? "").trim()
const runId = String(body.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
if (probeId) {
db.update(uptimeSpeedProbes).set({
lastRunAt: pingNow,
lastTxAvgMbps: txFinal,
lastRxAvgMbps: rxFinal,
lastStatus: "done",
lastError: "",
lastPingRttMs: afterBtPing.rttMs,
lastPingLossPct: afterBtPing.lossPct,
lastPingAt: pingNow,
lastPingError: afterBtPing.error ?? "",
updatedAt: pingNow,
}).where(eq(uptimeSpeedProbes.id, probeId)).run()
}
db.insert(uptimeSpeedTestRuns).values({
id: runId,
probeId: probeId || null,
srcServerId: srcId,
dstServerId: dstId,
srcInterface: srcInterface || "",
dstInterface: dstInterface || "",
srcAddress: srcAddress || src.host,
dstAddress: initialAddress,
srcInterfaceAddress,
dstInterfaceAddress,
protocol,
direction,
durationSec,
txAvgMbps: txFinal,
rxAvgMbps: rxFinal,
pingRttMs: afterBtPing.rttMs,
pingLossPct: afterBtPing.lossPct,
pingError: afterBtPing.error ?? null,
status: "done",
error: null,
createdAt: pingNow,
}).run()
return reply.send({
ok: true,
result: {
runId,
srcInterface: srcInterface || null,
dstInterface: dstInterface || null,
address: initialAddress,
srcAddress: srcAddress || src.host,
dstAddress: initialAddress,
srcInterfaceAddress,
dstInterfaceAddress,
protocol,
direction,
durationSec,
txAvgMbps: txFinal,
rxAvgMbps: rxFinal,
txSeries,
rxSeries,
raw: rows,
afterBtPing,
},
})
})
} catch (e) {
const raw = e instanceof Error ? e.message : "Bandwidth test failed"
const message = /timed out|socket hang up|ECONNRESET/i.test(raw)
? "BTTest timeout/socket hang up: проверь доступность destination, /tool bandwidth-server, firewall и корректность интерфейсов"
: raw
const probeId = String((req.body as { probeId?: string }).probeId ?? "").trim()
if (probeId) {
db.update(uptimeSpeedProbes).set({
lastRunAt: new Date().toISOString(),
lastStatus: "error",
lastError: message,
updatedAt: new Date().toISOString(),
}).where(eq(uptimeSpeedProbes.id, probeId)).run()
}
const body = req.body as {
runId?: string
srcServerId?: string | number
dstServerId?: string | number
srcInterface?: string
dstInterface?: string
protocol?: "tcp" | "udp"
direction?: "transmit" | "receive" | "both"
durationSec?: string | number
}
const srcId = Number.parseInt(String(body.srcServerId ?? ""), 10)
const dstId = Number.parseInt(String(body.dstServerId ?? ""), 10)
if (Number.isFinite(srcId) && Number.isFinite(dstId)) {
db.insert(uptimeSpeedTestRuns).values({
id: String(body.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
probeId: probeId || null,
srcServerId: srcId,
dstServerId: dstId,
srcInterface: String(body.srcInterface ?? "").trim(),
dstInterface: String(body.dstInterface ?? "").trim(),
srcAddress: null,
dstAddress: null,
srcInterfaceAddress: null,
dstInterfaceAddress: null,
protocol: body.protocol === "udp" ? "udp" : "tcp",
direction: body.direction === "transmit" || body.direction === "receive" ? body.direction : "both",
durationSec: Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10),
txAvgMbps: null,
rxAvgMbps: null,
pingRttMs: null,
pingLossPct: null,
pingError: null,
status: "error",
error: message,
createdAt: new Date().toISOString(),
}).run()
}
return reply.status(502).send({ error: message })
}
})
@@ -246,6 +568,13 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
const name = String(i.name ?? "").trim()
const addresses = ips
.filter((a) => String(a.interface ?? "") === name)
.filter((a) => String(a.disabled ?? "false").toLowerCase() !== "true")
.filter((a) => String(a.invalid ?? "false").toLowerCase() !== "true")
.sort((a, b) => {
const ad = String(a.dynamic ?? "false").toLowerCase() === "true" ? 1 : 0
const bd = String(b.dynamic ?? "false").toLowerCase() === "true" ? 1 : 0
return ad - bd
})
.map((a) => String(a.address ?? "").split("/")[0])
.filter((x) => x.length > 0)
return {
@@ -256,6 +585,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
}
})
.filter((i) => i.name.length > 0)
.filter((i) => i.running && !i.disabled)
return reply.send({ interfaces })
} catch (e) {
const message = e instanceof Error ? e.message : "Failed to fetch speed-test interfaces"
@@ -288,6 +618,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
status: (last?.status ?? "down") as "up" | "warn" | "down",
series,
enabled: p.enabled,
showOnDashboard: p.showOnDashboard,
}
})