Init commit

This commit is contained in:
Denozordec
2026-05-02 01:17:08 +07:00
commit f3f831653f
104 changed files with 43827 additions and 0 deletions
+304
View File
@@ -0,0 +1,304 @@
import http from "node:http"
import https from "node:https"
import type { Server } from "../db/schema.js"
import type {
RosIdentity, RosInterface, RosIpAddress, RosResource,
RosBgpSession,
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
RosBfdSession,
RosIpRoute, RosFirewallFilter, RosLogEntry, RosPingResult,
} from "../types/server.js"
// ── connection params ─────────────────────────────────────────────────────────
export interface MikrotikConnectParams {
host: string
port: number
useSsl: boolean
verifySsl: boolean
username: string
password: string
apiPath?: string // defaults to "/rest"
}
// ── low-level HTTP helpers ────────────────────────────────────────────────────
function rosRequest(
params: MikrotikConnectParams,
path: string,
timeoutMs: number,
): Promise<unknown> {
return new Promise((resolve, reject) => {
const basePath = params.apiPath ?? "/rest"
const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64")
const options: https.RequestOptions = {
hostname: params.host,
port: params.port,
path: basePath + path,
method: "GET",
headers: { Authorization: authHeader, "Content-Type": "application/json" },
rejectUnauthorized: params.useSsl ? params.verifySsl : undefined,
}
const lib = params.useSsl ? https : http
const timer = setTimeout(() => {
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
}, timeoutMs)
const req = lib.request(options, (res) => {
let body = ""
res.setEncoding("utf8")
res.on("data", (chunk: string) => { body += chunk })
res.on("end", () => {
clearTimeout(timer)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, body))
return
}
try {
resolve(JSON.parse(body))
} catch {
reject(new Error(`Invalid JSON from RouterOS: ${body.slice(0, 200)}`))
}
})
})
req.on("error", (err) => {
clearTimeout(timer)
reject(err)
})
req.end()
})
}
function rosPost(
params: MikrotikConnectParams,
path: string,
body: Record<string, string>,
timeoutMs: number,
): Promise<unknown> {
return new Promise((resolve, reject) => {
const basePath = params.apiPath ?? "/rest"
const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64")
const payload = JSON.stringify(body)
const options: https.RequestOptions = {
hostname: params.host,
port: params.port,
path: basePath + path,
method: "POST",
headers: {
Authorization: authHeader,
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
rejectUnauthorized: params.useSsl ? params.verifySsl : undefined,
}
const lib = params.useSsl ? https : http
const timer = setTimeout(() => {
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
}, timeoutMs)
const req = lib.request(options, (res) => {
let buf = ""
res.setEncoding("utf8")
res.on("data", (chunk: string) => { buf += chunk })
res.on("end", () => {
clearTimeout(timer)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, buf)); return
}
try { resolve(JSON.parse(buf)) } catch {
reject(new Error(`Invalid JSON from RouterOS: ${buf.slice(0, 200)}`))
}
})
})
req.on("error", (err) => { clearTimeout(timer); reject(err) })
req.write(payload)
req.end()
})
}
function rosDelete(
params: MikrotikConnectParams,
path: string,
timeoutMs: number,
): Promise<void> {
return new Promise((resolve, reject) => {
const basePath = params.apiPath ?? "/rest"
const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64")
const options: https.RequestOptions = {
hostname: params.host,
port: params.port,
path: basePath + path,
method: "DELETE",
headers: { Authorization: authHeader, "Content-Type": "application/json" },
rejectUnauthorized: params.useSsl ? params.verifySsl : undefined,
}
const lib = params.useSsl ? https : http
const timer = setTimeout(() => {
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
}, timeoutMs)
const req = lib.request(options, (res) => {
let body = ""
res.setEncoding("utf8")
res.on("data", (chunk: string) => { body += chunk })
res.on("end", () => {
clearTimeout(timer)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, body)); return
}
resolve()
})
})
req.on("error", (err) => {
clearTimeout(timer)
reject(err)
})
req.end()
})
}
// ── MikrotikClient ─────────────────────────────────────────────────────────────
export class MikrotikClient {
constructor(private readonly params: MikrotikConnectParams) {}
/** Convenience factory from a DB Server row */
static fromServer(server: Server): MikrotikClient {
return new MikrotikClient({
host: server.host,
port: server.port,
useSsl: server.useSsl,
verifySsl: server.verifySsl,
username: server.username,
password: server.password,
})
}
async get<T>(path: string, timeoutMs = 10_000): Promise<T> {
return rosRequest(this.params, path, timeoutMs) as Promise<T>
}
async post<T>(path: string, body: Record<string, string>, timeoutMs = 15_000): Promise<T> {
return rosPost(this.params, path, body, timeoutMs) as Promise<T>
}
async delete(path: string, timeoutMs = 10_000): Promise<void> {
return rosDelete(this.params, path, timeoutMs)
}
// ── typed helpers ──────────────────────────────────────────────────────────
async getIdentity(): Promise<RosIdentity> {
return this.get<RosIdentity>("/system/identity")
}
async getResource(): Promise<RosResource> {
return this.get<RosResource>("/system/resource")
}
async getInterfaces(): Promise<RosInterface[]> {
return this.get<RosInterface[]>("/interface")
}
async getIpAddresses(): Promise<RosIpAddress[]> {
return this.get<RosIpAddress[]>("/ip/address")
}
async getBgpSessions(): Promise<RosBgpSession[]> {
return this.get<RosBgpSession[]>("/routing/bgp/session")
}
/** Returns the raw (un-typed) BGP session objects — used for debugging */
async getBgpSessionsRaw(): Promise<unknown[]> {
return this.get<unknown[]>("/routing/bgp/session")
}
// ── OSPF ──────────────────────────────────────────────────────────────────
async getOspfNeighbors(): Promise<RosOspfNeighbor[]> {
return this.get<RosOspfNeighbor[]>("/routing/ospf/neighbor")
}
async getOspfAreas(): Promise<RosOspfArea[]> {
return this.get<RosOspfArea[]>("/routing/ospf/area")
}
async getOspfInterfaceTemplates(): Promise<RosOspfInterfaceTemplate[]> {
return this.get<RosOspfInterfaceTemplate[]>("/routing/ospf/interface-template")
}
async getOspfInstances(): Promise<RosOspfInstance[]> {
return this.get<RosOspfInstance[]>("/routing/ospf/instance")
}
async getBfdSessions(): Promise<RosBfdSession[]> {
return this.get<RosBfdSession[]>("/routing/bfd/session")
}
// ── extra endpoints for exec route ────────────────────────────────────────
async getIpRoutes(): Promise<RosIpRoute[]> {
return this.get<RosIpRoute[]>("/ip/route")
}
async getFirewallFilters(): Promise<RosFirewallFilter[]> {
return this.get<RosFirewallFilter[]>("/ip/firewall/filter")
}
async getLogs(limit = 50): Promise<RosLogEntry[]> {
return this.get<RosLogEntry[]>(`/log?limit=${limit}`)
}
async ping(address: string, count = 4, interfaceName?: string): Promise<RosPingResult[]> {
const body: Record<string, string> = {
address,
count: String(count),
interval: "0.2s",
}
if (interfaceName && interfaceName.trim()) body.interface = interfaceName.trim()
return this.post<RosPingResult[]>("/tool/ping", body, 20_000)
}
async bandwidthTest(params: {
address: string
user: string
password: string
protocol?: "tcp" | "udp"
direction?: "transmit" | "receive" | "both"
durationSec?: number
}): Promise<Array<Record<string, string>>> {
const body: Record<string, string> = {
address: params.address,
user: params.user,
password: params.password,
protocol: params.protocol ?? "tcp",
direction: params.direction ?? "both",
duration: `${Math.max(3, params.durationSec ?? 10)}s`,
}
return this.post<Array<Record<string, string>>>("/tool/bandwidth-test", body, 30_000)
}
}
// ── Error type ─────────────────────────────────────────────────────────────────
export class MikrotikError extends Error {
constructor(
public readonly statusCode: number,
public readonly path: string,
public readonly body: string,
) {
super(`RouterOS API error ${statusCode} on ${path}: ${body}`)
this.name = "MikrotikError"
}
}