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

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

338 lines
15 KiB
TypeScript

import { eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { z } from "zod"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
import type {
RosIpAddress, RosInterface, RosResource, RosIdentity, RosIpRoute,
RosFirewallFilter, RosLogEntry, RosBgpSession, RosPingResult,
RosOspfNeighbor, RosOspfArea, RosOspfInstance, RosOspfInterfaceTemplate,
RosBfdSession,
} from "../types/server.js"
// ── string helpers ─────────────────────────────────────────────────────────────
function p(s: string | undefined, len: number): string {
return (s ?? "").padEnd(len)
}
function flag(val: string | undefined, char: string): string {
return val === "true" ? char : " "
}
// ── formatters ─────────────────────────────────────────────────────────────────
function fmtIpAddresses(addrs: RosIpAddress[]): string {
const hdr = [
"Flags: X - disabled, I - invalid, D - dynamic",
" # ADDRESS NETWORK INTERFACE",
]
const rows = addrs.map((a, i) => {
const f = `${flag(a.disabled,"X")}${flag(a.invalid,"I")}${flag(a.dynamic,"D")}`
return ` ${String(i).padStart(2)} ${f} ${p(a.address,18)} ${p(a.network,15)} ${a.interface}${a.comment ? ` ; ${a.comment}` : ""}`
})
return [...hdr, ...rows].join("\n")
}
function fmtInterfaces(ifaces: RosInterface[]): string {
const hdr = [
"Flags: X - disabled, D - dynamic, R - running",
" # NAME TYPE MTU MAC-ADDRESS",
]
const rows = ifaces.map((f, i) => {
const flags = `${flag(f.disabled,"X")} ${flag(f.running,"R")}`
const name = p(f.name, 20)
const type = p(f.type, 12)
const mtu = p(f["actual-mtu"] ?? f.mtu, 6)
const mac = f["mac-address"] ?? ""
return ` ${String(i).padStart(2)} ${flags} ${name} ${type} ${mtu} ${mac}${f.comment ? ` ; ${f.comment}` : ""}`
})
return [...hdr, ...rows].join("\n")
}
function fmtResource(r: RosResource, id: RosIdentity): string {
const kv = (k: string, v: string) => `${k.padStart(30)}: ${v}`
const mib = (b: string | undefined) => b ? `${(parseInt(b) / 1024 / 1024).toFixed(1)} MiB` : "?"
return [
kv("uptime", r["uptime"] ?? "?"),
kv("version", r["version"] ?? "?"),
kv("build-time", r["build-time"] ?? "?"),
kv("free-memory", mib(r["free-memory"])),
kv("total-memory", mib(r["total-memory"])),
kv("cpu", r["cpu"] ?? "?"),
kv("cpu-count", r["cpu-count"] ?? "?"),
kv("cpu-frequency", r["cpu-frequency"] ? `${r["cpu-frequency"]} MHz` : "?"),
kv("cpu-load", r["cpu-load"] ? `${r["cpu-load"]}%` : "?"),
kv("free-hdd-space", mib(r["free-hdd-space"])),
kv("total-hdd-space", mib(r["total-hdd-space"])),
kv("architecture-name",r["architecture-name"] ?? "?"),
kv("board-name", r["board-name"] ?? "?"),
kv("platform", r["platform"] ?? "MikroTik"),
kv("identity", id.name),
].join("\n")
}
function fmtIpRoutes(routes: RosIpRoute[]): string {
const hdr = [
"Flags: X - disabled, A - active, D - dynamic, C - connect, S - static,",
" r - rip, b - bgp, o - ospf, B - blackhole, U - unreachable, P - prohibit",
" # DST-ADDRESS PREF-SRC GATEWAY DIST",
]
const rows = routes.map((r, i) => {
const f = [
flag(r.active, "A"),
flag(r.dynamic, "D"),
flag(r.connect, "C"),
flag(r.static, "S"),
flag(r.bgp, "b"),
flag(r.ospf, "o"),
flag(r.blackhole, "B"),
flag(r.unreachable, "U"),
flag(r.prohibit, "P"),
].join("")
const dst = p(r["dst-address"] ?? "", 18)
const pref = p(r["pref-src"] ?? "", 15)
const gw = p(r["gateway"] ?? r["interface"] ?? "", 16)
const dist = r["distance"] ?? "?"
return ` ${String(i).padStart(2)} ${f} ${dst} ${pref} ${gw} ${dist}`
})
return [...hdr, ...rows].join("\n")
}
function fmtBgpSessions(sessions: RosBgpSession[]): string {
const hdr = [
"Flags: E - established",
" # NAME REMOTE-AS REMOTE-ADDRESS STATE UPTIME",
]
const rows = sessions.map((s, i) => {
const est = s["established"] === "true" || s["state"] === "established"
const fl = est ? "E" : " "
const name = p(s["name"] ?? s[".id"], 23)
const remAs = p(s["remote.as"], 10)
const remIp = p((s["remote.address"] ?? "").split("/")[0], 22)
const state = est ? "established" : (s["state"] ?? "active")
const up = s["uptime"] ?? "—"
return ` ${String(i).padStart(2)} ${fl} ${name} ${remAs} ${remIp} ${p(state, 12)} ${up}`
})
return [...hdr, ...rows].join("\n")
}
function fmtOspfNeighbors(neighbors: RosOspfNeighbor[]): string {
const hdr = [
"Flags: V - virtual",
" # ROUTER-ID STATE CHANGES ADJACENCY INTERFACE",
]
const rows = neighbors.map((n, i) => {
return ` ${String(i).padStart(2)} ${p(n["router-id"],16)} ${p(n["state"],8)} ${p(n["state-changes"] ?? "0",8)} ${p(n["adjacency"] ?? "—",15)} ${n["interface"] ?? ""}`
})
return [...hdr, ...rows].join("\n")
}
function fmtOspfAreas(areas: RosOspfArea[]): string {
const hdr = [" # NAME AREA-ID TYPE INSTANCE"]
const rows = areas.map((a, i) =>
` ${String(i).padStart(2)} ${p(a.name,20)} ${p(a["area-id"] ?? "0.0.0.0",15)} ${p(a.type ?? "default",12)} ${a.instance}`
)
return [...hdr, ...rows].join("\n")
}
function fmtOspfInstances(insts: RosOspfInstance[]): string {
const hdr = [" # NAME ROUTER-ID VER DISTRIBUTE"]
const rows = insts.map((inst, i) =>
` ${String(i).padStart(2)} ${p(inst.name,15)} ${p(inst["router-id"],18)} ${p(inst.version ?? "2",4)} ${inst["redistribute"] ?? "—"}`
)
return [...hdr, ...rows].join("\n")
}
function fmtOspfIfaceTemplates(templates: RosOspfInterfaceTemplate[]): string {
const hdr = [" # INTERFACES AREA COST TYPE BFD"]
const rows = templates.map((t, i) =>
` ${String(i).padStart(2)} ${p(t.interfaces ?? "—",21)} ${p(t.area,19)} ${p(t.cost ?? "10",5)} ${p(t.type ?? "broadcast",10)} ${t["use-bfd"] === "true" ? "yes" : "no"}`
)
return [...hdr, ...rows].join("\n")
}
function fmtBfdSessions(sessions: RosBfdSession[]): string {
const hdr = [" # LOCAL-ADDRESS REMOTE-ADDRESS STATE UPTIME"]
const rows = sessions.map((s, i) =>
` ${String(i).padStart(2)} ${p(s["local-address"],27)} ${p(s["remote-address"],27)} ${p(s["state"] ?? "down",8)} ${s["uptime"] ?? "—"}`
)
return [...hdr, ...rows].join("\n")
}
function fmtFirewallFilter(rules: RosFirewallFilter[]): string {
const lines: string[] = ["Flags: X - disabled, I - invalid, D - dynamic"]
rules.forEach((r, i) => {
const f = `${flag(r.disabled,"X")}${flag(r.invalid,"I")}${flag(r.dynamic,"D")}`
lines.push(``)
lines.push(` ${String(i).padStart(2)} ${f}`)
if (r.comment) lines.push(` ;;; ${r.comment}`)
const parts = [`chain=${r.chain}`, `action=${r.action}`]
if (r.protocol) parts.push(`protocol=${r.protocol}`)
if (r["connection-state"])parts.push(`connection-state=${r["connection-state"]}`)
if (r["src-address"]) parts.push(`src-address=${r["src-address"]}`)
if (r["dst-address"]) parts.push(`dst-address=${r["dst-address"]}`)
if (r["src-address-list"])parts.push(`src-address-list=${r["src-address-list"]}`)
if (r["dst-address-list"])parts.push(`dst-address-list=${r["dst-address-list"]}`)
if (r["src-port"]) parts.push(`src-port=${r["src-port"]}`)
if (r["dst-port"]) parts.push(`dst-port=${r["dst-port"]}`)
if (r["in-interface"]) parts.push(`in-interface=${r["in-interface"]}`)
if (r["out-interface"]) parts.push(`out-interface=${r["out-interface"]}`)
if (r["tls-host"]) parts.push(`tls-host=${r["tls-host"]}`)
lines.push(` ${parts.join(" ")}`)
if (r.packets || r.bytes) lines.push(` packets=${r.packets ?? "0"} bytes=${r.bytes ?? "0"}`)
})
return lines.join("\n")
}
function fmtLogs(entries: RosLogEntry[]): string {
if (entries.length === 0) return "(no log entries)"
return entries.map(e =>
`${(e.time ?? "").padEnd(20)} ${(e.topics ?? "").padEnd(28)} ${e.message ?? ""}`
).join("\n")
}
function fmtPing(results: RosPingResult[], host: string): string {
const lines = [`PING ${host}`]
// individual replies
for (const r of results) {
if (!r.time && !r.ttl) continue // skip summary-only items
if (r.status === "timeout") {
lines.push(` seq=${r.seq} timeout`)
} else {
lines.push(` seq=${r.seq} ttl=${r.ttl ?? "?"} time=${r.time ?? "?"}`)
}
}
// summary — last item with sent/received fields
const sum = [...results].reverse().find(r => r.sent)
if (sum) {
const loss = sum["packet-loss"] ?? "0%"
lines.push(` sent=${sum.sent} received=${sum.received} packet-loss=${loss}`)
if (sum.time) lines.push(` avg-rtt=${sum.time}`)
}
return lines.join("\n")
}
// ── help text ──────────────────────────────────────────────────────────────────
const HELP_TEXT = [
"Доступные команды (RouterOS REST API):",
" ip address print — IP-адреса",
" ip route print — таблица маршрутов",
" interface print — интерфейсы",
" system resource print — ресурсы системы",
" system identity print — имя роутера",
" ip firewall filter print — правила firewall",
" routing bgp session print — BGP-сессии",
" routing ospf neighbor print — OSPF-соседи",
" routing ospf area print — OSPF-зоны",
" routing ospf instance print — OSPF-инстансы",
" routing ospf interface print — OSPF-интерфейсы",
" routing bfd session print — BFD-сессии",
" log print [limit=N] — системный лог",
" ping <host> [count=N] — пинг",
].join("\n")
// ── command dispatcher ─────────────────────────────────────────────────────────
async function dispatch(client: MikrotikClient, raw: string): Promise<string> {
// strip leading slashes, normalize whitespace
const cmd = raw.trim().replace(/^\/+/, "")
const lower = cmd.toLowerCase().replace(/\s+/g, " ")
if (lower === "?" || lower === "help") return HELP_TEXT
if (lower === "ip address print")
return fmtIpAddresses(await client.getIpAddresses())
if (lower === "interface print")
return fmtInterfaces(await client.getInterfaces())
if (lower === "system resource print") {
const [res, id] = await Promise.all([client.getResource(), client.getIdentity()])
return fmtResource(res, id)
}
if (lower === "system identity print")
return ` name: ${(await client.getIdentity()).name}`
if (lower === "ip route print")
return fmtIpRoutes(await client.getIpRoutes())
if (lower === "ip firewall filter print")
return fmtFirewallFilter(await client.getFirewallFilters())
if (lower === "routing bgp session print" || lower === "bgp session print" || lower === "bgp peer print")
return fmtBgpSessions(await client.getBgpSessions())
if (lower === "routing ospf neighbor print" || lower === "ospf neighbor print")
return fmtOspfNeighbors(await client.getOspfNeighbors())
if (lower === "routing ospf area print" || lower === "ospf area print")
return fmtOspfAreas(await client.getOspfAreas())
if (lower === "routing ospf instance print" || lower === "ospf instance print")
return fmtOspfInstances(await client.getOspfInstances())
if (lower === "routing ospf interface print" || lower === "routing ospf interface-template print")
return fmtOspfIfaceTemplates(await client.getOspfInterfaceTemplates())
if (lower === "routing bfd session print" || lower === "bfd session print")
return fmtBfdSessions(await client.getBfdSessions().catch(() => []))
if (lower.startsWith("log print")) {
const limitArg = lower.match(/limit=(\d+)/)
const limit = limitArg ? parseInt(limitArg[1]) : 50
return fmtLogs(await client.getLogs(Math.min(limit, 200)))
}
if (lower.startsWith("ping")) {
const parts = cmd.split(/\s+/)
const host = parts[1]
if (!host) return "Usage: ping <address> [count=N]"
const countArg = parts.find(p => p.toLowerCase().startsWith("count="))
const count = countArg ? parseInt(countArg.split("=")[1]) : 4
const results = await client.ping(host, Math.min(count, 20))
return fmtPing(results, host)
}
const firstToken = cmd.split(" ")[0]
return `bad command name ${firstToken} (line 1 column 1)`
}
// ── route plugin ───────────────────────────────────────────────────────────────
const ExecBodySchema = z.object({
command: z.string().min(1).max(1024),
})
const execRoutes: FastifyPluginAsyncZod = async (app) => {
// POST /api/servers/:id/exec — execute a RouterOS CLI-style command via REST API
app.post(
"/servers/:id/exec",
{ schema: { params: ServerIdParamSchema, body: ExecBodySchema } },
async (req, reply) => {
const params = req.params as ServerIdParams
const server = (await db
.select().from(servers)
.where(eq(servers.id, params.id))
.limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const client = MikrotikClient.fromServer(server)
try {
const output = await dispatch(client, req.body.command)
return reply.send({ output })
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
// Return as output (not HTTP error) so the terminal can display it
return reply.send({ output: `error: ${msg}` })
}
}
)
}
export default execRoutes