Files
MikrotikManager/backend/src/services/certificate-parse.ts
T

173 lines
6.3 KiB
TypeScript

import type { CertificateDto } from "@mmapp/contracts/certificates"
export type RosCertificateRow = Record<string, string | undefined>
/** Роль сертификата в контексте IPsec/IKEv2. */
export type CertRole = "ca" | "server" | "client" | "other"
/** Контекст для reference-based определения роли (peer/identity ссылки точнее, чем key-usage). */
export interface CertificateRoleContext {
/** Имена сертификатов из `peer.certificate`. */
peerCertNames?: Iterable<string>
/** Имена сертификатов из `identity.remote-certificate`. */
identityCertNames?: Iterable<string>
}
function rosTrue(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
/** Флаги RouterOS (`flags` = строка вида "KLAT"). */
function certFlags(row: RosCertificateRow): string {
return (row.flags ?? "").toUpperCase()
}
/**
* CA: `key-cert-sign`, серт self-signed (read-only `ca` пуст).
* Только флаг authority/`A` не годится: RouterOS ставит `A` и серверным сертам,
* т.к. дефолтный `key-usage` содержит `key-cert-sign`. Реальный признак — отсутствие подписавшего.
*/
export function isCaCertificate(row: RosCertificateRow): boolean {
const usage = row["key-usage"] ?? ""
const signer = (row.ca ?? "").trim()
if (signer !== "") return false
return certFlags(row).includes("A") || rosTrue(row.authority) || usage.includes("key-cert-sign")
}
/**
* Роль сертификата. Приоритет:
* 1. CA (authority / key-cert-sign без подписавшего);
* 2. серт из `peer.certificate` → server;
* 3. серт из `identity.remote-certificate` → client;
* 4. `key-usage` tls-server → server, tls-client → client (reference-based важнее: RouterOS
* дефолт содержит и tls-server, и tls-client).
*/
export function certificateRole(
row: RosCertificateRow,
ctx: CertificateRoleContext = {},
): CertRole {
const name = (row.name ?? "").trim()
if (isCaCertificate(row)) return "ca"
if (name && new Set(ctx.peerCertNames ?? []).has(name)) return "server"
if (name && new Set(ctx.identityCertNames ?? []).has(name)) return "client"
// Серт, подписанный локальным CA (`ca` заполнен), но не используемый как peer-серт — клиентский.
// `client1`/`anakondra` имеют дефолтный key-usage (tls-server+tls-client), поэтому по usage
// они выглядели бы «server».
if ((row.ca ?? "").trim() !== "") return "client"
const usage = row["key-usage"] ?? ""
if (usage.includes("tls-server")) return "server"
if (usage.includes("tls-client")) return "client"
return "other"
}
function parseRosDate(raw: string | undefined): Date | null {
if (!raw?.trim()) return null
const d = new Date(raw)
return Number.isNaN(d.getTime()) ? null : d
}
function fmtDate(d: Date): string {
return d.toISOString().slice(0, 10)
}
function parseSans(raw: string | undefined): string[] {
if (!raw?.trim()) return []
return raw
.split(",")
.map((part) => part.trim())
.map((part) => {
const m = part.match(/^(?:DNS|IP|email):(.+)$/i)
return (m?.[1] ?? part).trim()
})
.filter(Boolean)
}
function parseUsage(raw: string | undefined): CertificateDto["usage"] {
if (!raw?.trim()) return []
const out = new Set<CertificateDto["usage"][number]>()
for (const part of raw.split(",")) {
const token = part.trim().toLowerCase()
if (token === "tls-server") out.add("server")
else if (token === "tls-client") out.add("client")
else if (token === "key-cert-sign") out.add("ca")
else if (token === "crl-sign") out.add("crl-sign")
}
return [...out]
}
function parseFlags(raw: string | undefined): { revoked: boolean; expired: boolean } {
const flags = (raw ?? "").toUpperCase()
return {
revoked: flags.includes("R"),
expired: flags.includes("E"),
}
}
export function mapRosCertificateRow(
serverId: number,
serverName: string,
row: RosCertificateRow,
): CertificateDto | null {
const name = (row.name ?? "").trim()
if (!name) return null
const rosId = (row[".id"] ?? "").trim()
const fingerprint = (row.fingerprint ?? "").trim()
const serialNumber = (row["serial-number"] ?? "").trim()
const stableId = rosId || fingerprint || serialNumber || name
const { revoked, expired: expiredFlag } = parseFlags(row.flags)
const validFrom = parseRosDate(row["invalid-before"])
const validUntil = parseRosDate(row["invalid-after"])
const now = new Date()
const daysLeft = validUntil
? Math.ceil((validUntil.getTime() - now.getTime()) / 86_400_000)
: 0
let status: CertificateDto["status"] = "valid"
if (revoked) status = "revoked"
else if (expiredFlag || (validUntil != null && validUntil.getTime() < now.getTime())) status = "expired"
const keySize = Number.parseInt(String(row["key-size"] ?? "0"), 10)
return {
id: `${serverId}:${stableId}`,
name,
serverId: String(serverId),
serverName,
commonName: (row["common-name"] ?? name).trim(),
sans: parseSans(row["subject-alt-name"]),
issuedBy: (row.issuer ?? "—").trim() || "—",
validFrom: validFrom ? fmtDate(validFrom) : "—",
validUntil: validUntil ? fmtDate(validUntil) : "—",
daysLeft,
keySize: Number.isFinite(keySize) ? keySize : 0,
usage: parseUsage(row["key-usage"]),
trusted: row.trusted === "true",
status,
acmeStatus: row["acme-status"]?.trim() || undefined,
/** Имя CA, которым серт подписан на устройстве (read-only поле `ca`). */
signedByCertName: (row.ca ?? "").trim() || undefined,
}
}
export function mapRosCertificates(
serverId: number,
serverName: string,
rows: RosCertificateRow[],
): CertificateDto[] {
const seen = new Set<string>()
return rows
.map((row) => mapRosCertificateRow(serverId, serverName, row))
.filter((row): row is CertificateDto => row != null)
.map((cert, index) => {
if (!seen.has(cert.id)) {
seen.add(cert.id)
return cert
}
const uniqueId = `${cert.id}#${index}`
seen.add(uniqueId)
return { ...cert, id: uniqueId }
})
}