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 [count=N] — пинг", ].join("\n") // ── command dispatcher ───────────────────────────────────────────────────────── async function dispatch(client: MikrotikClient, raw: string): Promise { // 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
[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