import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { ServerCreateSchema, ServerUpdateSchema, ServerIdParamSchema, SnapshotsQuerySchema, TestConnectionSchema, type ServerCreateRequest, type ServerIdParams, type ServerUpdateRequest, type SnapshotsQuery, type TestConnectionRequest, } from "../types/server.js" import { pollServer } from "../services/poller.js" import { MikrotikClient, MikrotikError } from "../services/mikrotik.js" import { resolveRosSrcIpv4 } from "../utils/ros-src-address.js" import { createServer, deleteServer, getServerReadById, listServerSnapshots, listServersRead, updateServer, } from "../modules/servers/service/servers-service.js" import { getServerRowById } from "../modules/servers/repository/servers-repository.js" import {serverReadForUser} from "../modules/servers/mapper/servers-mapper.js" import { readInternetPathRows } from "../services/internet-path-snapshot.js" // ── plugin ───────────────────────────────────────────────────────────────────── const serversRoutes: FastifyPluginAsyncZod = async (app) => { // POST /api/servers/test-connection — check credentials before saving app.post("/test-connection", { schema: { body: TestConnectionSchema } }, async (req, reply) => { const { host, port, useSsl, verifySsl, apiPath, username, password } = req.body as TestConnectionRequest const client = new MikrotikClient({ host, port, useSsl, verifySsl, apiPath, username, password }) try { const t0 = performance.now() const [identity, resource] = await Promise.all([ client.getIdentity(), client.getResource(), ]) const latencyMs = Math.round(performance.now() - t0) return reply.send({ success: true, latencyMs, identity: identity.name, version: resource["version"], boardName: resource["board-name"], uptime: resource["uptime"], message: `Подключено · RouterOS ${resource["version"]} · ${resource["board-name"]}`, }) } catch (err) { let message = "Не удалось подключиться" if (err instanceof MikrotikError) { if (err.statusCode === 401) message = "Неверный логин или пароль (401 Unauthorized)" else if (err.statusCode === 403) message = "Нет прав доступа к REST API (403 Forbidden)" else message = `Ошибка RouterOS API: ${err.statusCode}` } else if (err instanceof Error) { const msg = err.message.toLowerCase() if (msg.includes("timeout") || msg.includes("abort")) message = `Нет ответа от ${host}:${port} — проверьте IP и что сервис www/www-ssl включён` else if (msg.includes("econnrefused")) message = `Соединение отклонено — порт ${port} закрыт` else if (msg.includes("cert") || msg.includes("ssl") || msg.includes("tls")) message = "Ошибка SSL-сертификата — отключите «Проверять SSL» для self-signed" else if (msg.includes("enotfound") || msg.includes("getaddrinfo")) message = `Хост не найден: ${host}` else message = err.message } return reply.status(502).send({ success: false, message }) } }) // GET /api/servers app.get("/", async (req, reply) => { return reply.send((await listServersRead()).map(server=>serverReadForUser(server,req.authUser))) }) // 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 params = req.params as ServerIdParams const server = await getServerRowById(params.id) if (!server) return reply.status(404).send({ error: "Server not found" }) const ipv4 = await resolveRosSrcIpv4(server.host) return reply.send({ host: server.host, ipv4 }) }) // GET /api/servers/:id/wan-runtime — DHCP lease + active default route for HomeRouter WAN uplinks app.get("/:id/wan-runtime", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { const params = req.params as ServerIdParams const server = await getServerReadById(params.id) if (!server) return reply.status(404).send({ error: "Server not found" }) if (server.enabled === false) return reply.status(409).send({ error: "Сервер выключен. Опрос WAN не выполнялся" }) const toIp = (raw: string | null | undefined): string | null => { const v = String(raw ?? "").trim() if (!v) return null return v.split("/")[0]?.trim() ?? null } const 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) } const maskFromLen = (len: number): number => { if (len <= 0) return 0 if (len >= 32) return 0xffffffff return (~((1 << (32 - len)) - 1)) >>> 0 } const 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 } } const routeHasIp = (routeDst: string, ip: string): boolean => { const ipu = ipv4ToUint(ip) const cidr = parseDstRoute(routeDst) if (ipu == null || !cidr) return false const mask = maskFromLen(cidr.maskBits) return (ipu & mask) === (cidr.net & mask) } const gatewayIface = (gw: string | null | undefined): string | null => { const v = String(gw ?? "").trim() if (!v) return null const idx = v.indexOf("%") if (idx < 0) return null return v.slice(idx + 1).trim() || null } try { const row = await getServerRowById(params.id) if (!row) return reply.status(404).send({ error: "Server not found" }) if (row.enabled === false) return reply.status(409).send({ error: "Сервер выключен. Опрос WAN не выполнялся" }) const client = MikrotikClient.fromServer(row) const isTrue = (v: unknown) => { const s = String(v ?? "").trim().toLowerCase() return s === "true" || s === "yes" } const [dhcpRaw, ipAddrs, routes] = await Promise.all([ client.get("/ip/dhcp-client").then(raw => readInternetPathRows(raw, "WAN DHCP", ["interface"])), client.get("/ip/address").then(raw => readInternetPathRows(raw, "WAN addresses", ["address", "interface"])), client.get("/ip/route").then(raw => readInternetPathRows(raw, "WAN routes", ["dst-address"])), ]) const defaultRoute = routes .filter((r) => String(r["dst-address"] ?? "").trim() === "0.0.0.0/0") // Только реально ACTIVE default routes. .filter((r) => isTrue((r as unknown as Record).active)) // Эквивалент CLI: routing-table=main .filter((r) => { const rt = String((r as unknown as Record)["routing-table"] ?? "").trim().toLowerCase() return rt === "" || rt === "main" }) .filter((r) => String((r as unknown as Record).disabled ?? "false").toLowerCase() !== "true") .filter((r) => String((r as unknown as Record).inactive ?? "false").toLowerCase() !== "true") .sort((a, b) => Number(a.distance ?? 255) - Number(b.distance ?? 255))[0] const defaultGateway = String(defaultRoute?.gateway ?? "").trim() || null const immediateGw = String((defaultRoute as unknown as Record | undefined)?.["immediate-gw"] ?? "").trim() || null const gwFromImmediate = immediateGw ? immediateGw.split("%")[0]?.trim() ?? null : null const gwIp = toIp(defaultGateway) ?? gwFromImmediate const dhcpIfaceByGateway = gwIp == null ? null : ( dhcpRaw.find((d) => { const status = String(d.status ?? "").trim().toLowerCase() if (status && status !== "bound") return false return toIp(d.gateway) === gwIp }) ) const directIfaceByGwSubnet = gwIp == null ? null : ( routes .filter((r) => String((r as unknown as Record).active ?? "").toLowerCase() === "true") .filter((r) => String((r as unknown as Record)["dst-address"] ?? "").trim() !== "0.0.0.0/0") .filter((r) => String((r as unknown as Record).disabled ?? "false").toLowerCase() !== "true") .filter((r) => String((r as unknown as Record).inactive ?? "false").toLowerCase() !== "true") .find((r) => routeHasIp(String((r as unknown as Record)["dst-address"] ?? ""), gwIp)) ) const defaultInterface = gatewayIface(immediateGw) || gatewayIface(defaultGateway) || String((dhcpIfaceByGateway as unknown as Record | undefined)?.interface ?? "").trim() || String(defaultRoute?.interface ?? "").trim() || String((directIfaceByGwSubnet as unknown as Record | undefined)?.interface ?? "").trim() || null const uplinks = (server.wanUplinks ?? []).map((w) => { const iface = String(w.iface ?? "").trim() const dhcp = dhcpRaw.find((d) => String(d.interface ?? "").trim() === iface) const fromDhcp = toIp(dhcp?.address) const fromIpAddr = toIp(ipAddrs.find((a) => String(a.interface ?? "").trim() === iface)?.address) const leasedIp = fromDhcp ?? fromIpAddr return { id: w.id, iface, name: w.name, isp: w.isp, configuredIp: w.ip, leasedIp, dhcpStatus: String(dhcp?.status ?? "").trim() || null, isDefault: defaultInterface != null && defaultInterface === iface, } }) return reply.send({ defaultGateway, immediateGateway: immediateGw, defaultInterface, uplinks, }) } catch (err) { return reply.status(502).send({ error: err instanceof Error ? `WAN: ${err.message}` : "Не удалось прочитать состояние WAN" }) } }) // POST /api/servers app.post("/", { schema: { body: ServerCreateSchema } }, async (req, reply) => { return reply.status(201).send(await createServer(req.body as ServerCreateRequest)) }) // GET /api/servers/:id app.get("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { const params = req.params as ServerIdParams const server = await getServerReadById(params.id) if (!server) return reply.status(404).send({ error: "Server not found" }) return reply.send(serverReadForUser(server,req.authUser)) }) // PUT /api/servers/:id app.put( "/:id", { schema: { params: ServerIdParamSchema, body: ServerUpdateSchema } }, async (req, reply) => { const params = req.params as ServerIdParams const existing = await getServerReadById(params.id) if (!existing) return reply.status(404).send({ error: "Server not found" }) return reply.send(await updateServer(params.id, req.body as ServerUpdateRequest)) }, ) // DELETE /api/servers/:id app.delete("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { const params = req.params as ServerIdParams const existing = await getServerReadById(params.id) if (!existing) return reply.status(404).send({ error: "Server not found" }) await deleteServer(params.id) return reply.status(204).send() }) // POST /api/servers/:id/poll app.post("/:id/poll", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { const params = req.params as ServerIdParams const existing = await getServerReadById(params.id) if (!existing) return reply.status(404).send({ error: "Server not found" }) try { const snap = await pollServer(params.id) return reply.send(snap) } catch (err) { return reply.status(500).send({ error: (err as Error).message }) } }) // GET /api/servers/:id/snapshots app.get( "/:id/snapshots", { schema: { params: ServerIdParamSchema, querystring: SnapshotsQuerySchema } }, async (req, reply) => { const params = req.params as ServerIdParams const query = req.query as SnapshotsQuery const existing = await getServerReadById(params.id) if (!existing) return reply.status(404).send({ error: "Server not found" }) return reply.send(await listServerSnapshots(params.id, query.limit)) }, ) } export default serversRoutes