Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m18s
Docker images / frontend-image (push) Successful in 2m54s
Docker images / updater-image (push) Successful in 45s
Docker images / backend-image (push) Successful in 2m43s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 14s
Co-authored-by: Cursor <[email protected]>
131 lines
5.7 KiB
TypeScript
131 lines
5.7 KiB
TypeScript
import type { MikrotikClient } from "./mikrotik.js"
|
||
import { IPSEC_CA_CERT, IPSEC_SERVER_CERT, clientCertName, ipsecManagedComment } from "./ipsec-config.js"
|
||
|
||
type RosCertRow = Record<string, string | undefined>
|
||
|
||
const SIGN_POLL_TIMEOUT_MS = 90_000
|
||
const SIGN_POLL_INTERVAL_MS = 1_000
|
||
|
||
function isIp(value: string): boolean {
|
||
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value.trim())
|
||
}
|
||
|
||
export async function findCertificate(client: MikrotikClient, name: string): Promise<RosCertRow | undefined> {
|
||
const certs = await client.getCertificates()
|
||
return certs.find((c) => String(c.name ?? "").trim() === name)
|
||
}
|
||
|
||
/** Сертификат подписан: у него заполнен invalid-after. */
|
||
export async function isCertificateSigned(client: MikrotikClient, name: string): Promise<boolean> {
|
||
const cert = await findCertificate(client, name)
|
||
return Boolean(cert && String(cert["invalid-after"] ?? "").trim() !== "")
|
||
}
|
||
|
||
/** sign в RouterOS не мгновенный: ждём появления invalid-after. */
|
||
export async function waitCertificateSigned(client: MikrotikClient, name: string, timeoutMs = SIGN_POLL_TIMEOUT_MS): Promise<void> {
|
||
const startedAt = Date.now()
|
||
while (Date.now() - startedAt < timeoutMs) {
|
||
if (await isCertificateSigned(client, name)) return
|
||
await new Promise((resolve) => setTimeout(resolve, SIGN_POLL_INTERVAL_MS))
|
||
}
|
||
throw new Error(`Сертификат ${name} не подписан за ${Math.round(timeoutMs / 1000)} с`)
|
||
}
|
||
|
||
/** Локальный CA для IKEv2 (self-signed); идемпотентно. Возвращает имя сертификата. */
|
||
export async function ensureCaCertificate(client: MikrotikClient, daysValid: number): Promise<string> {
|
||
const existing = await findCertificate(client, IPSEC_CA_CERT)
|
||
if (!existing) {
|
||
await client.addCertificate({
|
||
name: IPSEC_CA_CERT,
|
||
"common-name": "MikrotikManager IPsec CA",
|
||
"key-size": "4096",
|
||
"key-usage": "key-cert-sign,crl-sign",
|
||
"days-valid": String(daysValid),
|
||
comment: ipsecManagedComment("CA"),
|
||
})
|
||
}
|
||
if (!(await isCertificateSigned(client, IPSEC_CA_CERT))) {
|
||
await client.signCertificate({ name: IPSEC_CA_CERT, daysValid })
|
||
await waitCertificateSigned(client, IPSEC_CA_CERT)
|
||
}
|
||
return IPSEC_CA_CERT
|
||
}
|
||
|
||
/** Серверный сертификат (CN/SAN = адрес, по которому стучатся клиенты); подписывается CA. */
|
||
export async function ensureServerCertificate(
|
||
client: MikrotikClient,
|
||
args: { serverEndpoint: string; caCertName: string; daysValid: number },
|
||
): Promise<string> {
|
||
const endpoint = args.serverEndpoint.trim()
|
||
const san = isIp(endpoint) ? `IP:${endpoint}` : `DNS:${endpoint}`
|
||
const existing = await findCertificate(client, IPSEC_SERVER_CERT)
|
||
if (!existing) {
|
||
await client.addCertificate({
|
||
name: IPSEC_SERVER_CERT,
|
||
"common-name": endpoint,
|
||
"subject-alt-name": san,
|
||
"key-size": "2048",
|
||
"key-usage": "digital-signature,key-encipherment,tls-server",
|
||
"days-valid": String(args.daysValid),
|
||
comment: ipsecManagedComment("server"),
|
||
})
|
||
}
|
||
if (!(await isCertificateSigned(client, IPSEC_SERVER_CERT))) {
|
||
await client.signCertificate({ name: IPSEC_SERVER_CERT, ca: args.caCertName, daysValid: args.daysValid })
|
||
await waitCertificateSigned(client, IPSEC_SERVER_CERT)
|
||
}
|
||
return IPSEC_SERVER_CERT
|
||
}
|
||
|
||
export interface IssuedClientCert {
|
||
certName: string
|
||
commonName: string
|
||
/** Сертификат с этим CN уже существовал (перевыпуск не выполнялся). */
|
||
existed: boolean
|
||
}
|
||
|
||
/** Клиентский сертификат: add + sign CA. CN = имя пользователя. */
|
||
export async function issueClientCertificate(
|
||
client: MikrotikClient,
|
||
args: { userName: string; caCertName: string; daysValid: number },
|
||
): Promise<IssuedClientCert> {
|
||
const certName = clientCertName(args.userName)
|
||
const existing = await findCertificate(client, certName)
|
||
if (existing) {
|
||
return { certName, commonName: String(existing["common-name"] ?? args.userName), existed: true }
|
||
}
|
||
await client.addCertificate({
|
||
name: certName,
|
||
"common-name": args.userName.trim(),
|
||
"key-size": "2048",
|
||
"key-usage": "digital-signature,key-encipherment,tls-client",
|
||
"days-valid": String(args.daysValid),
|
||
comment: ipsecManagedComment(`client ${args.userName.trim()}`),
|
||
})
|
||
await client.signCertificate({ name: certName, ca: args.caCertName, daysValid: args.daysValid })
|
||
await waitCertificateSigned(client, certName)
|
||
return { certName, commonName: args.userName.trim(), existed: false }
|
||
}
|
||
|
||
/** Экспорт произвольного сертификата по имени (существующие client1/anakondra и т.п.). */
|
||
export async function exportCertificateP12ByName(
|
||
client: MikrotikClient,
|
||
certName: string,
|
||
passphrase: string,
|
||
): Promise<{ fileName: string; content: Buffer; certName: string; passphrase: string }> {
|
||
const name = certName.trim()
|
||
const cert = await findCertificate(client, name)
|
||
if (!cert) throw new Error(`Сертификат ${name} не найден на роутере`)
|
||
const { fileName, content, passphrase: effective } = await client.exportCertificatePkcs12({ name, passphrase })
|
||
return { fileName, content, certName: name, passphrase: effective }
|
||
}
|
||
|
||
/** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */
|
||
export async function exportClientP12(
|
||
client: MikrotikClient,
|
||
userName: string,
|
||
passphrase: string,
|
||
): Promise<{ fileName: string; content: Buffer; certName: string; passphrase: string }> {
|
||
return exportCertificateP12ByName(client, clientCertName(userName), passphrase)
|
||
}
|