298 lines
13 KiB
TypeScript
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
|