59 lines
5.7 KiB
TypeScript
59 lines
5.7 KiB
TypeScript
import { isIP } from "node:net"
|
|
import { lookup } from "node:dns/promises"
|
|
import { randomUUID } from "node:crypto"
|
|
import { z } from "zod"
|
|
import { pool } from "../../db/index.js"
|
|
import { MikrotikClient, type MikrotikConnectParams } from "../../services/mikrotik.js"
|
|
import { routerClient } from "./resources.js"
|
|
import { accessGeneration, encryptSecret, decryptSecret, fingerprint, McpError } from "./security.js"
|
|
import { audit, transaction } from "./store.js"
|
|
import type { AuthUser } from "../../lib/permissions.js"
|
|
import { hasPermission } from "../../lib/permissions.js"
|
|
|
|
export const managementChannelSchema = z.object({
|
|
serverId: z.number().int().positive(), host: z.string().refine(value => !!isIP(value), "Нужен буквальный IP независимого канала"),
|
|
port: z.number().int().min(1).max(65535), useSsl: z.boolean(), verifySsl: z.literal(true),
|
|
username: z.string().min(1).max(100), password: z.string().min(1).max(1000),
|
|
attestation: z.string().trim().min(30).max(2000), expiresAt: z.string().datetime({ offset: true }),
|
|
}).strict()
|
|
async function identity(client: MikrotikClient) {
|
|
const raw = await client.get<Record<string, unknown> | Record<string, unknown>[]>("/system/license", 5000)
|
|
const row = Array.isArray(raw) ? raw[0] : raw
|
|
const id = row?.["system-id"] ?? row?.["software-id"]
|
|
if (typeof id !== "string" || !id.trim()) throw new McpError("UNSUPPORTED_CAPABILITY", "Устройство не подтвердило стабильный идентификатор лицензии", 409)
|
|
return fingerprint(id.trim())
|
|
}
|
|
export async function registerManagementChannel(raw: unknown, actor: AuthUser) {
|
|
if (!hasPermission(actor.permissions, "mm:network:admin")) throw new McpError("PERMISSION_DENIED", "Для допуска независимого управления нужны права администратора сети")
|
|
const input = managementChannelSchema.parse(raw), { server, client } = await routerClient(input.serverId)
|
|
const primary = isIP(server.host) ? [server.host] : (await lookup(server.host, { all: true })).map(value => value.address)
|
|
if (primary.includes(input.host)) throw new McpError("INVALID_INPUT", "Другой порт или DNS-алиас основного адреса не является независимым каналом", 400)
|
|
const expiry = Date.parse(input.expiresAt)
|
|
if (expiry <= Date.now() || expiry > Math.min(Date.now() + 86400000, actor.expiresAt ?? Date.now() + 900000)) throw new McpError("INVALID_EXPIRATION", "Допуск канала ограничен 24 часами и текущими правами человека", 400)
|
|
const config: MikrotikConnectParams = { host: input.host, port: input.port, useSsl: input.useSsl, verifySsl: true, username: input.username, password: input.password }
|
|
const alternate = new MikrotikClient(config)
|
|
const [a, b] = await Promise.all([identity(client), identity(alternate)])
|
|
if (a !== b) throw new McpError("STATE_CONFLICT", "Независимый адрес ведёт к другому RouterOS", 409)
|
|
const id = randomUUID(), generation = await accessGeneration(), sealed = await encryptSecret(JSON.stringify(config))
|
|
await transaction(async db => {
|
|
await db.query("INSERT INTO mcp_management_channels(id,server_id,config_sealed,identity_hash,attestation,created_by,generation,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(server_id) DO UPDATE SET id=EXCLUDED.id,config_sealed=EXCLUDED.config_sealed,identity_hash=EXCLUDED.identity_hash,attestation=EXCLUDED.attestation,created_by=EXCLUDED.created_by,generation=EXCLUDED.generation,expires_at=EXCLUDED.expires_at,verified_at=now(),revoked_at=NULL", [id, input.serverId, sealed, a, input.attestation, actor.id, generation, input.expiresAt])
|
|
await audit(db, actor.id, "management.register", null, { channelId: id, serverId: input.serverId, expiresAt: input.expiresAt, verification: "identity-and-reachability", independence: "human-attestation" })
|
|
})
|
|
return { id, serverId: input.serverId, expiresAt: input.expiresAt, verification: "identity-and-reachability", independence: "human-attestation" }
|
|
}
|
|
export async function verifyManagementChannel(serverId: number, requiredId?: string) {
|
|
const { rows: [row] } = await pool.query("SELECT * FROM mcp_management_channels WHERE server_id=$1 AND revoked_at IS NULL AND expires_at>now()", [serverId])
|
|
if (!row || requiredId && row.id !== requiredId || row.generation !== await accessGeneration()) throw new McpError("APPROVAL_REQUIRED", "Нет актуального проверенного независимого канала управления", 409)
|
|
const config = JSON.parse(await decryptSecret(row.config_sealed)) as MikrotikConnectParams
|
|
const primary = (await routerClient(serverId)).client
|
|
const hashes = await Promise.all([identity(new MikrotikClient(config)), identity(primary)])
|
|
if (hashes.some(value => value !== row.identity_hash)) throw new McpError("STATE_CONFLICT", "Идентичность основного или management-канала изменилась", 409)
|
|
const changed = await pool.query("UPDATE mcp_management_channels SET verified_at=now() WHERE id=$1 AND revoked_at IS NULL AND expires_at>now() RETURNING id", [row.id])
|
|
if (!changed.rowCount) throw new McpError("APPROVAL_REQUIRED", "Допуск management-канала был отозван во время проверки", 409)
|
|
return { channelId: row.id as string, serverId, expiresAt: row.expires_at.toISOString() as string }
|
|
}
|
|
export async function managementEvidence(serverIds: number[]) {
|
|
try { return { requiresHuman: false, managementChannels: await Promise.all([...new Set(serverIds)].map(serverId => verifyManagementChannel(serverId))) } }
|
|
catch { return { requiresHuman: true, managementChannels: undefined } }
|
|
}
|