Init commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# Path to SQLite database file
|
||||
DATABASE_PATH=./mikrotik.db
|
||||
|
||||
# Port for the Fastify server
|
||||
PORT=8000
|
||||
|
||||
# Allowed CORS origin (Next.js frontend)
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.env
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "drizzle-kit"
|
||||
import { config } from "dotenv"
|
||||
|
||||
config()
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "sqlite",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_PATH ?? "./mikrotik.db",
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
CREATE TABLE `server_snapshots` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`server_id` integer NOT NULL,
|
||||
`polled_at` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`latency_ms` real,
|
||||
`ros_version` text,
|
||||
`board_name` text,
|
||||
`uptime` text,
|
||||
`cpu_load` integer,
|
||||
`free_memory` integer,
|
||||
`total_memory` integer,
|
||||
`identity_name` text,
|
||||
`raw_interfaces` text,
|
||||
`raw_ip_addresses` text,
|
||||
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `servers` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text DEFAULT '' NOT NULL,
|
||||
`host` text NOT NULL,
|
||||
`port` integer DEFAULT 443 NOT NULL,
|
||||
`username` text DEFAULT 'admin' NOT NULL,
|
||||
`password` text DEFAULT '' NOT NULL,
|
||||
`use_ssl` integer DEFAULT true NOT NULL,
|
||||
`verify_ssl` integer DEFAULT false NOT NULL,
|
||||
`type` text DEFAULT 'home-router' NOT NULL,
|
||||
`site` text DEFAULT '' NOT NULL,
|
||||
`country` text DEFAULT '' NOT NULL,
|
||||
`asn` text DEFAULT '' NOT NULL,
|
||||
`comment` text DEFAULT '' NOT NULL,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`created_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||
`updated_at` text DEFAULT (datetime('now')) NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,276 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "0b2f7dde-98dc-44e9-84a9-fd04e004299d",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"server_snapshots": {
|
||||
"name": "server_snapshots",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"server_id": {
|
||||
"name": "server_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"polled_at": {
|
||||
"name": "polled_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"latency_ms": {
|
||||
"name": "latency_ms",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ros_version": {
|
||||
"name": "ros_version",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"board_name": {
|
||||
"name": "board_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"uptime": {
|
||||
"name": "uptime",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"cpu_load": {
|
||||
"name": "cpu_load",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"free_memory": {
|
||||
"name": "free_memory",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"total_memory": {
|
||||
"name": "total_memory",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"identity_name": {
|
||||
"name": "identity_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"raw_interfaces": {
|
||||
"name": "raw_interfaces",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"raw_ip_addresses": {
|
||||
"name": "raw_ip_addresses",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"server_snapshots_server_id_servers_id_fk": {
|
||||
"name": "server_snapshots_server_id_servers_id_fk",
|
||||
"tableFrom": "server_snapshots",
|
||||
"tableTo": "servers",
|
||||
"columnsFrom": [
|
||||
"server_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"servers": {
|
||||
"name": "servers",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"host": {
|
||||
"name": "host",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"port": {
|
||||
"name": "port",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 443
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'admin'"
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"use_ssl": {
|
||||
"name": "use_ssl",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"verify_ssl": {
|
||||
"name": "verify_ssl",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'home-router'"
|
||||
},
|
||||
"site": {
|
||||
"name": "site",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"country": {
|
||||
"name": "country",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"asn": {
|
||||
"name": "asn",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"comment": {
|
||||
"name": "comment",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(datetime('now'))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(datetime('now'))"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1777572014210,
|
||||
"tag": "0000_living_xorn",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+3008
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "0.1.0",
|
||||
"description": "MikroTik Manager backend — Fastify + Drizzle + SQLite",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/type-provider-zod": "^1.0.0",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fastify": "^5.8.5",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"undici": "^8.1.0",
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.15.3",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { config } from "dotenv"
|
||||
import { z } from "zod"
|
||||
|
||||
config()
|
||||
|
||||
const envSchema = z.object({
|
||||
DATABASE_PATH: z.string().default("./mikrotik.db"),
|
||||
PORT: z.coerce.number().int().positive().default(8000),
|
||||
CORS_ORIGIN: z.string().default("http://localhost:3000"),
|
||||
})
|
||||
|
||||
const parsed = envSchema.safeParse(process.env)
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
export const env = parsed.data
|
||||
@@ -0,0 +1,164 @@
|
||||
import Database from "better-sqlite3"
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3"
|
||||
import { env } from "../config.js"
|
||||
import * as schema from "./schema.js"
|
||||
|
||||
const sqlite = new Database(env.DATABASE_PATH)
|
||||
|
||||
// WAL mode for better concurrent read performance
|
||||
sqlite.pragma("journal_mode = WAL")
|
||||
sqlite.pragma("foreign_keys = ON")
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS filter_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
community TEXT NOT NULL,
|
||||
community_name TEXT,
|
||||
action TEXT NOT NULL DEFAULT 'route',
|
||||
gateway TEXT NOT NULL DEFAULT '',
|
||||
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort
|
||||
ON filter_rules(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recursive_routes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
dst_address TEXT NOT NULL,
|
||||
gateway TEXT NOT NULL,
|
||||
distance INTEGER NOT NULL DEFAULT 1,
|
||||
scope INTEGER,
|
||||
target_scope INTEGER,
|
||||
routing_table TEXT NOT NULL DEFAULT 'main',
|
||||
check_gateway TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort
|
||||
ON recursive_routes(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 30,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TEXT,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
interface_name TEXT NOT NULL,
|
||||
sampled_at TEXT NOT NULL,
|
||||
rx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
tx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
rx_bps INTEGER NOT NULL DEFAULT 0,
|
||||
tx_bps INTEGER NOT NULL DEFAULT 0,
|
||||
running INTEGER NOT NULL DEFAULT 0,
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time
|
||||
ON traffic_samples(server_id, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time
|
||||
ON traffic_samples(server_id, interface_name, sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TEXT,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id INTEGER NOT NULL,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
probe_filter TEXT NOT NULL DEFAULT '—',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (src_server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort
|
||||
ON uptime_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
probe_id TEXT NOT NULL,
|
||||
sampled_at TEXT NOT NULL,
|
||||
rtt_ms INTEGER,
|
||||
loss_pct INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'down',
|
||||
FOREIGN KEY (probe_id) REFERENCES uptime_probes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_probe_time
|
||||
ON uptime_probe_samples(probe_id, sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
sampled_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'offline',
|
||||
cpu_load INTEGER NOT NULL DEFAULT 0,
|
||||
free_memory INTEGER NOT NULL DEFAULT 0,
|
||||
total_memory INTEGER NOT NULL DEFAULT 0,
|
||||
free_hdd_space INTEGER NOT NULL DEFAULT 0,
|
||||
total_hdd_space INTEGER NOT NULL DEFAULT 0,
|
||||
uptime_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
board_name TEXT NOT NULL DEFAULT '',
|
||||
ros_version TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_server_time
|
||||
ON uptime_resource_samples(server_id, sampled_at);
|
||||
`)
|
||||
|
||||
// Lightweight schema evolution for existing databases without migrations
|
||||
const recursiveCols = sqlite.prepare(`PRAGMA table_info('recursive_routes')`).all() as Array<{ name?: string }>
|
||||
const hasCountryColumn = recursiveCols.some((c) => c.name === "country")
|
||||
if (!hasCountryColumn) {
|
||||
sqlite.exec(`ALTER TABLE recursive_routes ADD COLUMN country TEXT NOT NULL DEFAULT ''`)
|
||||
}
|
||||
|
||||
const uptimeProbeCols = sqlite.prepare(`PRAGMA table_info('uptime_probes')`).all() as Array<{ name?: string }>
|
||||
const hasSrcInterfaceColumn = uptimeProbeCols.some((c) => c.name === "src_interface")
|
||||
if (!hasSrcInterfaceColumn) {
|
||||
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN src_interface TEXT NOT NULL DEFAULT ''`)
|
||||
}
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO traffic_settings (id, enabled, interval_sec, retention_days)
|
||||
SELECT 1, 1, 30, 14
|
||||
WHERE NOT EXISTS (SELECT 1 FROM traffic_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
|
||||
SELECT 1, 1, 15, 14
|
||||
WHERE NOT EXISTS (SELECT 1 FROM uptime_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
export const db = drizzle(sqlite, { schema })
|
||||
@@ -0,0 +1,200 @@
|
||||
import { sql } from "drizzle-orm"
|
||||
import {
|
||||
integer,
|
||||
real,
|
||||
sqliteTable,
|
||||
text,
|
||||
} from "drizzle-orm/sqlite-core"
|
||||
|
||||
// ── servers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const servers = sqliteTable("servers", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull().default(""),
|
||||
host: text("host").notNull(),
|
||||
port: integer("port").notNull().default(443),
|
||||
username: text("username").notNull().default("admin"),
|
||||
password: text("password").notNull().default(""),
|
||||
useSsl: integer("use_ssl", { mode: "boolean" }).notNull().default(true),
|
||||
verifySsl: integer("verify_ssl", { mode: "boolean" }).notNull().default(false),
|
||||
|
||||
// metadata set manually by user
|
||||
type: text("type", { enum: ["jump-host", "exit-node", "home-router"] })
|
||||
.notNull().default("home-router"),
|
||||
site: text("site").notNull().default(""),
|
||||
country: text("country").notNull().default(""),
|
||||
asn: text("asn").notNull().default(""),
|
||||
comment: text("comment").notNull().default(""),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
// ── server_snapshots ───────────────────────────────────────────────────────────
|
||||
|
||||
export const serverSnapshots = sqliteTable("server_snapshots", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
polledAt: text("polled_at").notNull(),
|
||||
status: text("status", { enum: ["online", "offline"] }).notNull(),
|
||||
latencyMs: real("latency_ms"),
|
||||
|
||||
// data from RouterOS
|
||||
rosVersion: text("ros_version"),
|
||||
boardName: text("board_name"),
|
||||
uptime: text("uptime"),
|
||||
cpuLoad: integer("cpu_load"),
|
||||
freeMemory: integer("free_memory"),
|
||||
totalMemory: integer("total_memory"),
|
||||
identityName: text("identity_name"),
|
||||
|
||||
// raw JSON payloads for future use
|
||||
rawInterfaces: text("raw_interfaces"),
|
||||
rawIpAddresses: text("raw_ip_addresses"),
|
||||
})
|
||||
|
||||
// ── filter_rules ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const filterRules = sqliteTable("filter_rules", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
community: text("community").notNull(),
|
||||
communityName: text("community_name"),
|
||||
action: text("action", { enum: ["route", "blackhole"] }).notNull().default("route"),
|
||||
gateway: text("gateway").notNull().default(""),
|
||||
gatewayTunnelId: text("gateway_tunnel_id").notNull().default(""),
|
||||
description: text("description").notNull().default(""),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
// ── recursive_routes ───────────────────────────────────────────────────────────
|
||||
|
||||
export const recursiveRoutes = sqliteTable("recursive_routes", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
dstAddress: text("dst_address").notNull(),
|
||||
gateway: text("gateway").notNull(),
|
||||
distance: integer("distance").notNull().default(1),
|
||||
scope: integer("scope"),
|
||||
targetScope: integer("target_scope"),
|
||||
routingTable: text("routing_table").notNull().default("main"),
|
||||
checkGateway: text("check_gateway").notNull().default(""),
|
||||
country: text("country").notNull().default(""),
|
||||
comment: text("comment").notNull().default(""),
|
||||
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
// ── traffic collection settings ────────────────────────────────────────────────
|
||||
|
||||
export const trafficSettings = sqliteTable("traffic_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
intervalSec: integer("interval_sec").notNull().default(30),
|
||||
retentionDays: integer("retention_days").notNull().default(14),
|
||||
lastCollectedAt: text("last_collected_at"),
|
||||
lastDurationMs: integer("last_duration_ms"),
|
||||
lastError: text("last_error"),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
// ── raw traffic samples (per server/interface/timepoint) ──────────────────────
|
||||
|
||||
export const trafficSamples = sqliteTable("traffic_samples", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
interfaceName: text("interface_name").notNull(),
|
||||
sampledAt: text("sampled_at").notNull(),
|
||||
rxBytes: integer("rx_bytes").notNull().default(0),
|
||||
txBytes: integer("tx_bytes").notNull().default(0),
|
||||
rxBps: integer("rx_bps").notNull().default(0),
|
||||
txBps: integer("tx_bps").notNull().default(0),
|
||||
running: integer("running", { mode: "boolean" }).notNull().default(false),
|
||||
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
|
||||
})
|
||||
|
||||
// ── uptime monitor settings ────────────────────────────────────────────────────
|
||||
|
||||
export const uptimeSettings = sqliteTable("uptime_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
intervalSec: integer("interval_sec").notNull().default(15),
|
||||
retentionDays: integer("retention_days").notNull().default(14),
|
||||
lastCollectedAt: text("last_collected_at"),
|
||||
lastDurationMs: integer("last_duration_ms"),
|
||||
lastError: text("last_error"),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
export const uptimeProbes = sqliteTable("uptime_probes", {
|
||||
id: text("id").primaryKey(),
|
||||
srcServerId: integer("src_server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
srcInterface: text("src_interface").notNull().default(""),
|
||||
name: text("name").notNull(),
|
||||
target: text("target").notNull(),
|
||||
probeFilter: text("probe_filter").notNull().default("—"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
export const uptimeProbeSamples = sqliteTable("uptime_probe_samples", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
probeId: text("probe_id")
|
||||
.notNull()
|
||||
.references(() => uptimeProbes.id, { onDelete: "cascade" }),
|
||||
sampledAt: text("sampled_at").notNull(),
|
||||
rttMs: integer("rtt_ms"),
|
||||
lossPct: integer("loss_pct").notNull().default(0),
|
||||
status: text("status", { enum: ["up", "warn", "down"] }).notNull().default("down"),
|
||||
})
|
||||
|
||||
export const uptimeResourceSamples = sqliteTable("uptime_resource_samples", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
sampledAt: text("sampled_at").notNull(),
|
||||
status: text("status", { enum: ["online", "offline"] }).notNull().default("offline"),
|
||||
cpuLoad: integer("cpu_load").notNull().default(0),
|
||||
freeMemory: integer("free_memory").notNull().default(0),
|
||||
totalMemory: integer("total_memory").notNull().default(0),
|
||||
freeHddSpace: integer("free_hdd_space").notNull().default(0),
|
||||
totalHddSpace: integer("total_hdd_space").notNull().default(0),
|
||||
uptimeSeconds: integer("uptime_seconds").notNull().default(0),
|
||||
boardName: text("board_name").notNull().default(""),
|
||||
rosVersion: text("ros_version").notNull().default(""),
|
||||
})
|
||||
|
||||
// ── inferred types ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type Server = typeof servers.$inferSelect
|
||||
export type ServerInsert = typeof servers.$inferInsert
|
||||
export type Snapshot = typeof serverSnapshots.$inferSelect
|
||||
export type SnapshotInsert = typeof serverSnapshots.$inferInsert
|
||||
export type FilterRuleRow = typeof filterRules.$inferSelect
|
||||
export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect
|
||||
export type TrafficSettingsRow = typeof trafficSettings.$inferSelect
|
||||
export type TrafficSampleRow = typeof trafficSamples.$inferSelect
|
||||
export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
|
||||
export type UptimeProbeRow = typeof uptimeProbes.$inferSelect
|
||||
export type UptimeProbeSampleRow = typeof uptimeProbeSamples.$inferSelect
|
||||
export type UptimeResourceSampleRow = typeof uptimeResourceSamples.$inferSelect
|
||||
@@ -0,0 +1,68 @@
|
||||
import Fastify from "fastify"
|
||||
import cors from "@fastify/cors"
|
||||
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
|
||||
import { env } from "./config.js"
|
||||
import serversRoutes from "./routes/servers.js"
|
||||
import bgpRoutes from "./routes/bgp.js"
|
||||
import ospfRoutes from "./routes/ospf.js"
|
||||
import execRoutes from "./routes/exec.js"
|
||||
import filtersRoutes from "./routes/filters.js"
|
||||
import recursiveRoutes from "./routes/recursive-routes.js"
|
||||
import trafficRoutes from "./routes/traffic.js"
|
||||
import uptimeRoutes from "./routes/uptime.js"
|
||||
import { collectTrafficOnce, restartTrafficCollector, stopTrafficCollector } from "./services/traffic-collector.js"
|
||||
import { collectUptimeOnce, restartUptimeCollector, stopUptimeCollector } from "./services/uptime-collector.js"
|
||||
|
||||
// ── app factory ────────────────────────────────────────────────────────────────
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Use Zod for request validation and response serialization
|
||||
app.setValidatorCompiler(validatorCompiler)
|
||||
app.setSerializerCompiler(serializerCompiler)
|
||||
|
||||
// CORS — allow Next.js frontend
|
||||
await app.register(cors, {
|
||||
origin: env.CORS_ORIGIN,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
})
|
||||
|
||||
// ── routes ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get("/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }))
|
||||
|
||||
await app.register(serversRoutes, { prefix: "/api/servers" })
|
||||
await app.register(bgpRoutes, { prefix: "/api" })
|
||||
await app.register(ospfRoutes, { prefix: "/api" })
|
||||
await app.register(execRoutes, { prefix: "/api" })
|
||||
await app.register(filtersRoutes, { prefix: "/api" })
|
||||
await app.register(recursiveRoutes, { prefix: "/api" })
|
||||
await app.register(trafficRoutes, { prefix: "/api" })
|
||||
await app.register(uptimeRoutes, { prefix: "/api" })
|
||||
|
||||
restartTrafficCollector()
|
||||
void collectTrafficOnce()
|
||||
restartUptimeCollector()
|
||||
void collectUptimeOnce()
|
||||
app.addHook("onClose", async () => {
|
||||
stopTrafficCollector()
|
||||
stopUptimeCollector()
|
||||
})
|
||||
|
||||
// ── start ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
await app.listen({ port: env.PORT, host: "0.0.0.0" })
|
||||
console.log(`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`)
|
||||
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
import { MikrotikClient } from "../services/mikrotik.js"
|
||||
import { ServerIdParamSchema } from "../types/server.js"
|
||||
import type { RosBgpSession, BgpSessionRead } from "../types/server.js"
|
||||
|
||||
type ServerRow = typeof servers.$inferSelect
|
||||
|
||||
/** Parse RouterOS duration like "1m30s", "1d5h13m23s580ms", "30s" → seconds */
|
||||
function parseDuration(s: string | undefined): number {
|
||||
if (!s) return 0
|
||||
let total = 0
|
||||
const matches = s.matchAll(/(\d+)(w|d|h|m(?!s)|s|ms)/g)
|
||||
for (const m of matches) {
|
||||
const n = parseInt(m[1])
|
||||
switch (m[2]) {
|
||||
case "w": total += n * 604800; break
|
||||
case "d": total += n * 86400; break
|
||||
case "h": total += n * 3600; break
|
||||
case "m": total += n * 60; break
|
||||
case "s": total += n; break
|
||||
// ms ignored for seconds precision
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Map RouterOS 7.x short capability codes to human-readable names.
|
||||
* Both local and remote capability strings are comma-separated short codes.
|
||||
*/
|
||||
function parseCapabilities(capStr: string | undefined): string[] {
|
||||
if (!capStr) return []
|
||||
const codeMap: Record<string, string> = {
|
||||
mp: "MP-BGP",
|
||||
rr: "Route Refresh",
|
||||
gr: "Graceful Restart",
|
||||
as4: "4-byte-AS",
|
||||
enhe: "Extended Next-Hop",
|
||||
role: "BGP Role",
|
||||
err: "Extended Route Refresh",
|
||||
llgr: "Long-Lived GR",
|
||||
"add-path": "ADD-PATH",
|
||||
}
|
||||
return capStr.split(",")
|
||||
.map(c => c.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.map(c => codeMap[c] ?? c)
|
||||
.filter((v, i, a) => a.indexOf(v) === i) // deduplicate
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine BGP session state from a RouterOS 7.x session object.
|
||||
*
|
||||
* RouterOS 7.x does NOT include a "state" field.
|
||||
* Instead: "established": "true" means the session is up.
|
||||
* For non-established sessions the "state" field may appear (RouterOS 6.x compat),
|
||||
* or we fall back by checking other indicators.
|
||||
*/
|
||||
function detectState(s: RosBgpSession): string {
|
||||
// RouterOS 7.x: explicit established flag
|
||||
if (s["established"] === "true") return "Established"
|
||||
|
||||
// RouterOS 6.x / some 7.x builds: "state" field
|
||||
if (s["state"]) {
|
||||
const map: Record<string, string> = {
|
||||
established: "Established",
|
||||
active: "Active",
|
||||
idle: "Idle",
|
||||
connect: "Connect",
|
||||
opensent: "OpenSent",
|
||||
openconfirm: "OpenConfirm",
|
||||
"open sent": "OpenSent",
|
||||
"open confirm": "OpenConfirm",
|
||||
}
|
||||
const key = s["state"].trim().toLowerCase()
|
||||
if (map[key]) return map[key]
|
||||
// partial match
|
||||
for (const [k, v] of Object.entries(map)) {
|
||||
if (key.startsWith(k)) return v
|
||||
}
|
||||
return key.charAt(0).toUpperCase() + key.slice(1)
|
||||
}
|
||||
|
||||
// Has uptime but no established flag → assume degraded Active
|
||||
if (s["uptime"]) return "Active"
|
||||
|
||||
return "Idle"
|
||||
}
|
||||
|
||||
function parseSessions(server: ServerRow, raw: RosBgpSession[]): BgpSessionRead[] {
|
||||
return raw.map((s, idx) => {
|
||||
const remoteAs = parseInt(s["remote.as"] ?? "0") || 0
|
||||
const localAs = parseInt(s["local.as"] ?? "0") || 0
|
||||
const type: "eBGP" | "iBGP" = (localAs > 0 && localAs === remoteAs) ? "iBGP" : "eBGP"
|
||||
|
||||
// Capabilities: prefer local caps (what we negotiated); merge with remote
|
||||
const localCaps = parseCapabilities(s["local.capabilities"])
|
||||
const remoteCaps = parseCapabilities(s["remote.capabilities"])
|
||||
// Old-style boolean capability fields (RouterOS 6.x)
|
||||
const legacyCaps: string[] = []
|
||||
if (s["4-octet-as-capability"] === "true") legacyCaps.push("4-byte-AS")
|
||||
if (s["as4-capability"] === "true") legacyCaps.push("4-byte-AS")
|
||||
if (s["refresh-capability"] === "true") legacyCaps.push("Route Refresh")
|
||||
if (s["add-path-capability"] === "true") legacyCaps.push("ADD-PATH")
|
||||
if (s["graceful-restart-capability"] === "true") legacyCaps.push("Graceful Restart")
|
||||
if (s["extended-message-capability"] === "true") legacyCaps.push("Extended Messages")
|
||||
const caps = [...new Set([...localCaps, ...legacyCaps])]
|
||||
|
||||
// Hold time: RouterOS 7 uses "hold-time" ("1m30s"), RouterOS 6 uses "active-holdtime" (seconds)
|
||||
const holdTime = parseDuration(s["hold-time"])
|
||||
|| parseInt(s["active-holdtime"] ?? "0")
|
||||
|| 90
|
||||
|
||||
// Keepalive: may have "s" suffix
|
||||
const keepalive = parseDuration(s["keepalive-time"])
|
||||
|| 30
|
||||
|
||||
// Message counts: RouterOS 7 uses "remote.messages" / "local.messages"
|
||||
const inputMessages = parseInt(s["remote.messages"] ?? s["total-messages-received"] ?? "0") || 0
|
||||
const outputMessages = parseInt(s["local.messages"] ?? s["total-messages-sent"] ?? "0") || 0
|
||||
|
||||
const peerIp = (s["remote.address"] ?? "").replace(/\/\d+$/, "")
|
||||
|
||||
return {
|
||||
id: s[".id"] ?? String(idx),
|
||||
serverId: server.id,
|
||||
serverName: server.name || server.host,
|
||||
serverSite: server.site,
|
||||
serverCountry: server.country,
|
||||
name: s["name"] ?? peerIp,
|
||||
peerIp,
|
||||
remoteAs,
|
||||
localAs,
|
||||
localId: s["local.id"] ?? "",
|
||||
remoteId: s["remote.id"] ?? "",
|
||||
state: detectState(s),
|
||||
type,
|
||||
uptime: s["uptime"] || null,
|
||||
holdTime,
|
||||
keepalive,
|
||||
prefixesRx: parseInt(s["prefix-count"] ?? s["total-updates-received"] ?? "0") || 0,
|
||||
prefixesTx: parseInt(s["total-updates-sent"] ?? "0") || 0,
|
||||
inputMessages,
|
||||
outputMessages,
|
||||
capabilities: caps,
|
||||
lastError: s["last-notification"] || null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
|
||||
// GET /api/bgp/sessions/raw/:id — raw RouterOS response for a single server (debug)
|
||||
app.get("/bgp/sessions/raw/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||
const server = db.select().from(servers).where(eq(servers.id, req.params.id)).limit(1).all()[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const raw = await client.getBgpSessionsRaw()
|
||||
app.log.info({ serverId: server.id, host: server.host, raw }, "BGP raw response")
|
||||
return reply.send({ server: { id: server.id, host: server.host, name: server.name }, raw })
|
||||
} catch (err) {
|
||||
return reply.status(500).send({ error: String(err) })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/bgp/sessions — aggregate from ALL enabled servers
|
||||
app.get("/bgp/sessions", async (_req, reply) => {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
|
||||
const results: BgpSessionRead[][] = await Promise.all(
|
||||
allServers.map(async (server) => {
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const raw = await client.getBgpSessions()
|
||||
return parseSessions(server, raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return reply.send(results.flat())
|
||||
})
|
||||
|
||||
// GET /api/servers/:id/bgp/sessions — single server
|
||||
app.get("/servers/:id/bgp/sessions", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||
const server = db
|
||||
.select().from(servers)
|
||||
.where(eq(servers.id, req.params.id))
|
||||
.limit(1).all()[0]
|
||||
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const raw = await client.getBgpSessions()
|
||||
return reply.send(parseSessions(server, raw))
|
||||
} catch {
|
||||
return reply.send([])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default bgpRoutes
|
||||
@@ -0,0 +1,336 @@
|
||||
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 } 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 <host> [count=N] — пинг",
|
||||
].join("\n")
|
||||
|
||||
// ── command dispatcher ─────────────────────────────────────────────────────────
|
||||
|
||||
async function dispatch(client: MikrotikClient, raw: string): Promise<string> {
|
||||
// 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 <address> [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 server = db
|
||||
.select().from(servers)
|
||||
.where(eq(servers.id, req.params.id))
|
||||
.limit(1).all()[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
|
||||
@@ -0,0 +1,336 @@
|
||||
import { and, asc, eq, inArray } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { db } from "../db/index.js"
|
||||
import { filterRules, servers } from "../db/schema.js"
|
||||
import { MikrotikClient } from "../services/mikrotik.js"
|
||||
|
||||
type ServerRow = typeof servers.$inferSelect
|
||||
|
||||
function parseServerId(raw: unknown): number | null {
|
||||
const direct = Number.parseInt(String(raw ?? ""), 10)
|
||||
if (Number.isFinite(direct)) return direct
|
||||
const fromLegacy = String(raw ?? "").match(/(\d+)/)?.[1]
|
||||
if (!fromLegacy) return null
|
||||
const parsed = Number.parseInt(fromLegacy, 10)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
interface RosGre {
|
||||
".id"?: string
|
||||
name?: string
|
||||
"local-address"?: string
|
||||
"remote-address"?: string
|
||||
"allow-fast-path"?: string
|
||||
"clamp-tcp-mss"?: string
|
||||
mtu?: string
|
||||
"keepalive"?: string
|
||||
dscp?: string
|
||||
running?: string
|
||||
disabled?: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
interface RosFilterRule {
|
||||
".id"?: string
|
||||
chain?: string
|
||||
rule?: string
|
||||
comment?: string
|
||||
disabled?: string
|
||||
}
|
||||
|
||||
interface LiveGreTunnel {
|
||||
id: string
|
||||
name: string
|
||||
serverId: string
|
||||
localAddress: string
|
||||
remoteAddress: string
|
||||
localInnerIp: string
|
||||
remoteInnerIp: string
|
||||
poolId: string
|
||||
ipsec: null
|
||||
mtu: number
|
||||
keepaliveInterval: number
|
||||
keepaliveRetries: number
|
||||
dscp: "inherit" | number
|
||||
clampTcpMss: boolean
|
||||
allowFastPath: boolean
|
||||
comment: string
|
||||
enabled: boolean
|
||||
status: "up" | "down" | "degraded"
|
||||
}
|
||||
|
||||
interface ApiFilterRule {
|
||||
id: string
|
||||
community: string
|
||||
communityName?: string
|
||||
action?: "route" | "blackhole"
|
||||
gateway: string
|
||||
gatewayTunnelId: string
|
||||
description: string
|
||||
}
|
||||
|
||||
function parseKeepalive(value: string | undefined): { interval: number; retries: number } {
|
||||
if (!value || value.toLowerCase() === "none") return { interval: 0, retries: 0 }
|
||||
const [intervalRaw, retriesRaw] = value.split(",")
|
||||
const interval = Number.parseInt((intervalRaw ?? "").trim(), 10)
|
||||
const retries = Number.parseInt((retriesRaw ?? "").trim(), 10)
|
||||
return {
|
||||
interval: Number.isFinite(interval) ? interval : 0,
|
||||
retries: Number.isFinite(retries) ? retries : 0,
|
||||
}
|
||||
}
|
||||
|
||||
function parseDscp(value: string | undefined): "inherit" | number {
|
||||
if (!value || value === "inherit") return "inherit"
|
||||
const n = Number.parseInt(value, 10)
|
||||
return Number.isFinite(n) ? n : "inherit"
|
||||
}
|
||||
|
||||
function parseInnerIps(rule: string | undefined): { localInnerIp: string; remoteInnerIp: string } {
|
||||
if (!rule) return { localInnerIp: "", remoteInnerIp: "" }
|
||||
const local = rule.match(/address\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? ""
|
||||
const remote = rule.match(/(?:network|gateway)\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? ""
|
||||
return { localInnerIp: local, remoteInnerIp: remote }
|
||||
}
|
||||
|
||||
function parseFilterRule(raw: RosFilterRule): ApiFilterRule[] {
|
||||
const text = raw.rule ?? ""
|
||||
// RouterOS rule formats vary across versions/config styles:
|
||||
// - bgp-communities.has("AS:NNN")
|
||||
// - bgp-communities includes AS:NNN
|
||||
const communities = [
|
||||
...text.matchAll(/bgp-communities\.has\("([^"]+)"\)/gi),
|
||||
...text.matchAll(/bgp-communities\s+includes\s+([0-9]+:[0-9]+)/gi),
|
||||
].map(m => m[1]).filter(Boolean)
|
||||
|
||||
if (communities.length === 0) return []
|
||||
|
||||
const uniqueCommunities = [...new Set(communities)]
|
||||
const isBlackhole = /set\s+type\s+blackhole/i.test(text)
|
||||
const gwToken = text.match(/set\s+gw(?:ateway)?\s+([^\s;]+)/i)?.[1] ?? ""
|
||||
const outIface = text.match(/set\s+out-interface\s+([^\s;]+)/i)?.[1] ?? ""
|
||||
const inlineComment = text.match(/#\s*([^\n]+)/)?.[1]?.trim()
|
||||
|
||||
return uniqueCommunities.map((community, idx) => ({
|
||||
id: `${raw[".id"] ?? "live"}-${idx}`,
|
||||
community,
|
||||
action: isBlackhole ? "blackhole" : "route",
|
||||
gateway: isBlackhole ? "" : gwToken,
|
||||
gatewayTunnelId: isBlackhole ? "" : outIface,
|
||||
description: inlineComment || raw.comment?.trim() || "",
|
||||
}))
|
||||
}
|
||||
|
||||
async function fetchServerFilters(server: ServerRow) {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const [greRaw, filterRaw] = await Promise.all([
|
||||
client.get<RosGre[]>("/interface/gre"),
|
||||
client.get<RosFilterRule[]>("/routing/filter/rule"),
|
||||
])
|
||||
|
||||
const tunnels: LiveGreTunnel[] = greRaw.map((g, idx) => {
|
||||
const keepalive = parseKeepalive(g["keepalive"])
|
||||
const inner = parseInnerIps(g.comment)
|
||||
const running = g.running === "true"
|
||||
const disabled = g.disabled === "true"
|
||||
|
||||
return {
|
||||
id: g.name || g[".id"] || `gre-${server.id}-${idx}`,
|
||||
name: g.name || `gre-${idx + 1}`,
|
||||
serverId: String(server.id),
|
||||
localAddress: g["local-address"] ?? "",
|
||||
remoteAddress: g["remote-address"] ?? "",
|
||||
localInnerIp: inner.localInnerIp,
|
||||
remoteInnerIp: inner.remoteInnerIp,
|
||||
poolId: "live",
|
||||
ipsec: null,
|
||||
mtu: Number.parseInt(g.mtu ?? "1476", 10) || 1476,
|
||||
keepaliveInterval: keepalive.interval,
|
||||
keepaliveRetries: keepalive.retries,
|
||||
dscp: parseDscp(g.dscp),
|
||||
clampTcpMss: g["clamp-tcp-mss"] !== "false",
|
||||
allowFastPath: g["allow-fast-path"] !== "false",
|
||||
comment: g.comment ?? "",
|
||||
enabled: !disabled,
|
||||
status: disabled ? "down" : (running ? "up" : "degraded"),
|
||||
}
|
||||
})
|
||||
|
||||
const rules = filterRaw
|
||||
.filter(r => (r.chain ?? "").trim().toLowerCase() === "bgp-in")
|
||||
.flatMap(parseFilterRule)
|
||||
|
||||
return {
|
||||
serverId: String(server.id),
|
||||
rules,
|
||||
tunnels,
|
||||
}
|
||||
}
|
||||
|
||||
function toApiRulesets(serverRows: ServerRow[]) {
|
||||
const dbRules = db.select().from(filterRules).orderBy(asc(filterRules.serverId), asc(filterRules.sortOrder)).all()
|
||||
return serverRows.map(s => ({
|
||||
serverId: String(s.id),
|
||||
rules: dbRules
|
||||
.filter(r => r.serverId === s.id)
|
||||
.map((r): ApiFilterRule => ({
|
||||
id: String(r.id),
|
||||
community: r.community,
|
||||
communityName: r.communityName ?? undefined,
|
||||
action: r.action,
|
||||
gateway: r.gateway,
|
||||
gatewayTunnelId: r.gatewayTunnelId,
|
||||
description: r.description,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
function toRouterRuleBody(rules: ApiFilterRule[]): string {
|
||||
return rules.map((rule, i) => {
|
||||
const kw = i === 0 ? "if" : "} else if"
|
||||
const comment = rule.description ? ` # ${rule.description}` : ""
|
||||
if (rule.action === "blackhole") {
|
||||
return [
|
||||
` ${kw} (bgp-communities.has("${rule.community}")) {`,
|
||||
comment,
|
||||
" set type blackhole;",
|
||||
" accept;",
|
||||
].filter(Boolean).join("\n")
|
||||
}
|
||||
return [
|
||||
` ${kw} (bgp-communities.has("${rule.community}")) {`,
|
||||
comment,
|
||||
` set gateway ${rule.gateway};`,
|
||||
` set out-interface ${rule.gatewayTunnelId};`,
|
||||
" accept;",
|
||||
].filter(Boolean).join("\n")
|
||||
}).join("\n")
|
||||
}
|
||||
|
||||
async function replaceDbRules(serverId: number, rules: ApiFilterRule[]) {
|
||||
db.delete(filterRules).where(eq(filterRules.serverId, serverId)).run()
|
||||
if (rules.length === 0) return
|
||||
const now = new Date().toISOString()
|
||||
db.insert(filterRules).values(
|
||||
rules.map((r, i) => ({
|
||||
serverId,
|
||||
sortOrder: i,
|
||||
community: r.community,
|
||||
communityName: r.communityName ?? null,
|
||||
action: r.action ?? "route",
|
||||
gateway: r.gateway,
|
||||
gatewayTunnelId: r.gatewayTunnelId,
|
||||
description: r.description,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
).run()
|
||||
}
|
||||
|
||||
const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/filters/rules", async (_req, reply) => {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const dbRulesets = toApiRulesets(allServers)
|
||||
const results = await Promise.all(allServers.map(async (server) => {
|
||||
try {
|
||||
const remote = await fetchServerFilters(server)
|
||||
return { serverId: String(server.id), tunnels: remote.tunnels }
|
||||
} catch {
|
||||
return { serverId: String(server.id), tunnels: [] as LiveGreTunnel[] }
|
||||
}
|
||||
}))
|
||||
|
||||
return reply.send({
|
||||
rulesets: dbRulesets,
|
||||
greTunnels: results.flatMap(r => r.tunnels),
|
||||
})
|
||||
})
|
||||
|
||||
app.put("/filters/rules", async (req, reply) => {
|
||||
const body = req.body as { rulesets?: Array<{ serverId: string; rules: ApiFilterRule[] }> }
|
||||
const payload = body.rulesets ?? []
|
||||
const serverIds = payload.map(r => Number.parseInt(r.serverId, 10)).filter(Number.isFinite)
|
||||
if (serverIds.length > 0) {
|
||||
db.delete(filterRules).where(inArray(filterRules.serverId, serverIds)).run()
|
||||
}
|
||||
for (const rs of payload) {
|
||||
const sid = Number.parseInt(rs.serverId, 10)
|
||||
if (!Number.isFinite(sid)) continue
|
||||
await replaceDbRules(sid, rs.rules ?? [])
|
||||
}
|
||||
return reply.send({ ok: true })
|
||||
})
|
||||
|
||||
app.post("/filters/sync/from-router", async (_req, reply) => {
|
||||
const body = _req.body as { serverId?: string | number } | undefined
|
||||
const rawServerId = body?.serverId
|
||||
const serverId = parseServerId(rawServerId)
|
||||
if (serverId === null) {
|
||||
return reply.status(400).send({ error: "serverId is required" })
|
||||
}
|
||||
|
||||
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
try {
|
||||
app.log.info({ serverId, host: server.host }, "Filters sync from router started")
|
||||
const remote = await fetchServerFilters(server)
|
||||
await replaceDbRules(server.id, remote.rules)
|
||||
app.log.info({ serverId, totalRules: remote.rules.length }, "Filters sync from router completed")
|
||||
return reply.send({ ok: true, updatedServers: 1, totalRules: remote.rules.length, serverId })
|
||||
} catch (err) {
|
||||
app.log.error({ serverId, err: String(err) }, "Filters sync from router failed")
|
||||
return reply.status(500).send({ error: String(err) })
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/filters/sync/to-router", async (_req, reply) => {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
let updatedServers = 0
|
||||
let pushedRules = 0
|
||||
|
||||
for (const server of allServers) {
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const existing = await client.get<RosFilterRule[]>("/routing/filter/rule")
|
||||
const managed = existing.filter(r => (r.comment ?? "").startsWith("RouterLists:"))
|
||||
for (const r of managed) {
|
||||
if (!r[".id"]) continue
|
||||
await client.delete(`/routing/filter/rule/${encodeURIComponent(r[".id"])}`)
|
||||
}
|
||||
|
||||
const rows = db.select().from(filterRules)
|
||||
.where(and(eq(filterRules.serverId, server.id)))
|
||||
.orderBy(asc(filterRules.sortOrder))
|
||||
.all()
|
||||
|
||||
const rules: ApiFilterRule[] = rows.map(r => ({
|
||||
id: String(r.id),
|
||||
community: r.community,
|
||||
communityName: r.communityName ?? undefined,
|
||||
action: r.action,
|
||||
gateway: r.gateway,
|
||||
gatewayTunnelId: r.gatewayTunnelId,
|
||||
description: r.description,
|
||||
}))
|
||||
|
||||
if (rules.length > 0) {
|
||||
const ruleBody = toRouterRuleBody(rules)
|
||||
await client.post("/routing/filter/rule", {
|
||||
chain: "bgp-in",
|
||||
comment: `RouterLists: ${server.name || server.host}`,
|
||||
rule: ruleBody,
|
||||
})
|
||||
pushedRules += rules.length
|
||||
}
|
||||
updatedServers += 1
|
||||
} catch {
|
||||
// ignore failed server push
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({ ok: true, updatedServers, pushedRules })
|
||||
})
|
||||
}
|
||||
|
||||
export default filtersRoutes
|
||||
@@ -0,0 +1,327 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
import { MikrotikClient } from "../services/mikrotik.js"
|
||||
import { ServerIdParamSchema } from "../types/server.js"
|
||||
import type {
|
||||
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
|
||||
RosBfdSession,
|
||||
OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, BfdSessionRead,
|
||||
} from "../types/server.js"
|
||||
|
||||
type ServerRow = typeof servers.$inferSelect
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Parse RouterOS duration string like "1h30m17s", "40s", "10m" → seconds */
|
||||
function parseDuration(s: string | undefined): number {
|
||||
if (!s) return 0
|
||||
let total = 0
|
||||
for (const m of s.matchAll(/(\d+)(w|d|h|m(?!s)|s)/g)) {
|
||||
const n = parseInt(m[1])
|
||||
switch (m[2]) {
|
||||
case "w": total += n * 604800; break
|
||||
case "d": total += n * 86400; break
|
||||
case "h": total += n * 3600; break
|
||||
case "m": total += n * 60; break
|
||||
case "s": total += n; break
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/** Build area-name → area-id lookup from a list of raw areas */
|
||||
function buildAreaMap(areas: RosOspfArea[]): Map<string, string> {
|
||||
const map = new Map<string, string>()
|
||||
for (const a of areas) {
|
||||
map.set(a.name, a["area-id"] ?? "0.0.0.0")
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** Parse "200ms" | "1s" | "500ms" → milliseconds */
|
||||
function parseMs(s: string | undefined): number {
|
||||
if (!s) return 0
|
||||
const ms = s.match(/^(\d+)ms$/)
|
||||
if (ms) return parseInt(ms[1])
|
||||
const sec = s.match(/^(\d+)s$/)
|
||||
if (sec) return parseInt(sec[1]) * 1000
|
||||
return parseInt(s) || 0
|
||||
}
|
||||
|
||||
/** Normalize BFD state: "up" → "Up", "down" → "Down", etc. */
|
||||
function normalizeBfdState(state: string | undefined): string {
|
||||
const map: Record<string, string> = {
|
||||
up: "Up", down: "Down", init: "Init", admindown: "AdminDown",
|
||||
}
|
||||
return map[(state ?? "").toLowerCase()] ?? (state ?? "Down")
|
||||
}
|
||||
|
||||
/** Extract IP and interface from RouterOS address format "10.0.0.1%eth0" */
|
||||
function parseAddrIface(addr: string): { ip: string; iface: string } {
|
||||
const [ip, iface = ""] = addr.split("%")
|
||||
return { ip, iface }
|
||||
}
|
||||
|
||||
/** Fetch all OSPF + BFD data for one server */
|
||||
async function fetchServerOspf(server: ServerRow) {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const [neighbors, areas, ifaceTemplates, instances, bfdSessions] = await Promise.all([
|
||||
client.getOspfNeighbors(),
|
||||
client.getOspfAreas(),
|
||||
client.getOspfInterfaceTemplates(),
|
||||
client.getOspfInstances(),
|
||||
client.getBfdSessions().catch(() => [] as RosBfdSession[]), // BFD is optional
|
||||
])
|
||||
return { neighbors, areas, ifaceTemplates, instances, bfdSessions }
|
||||
}
|
||||
|
||||
// ── BFD parser ────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseBfdSessions(server: ServerRow, raw: RosBfdSession[]): BfdSessionRead[] {
|
||||
return raw.map((s, idx) => {
|
||||
const local = parseAddrIface(s["local-address"])
|
||||
const remote = parseAddrIface(s["remote-address"])
|
||||
// Prefer interface from local-address; fallback to remote-address suffix
|
||||
const iface = local.iface || remote.iface
|
||||
|
||||
return {
|
||||
id: s[".id"] ?? String(idx),
|
||||
serverId: server.id,
|
||||
serverName: server.name || server.host,
|
||||
serverSite: server.site,
|
||||
serverCountry: server.country,
|
||||
localAddr: local.ip,
|
||||
remoteAddr: remote.ip,
|
||||
interface: iface,
|
||||
state: normalizeBfdState(s["state"]),
|
||||
uptime: s["uptime"] ?? null,
|
||||
multihop: s["multihop"] === "true",
|
||||
multiplier: parseInt(s["multiplier"] ?? "3") || 3,
|
||||
txInterval: parseMs(s["actual-tx-interval"] ?? s["desired-tx-interval"]),
|
||||
rxInterval: parseMs(s["required-min-rx"]),
|
||||
holdTime: parseMs(s["hold-time"]),
|
||||
packetsRx: parseInt(s["packets-rx"] ?? "0") || 0,
|
||||
packetsTx: parseInt(s["packets-tx"] ?? "0") || 0,
|
||||
stateChanges: parseInt(s["state-changes"] ?? "0") || 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── parsers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseNeighbors(
|
||||
server: ServerRow,
|
||||
neighbors: RosOspfNeighbor[],
|
||||
areaMap: Map<string, string>,
|
||||
): OspfNeighborRead[] {
|
||||
return neighbors.map((n, idx) => ({
|
||||
id: n[".id"] ?? String(idx),
|
||||
serverId: server.id,
|
||||
serverName: server.name || server.host,
|
||||
serverSite: server.site,
|
||||
serverCountry: server.country,
|
||||
address: n.address,
|
||||
routerId: n["router-id"],
|
||||
instance: n.instance,
|
||||
area: n.area,
|
||||
areaId: areaMap.get(n.area) ?? n.area,
|
||||
interface: n.interface,
|
||||
state: n.state,
|
||||
uptime: n.adjacency ?? null,
|
||||
stateChanges: parseInt(n["state-changes"] ?? "0") || 0,
|
||||
priority: parseInt(n.priority ?? "1") || 1,
|
||||
}))
|
||||
}
|
||||
|
||||
function parseInterfaces(
|
||||
server: ServerRow,
|
||||
templates: RosOspfInterfaceTemplate[],
|
||||
areas: RosOspfArea[],
|
||||
instances: RosOspfInstance[],
|
||||
areaMap: Map<string, string>,
|
||||
): OspfInterfaceRead[] {
|
||||
// Build instance-id → instance name map (for interface-template instance-id field)
|
||||
const instanceIdMap = new Map<string, string>()
|
||||
instances.forEach((inst, i) => { instanceIdMap.set(String(i), inst.name) })
|
||||
|
||||
return templates.map((t, idx) => {
|
||||
// Resolve interface name: may be a "*ID" reference (RouterOS internal ID)
|
||||
const ifaceName = (t.interfaces ?? "").startsWith("*")
|
||||
? `(ref ${t.interfaces})`
|
||||
: (t.interfaces ?? "—")
|
||||
|
||||
return {
|
||||
id: t[".id"] ?? String(idx),
|
||||
serverId: server.id,
|
||||
serverName: server.name || server.host,
|
||||
serverSite: server.site,
|
||||
serverCountry: server.country,
|
||||
instance: instanceIdMap.get(t["instance-id"] ?? "") ?? t["instance-id"] ?? "",
|
||||
area: t.area,
|
||||
areaId: areaMap.get(t.area) ?? t.area,
|
||||
interface: ifaceName,
|
||||
cost: parseInt(t.cost ?? "10") || 10,
|
||||
type: t.type ?? "broadcast",
|
||||
disabled: t.disabled === "true",
|
||||
inactive: t.inactive === "true",
|
||||
priority: parseInt(t.priority ?? "1") || 1,
|
||||
helloInterval: parseDuration(t["hello-interval"]),
|
||||
deadInterval: parseDuration(t["dead-interval"]),
|
||||
useBfd: t["use-bfd"] === "true",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function parseInstances(
|
||||
server: ServerRow,
|
||||
instances: RosOspfInstance[],
|
||||
): OspfInstanceRead[] {
|
||||
return instances.map((inst, idx) => ({
|
||||
id: inst[".id"] ?? String(idx),
|
||||
serverId: server.id,
|
||||
serverName: server.name || server.host,
|
||||
serverSite: server.site,
|
||||
serverCountry: server.country,
|
||||
name: inst.name,
|
||||
routerId: inst["router-id"],
|
||||
version: parseInt(inst.version ?? "2") || 2,
|
||||
disabled: inst.disabled === "true",
|
||||
inactive: inst.inactive === "true",
|
||||
redistribute: inst.redistribute ?? "",
|
||||
}))
|
||||
}
|
||||
|
||||
// ── route plugin ──────────────────────────────────────────────────────────────
|
||||
|
||||
const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
|
||||
// GET /api/ospf/neighbors — aggregate OSPF neighbors from ALL enabled servers
|
||||
app.get("/ospf/neighbors", async (_req, reply) => {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
|
||||
const results = await Promise.all(
|
||||
allServers.map(async (server) => {
|
||||
try {
|
||||
const { neighbors, areas } = await fetchServerOspf(server)
|
||||
const areaMap = buildAreaMap(areas)
|
||||
return parseNeighbors(server, neighbors, areaMap)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return reply.send(results.flat())
|
||||
})
|
||||
|
||||
// GET /api/ospf/interfaces — aggregate OSPF interface templates from ALL enabled servers
|
||||
app.get("/ospf/interfaces", async (_req, reply) => {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
|
||||
const results = await Promise.all(
|
||||
allServers.map(async (server) => {
|
||||
try {
|
||||
const { ifaceTemplates, areas, instances } = await fetchServerOspf(server)
|
||||
const areaMap = buildAreaMap(areas)
|
||||
return parseInterfaces(server, ifaceTemplates, areas, instances, areaMap)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return reply.send(results.flat())
|
||||
})
|
||||
|
||||
// GET /api/ospf/instances — aggregate OSPF instances from ALL enabled servers
|
||||
app.get("/ospf/instances", async (_req, reply) => {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
|
||||
const results = await Promise.all(
|
||||
allServers.map(async (server) => {
|
||||
try {
|
||||
const { instances } = await fetchServerOspf(server)
|
||||
return parseInstances(server, instances)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return reply.send(results.flat())
|
||||
})
|
||||
|
||||
// GET /api/ospf/all — single round-trip: neighbors + interfaces + instances + BFD
|
||||
app.get("/ospf/all", async (_req, reply) => {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
|
||||
const perServer = await Promise.all(
|
||||
allServers.map(async (server) => {
|
||||
try {
|
||||
const { neighbors, areas, ifaceTemplates, instances, bfdSessions } = await fetchServerOspf(server)
|
||||
const areaMap = buildAreaMap(areas)
|
||||
return {
|
||||
neighbors: parseNeighbors(server, neighbors, areaMap),
|
||||
interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap),
|
||||
instances: parseInstances(server, instances),
|
||||
bfdSessions: parseBfdSessions(server, bfdSessions),
|
||||
}
|
||||
} catch {
|
||||
return { neighbors: [], interfaces: [], instances: [], bfdSessions: [] }
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return reply.send({
|
||||
neighbors: perServer.flatMap(r => r.neighbors),
|
||||
interfaces: perServer.flatMap(r => r.interfaces),
|
||||
instances: perServer.flatMap(r => r.instances),
|
||||
bfdSessions: perServer.flatMap(r => r.bfdSessions),
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/bfd/sessions — BFD sessions only (for direct access)
|
||||
app.get("/bfd/sessions", async (_req, reply) => {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const results = await Promise.all(
|
||||
allServers.map(async (server) => {
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const raw = await client.getBfdSessions()
|
||||
return parseBfdSessions(server, raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}),
|
||||
)
|
||||
return reply.send(results.flat())
|
||||
})
|
||||
|
||||
// GET /api/servers/:id/ospf — single server OSPF + BFD data
|
||||
app.get("/servers/:id/ospf", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||
const server = db
|
||||
.select().from(servers)
|
||||
.where(eq(servers.id, req.params.id))
|
||||
.limit(1).all()[0]
|
||||
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
try {
|
||||
const { neighbors, areas, ifaceTemplates, instances, bfdSessions } = await fetchServerOspf(server)
|
||||
const areaMap = buildAreaMap(areas)
|
||||
return reply.send({
|
||||
neighbors: parseNeighbors(server, neighbors, areaMap),
|
||||
interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap),
|
||||
instances: parseInstances(server, instances),
|
||||
bfdSessions: parseBfdSessions(server, bfdSessions),
|
||||
areas: areas.map(a => ({ name: a.name, areaId: a["area-id"] ?? "0.0.0.0", type: a.type, disabled: a.disabled === "true", inactive: a.inactive === "true", instance: a.instance })),
|
||||
})
|
||||
} catch (err) {
|
||||
return reply.status(500).send({ error: String(err) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default ospfRoutes
|
||||
@@ -0,0 +1,264 @@
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { db } from "../db/index.js"
|
||||
import { recursiveRoutes, servers } from "../db/schema.js"
|
||||
import { MikrotikClient } from "../services/mikrotik.js"
|
||||
|
||||
type ServerRow = typeof servers.$inferSelect
|
||||
|
||||
interface RosRoute {
|
||||
".id"?: string
|
||||
"dst-address"?: string
|
||||
gateway?: string
|
||||
distance?: string
|
||||
scope?: string
|
||||
"target-scope"?: string
|
||||
"routing-table"?: string
|
||||
"check-gateway"?: string
|
||||
comment?: string
|
||||
disabled?: string
|
||||
static?: string
|
||||
dynamic?: string
|
||||
blackhole?: string
|
||||
unreachable?: string
|
||||
prohibit?: string
|
||||
active?: string
|
||||
}
|
||||
|
||||
interface GatewayOptionDto {
|
||||
id: string
|
||||
name: string
|
||||
ip: string
|
||||
status: "up" | "down"
|
||||
}
|
||||
|
||||
interface RecursiveRouteDto {
|
||||
id: string
|
||||
dstAddress: string
|
||||
gateway: string
|
||||
distance: number
|
||||
scope: number | null
|
||||
targetScope: number | null
|
||||
routingTable: string
|
||||
checkGateway: string
|
||||
country: string
|
||||
comment: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
function parseServerId(raw: unknown): number | null {
|
||||
const direct = Number.parseInt(String(raw ?? ""), 10)
|
||||
if (Number.isFinite(direct)) return direct
|
||||
const legacy = String(raw ?? "").match(/(\d+)/)?.[1]
|
||||
if (!legacy) return null
|
||||
const parsed = Number.parseInt(legacy, 10)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function isIpGateway(gw: string): boolean {
|
||||
return /^\d{1,3}(\.\d{1,3}){3}(?:%\S+)?$/.test(gw.trim())
|
||||
}
|
||||
|
||||
function isRecursiveRoute(r: RosRoute): boolean {
|
||||
if ((r.static ?? "false") !== "true") return false
|
||||
if ((r.dynamic ?? "false") === "true") return false
|
||||
if ((r.blackhole ?? "false") === "true") return false
|
||||
if ((r.unreachable ?? "false") === "true") return false
|
||||
if ((r.prohibit ?? "false") === "true") return false
|
||||
const dst = r["dst-address"] ?? ""
|
||||
const gw = r.gateway ?? ""
|
||||
if (!dst || !gw) return false
|
||||
return isIpGateway(gw)
|
||||
}
|
||||
|
||||
function hasRecursiveCommentMask(comment: string | undefined): boolean {
|
||||
if (!comment) return false
|
||||
return /^recursive:\s*/i.test(comment.trim())
|
||||
}
|
||||
|
||||
function splitGateway(raw: string): { ip: string; name: string } | null {
|
||||
const v = raw.trim()
|
||||
if (!v) return null
|
||||
const [ip, name] = v.split("%")
|
||||
if (!ip || !/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) return null
|
||||
return { ip, name: name || ip }
|
||||
}
|
||||
|
||||
function mapDbRoutes(serverId: number): RecursiveRouteDto[] {
|
||||
const rows = db
|
||||
.select()
|
||||
.from(recursiveRoutes)
|
||||
.where(eq(recursiveRoutes.serverId, serverId))
|
||||
.orderBy(asc(recursiveRoutes.sortOrder))
|
||||
.all()
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: String(r.id),
|
||||
dstAddress: r.dstAddress,
|
||||
gateway: r.gateway,
|
||||
distance: r.distance,
|
||||
scope: r.scope ?? null,
|
||||
targetScope: r.targetScope ?? null,
|
||||
routingTable: r.routingTable,
|
||||
checkGateway: r.checkGateway,
|
||||
country: r.country ?? "",
|
||||
comment: r.comment,
|
||||
disabled: r.disabled,
|
||||
}))
|
||||
}
|
||||
|
||||
function toRouterPayload(route: RecursiveRouteDto): Record<string, string> {
|
||||
return {
|
||||
"dst-address": route.dstAddress,
|
||||
gateway: route.gateway,
|
||||
distance: String(route.distance),
|
||||
...(route.scope != null ? { scope: String(route.scope) } : {}),
|
||||
...(route.targetScope != null ? { "target-scope": String(route.targetScope) } : {}),
|
||||
...(route.routingTable ? { "routing-table": route.routingTable } : {}),
|
||||
...(route.checkGateway ? { "check-gateway": route.checkGateway } : {}),
|
||||
comment: route.comment
|
||||
? `RouterLists:recursive ${route.comment}`
|
||||
: "RouterLists:recursive",
|
||||
disabled: route.disabled ? "true" : "false",
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
|
||||
db.delete(recursiveRoutes).where(eq(recursiveRoutes.serverId, serverId)).run()
|
||||
if (routes.length === 0) return
|
||||
const now = new Date().toISOString()
|
||||
db.insert(recursiveRoutes).values(
|
||||
routes.map((r, i) => ({
|
||||
serverId,
|
||||
sortOrder: i,
|
||||
dstAddress: r.dstAddress,
|
||||
gateway: r.gateway,
|
||||
distance: r.distance,
|
||||
scope: r.scope,
|
||||
targetScope: r.targetScope,
|
||||
routingTable: r.routingTable || "main",
|
||||
checkGateway: r.checkGateway ?? "",
|
||||
country: r.country ?? "",
|
||||
comment: r.comment ?? "",
|
||||
disabled: r.disabled,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
).run()
|
||||
}
|
||||
|
||||
const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/recursive-routes/gateways", async (req, reply) => {
|
||||
const q = req.query as { serverId?: string | number }
|
||||
const serverId = parseServerId(q.serverId)
|
||||
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||||
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const rosRoutes = await client.get<RosRoute[]>("/ip/route")
|
||||
const map = new Map<string, GatewayOptionDto>()
|
||||
for (const r of rosRoutes) {
|
||||
const g = splitGateway(r.gateway ?? "")
|
||||
if (!g) continue
|
||||
const id = `${g.ip}%${g.name}`
|
||||
if (!map.has(id)) {
|
||||
map.set(id, {
|
||||
id,
|
||||
name: g.name,
|
||||
ip: g.ip,
|
||||
status: r.active === "true" ? "up" : "down",
|
||||
})
|
||||
}
|
||||
}
|
||||
return reply.send({ gateways: [...map.values()] })
|
||||
} catch (err) {
|
||||
return reply.status(500).send({ error: String(err) })
|
||||
}
|
||||
})
|
||||
|
||||
app.get("/recursive-routes", async (req, reply) => {
|
||||
const q = req.query as { serverId?: string | number }
|
||||
const serverId = parseServerId(q.serverId)
|
||||
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||||
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
return reply.send({ routes: mapDbRoutes(serverId) })
|
||||
})
|
||||
|
||||
app.put("/recursive-routes", async (req, reply) => {
|
||||
const body = req.body as { serverId?: string | number; routes?: RecursiveRouteDto[] }
|
||||
const serverId = parseServerId(body.serverId)
|
||||
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||||
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
await replaceDbRoutes(serverId, body.routes ?? [])
|
||||
return reply.send({ ok: true })
|
||||
})
|
||||
|
||||
app.post("/recursive-routes/sync/from-router", async (req, reply) => {
|
||||
const body = req.body as { serverId?: string | number } | undefined
|
||||
const serverId = parseServerId(body?.serverId)
|
||||
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||||
const server: ServerRow | undefined = db
|
||||
.select().from(servers)
|
||||
.where(eq(servers.id, serverId))
|
||||
.limit(1).all()[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const rosRoutes = await client.get<RosRoute[]>("/ip/route")
|
||||
const rec = rosRoutes.filter(r =>
|
||||
isRecursiveRoute(r) && hasRecursiveCommentMask(r.comment),
|
||||
)
|
||||
const mapped: RecursiveRouteDto[] = rec.map((r, i) => ({
|
||||
id: r[".id"] ?? `ros-${i}`,
|
||||
dstAddress: r["dst-address"] ?? "",
|
||||
gateway: r.gateway ?? "",
|
||||
distance: Number.parseInt(r.distance ?? "1", 10) || 1,
|
||||
scope: r.scope ? (Number.parseInt(r.scope, 10) || null) : null,
|
||||
targetScope: r["target-scope"] ? (Number.parseInt(r["target-scope"], 10) || null) : null,
|
||||
routingTable: r["routing-table"] ?? "main",
|
||||
checkGateway: r["check-gateway"] ?? "",
|
||||
country: "",
|
||||
comment: r.comment ?? "",
|
||||
disabled: r.disabled === "true",
|
||||
}))
|
||||
await replaceDbRoutes(serverId, mapped)
|
||||
return reply.send({ ok: true, serverId, totalRoutes: mapped.length })
|
||||
} catch (err) {
|
||||
return reply.status(500).send({ error: String(err) })
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/recursive-routes/sync/to-router", async (req, reply) => {
|
||||
const body = req.body as { serverId?: string | number } | undefined
|
||||
const serverId = parseServerId(body?.serverId)
|
||||
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||||
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const existing = await client.get<RosRoute[]>("/ip/route")
|
||||
const managed = existing.filter(r => (r.comment ?? "").startsWith("RouterLists:recursive"))
|
||||
for (const r of managed) {
|
||||
if (!r[".id"]) continue
|
||||
await client.delete(`/ip/route/${encodeURIComponent(r[".id"])}`)
|
||||
}
|
||||
|
||||
const dbRows = mapDbRoutes(serverId)
|
||||
for (const route of dbRows) {
|
||||
await client.post("/ip/route", toRouterPayload(route))
|
||||
}
|
||||
|
||||
return reply.send({ ok: true, serverId, pushedRoutes: dbRows.length })
|
||||
} catch (err) {
|
||||
return reply.status(500).send({ error: String(err) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default recursiveRoutesPlugin
|
||||
@@ -0,0 +1,224 @@
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers, serverSnapshots } from "../db/schema.js"
|
||||
import type { ServerRead } from "../types/server.js"
|
||||
import {
|
||||
ServerCreateSchema,
|
||||
ServerUpdateSchema,
|
||||
ServerIdParamSchema,
|
||||
SnapshotsQuerySchema,
|
||||
TestConnectionSchema,
|
||||
} from "../types/server.js"
|
||||
import { pollServer, toSnapshotRead } from "../services/poller.js"
|
||||
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ServerRow = typeof servers.$inferSelect
|
||||
type SnapshotRow = typeof serverSnapshots.$inferSelect
|
||||
|
||||
/** Merge a server row with its latest snapshot into the frontend-compatible shape */
|
||||
function toServerRead(server: ServerRow, snap: SnapshotRow | undefined): ServerRead {
|
||||
return {
|
||||
id: server.id,
|
||||
name: server.name || server.host,
|
||||
host: server.host,
|
||||
port: server.port,
|
||||
useSsl: server.useSsl,
|
||||
verifySsl: server.verifySsl,
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
type: server.type,
|
||||
site: server.site,
|
||||
country: server.country,
|
||||
asn: server.asn,
|
||||
comment: server.comment,
|
||||
enabled: server.enabled,
|
||||
createdAt: server.createdAt,
|
||||
updatedAt: server.updatedAt,
|
||||
// snapshot fields (null if never polled)
|
||||
status: snap ? snap.status : null,
|
||||
latency: snap?.latencyMs ?? null,
|
||||
os: snap?.rosVersion ?? null,
|
||||
model: snap?.boardName ?? null,
|
||||
uptime: snap?.uptime ?? null,
|
||||
cpuLoad: snap?.cpuLoad ?? null,
|
||||
freeMemory: snap?.freeMemory ?? null,
|
||||
totalMemory: snap?.totalMemory ?? null,
|
||||
identityName: snap?.identityName ?? null,
|
||||
sessions: 0,
|
||||
polledAt: snap?.polledAt ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Get latest snapshot for a single server */
|
||||
function getLatestSnapshot(serverId: number): SnapshotRow | undefined {
|
||||
return db
|
||||
.select()
|
||||
.from(serverSnapshots)
|
||||
.where(eq(serverSnapshots.serverId, serverId))
|
||||
.orderBy(desc(serverSnapshots.polledAt))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
}
|
||||
|
||||
// ── 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
|
||||
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) => {
|
||||
const all = db.select().from(servers).all()
|
||||
const result: ServerRead[] = all.map(s => toServerRead(s, getLatestSnapshot(s.id)))
|
||||
return reply.send(result)
|
||||
})
|
||||
|
||||
// POST /api/servers
|
||||
app.post("/", { schema: { body: ServerCreateSchema } }, async (req, reply) => {
|
||||
const now = new Date().toISOString()
|
||||
const [inserted] = db
|
||||
.insert(servers)
|
||||
.values({ ...req.body, createdAt: now, updatedAt: now })
|
||||
.returning()
|
||||
.all()
|
||||
return reply.status(201).send(toServerRead(inserted, undefined))
|
||||
})
|
||||
|
||||
// GET /api/servers/:id
|
||||
app.get("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||
const server = db
|
||||
.select().from(servers)
|
||||
.where(eq(servers.id, req.params.id))
|
||||
.limit(1).all()[0]
|
||||
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
return reply.send(toServerRead(server, getLatestSnapshot(server.id)))
|
||||
})
|
||||
|
||||
// PUT /api/servers/:id
|
||||
app.put(
|
||||
"/:id",
|
||||
{ schema: { params: ServerIdParamSchema, body: ServerUpdateSchema } },
|
||||
async (req, reply) => {
|
||||
const existing = db
|
||||
.select().from(servers)
|
||||
.where(eq(servers.id, req.params.id))
|
||||
.limit(1).all()[0]
|
||||
|
||||
if (!existing) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
const [updated] = db
|
||||
.update(servers)
|
||||
.set({ ...req.body, updatedAt: new Date().toISOString() })
|
||||
.where(eq(servers.id, req.params.id))
|
||||
.returning()
|
||||
.all()
|
||||
|
||||
return reply.send(toServerRead(updated, getLatestSnapshot(updated.id)))
|
||||
},
|
||||
)
|
||||
|
||||
// DELETE /api/servers/:id
|
||||
app.delete("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||
const existing = db
|
||||
.select().from(servers)
|
||||
.where(eq(servers.id, req.params.id))
|
||||
.limit(1).all()[0]
|
||||
|
||||
if (!existing) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
db.delete(servers).where(eq(servers.id, req.params.id)).run()
|
||||
return reply.status(204).send()
|
||||
})
|
||||
|
||||
// POST /api/servers/:id/poll
|
||||
app.post("/:id/poll", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||
const existing = db
|
||||
.select().from(servers)
|
||||
.where(eq(servers.id, req.params.id))
|
||||
.limit(1).all()[0]
|
||||
|
||||
if (!existing) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
try {
|
||||
const snap = await pollServer(req.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 existing = db
|
||||
.select().from(servers)
|
||||
.where(eq(servers.id, req.params.id))
|
||||
.limit(1).all()[0]
|
||||
|
||||
if (!existing) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
const snaps = db
|
||||
.select()
|
||||
.from(serverSnapshots)
|
||||
.where(eq(serverSnapshots.serverId, req.params.id))
|
||||
.orderBy(desc(serverSnapshots.polledAt))
|
||||
.limit(req.query.limit)
|
||||
.all()
|
||||
|
||||
return reply.send(snaps.map(toSnapshotRead))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default serversRoutes
|
||||
@@ -0,0 +1,285 @@
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { db } from "../db/index.js"
|
||||
import { serverSnapshots, servers } from "../db/schema.js"
|
||||
import {
|
||||
collectTrafficOnce,
|
||||
getTrafficCollectorState,
|
||||
getTrafficSettings,
|
||||
readServerSamplesInRange,
|
||||
updateTrafficSettings,
|
||||
} from "../services/traffic-collector.js"
|
||||
|
||||
type SnapshotRow = typeof serverSnapshots.$inferSelect
|
||||
|
||||
interface TrafficServerDto {
|
||||
id: string
|
||||
name: string
|
||||
site: string
|
||||
country: string
|
||||
status: "online" | "offline" | "degraded"
|
||||
rxNow: number
|
||||
txNow: number
|
||||
rxPeak: number
|
||||
txPeak: number
|
||||
rxTotal: number
|
||||
txTotal: number
|
||||
sessions: number
|
||||
rxSeries: number[]
|
||||
txSeries: number[]
|
||||
}
|
||||
|
||||
interface TrafficInterfaceDto {
|
||||
name: string
|
||||
running: boolean
|
||||
disabled: boolean
|
||||
rxNow: number
|
||||
txNow: number
|
||||
}
|
||||
|
||||
function latestSnapshot(serverId: number): SnapshotRow | undefined {
|
||||
return db
|
||||
.select()
|
||||
.from(serverSnapshots)
|
||||
.where(eq(serverSnapshots.serverId, serverId))
|
||||
.orderBy(desc(serverSnapshots.polledAt))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
}
|
||||
|
||||
function rangeToMinutes(range: string | undefined): number {
|
||||
switch ((range ?? "1h").toLowerCase()) {
|
||||
case "5m": return 5
|
||||
case "15m": return 15
|
||||
case "1h": return 60
|
||||
case "4h": return 240
|
||||
case "24h": return 1440
|
||||
default: return 60
|
||||
}
|
||||
}
|
||||
|
||||
function toSeries(values: number[], target = 60): number[] {
|
||||
if (values.length === 0) return Array(target).fill(0)
|
||||
if (values.length === target) return values
|
||||
if (values.length > target) return values.slice(values.length - target)
|
||||
const head = Array(target - values.length).fill(values[0] ?? 0)
|
||||
return [...head, ...values]
|
||||
}
|
||||
|
||||
function buildServerTraffic(
|
||||
s: typeof servers.$inferSelect,
|
||||
status: TrafficServerDto["status"],
|
||||
rows: Array<{
|
||||
interfaceName: string
|
||||
sampledAt: string
|
||||
rxBps: number
|
||||
txBps: number
|
||||
rxBytes: number
|
||||
txBytes: number
|
||||
running: boolean
|
||||
disabled: boolean
|
||||
}>,
|
||||
onlyInterface?: string,
|
||||
): TrafficServerDto {
|
||||
const filteredRows = onlyInterface
|
||||
? rows.filter((r) => r.interfaceName === onlyInterface)
|
||||
: rows
|
||||
|
||||
if (filteredRows.length === 0) {
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
site: s.site || "—",
|
||||
country: s.country || "UN",
|
||||
status,
|
||||
rxNow: 0,
|
||||
txNow: 0,
|
||||
rxPeak: 0,
|
||||
txPeak: 0,
|
||||
rxTotal: 0,
|
||||
txTotal: 0,
|
||||
sessions: 0,
|
||||
rxSeries: Array(60).fill(0),
|
||||
txSeries: Array(60).fill(0),
|
||||
}
|
||||
}
|
||||
|
||||
const bySampleTs = new Map<string, { rx: number; tx: number }>()
|
||||
const byIface = new Map<string, typeof filteredRows>()
|
||||
for (const r of filteredRows) {
|
||||
const ts = r.sampledAt
|
||||
const cur = bySampleTs.get(ts) ?? { rx: 0, tx: 0 }
|
||||
cur.rx += Math.max(0, r.rxBps) / 1_000_000
|
||||
cur.tx += Math.max(0, r.txBps) / 1_000_000
|
||||
bySampleTs.set(ts, cur)
|
||||
const arr = byIface.get(r.interfaceName) ?? []
|
||||
arr.push(r)
|
||||
byIface.set(r.interfaceName, arr)
|
||||
}
|
||||
|
||||
const seriesPoints = [...bySampleTs.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([, v]) => ({ rx: Math.round(v.rx), tx: Math.round(v.tx) }))
|
||||
const rxSeries = toSeries(seriesPoints.map((p) => p.rx))
|
||||
const txSeries = toSeries(seriesPoints.map((p) => p.tx))
|
||||
const rxNow = rxSeries[rxSeries.length - 1] ?? 0
|
||||
const txNow = txSeries[txSeries.length - 1] ?? 0
|
||||
const rxPeak = rxSeries.reduce((m, v) => Math.max(m, v), 0)
|
||||
const txPeak = txSeries.reduce((m, v) => Math.max(m, v), 0)
|
||||
|
||||
let rxBytesDelta = 0
|
||||
let txBytesDelta = 0
|
||||
let sessions = 0
|
||||
for (const arr of byIface.values()) {
|
||||
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
||||
const first = sorted[0]
|
||||
const last = sorted[sorted.length - 1]
|
||||
if (first && last) {
|
||||
const dRx = last.rxBytes - first.rxBytes
|
||||
const dTx = last.txBytes - first.txBytes
|
||||
rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes
|
||||
txBytesDelta += dTx >= 0 ? dTx : last.txBytes
|
||||
if (last.running && !last.disabled) sessions += 1
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
site: s.site || "—",
|
||||
country: s.country || "UN",
|
||||
status,
|
||||
rxNow,
|
||||
txNow,
|
||||
rxPeak,
|
||||
txPeak,
|
||||
rxTotal: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)),
|
||||
txTotal: Number((txBytesDelta / (1024 ** 3)).toFixed(1)),
|
||||
sessions,
|
||||
rxSeries,
|
||||
txSeries,
|
||||
}
|
||||
}
|
||||
|
||||
const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/traffic/settings", async (_req, reply) => {
|
||||
const settings = getTrafficSettings()
|
||||
const state = getTrafficCollectorState()
|
||||
return reply.send({
|
||||
enabled: settings.enabled,
|
||||
intervalSec: settings.intervalSec,
|
||||
retentionDays: settings.retentionDays,
|
||||
lastCollectedAt: settings.lastCollectedAt ?? null,
|
||||
lastDurationMs: settings.lastDurationMs ?? null,
|
||||
lastError: settings.lastError || null,
|
||||
collectorRunning: state.running,
|
||||
})
|
||||
})
|
||||
|
||||
app.put("/traffic/settings", async (req, reply) => {
|
||||
const body = req.body as {
|
||||
enabled?: boolean
|
||||
intervalSec?: number | string
|
||||
retentionDays?: number | string
|
||||
}
|
||||
const intervalSec = body.intervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.intervalSec), 10) || 30)
|
||||
const retentionDays = body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14)
|
||||
const updated = updateTrafficSettings({
|
||||
enabled: body.enabled,
|
||||
intervalSec,
|
||||
retentionDays,
|
||||
})
|
||||
return reply.send({
|
||||
ok: true,
|
||||
settings: {
|
||||
enabled: updated.enabled,
|
||||
intervalSec: updated.intervalSec,
|
||||
retentionDays: updated.retentionDays,
|
||||
lastCollectedAt: updated.lastCollectedAt ?? null,
|
||||
lastDurationMs: updated.lastDurationMs ?? null,
|
||||
lastError: updated.lastError || null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
app.post("/traffic/collect-now", async (_req, reply) => {
|
||||
await collectTrafficOnce()
|
||||
const updated = getTrafficSettings()
|
||||
return reply.send({
|
||||
ok: true,
|
||||
lastCollectedAt: updated.lastCollectedAt ?? null,
|
||||
lastDurationMs: updated.lastDurationMs ?? null,
|
||||
lastError: updated.lastError || null,
|
||||
})
|
||||
})
|
||||
|
||||
app.get("/traffic/servers", async (req, reply) => {
|
||||
const q = req.query as { range?: string }
|
||||
const minutes = rangeToMinutes(q.range)
|
||||
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const data = allServers.map((s): TrafficServerDto => {
|
||||
const snap = latestSnapshot(s.id)
|
||||
const status: TrafficServerDto["status"] =
|
||||
snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
|
||||
|
||||
const rows = readServerSamplesInRange(s.id, sinceIso)
|
||||
return buildServerTraffic(s, status, rows)
|
||||
})
|
||||
|
||||
return reply.send({ servers: data })
|
||||
})
|
||||
|
||||
app.get("/traffic/servers/:id/interfaces", async (req, reply) => {
|
||||
const p = req.params as { id?: string | number }
|
||||
const serverId = Number.parseInt(String(p.id ?? ""), 10)
|
||||
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "id is required" })
|
||||
|
||||
const allRows = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()
|
||||
const server = allRows[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
const sinceIso = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
|
||||
const rows = readServerSamplesInRange(serverId, sinceIso)
|
||||
const byIface = new Map<string, typeof rows>()
|
||||
for (const r of rows) {
|
||||
const arr = byIface.get(r.interfaceName) ?? []
|
||||
arr.push(r)
|
||||
byIface.set(r.interfaceName, arr)
|
||||
}
|
||||
const interfaces: TrafficInterfaceDto[] = [...byIface.entries()].map(([name, arr]) => {
|
||||
const sorted = arr.sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
||||
const last = sorted[sorted.length - 1]
|
||||
return {
|
||||
name,
|
||||
running: Boolean(last?.running),
|
||||
disabled: Boolean(last?.disabled),
|
||||
rxNow: Math.round((last?.rxBps ?? 0) / 1_000_000),
|
||||
txNow: Math.round((last?.txBps ?? 0) / 1_000_000),
|
||||
}
|
||||
}).sort((a, b) => (b.rxNow + b.txNow) - (a.rxNow + a.txNow))
|
||||
|
||||
return reply.send({ interfaces })
|
||||
})
|
||||
|
||||
app.get("/traffic/servers/:id", async (req, reply) => {
|
||||
const p = req.params as { id?: string | number }
|
||||
const q = req.query as { range?: string; iface?: string }
|
||||
const serverId = Number.parseInt(String(p.id ?? ""), 10)
|
||||
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "id is required" })
|
||||
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
const minutes = rangeToMinutes(q.range)
|
||||
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
|
||||
const rows = readServerSamplesInRange(server.id, sinceIso)
|
||||
const snap = latestSnapshot(server.id)
|
||||
const status: TrafficServerDto["status"] =
|
||||
snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
|
||||
const data = buildServerTraffic(server, status, rows, q.iface && q.iface !== "__all__" ? q.iface : undefined)
|
||||
return reply.send({ server: data })
|
||||
})
|
||||
}
|
||||
|
||||
export default trafficRoutes
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
import { MikrotikClient } from "../services/mikrotik.js"
|
||||
import {
|
||||
collectUptimeOnce,
|
||||
readProbeRows,
|
||||
readProbeSamplesSince,
|
||||
readResourceSamplesSince,
|
||||
readUptimeSettings,
|
||||
replaceProbes,
|
||||
updateUptimeSettings,
|
||||
} from "../services/uptime-collector.js"
|
||||
|
||||
function rangeToMinutes(range: string | undefined): number {
|
||||
switch ((range ?? "1h").toLowerCase()) {
|
||||
case "5m": return 5
|
||||
case "15m": return 15
|
||||
case "1h": return 60
|
||||
case "4h": return 240
|
||||
case "24h": return 1440
|
||||
default: return 60
|
||||
}
|
||||
}
|
||||
|
||||
function toSeries(values: number[], target = 40): number[] {
|
||||
if (values.length === 0) return Array(target).fill(0)
|
||||
if (values.length === target) return values
|
||||
if (values.length > target) return values.slice(values.length - target)
|
||||
return [...Array(target - values.length).fill(values[0] ?? 0), ...values]
|
||||
}
|
||||
|
||||
function parseRateToMbps(raw: unknown): number {
|
||||
const txt = String(raw ?? "").trim().toLowerCase()
|
||||
if (!txt) return 0
|
||||
const m = txt.match(/^(\d+(?:\.\d+)?)\s*(g|m|k)?(?:bps|bit\/s|bits\/s)?$/)
|
||||
if (m) {
|
||||
const n = Number.parseFloat(m[1])
|
||||
const u = m[2] ?? ""
|
||||
if (!Number.isFinite(n)) return 0
|
||||
if (u === "g") return n * 1000
|
||||
if (u === "m") return n
|
||||
if (u === "k") return n / 1000
|
||||
return n / 1_000_000
|
||||
}
|
||||
const n = Number.parseFloat(txt.replace(/[^\d.]/g, ""))
|
||||
if (!Number.isFinite(n)) return 0
|
||||
return n > 10000 ? n / 1_000_000 : n
|
||||
}
|
||||
|
||||
const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/uptime/settings", async (_req, reply) => {
|
||||
const s = readUptimeSettings()
|
||||
return reply.send({
|
||||
enabled: s.enabled,
|
||||
intervalSec: s.intervalSec,
|
||||
retentionDays: s.retentionDays,
|
||||
lastCollectedAt: s.lastCollectedAt ?? null,
|
||||
lastDurationMs: s.lastDurationMs ?? null,
|
||||
lastError: s.lastError || null,
|
||||
})
|
||||
})
|
||||
|
||||
app.put("/uptime/settings", async (req, reply) => {
|
||||
const body = req.body as { enabled?: boolean; intervalSec?: number | string; retentionDays?: number | string }
|
||||
const updated = updateUptimeSettings({
|
||||
enabled: body.enabled,
|
||||
intervalSec: body.intervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.intervalSec), 10) || 15),
|
||||
retentionDays: body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14),
|
||||
})
|
||||
return reply.send({
|
||||
ok: true,
|
||||
settings: {
|
||||
enabled: updated.enabled,
|
||||
intervalSec: updated.intervalSec,
|
||||
retentionDays: updated.retentionDays,
|
||||
lastCollectedAt: updated.lastCollectedAt ?? null,
|
||||
lastDurationMs: updated.lastDurationMs ?? null,
|
||||
lastError: updated.lastError || null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
app.post("/uptime/collect-now", async (_req, reply) => {
|
||||
await collectUptimeOnce()
|
||||
const s = readUptimeSettings()
|
||||
return reply.send({
|
||||
ok: true,
|
||||
lastCollectedAt: s.lastCollectedAt ?? null,
|
||||
lastDurationMs: s.lastDurationMs ?? null,
|
||||
lastError: s.lastError || null,
|
||||
})
|
||||
})
|
||||
|
||||
app.get("/uptime/probes", async (_req, reply) => {
|
||||
return reply.send({ probes: readProbeRows() })
|
||||
})
|
||||
|
||||
app.put("/uptime/probes", async (req, reply) => {
|
||||
const body = req.body as {
|
||||
probes?: Array<{
|
||||
id?: string
|
||||
srcServerId?: string | number
|
||||
srcInterface?: string
|
||||
name?: string
|
||||
target?: string
|
||||
filter?: string
|
||||
enabled?: boolean
|
||||
}>
|
||||
}
|
||||
const normalized = (body.probes ?? [])
|
||||
.map((p, i) => ({
|
||||
id: p.id || `p-${Date.now()}-${i}`,
|
||||
srcServerId: Number.parseInt(String(p.srcServerId ?? ""), 10),
|
||||
srcInterface: String(p.srcInterface ?? "").trim(),
|
||||
name: String(p.name ?? "").trim(),
|
||||
target: String(p.target ?? "").trim(),
|
||||
probeFilter: String(p.filter ?? "—"),
|
||||
enabled: p.enabled !== false,
|
||||
}))
|
||||
.filter((p) => Number.isFinite(p.srcServerId) && p.name && p.target)
|
||||
replaceProbes(normalized)
|
||||
return reply.send({ ok: true })
|
||||
})
|
||||
|
||||
app.get("/uptime/sources/:id/interfaces", async (req, reply) => {
|
||||
const params = req.params as { id?: string | number }
|
||||
const serverId = Number.parseInt(String(params.id ?? ""), 10)
|
||||
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "Invalid server id" })
|
||||
const srv = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
if (!srv) return reply.status(404).send({ error: "Server not found" })
|
||||
try {
|
||||
const rows = await MikrotikClient.fromServer(srv).getInterfaces()
|
||||
const interfaces = rows
|
||||
.map((r) => ({
|
||||
name: String(r.name ?? "").trim(),
|
||||
running: String(r.running ?? "false").toLowerCase() === "true",
|
||||
disabled: String(r.disabled ?? "false").toLowerCase() === "true",
|
||||
}))
|
||||
.filter((r) => r.name.length > 0)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return reply.send({ interfaces })
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Failed to fetch interfaces"
|
||||
return reply.status(502).send({ error: message })
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/uptime/speed-test", async (req, reply) => {
|
||||
const body = req.body as {
|
||||
srcServerId?: string | number
|
||||
dstServerId?: string | number
|
||||
srcInterface?: string
|
||||
dstInterface?: string
|
||||
protocol?: "tcp" | "udp"
|
||||
direction?: "transmit" | "receive" | "both"
|
||||
durationSec?: string | number
|
||||
}
|
||||
const srcId = Number.parseInt(String(body.srcServerId ?? ""), 10)
|
||||
const dstId = Number.parseInt(String(body.dstServerId ?? ""), 10)
|
||||
if (!Number.isFinite(srcId) || !Number.isFinite(dstId)) {
|
||||
return reply.status(400).send({ error: "Invalid src/dst server id" })
|
||||
}
|
||||
if (srcId === dstId) return reply.status(400).send({ error: "Source and destination must be different" })
|
||||
const src = db.select().from(servers).where(eq(servers.id, srcId)).limit(1).all()[0]
|
||||
const dst = db.select().from(servers).where(eq(servers.id, dstId)).limit(1).all()[0]
|
||||
if (!src || !dst) return reply.status(404).send({ error: "Server not found" })
|
||||
try {
|
||||
const protocol = body.protocol === "udp" ? "udp" : "tcp"
|
||||
const direction = body.direction === "transmit" || body.direction === "receive" ? body.direction : "both"
|
||||
const durationSec = Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10)
|
||||
const srcInterface = String(body.srcInterface ?? "").trim()
|
||||
const dstInterface = String(body.dstInterface ?? "").trim()
|
||||
const srcClient = MikrotikClient.fromServer(src)
|
||||
const dstAddressRows = dstInterface
|
||||
? await MikrotikClient.fromServer(dst).getIpAddresses().catch(() => [])
|
||||
: []
|
||||
const dstAddress = dstInterface
|
||||
? String(
|
||||
dstAddressRows.find((a) => String(a.interface ?? "") === dstInterface && String(a.address ?? "").includes("/"))?.address ??
|
||||
"",
|
||||
).split("/")[0]
|
||||
: dst.host
|
||||
if (dstInterface && !dstAddress) {
|
||||
return reply.status(400).send({ error: `На интерфейсе назначения '${dstInterface}' нет IP-адреса` })
|
||||
}
|
||||
const initialAddress = dstAddress || dst.host
|
||||
|
||||
const rows = await srcClient.bandwidthTest({
|
||||
address: initialAddress,
|
||||
user: dst.username,
|
||||
password: dst.password,
|
||||
protocol,
|
||||
direction,
|
||||
durationSec,
|
||||
})
|
||||
const txSeries = rows
|
||||
.map((r) => parseRateToMbps(r["tx-current"] ?? r["tx-10-second-average"] ?? r["tx-total-average"]))
|
||||
.filter((v) => Number.isFinite(v) && v >= 0)
|
||||
const rxSeries = rows
|
||||
.map((r) => parseRateToMbps(r["rx-current"] ?? r["rx-10-second-average"] ?? r["rx-total-average"]))
|
||||
.filter((v) => Number.isFinite(v) && v >= 0)
|
||||
const last = rows[rows.length - 1] ?? {}
|
||||
const txAvg = parseRateToMbps(last["tx-total-average"] ?? last["tx-10-second-average"])
|
||||
const rxAvg = parseRateToMbps(last["rx-total-average"] ?? last["rx-10-second-average"])
|
||||
return reply.send({
|
||||
ok: true,
|
||||
result: {
|
||||
srcInterface: srcInterface || null,
|
||||
dstInterface: dstInterface || null,
|
||||
address: initialAddress,
|
||||
protocol,
|
||||
direction,
|
||||
durationSec,
|
||||
txAvgMbps: txAvg || (txSeries.length ? txSeries.reduce((a, b) => a + b, 0) / txSeries.length : 0),
|
||||
rxAvgMbps: rxAvg || (rxSeries.length ? rxSeries.reduce((a, b) => a + b, 0) / rxSeries.length : 0),
|
||||
txSeries,
|
||||
rxSeries,
|
||||
raw: rows,
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
const raw = e instanceof Error ? e.message : "Bandwidth test failed"
|
||||
const message = /timed out|socket hang up|ECONNRESET/i.test(raw)
|
||||
? "BTTest timeout/socket hang up: проверь доступность destination, /tool bandwidth-server, firewall и корректность интерфейсов"
|
||||
: raw
|
||||
return reply.status(502).send({ error: message })
|
||||
}
|
||||
})
|
||||
|
||||
app.get("/uptime/speed-test/endpoints/:id/interfaces", async (req, reply) => {
|
||||
const params = req.params as { id?: string | number }
|
||||
const serverId = Number.parseInt(String(params.id ?? ""), 10)
|
||||
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "Invalid server id" })
|
||||
const srv = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
if (!srv) return reply.status(404).send({ error: "Server not found" })
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(srv)
|
||||
const [ifaces, ips] = await Promise.all([
|
||||
client.getInterfaces(),
|
||||
client.getIpAddresses(),
|
||||
])
|
||||
const interfaces = ifaces
|
||||
.map((i) => {
|
||||
const name = String(i.name ?? "").trim()
|
||||
const addresses = ips
|
||||
.filter((a) => String(a.interface ?? "") === name)
|
||||
.map((a) => String(a.address ?? "").split("/")[0])
|
||||
.filter((x) => x.length > 0)
|
||||
return {
|
||||
name,
|
||||
running: String(i.running ?? "false").toLowerCase() === "true",
|
||||
disabled: String(i.disabled ?? "false").toLowerCase() === "true",
|
||||
addresses,
|
||||
}
|
||||
})
|
||||
.filter((i) => i.name.length > 0)
|
||||
return reply.send({ interfaces })
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Failed to fetch speed-test interfaces"
|
||||
return reply.status(502).send({ error: message })
|
||||
}
|
||||
})
|
||||
|
||||
app.get("/uptime/overview", async (req, reply) => {
|
||||
const q = req.query as { range?: string }
|
||||
const minutes = rangeToMinutes(q.range)
|
||||
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
|
||||
|
||||
const allServers = db.select().from(servers).all()
|
||||
const probeRows = readProbeRows()
|
||||
const probeSamples = readProbeSamplesSince(sinceIso)
|
||||
|
||||
const probes = probeRows.map((p) => {
|
||||
const rows = probeSamples.filter((r) => r.probeId === p.id)
|
||||
const last = rows[rows.length - 1]
|
||||
const series = toSeries(rows.map((r) => r.rttMs ?? 0))
|
||||
return {
|
||||
id: p.id,
|
||||
srcServerId: String(p.srcServerId),
|
||||
srcInterface: p.srcInterface || "",
|
||||
name: p.name,
|
||||
target: p.target,
|
||||
filter: p.probeFilter || "—",
|
||||
rtt: last?.rttMs ?? null,
|
||||
loss: last?.lossPct ?? 100,
|
||||
status: (last?.status ?? "down") as "up" | "warn" | "down",
|
||||
series,
|
||||
enabled: p.enabled,
|
||||
}
|
||||
})
|
||||
|
||||
const resources = allServers.map((s) => {
|
||||
const rows = readResourceSamplesSince(sinceIso, s.id)
|
||||
const last = rows[rows.length - 1]
|
||||
const cpuHistory = toSeries(rows.map((r) => r.cpuLoad), 40)
|
||||
return {
|
||||
serverId: String(s.id),
|
||||
cpu: last?.cpuLoad ?? 0,
|
||||
cpuHistory,
|
||||
ramUsed: Math.max(0, ((last?.totalMemory ?? 0) - (last?.freeMemory ?? 0)) / (1024 * 1024)),
|
||||
ramTotal: Math.max(0, (last?.totalMemory ?? 0) / (1024 * 1024)),
|
||||
hddUsed: Math.max(0, ((last?.totalHddSpace ?? 0) - (last?.freeHddSpace ?? 0)) / (1024 * 1024)),
|
||||
hddTotal: Math.max(0, (last?.totalHddSpace ?? 0) / (1024 * 1024)),
|
||||
uptimeSeconds: last?.uptimeSeconds ?? 0,
|
||||
boardName: last?.boardName || "RouterBOARD",
|
||||
temp: undefined as number | undefined,
|
||||
}
|
||||
})
|
||||
|
||||
return reply.send({ probes, resources })
|
||||
})
|
||||
}
|
||||
|
||||
export default uptimeRoutes
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers, serverSnapshots } from "../db/schema.js"
|
||||
import type { SnapshotInsert } from "../db/schema.js"
|
||||
import type { SnapshotRead } from "../types/server.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
|
||||
// ── pollServer ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Connect to a RouterOS device, collect system info, persist a snapshot,
|
||||
* and sync the server's display name from system/identity.
|
||||
*/
|
||||
export async function pollServer(serverId: number): Promise<SnapshotRead> {
|
||||
const server = db
|
||||
.select()
|
||||
.from(servers)
|
||||
.where(eq(servers.id, serverId))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
|
||||
if (!server) {
|
||||
throw new Error(`Server with id=${serverId} not found`)
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const t0 = performance.now()
|
||||
|
||||
const partialSnap: Partial<SnapshotInsert> = {
|
||||
serverId,
|
||||
polledAt: now,
|
||||
status: "offline",
|
||||
latencyMs: null,
|
||||
}
|
||||
|
||||
try {
|
||||
// Fire all requests in parallel for speed
|
||||
const [identity, resource, ifaces, addresses] = await Promise.all([
|
||||
client.getIdentity(),
|
||||
client.getResource(),
|
||||
client.getInterfaces(),
|
||||
client.getIpAddresses(),
|
||||
])
|
||||
|
||||
const latencyMs = performance.now() - t0
|
||||
|
||||
// Parse RouterOS string values (RouterOS REST API returns everything as strings)
|
||||
const cpuLoad = parseInt(resource["cpu-load"], 10)
|
||||
const freeMem = parseInt(resource["free-memory"], 10)
|
||||
const totalMem = parseInt(resource["total-memory"], 10)
|
||||
|
||||
Object.assign(partialSnap, {
|
||||
status: "online",
|
||||
latencyMs,
|
||||
identityName: identity.name,
|
||||
rosVersion: resource["version"],
|
||||
boardName: resource["board-name"],
|
||||
uptime: resource["uptime"],
|
||||
cpuLoad: isNaN(cpuLoad) ? null : cpuLoad,
|
||||
freeMemory: isNaN(freeMem) ? null : freeMem,
|
||||
totalMemory: isNaN(totalMem) ? null : totalMem,
|
||||
rawInterfaces: JSON.stringify(ifaces),
|
||||
rawIpAddresses: JSON.stringify(addresses),
|
||||
} satisfies Partial<SnapshotInsert>)
|
||||
|
||||
// Keep server.name in sync with RouterOS identity
|
||||
db.update(servers)
|
||||
.set({ name: identity.name, updatedAt: now })
|
||||
.where(eq(servers.id, serverId))
|
||||
.run()
|
||||
|
||||
} catch (err) {
|
||||
// Log but don't throw — we still persist the offline snapshot
|
||||
console.warn(`[poller] server id=${serverId} unreachable:`, (err as Error).message)
|
||||
}
|
||||
|
||||
const [inserted] = db
|
||||
.insert(serverSnapshots)
|
||||
.values(partialSnap as SnapshotInsert)
|
||||
.returning()
|
||||
.all()
|
||||
|
||||
return toSnapshotRead(inserted)
|
||||
}
|
||||
|
||||
// ── helper ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function toSnapshotRead(s: typeof serverSnapshots.$inferSelect): SnapshotRead {
|
||||
return {
|
||||
id: s.id,
|
||||
serverId: s.serverId,
|
||||
polledAt: s.polledAt,
|
||||
status: s.status,
|
||||
latencyMs: s.latencyMs ?? null,
|
||||
rosVersion: s.rosVersion ?? null,
|
||||
boardName: s.boardName ?? null,
|
||||
uptime: s.uptime ?? null,
|
||||
cpuLoad: s.cpuLoad ?? null,
|
||||
freeMemory: s.freeMemory ?? null,
|
||||
totalMemory: s.totalMemory ?? null,
|
||||
identityName: s.identityName ?? null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { and, asc, eq, gte, lt } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers, trafficSamples, trafficSettings } from "../db/schema.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
|
||||
interface RosIfaceTraffic {
|
||||
name?: string
|
||||
running?: string
|
||||
disabled?: string
|
||||
"rx-byte"?: string
|
||||
"tx-byte"?: string
|
||||
"rx-bits-per-second"?: string
|
||||
"tx-bits-per-second"?: string
|
||||
}
|
||||
|
||||
export interface TrafficCollectorState {
|
||||
running: boolean
|
||||
lastRunAt: string | null
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let collecting = false
|
||||
const state: TrafficCollectorState = {
|
||||
running: false,
|
||||
lastRunAt: null,
|
||||
lastError: null,
|
||||
}
|
||||
|
||||
function toNum(raw: unknown): number {
|
||||
const n = Number.parseFloat(String(raw ?? "0"))
|
||||
return Number.isFinite(n) ? Math.max(0, Math.round(n)) : 0
|
||||
}
|
||||
|
||||
function getSettingsRow() {
|
||||
const row = db.select().from(trafficSettings).where(eq(trafficSettings.id, 1)).limit(1).all()[0]
|
||||
if (row) return row
|
||||
const now = new Date().toISOString()
|
||||
db.insert(trafficSettings).values({
|
||||
id: 1,
|
||||
enabled: true,
|
||||
intervalSec: 30,
|
||||
retentionDays: 14,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run()
|
||||
return db.select().from(trafficSettings).where(eq(trafficSettings.id, 1)).limit(1).all()[0]
|
||||
}
|
||||
|
||||
function cleanupOldSamples(retentionDays: number) {
|
||||
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString()
|
||||
db.delete(trafficSamples)
|
||||
.where(lt(trafficSamples.sampledAt, cutoff))
|
||||
.run()
|
||||
}
|
||||
|
||||
export async function collectTrafficOnce(): Promise<void> {
|
||||
if (collecting) return
|
||||
collecting = true
|
||||
const startedAt = Date.now()
|
||||
const now = new Date().toISOString()
|
||||
const settings = getSettingsRow()
|
||||
|
||||
try {
|
||||
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
|
||||
for (const srv of enabledServers) {
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(srv)
|
||||
const ifaces = await client.get<RosIfaceTraffic[]>("/interface")
|
||||
if (ifaces.length === 0) continue
|
||||
db.insert(trafficSamples).values(
|
||||
ifaces.map((i) => ({
|
||||
serverId: srv.id,
|
||||
interfaceName: i.name ?? "unknown",
|
||||
sampledAt: now,
|
||||
rxBytes: toNum(i["rx-byte"]),
|
||||
txBytes: toNum(i["tx-byte"]),
|
||||
rxBps: toNum(i["rx-bits-per-second"]),
|
||||
txBps: toNum(i["tx-bits-per-second"]),
|
||||
running: (i.running ?? "false") === "true",
|
||||
disabled: (i.disabled ?? "false") === "true",
|
||||
})),
|
||||
).run()
|
||||
} catch {
|
||||
// Continue collecting from remaining servers
|
||||
}
|
||||
}
|
||||
|
||||
cleanupOldSamples(Math.max(1, settings.retentionDays))
|
||||
db.update(trafficSettings).set({
|
||||
lastCollectedAt: now,
|
||||
lastDurationMs: Date.now() - startedAt,
|
||||
lastError: "",
|
||||
updatedAt: now,
|
||||
}).where(eq(trafficSettings.id, 1)).run()
|
||||
|
||||
state.lastRunAt = now
|
||||
state.lastError = null
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
db.update(trafficSettings).set({
|
||||
lastCollectedAt: now,
|
||||
lastDurationMs: Date.now() - startedAt,
|
||||
lastError: msg,
|
||||
updatedAt: now,
|
||||
}).where(eq(trafficSettings.id, 1)).run()
|
||||
state.lastRunAt = now
|
||||
state.lastError = msg
|
||||
} finally {
|
||||
collecting = false
|
||||
}
|
||||
}
|
||||
|
||||
export function stopTrafficCollector() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
state.running = false
|
||||
}
|
||||
|
||||
export function restartTrafficCollector() {
|
||||
stopTrafficCollector()
|
||||
const settings = getSettingsRow()
|
||||
if (!settings.enabled) return
|
||||
const intervalMs = Math.max(5, settings.intervalSec) * 1000
|
||||
timer = setInterval(() => {
|
||||
void collectTrafficOnce()
|
||||
}, intervalMs)
|
||||
state.running = true
|
||||
}
|
||||
|
||||
export function getTrafficCollectorState(): TrafficCollectorState {
|
||||
return { ...state }
|
||||
}
|
||||
|
||||
export function getTrafficSettings() {
|
||||
return getSettingsRow()
|
||||
}
|
||||
|
||||
export function updateTrafficSettings(patch: {
|
||||
enabled?: boolean
|
||||
intervalSec?: number
|
||||
retentionDays?: number
|
||||
}) {
|
||||
const prev = getSettingsRow()
|
||||
const next = {
|
||||
enabled: patch.enabled ?? prev.enabled,
|
||||
intervalSec: patch.intervalSec ?? prev.intervalSec,
|
||||
retentionDays: patch.retentionDays ?? prev.retentionDays,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
db.update(trafficSettings).set(next).where(eq(trafficSettings.id, 1)).run()
|
||||
restartTrafficCollector()
|
||||
return getSettingsRow()
|
||||
}
|
||||
|
||||
export function readServerSamplesInRange(serverId: number, sinceIso: string) {
|
||||
return db.select()
|
||||
.from(trafficSamples)
|
||||
.where(and(
|
||||
eq(trafficSamples.serverId, serverId),
|
||||
gte(trafficSamples.sampledAt, sinceIso),
|
||||
))
|
||||
.orderBy(asc(trafficSamples.sampledAt))
|
||||
.all()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { and, asc, eq, gte, lt } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import {
|
||||
servers,
|
||||
uptimeProbeSamples,
|
||||
uptimeProbes,
|
||||
uptimeResourceSamples,
|
||||
uptimeSettings,
|
||||
} from "../db/schema.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let collecting = false
|
||||
|
||||
function parseDurationToSeconds(raw: string | undefined): number {
|
||||
if (!raw) return 0
|
||||
let total = 0
|
||||
for (const m of raw.matchAll(/(\d+)(w|d|h|m(?!s)|s)/g)) {
|
||||
const n = Number.parseInt(m[1], 10)
|
||||
if (!Number.isFinite(n)) continue
|
||||
switch (m[2]) {
|
||||
case "w": total += n * 604800; break
|
||||
case "d": total += n * 86400; break
|
||||
case "h": total += n * 3600; break
|
||||
case "m": total += n * 60; break
|
||||
case "s": total += n; break
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
function toNum(raw: unknown): number {
|
||||
const n = Number.parseInt(String(raw ?? "0"), 10)
|
||||
return Number.isFinite(n) ? Math.max(0, n) : 0
|
||||
}
|
||||
|
||||
function parsePingTimeMs(raw: string | undefined): number | null {
|
||||
if (!raw) return null
|
||||
const s = String(raw).trim().toLowerCase().replace(",", ".")
|
||||
const us = s.match(/^(\d+(?:\.\d+)?)\s*us$/)
|
||||
if (us) return Number.parseFloat(us[1]) / 1000
|
||||
const ms = s.match(/^(\d+(?:\.\d+)?)\s*ms$/)
|
||||
if (ms) return Number.parseFloat(ms[1])
|
||||
const sec = s.match(/^(\d+(?:\.\d+)?)\s*s$/)
|
||||
if (sec) return Number.parseFloat(sec[1]) * 1000
|
||||
|
||||
// HH:MM:SS(.sss) from some RouterOS outputs
|
||||
const clock = s.match(/^(\d+):(\d+):(\d+(?:\.\d+)?)$/)
|
||||
if (clock) {
|
||||
const h = Number.parseFloat(clock[1])
|
||||
const m = Number.parseFloat(clock[2])
|
||||
const sc = Number.parseFloat(clock[3])
|
||||
return (h * 3600 + m * 60 + sc) * 1000
|
||||
}
|
||||
|
||||
const n = Number.parseFloat(s)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
function getSettings() {
|
||||
const row = db.select().from(uptimeSettings).where(eq(uptimeSettings.id, 1)).limit(1).all()[0]
|
||||
if (row) return row
|
||||
const now = new Date().toISOString()
|
||||
db.insert(uptimeSettings).values({
|
||||
id: 1,
|
||||
enabled: true,
|
||||
intervalSec: 15,
|
||||
retentionDays: 14,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run()
|
||||
return db.select().from(uptimeSettings).where(eq(uptimeSettings.id, 1)).limit(1).all()[0]
|
||||
}
|
||||
|
||||
function cleanup(retentionDays: number) {
|
||||
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString()
|
||||
db.delete(uptimeProbeSamples).where(lt(uptimeProbeSamples.sampledAt, cutoff)).run()
|
||||
db.delete(uptimeResourceSamples).where(lt(uptimeResourceSamples.sampledAt, cutoff)).run()
|
||||
}
|
||||
|
||||
function parsePing(results: Array<{ time?: string; status?: string; sent?: string; received?: string; "packet-loss"?: string }>) {
|
||||
const sum = [...results].reverse().find((r) => r.sent || r.received || r["packet-loss"])
|
||||
const lossRaw = sum?.["packet-loss"] ?? "100%"
|
||||
const lossPct = Number.parseInt(String(lossRaw).replace("%", ""), 10)
|
||||
const okReplies = results.filter((r) => r.time && r.status !== "timeout")
|
||||
const rtts = okReplies
|
||||
.map((r) => parsePingTimeMs(r.time))
|
||||
.filter((v): v is number => v != null && Number.isFinite(v))
|
||||
const avgFromReplies = rtts.length ? Math.round(rtts.reduce((a, b) => a + b, 0) / rtts.length) : null
|
||||
const avgFromSummary = parsePingTimeMs(sum?.time)
|
||||
const avgRtt = avgFromReplies ?? (avgFromSummary != null ? Math.round(avgFromSummary) : null)
|
||||
const loss = Number.isFinite(lossPct) ? Math.max(0, Math.min(100, lossPct)) : 100
|
||||
const status: "up" | "warn" | "down" = loss >= 100 ? "down" : (loss > 1 || (avgRtt ?? 0) > 60 ? "warn" : "up")
|
||||
return { avgRtt, loss, status }
|
||||
}
|
||||
|
||||
export async function collectUptimeOnce(): Promise<void> {
|
||||
if (collecting) return
|
||||
collecting = true
|
||||
const started = Date.now()
|
||||
const now = new Date().toISOString()
|
||||
const settings = getSettings()
|
||||
|
||||
try {
|
||||
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
|
||||
for (const s of enabledServers) {
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(s)
|
||||
const resource = await client.getResource()
|
||||
db.insert(uptimeResourceSamples).values({
|
||||
serverId: s.id,
|
||||
sampledAt: now,
|
||||
status: "online",
|
||||
cpuLoad: toNum(resource["cpu-load"]),
|
||||
freeMemory: toNum(resource["free-memory"]),
|
||||
totalMemory: toNum(resource["total-memory"]),
|
||||
freeHddSpace: toNum(resource["free-hdd-space"]),
|
||||
totalHddSpace: toNum(resource["total-hdd-space"]),
|
||||
uptimeSeconds: parseDurationToSeconds(resource["uptime"]),
|
||||
boardName: String(resource["board-name"] ?? ""),
|
||||
rosVersion: String(resource["version"] ?? ""),
|
||||
}).run()
|
||||
} catch {
|
||||
db.insert(uptimeResourceSamples).values({
|
||||
serverId: s.id,
|
||||
sampledAt: now,
|
||||
status: "offline",
|
||||
cpuLoad: 0,
|
||||
freeMemory: 0,
|
||||
totalMemory: 0,
|
||||
freeHddSpace: 0,
|
||||
totalHddSpace: 0,
|
||||
uptimeSeconds: 0,
|
||||
boardName: "",
|
||||
rosVersion: "",
|
||||
}).run()
|
||||
}
|
||||
}
|
||||
|
||||
const probes = db.select().from(uptimeProbes).where(eq(uptimeProbes.enabled, true)).orderBy(asc(uptimeProbes.sortOrder)).all()
|
||||
for (const p of probes) {
|
||||
const src = enabledServers.find((s) => s.id === p.srcServerId)
|
||||
if (!src) continue
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(src)
|
||||
const results = await client.ping(p.target, 4, p.srcInterface || undefined)
|
||||
const parsed = parsePing(results)
|
||||
db.insert(uptimeProbeSamples).values({
|
||||
probeId: p.id,
|
||||
sampledAt: now,
|
||||
rttMs: parsed.avgRtt,
|
||||
lossPct: parsed.loss,
|
||||
status: parsed.status,
|
||||
}).run()
|
||||
} catch {
|
||||
db.insert(uptimeProbeSamples).values({
|
||||
probeId: p.id,
|
||||
sampledAt: now,
|
||||
rttMs: null,
|
||||
lossPct: 100,
|
||||
status: "down",
|
||||
}).run()
|
||||
}
|
||||
}
|
||||
|
||||
cleanup(Math.max(1, settings.retentionDays))
|
||||
db.update(uptimeSettings).set({
|
||||
lastCollectedAt: now,
|
||||
lastDurationMs: Date.now() - started,
|
||||
lastError: "",
|
||||
updatedAt: now,
|
||||
}).where(eq(uptimeSettings.id, 1)).run()
|
||||
} catch (e) {
|
||||
db.update(uptimeSettings).set({
|
||||
lastCollectedAt: now,
|
||||
lastDurationMs: Date.now() - started,
|
||||
lastError: e instanceof Error ? e.message : String(e),
|
||||
updatedAt: now,
|
||||
}).where(eq(uptimeSettings.id, 1)).run()
|
||||
} finally {
|
||||
collecting = false
|
||||
}
|
||||
}
|
||||
|
||||
export function restartUptimeCollector() {
|
||||
const s = getSettings()
|
||||
if (timer) clearInterval(timer)
|
||||
timer = null
|
||||
if (!s.enabled) return
|
||||
const intervalMs = Math.max(5, s.intervalSec) * 1000
|
||||
timer = setInterval(() => { void collectUptimeOnce() }, intervalMs)
|
||||
}
|
||||
|
||||
export function stopUptimeCollector() {
|
||||
if (timer) clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
export function readUptimeSettings() {
|
||||
return getSettings()
|
||||
}
|
||||
|
||||
export function updateUptimeSettings(patch: {
|
||||
enabled?: boolean
|
||||
intervalSec?: number
|
||||
retentionDays?: number
|
||||
}) {
|
||||
const prev = getSettings()
|
||||
const next = {
|
||||
enabled: patch.enabled ?? prev.enabled,
|
||||
intervalSec: patch.intervalSec ?? prev.intervalSec,
|
||||
retentionDays: patch.retentionDays ?? prev.retentionDays,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
db.update(uptimeSettings).set(next).where(eq(uptimeSettings.id, 1)).run()
|
||||
restartUptimeCollector()
|
||||
return readUptimeSettings()
|
||||
}
|
||||
|
||||
export function readProbeRows() {
|
||||
return db.select().from(uptimeProbes).orderBy(asc(uptimeProbes.sortOrder)).all()
|
||||
}
|
||||
|
||||
export function replaceProbes(rows: Array<{
|
||||
id: string
|
||||
srcServerId: number
|
||||
srcInterface: string
|
||||
name: string
|
||||
target: string
|
||||
probeFilter: string
|
||||
enabled: boolean
|
||||
}>) {
|
||||
db.delete(uptimeProbes).run()
|
||||
if (rows.length === 0) return
|
||||
const now = new Date().toISOString()
|
||||
db.insert(uptimeProbes).values(rows.map((r, i) => ({
|
||||
id: r.id,
|
||||
srcServerId: r.srcServerId,
|
||||
srcInterface: r.srcInterface || "",
|
||||
name: r.name,
|
||||
target: r.target,
|
||||
probeFilter: r.probeFilter || "—",
|
||||
enabled: r.enabled,
|
||||
sortOrder: i,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))).run()
|
||||
}
|
||||
|
||||
export function readProbeSamplesSince(sinceIso: string) {
|
||||
return db.select().from(uptimeProbeSamples)
|
||||
.where(gte(uptimeProbeSamples.sampledAt, sinceIso))
|
||||
.orderBy(asc(uptimeProbeSamples.sampledAt))
|
||||
.all()
|
||||
}
|
||||
|
||||
export function readResourceSamplesSince(sinceIso: string, serverId: number) {
|
||||
return db.select().from(uptimeResourceSamples)
|
||||
.where(and(
|
||||
eq(uptimeResourceSamples.serverId, serverId),
|
||||
gte(uptimeResourceSamples.sampledAt, sinceIso),
|
||||
))
|
||||
.orderBy(asc(uptimeResourceSamples.sampledAt))
|
||||
.all()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
import { z } from "zod"
|
||||
|
||||
// ── RouterOS raw response types ────────────────────────────────────────────────
|
||||
|
||||
export interface RosIdentity {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface RosResource {
|
||||
"uptime": string
|
||||
"version": string
|
||||
"build-time": string
|
||||
"factory-software": string
|
||||
"free-memory": string
|
||||
"total-memory": string
|
||||
"cpu": string
|
||||
"cpu-count": string
|
||||
"cpu-frequency": string
|
||||
"cpu-load": string
|
||||
"free-hdd-space": string
|
||||
"total-hdd-space": string
|
||||
"architecture-name": string
|
||||
"board-name": string
|
||||
"platform": string
|
||||
}
|
||||
|
||||
export interface RosInterface {
|
||||
".id": string
|
||||
name: string
|
||||
type: string
|
||||
mtu?: string
|
||||
"actual-mtu"?: string
|
||||
"mac-address"?: string
|
||||
running: string // "true" | "false" (RouterOS returns strings)
|
||||
disabled: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
export interface RosIpAddress {
|
||||
".id": string
|
||||
address: string // "192.168.1.1/24"
|
||||
network: string // "192.168.1.0"
|
||||
interface: string
|
||||
disabled: string
|
||||
dynamic?: string
|
||||
invalid?: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
// ── Zod schemas for API validation ────────────────────────────────────────────
|
||||
|
||||
export const ServerCreateSchema = z.object({
|
||||
host: z.string().min(1, "Host is required"),
|
||||
port: z.number().int().positive().default(443),
|
||||
username: z.string().default("admin"),
|
||||
password: z.string().default(""),
|
||||
useSsl: z.boolean().default(true),
|
||||
verifySsl: z.boolean().default(false),
|
||||
name: z.string().default(""),
|
||||
type: z.enum(["jump-host", "exit-node", "home-router"]).default("home-router"),
|
||||
site: z.string().default(""),
|
||||
country: z.string().default(""),
|
||||
asn: z.string().default(""),
|
||||
comment: z.string().default(""),
|
||||
enabled: z.boolean().default(true),
|
||||
})
|
||||
|
||||
export const ServerUpdateSchema = ServerCreateSchema.partial().omit({ host: true }).extend({
|
||||
host: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
export const TestConnectionSchema = z.object({
|
||||
host: z.string().min(1),
|
||||
port: z.number().int().positive().default(443),
|
||||
useSsl: z.boolean().default(true),
|
||||
verifySsl: z.boolean().default(false),
|
||||
apiPath: z.string().default("/rest"),
|
||||
username: z.string().default("admin"),
|
||||
password: z.string().default(""),
|
||||
})
|
||||
|
||||
export const ServerIdParamSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
})
|
||||
|
||||
export const SnapshotsQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().positive().max(500).default(50),
|
||||
})
|
||||
|
||||
// ── Response types (what the API returns) ─────────────────────────────────────
|
||||
|
||||
/** Flat server response — mirrors the frontend's Server interface from lib/data.ts */
|
||||
export interface ServerRead {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
useSsl: boolean
|
||||
verifySsl: boolean
|
||||
username: string
|
||||
password: string
|
||||
type: "jump-host" | "exit-node" | "home-router"
|
||||
site: string
|
||||
country: string
|
||||
asn: string
|
||||
comment: string
|
||||
enabled: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
// from latest snapshot (null if never polled)
|
||||
status: "online" | "offline" | null
|
||||
latency: number | null // ms
|
||||
os: string | null // RouterOS version
|
||||
model: string | null // board-name
|
||||
uptime: string | null
|
||||
cpuLoad: number | null
|
||||
freeMemory: number | null
|
||||
totalMemory: number | null
|
||||
identityName: string | null
|
||||
sessions: number // always 0 for now
|
||||
polledAt: string | null
|
||||
}
|
||||
|
||||
export interface SnapshotRead {
|
||||
id: number
|
||||
serverId: number
|
||||
polledAt: string
|
||||
status: "online" | "offline"
|
||||
latencyMs: number | null
|
||||
rosVersion: string | null
|
||||
boardName: string | null
|
||||
uptime: string | null
|
||||
cpuLoad: number | null
|
||||
freeMemory: number | null
|
||||
totalMemory: number | null
|
||||
identityName: string | null
|
||||
}
|
||||
|
||||
// ── BGP types ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Raw BGP session object returned by RouterOS 7.x REST API
|
||||
* (/rest/routing/bgp/session).
|
||||
*
|
||||
* NOTE: RouterOS 7.x does NOT have a "state" field.
|
||||
* - "established": "true" → session is Established
|
||||
* - Field absence → session is not established (Active/Idle/Connect…)
|
||||
* - "ebgp": "" → session role is eBGP
|
||||
* Capabilities are comma-separated short codes in "local.capabilities" /
|
||||
* "remote.capabilities" (e.g. "mp,rr,gr,as4,enhe,role").
|
||||
*/
|
||||
export interface RosBgpSession {
|
||||
".id": string
|
||||
"name"?: string
|
||||
// RouterOS 7.x state flags (no "state" field)
|
||||
"established"?: string // "true" when session is up
|
||||
"ebgp"?: string // "" when eBGP
|
||||
// Addressing
|
||||
"remote.address"?: string // "10.0.0.1" or "10.0.0.1/32"
|
||||
"remote.as"?: string
|
||||
"remote.id"?: string
|
||||
"remote.messages"?: string // total received messages
|
||||
"remote.hold-time"?: string // e.g. "1m30s"
|
||||
"remote.capabilities"?: string // "mp,rr,gr,as4"
|
||||
"remote.bytes"?: string
|
||||
"remote.eor"?: string
|
||||
"remote.afi"?: string
|
||||
"remote.gr-time"?: string
|
||||
"local.address"?: string
|
||||
"local.as"?: string
|
||||
"local.id"?: string
|
||||
"local.messages"?: string // total sent messages
|
||||
"local.capabilities"?: string // "mp,rr,enhe,role,gr,as4"
|
||||
"local.bytes"?: string
|
||||
"local.eor"?: string
|
||||
"local.afi"?: string
|
||||
"local.role"?: string // "ebgp-customer" | "ebgp-provider" | "ibgp" …
|
||||
"local.cluster-id"?: string
|
||||
// Timers
|
||||
"hold-time"?: string // "1m30s" (active hold time, RouterOS 7.x)
|
||||
"keepalive-time"?: string // "30s"
|
||||
// Routing
|
||||
"prefix-count"?: string
|
||||
"uptime"?: string // "1d5h13m23s580ms"
|
||||
"instance"?: string
|
||||
"multihop"?: string // "true" | "false"
|
||||
// Filters
|
||||
"input.filter"?: string
|
||||
"output.filter-chain"?: string
|
||||
"input.procid"?: string
|
||||
"output.procid"?: string
|
||||
// Timestamps
|
||||
"last-started"?: string
|
||||
"last-notification"?: string
|
||||
// Old RouterOS 6.x / some 7.x fields (kept for compatibility)
|
||||
"state"?: string
|
||||
"active-holdtime"?: string
|
||||
"total-messages-sent"?: string
|
||||
"total-messages-received"?: string
|
||||
"total-updates-sent"?: string
|
||||
"total-updates-received"?: string
|
||||
"4-octet-as-capability"?: string
|
||||
"refresh-capability"?: string
|
||||
"as4-capability"?: string
|
||||
"add-path-capability"?: string
|
||||
"graceful-restart-capability"?: string
|
||||
"extended-message-capability"?: string
|
||||
}
|
||||
|
||||
export interface BgpSessionRead {
|
||||
id: string
|
||||
serverId: number
|
||||
serverName: string
|
||||
serverSite: string
|
||||
serverCountry: string
|
||||
name: string
|
||||
peerIp: string
|
||||
remoteAs: number
|
||||
localAs: number
|
||||
localId: string
|
||||
remoteId: string
|
||||
state: string
|
||||
type: "eBGP" | "iBGP"
|
||||
uptime: string | null
|
||||
holdTime: number
|
||||
keepalive: number
|
||||
prefixesRx: number
|
||||
prefixesTx: number
|
||||
inputMessages: number
|
||||
outputMessages: number
|
||||
capabilities: string[]
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
// ── OSPF types ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Raw RouterOS 7.x /rest/routing/ospf/neighbor */
|
||||
export interface RosOspfNeighbor {
|
||||
".id": string
|
||||
"address": string // neighbor IP
|
||||
"router-id": string
|
||||
"instance": string // OSPF instance name
|
||||
"area": string // area NAME (not area-id)
|
||||
"interface": string // local interface
|
||||
"state": string // "Full" | "2-Way" | "ExStart" | "Exchange" | "Loading" | "Down"
|
||||
"adjacency"?: string // uptime of adjacency, e.g. "1h30m17s"
|
||||
"state-changes"?: string
|
||||
"timeout"?: string
|
||||
"dynamic"?: string
|
||||
"priority"?: string
|
||||
}
|
||||
|
||||
/** Raw RouterOS 7.x /rest/routing/ospf/area */
|
||||
export interface RosOspfArea {
|
||||
".id": string
|
||||
"name": string
|
||||
"area-id"?: string // "0.0.0.1" — may be absent for backbone when using ROS defaults
|
||||
"instance": string
|
||||
"type": string // "default" | "stub" | "nssa" | "totally-stub"
|
||||
"disabled": string
|
||||
"inactive": string
|
||||
".about"?: string // error note when area is inactive
|
||||
"nssa-translator"?: string
|
||||
}
|
||||
|
||||
/** Raw RouterOS 7.x /rest/routing/ospf/interface-template */
|
||||
export interface RosOspfInterfaceTemplate {
|
||||
".id": string
|
||||
"area": string // area NAME (not area-id)
|
||||
"interfaces"?: string // interface name or "*ID" ref
|
||||
"cost"?: string
|
||||
"priority"?: string
|
||||
"type"?: string // "ptp" | "broadcast" | "nbma" | "ptmp" | "ptmp-broadcast"
|
||||
"disabled": string
|
||||
"inactive": string
|
||||
"hello-interval"?: string // e.g. "10s"
|
||||
"dead-interval"?: string // e.g. "40s"
|
||||
"retransmit-interval"?: string
|
||||
"transmit-delay"?: string
|
||||
"use-bfd"?: string
|
||||
"instance-id"?: string
|
||||
".about"?: string
|
||||
}
|
||||
|
||||
/** Raw RouterOS 7.x /rest/routing/ospf/instance */
|
||||
export interface RosOspfInstance {
|
||||
".id": string
|
||||
"name": string
|
||||
"router-id": string // may be "main" to use main routing table router-id
|
||||
"version": string // "2" | "3"
|
||||
"disabled": string
|
||||
"inactive": string
|
||||
"redistribute"?: string
|
||||
"routing-table"?: string
|
||||
"vrf"?: string
|
||||
"in-filter-chain"?: string
|
||||
"out-filter-chain"?: string
|
||||
}
|
||||
|
||||
// ── Normalized OSPF read types (API response) ─────────────────────────────────
|
||||
|
||||
export interface OspfNeighborRead {
|
||||
id: string
|
||||
serverId: number
|
||||
serverName: string
|
||||
serverSite: string
|
||||
serverCountry: string
|
||||
address: string
|
||||
routerId: string
|
||||
instance: string
|
||||
area: string // area name
|
||||
areaId: string // area-id like "0.0.0.1"
|
||||
interface: string
|
||||
state: string
|
||||
uptime: string | null
|
||||
stateChanges: number
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface OspfInterfaceRead {
|
||||
id: string
|
||||
serverId: number
|
||||
serverName: string
|
||||
serverSite: string
|
||||
serverCountry: string
|
||||
instance: string
|
||||
area: string // area name
|
||||
areaId: string // area-id
|
||||
interface: string // interface name
|
||||
cost: number
|
||||
type: string // "ptp" | "broadcast" | ...
|
||||
disabled: boolean
|
||||
inactive: boolean
|
||||
priority: number
|
||||
helloInterval: number // seconds
|
||||
deadInterval: number // seconds
|
||||
useBfd: boolean
|
||||
}
|
||||
|
||||
export interface OspfInstanceRead {
|
||||
id: string
|
||||
serverId: number
|
||||
serverName: string
|
||||
serverSite: string
|
||||
serverCountry: string
|
||||
name: string
|
||||
routerId: string
|
||||
version: number
|
||||
disabled: boolean
|
||||
inactive: boolean
|
||||
redistribute: string
|
||||
}
|
||||
|
||||
// ── IP Route / Firewall / Log / Ping ─────────────────────────────────────────
|
||||
|
||||
export interface RosIpRoute {
|
||||
".id": string
|
||||
"dst-address": string
|
||||
"pref-src"?: string
|
||||
"gateway"?: string
|
||||
"distance"?: string
|
||||
"scope"?: string
|
||||
"active"?: string // "true"
|
||||
"dynamic"?: string
|
||||
"static"?: string
|
||||
"connect"?: string
|
||||
"bgp"?: string
|
||||
"ospf"?: string
|
||||
"rip"?: string
|
||||
"blackhole"?: string
|
||||
"unreachable"?: string
|
||||
"prohibit"?: string
|
||||
"interface"?: string
|
||||
"routing-mark"?: string
|
||||
"type"?: string
|
||||
}
|
||||
|
||||
export interface RosFirewallFilter {
|
||||
".id": string
|
||||
"chain": string
|
||||
"action": string
|
||||
"protocol"?: string
|
||||
"src-address"?: string
|
||||
"dst-address"?: string
|
||||
"src-port"?: string
|
||||
"dst-port"?: string
|
||||
"in-interface"?: string
|
||||
"out-interface"?: string
|
||||
"connection-state"?: string
|
||||
"src-address-list"?: string
|
||||
"dst-address-list"?: string
|
||||
"layer7-protocol"?: string
|
||||
"tls-host"?: string
|
||||
"comment"?: string
|
||||
"disabled"?: string
|
||||
"invalid"?: string
|
||||
"dynamic"?: string
|
||||
"bytes"?: string
|
||||
"packets"?: string
|
||||
}
|
||||
|
||||
export interface RosLogEntry {
|
||||
".id": string
|
||||
"time": string
|
||||
"message": string
|
||||
"topics": string
|
||||
}
|
||||
|
||||
/** One reply item from POST /rest/tool/ping */
|
||||
export interface RosPingResult {
|
||||
"host"?: string
|
||||
"seq": string
|
||||
"size"?: string
|
||||
"ttl"?: string
|
||||
"time"?: string
|
||||
"sent"?: string
|
||||
"received"?: string
|
||||
"packet-loss"?: string
|
||||
"status"?: string // "timeout" when lost
|
||||
}
|
||||
|
||||
// ── BFD types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Raw RouterOS 7.x /rest/routing/bfd/session */
|
||||
export interface RosBfdSession {
|
||||
".id": string
|
||||
"state": string // "up" | "down" | "init" | "admindown"
|
||||
"up"?: string // "true" when up
|
||||
"uptime"?: string // e.g. "6h15m29s"
|
||||
"local-address": string // "10.100.2.13%NSK-SERVHOST-MTS" (IP%iface)
|
||||
"remote-address": string // "10.100.2.14%NSK-SERVHOST-MTS"
|
||||
"multihop"?: string // "true" | "false"
|
||||
"multiplier"?: string // detection multiplier
|
||||
"desired-tx-interval"?: string // "200ms"
|
||||
"required-min-rx"?: string // "200ms"
|
||||
"actual-tx-interval"?: string // "200ms"
|
||||
"remote-min-rx"?: string // "200ms"
|
||||
"remote-min-tx"?: string // "200ms"
|
||||
"hold-time"?: string // "1s"
|
||||
"packets-rx"?: string
|
||||
"packets-tx"?: string
|
||||
"state-changes"?: string
|
||||
"vrf"?: string
|
||||
}
|
||||
|
||||
export interface BfdSessionRead {
|
||||
id: string
|
||||
serverId: number
|
||||
serverName: string
|
||||
serverSite: string
|
||||
serverCountry: string
|
||||
localAddr: string // IP only, without %iface
|
||||
remoteAddr: string // IP only
|
||||
interface: string // extracted from %iface suffix
|
||||
state: string // "Up" | "Down" | "Init" | "AdminDown"
|
||||
uptime: string | null
|
||||
multihop: boolean
|
||||
multiplier: number
|
||||
txInterval: number // ms
|
||||
rxInterval: number // ms
|
||||
holdTime: number // ms
|
||||
packetsRx: number
|
||||
packetsTx: number
|
||||
stateChanges: number
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user