173 lines
6.3 KiB
TypeScript
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 }
|
|
})
|
|
}
|