Files
MikrotikManager/backend/src/routes/servers.ts
T

298 lines
13 KiB
TypeScript

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<unknown>("/ip/dhcp-client").then(raw => readInternetPathRows(raw, "WAN DHCP", ["interface"])),
client.get<unknown>("/ip/address").then(raw => readInternetPathRows(raw, "WAN addresses", ["address", "interface"])),
client.get<unknown>("/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<string, unknown>).active))
// Эквивалент CLI: routing-table=main
.filter((r) => {
const rt = String((r as unknown as Record<string, unknown>)["routing-table"] ?? "").trim().toLowerCase()
return rt === "" || rt === "main"
})
.filter((r) => String((r as unknown as Record<string, unknown>).disabled ?? "false").toLowerCase() !== "true")
.filter((r) => String((r as unknown as Record<string, unknown>).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<string, unknown> | 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<string, unknown>).active ?? "").toLowerCase() === "true")
.filter((r) => String((r as unknown as Record<string, unknown>)["dst-address"] ?? "").trim() !== "0.0.0.0/0")
.filter((r) => String((r as unknown as Record<string, unknown>).disabled ?? "false").toLowerCase() !== "true")
.filter((r) => String((r as unknown as Record<string, unknown>).inactive ?? "false").toLowerCase() !== "true")
.find((r) => routeHasIp(String((r as unknown as Record<string, unknown>)["dst-address"] ?? ""), gwIp))
)
const defaultInterface =
gatewayIface(immediateGw)
|| gatewayIface(defaultGateway)
|| String((dhcpIfaceByGateway as unknown as Record<string, unknown> | undefined)?.interface ?? "").trim()
|| String(defaultRoute?.interface ?? "").trim()
|| String((directIfaceByGwSubnet as unknown as Record<string, unknown> | 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