feat(ipsec): add certificate export functionality by name
- Implemented a new backend route for exporting existing client certificates in .p12 format by name. - Enhanced the frontend to support exporting certificates directly from the IPsec server grid. - Updated the IPsec page to manage certificate states and handle exports effectively. - Introduced new utility functions for certificate handling and improved data structures to accommodate the changes. - Added tests to ensure the reliability of the new certificate export feature.
This commit is contained in:
@@ -40,6 +40,7 @@ import {
|
|||||||
deleteIpsecPeer,
|
deleteIpsecPeer,
|
||||||
deleteIpsecServer,
|
deleteIpsecServer,
|
||||||
deleteIpsecUser,
|
deleteIpsecUser,
|
||||||
|
exportIpsecCertByName,
|
||||||
exportIpsecUserCert,
|
exportIpsecUserCert,
|
||||||
initIpsecServer,
|
initIpsecServer,
|
||||||
listIpsec,
|
listIpsec,
|
||||||
@@ -97,6 +98,7 @@ const MOCK_IPSEC_SERVERS: IpsecServerSummaryDto[] = [
|
|||||||
serverId: "srv2",
|
serverId: "srv2",
|
||||||
serverName: "mt-spb-edge-01",
|
serverName: "mt-spb-edge-01",
|
||||||
initialized: true,
|
initialized: true,
|
||||||
|
ike2Ready: true,
|
||||||
serverEndpoint: "vpn.example.com",
|
serverEndpoint: "vpn.example.com",
|
||||||
pool: { id: "srv2:p1", rosId: "*P1", serverId: "srv2", name: "ipsec-vpn", ranges: "10.77.0.2-10.77.0.254", managed: true },
|
pool: { id: "srv2:p1", rosId: "*P1", serverId: "srv2", name: "ipsec-vpn", ranges: "10.77.0.2-10.77.0.254", managed: true },
|
||||||
sharedModeConfig: { id: "srv2:m1", rosId: "*M1", serverId: "srv2", name: "ipsec-vpn", addressPool: "ipsec-vpn", staticDns: "10.77.0.1", managed: true },
|
sharedModeConfig: { id: "srv2:m1", rosId: "*M1", serverId: "srv2", name: "ipsec-vpn", addressPool: "ipsec-vpn", staticDns: "10.77.0.1", managed: true },
|
||||||
@@ -181,6 +183,7 @@ export default function IpsecPage() {
|
|||||||
const [certOpen, setCertOpen] = useState(false)
|
const [certOpen, setCertOpen] = useState(false)
|
||||||
const [certBundle, setCertBundle] = useState<IpsecCertBundle | null>(null)
|
const [certBundle, setCertBundle] = useState<IpsecCertBundle | null>(null)
|
||||||
const [certClient, setCertClient] = useState<IpsecClientDto | null>(null)
|
const [certClient, setCertClient] = useState<IpsecClientDto | null>(null)
|
||||||
|
const [certByName, setCertByName] = useState<{ serverId: string; name: string } | null>(null)
|
||||||
const [peerOpen, setPeerOpen] = useState(false)
|
const [peerOpen, setPeerOpen] = useState(false)
|
||||||
const [editingPeer, setEditingPeer] = useState<{ serverId: string; peer: IpsecPeerDto } | null>(null)
|
const [editingPeer, setEditingPeer] = useState<{ serverId: string; peer: IpsecPeerDto } | null>(null)
|
||||||
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
|
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
|
||||||
@@ -318,7 +321,7 @@ export default function IpsecPage() {
|
|||||||
|
|
||||||
/** Подсказка свободного IP из пула выбранного сервера. */
|
/** Подсказка свободного IP из пула выбранного сервера. */
|
||||||
const freeIpHint = useMemo(() => {
|
const freeIpHint = useMemo(() => {
|
||||||
const summary = scopedSummaries.find((s) => s.initialized && s.pool)
|
const summary = scopedSummaries.find((s) => s.ike2Ready && s.pool)
|
||||||
if (!summary?.pool) return undefined
|
if (!summary?.pool) return undefined
|
||||||
const taken = scopedClients.map((c) => c.staticIp).filter(Boolean) as string[]
|
const taken = scopedClients.map((c) => c.staticIp).filter(Boolean) as string[]
|
||||||
return findFreePoolIp(summary.pool.ranges, taken) ?? undefined
|
return findFreePoolIp(summary.pool.ranges, taken) ?? undefined
|
||||||
@@ -388,6 +391,7 @@ export default function IpsecPage() {
|
|||||||
if (res.bundle) {
|
if (res.bundle) {
|
||||||
setCertBundle(res.bundle)
|
setCertBundle(res.bundle)
|
||||||
setCertClient(res.client ?? null)
|
setCertClient(res.client ?? null)
|
||||||
|
setCertByName(null)
|
||||||
setCertOpen(true)
|
setCertOpen(true)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -421,6 +425,7 @@ export default function IpsecPage() {
|
|||||||
|
|
||||||
const openCert = async (client: IpsecClientDto) => {
|
const openCert = async (client: IpsecClientDto) => {
|
||||||
setCertClient(client)
|
setCertClient(client)
|
||||||
|
setCertByName(null)
|
||||||
setCertBundle(null)
|
setCertBundle(null)
|
||||||
setCertOpen(true)
|
setCertOpen(true)
|
||||||
setCertBusy(true)
|
setCertBusy(true)
|
||||||
@@ -438,11 +443,34 @@ export default function IpsecPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reexportCert = async (passphrase: string) => {
|
/** Экспорт .p12 существующего клиентского серта по имени (client1/anakondra и т.п.). */
|
||||||
if (!certClient) return
|
const openCertByName = async (serverId: string, cert: IpsecCertInfoDto) => {
|
||||||
|
setCertByName({ serverId, name: cert.name })
|
||||||
|
setCertClient(null)
|
||||||
|
setCertBundle(null)
|
||||||
|
setCertOpen(true)
|
||||||
setCertBusy(true)
|
setCertBusy(true)
|
||||||
try {
|
try {
|
||||||
const bundle = await exportIpsecUserCert(backendUrl, certClient.serverId, certClient.rosId, passphrase)
|
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
const bundle = await exportIpsecCertByName(backendUrl, serverId, cert.name, passphrase)
|
||||||
|
setCertBundle(bundle)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Ошибка экспорта сертификата", {
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
})
|
||||||
|
setCertOpen(false)
|
||||||
|
} finally {
|
||||||
|
setCertBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reexportCert = async (passphrase: string) => {
|
||||||
|
if (!certClient && !certByName) return
|
||||||
|
setCertBusy(true)
|
||||||
|
try {
|
||||||
|
const bundle = certByName
|
||||||
|
? await exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase)
|
||||||
|
: await exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase)
|
||||||
setCertBundle(bundle)
|
setCertBundle(bundle)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("Ошибка экспорта", { description: e instanceof Error ? e.message : String(e) })
|
toast.error("Ошибка экспорта", { description: e instanceof Error ? e.message : String(e) })
|
||||||
@@ -718,6 +746,7 @@ export default function IpsecPage() {
|
|||||||
onEditPeer={isLive ? openEditPeer : undefined}
|
onEditPeer={isLive ? openEditPeer : undefined}
|
||||||
onDeletePeer={isLive ? (serverId, peer) => setPendingDelete({ kind: "peer", serverId, peer }) : undefined}
|
onDeletePeer={isLive ? (serverId, peer) => setPendingDelete({ kind: "peer", serverId, peer }) : undefined}
|
||||||
onDeleteCert={isLive ? (serverId, cert) => setPendingDelete({ kind: "cert", serverId, cert }) : undefined}
|
onDeleteCert={isLive ? (serverId, cert) => setPendingDelete({ kind: "cert", serverId, cert }) : undefined}
|
||||||
|
onExportCert={isLive ? (serverId, cert) => void openCertByName(serverId, cert) : undefined}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -820,7 +849,7 @@ export default function IpsecPage() {
|
|||||||
|
|
||||||
<IpsecCertSheet
|
<IpsecCertSheet
|
||||||
open={certOpen}
|
open={certOpen}
|
||||||
onOpenChange={setCertOpen}
|
onOpenChange={(v) => { setCertOpen(v); if (!v) setCertByName(null) }}
|
||||||
bundle={certBundle}
|
bundle={certBundle}
|
||||||
busy={certBusy}
|
busy={certBusy}
|
||||||
onReexport={reexportCert}
|
onReexport={reexportCert}
|
||||||
|
|||||||
+61
-15
@@ -2,6 +2,7 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
|||||||
import type { FastifyReply } from "fastify"
|
import type { FastifyReply } from "fastify"
|
||||||
import {
|
import {
|
||||||
ipsecCertDeleteRequestSchema,
|
ipsecCertDeleteRequestSchema,
|
||||||
|
ipsecCertExportByNameRequestSchema,
|
||||||
ipsecCertExportRequestSchema,
|
ipsecCertExportRequestSchema,
|
||||||
ipsecInitRequestSchema,
|
ipsecInitRequestSchema,
|
||||||
ipsecPeerPatchSchema,
|
ipsecPeerPatchSchema,
|
||||||
@@ -18,13 +19,16 @@ import {
|
|||||||
buildClientInstructions,
|
buildClientInstructions,
|
||||||
buildSswanConfig,
|
buildSswanConfig,
|
||||||
clientCertName,
|
clientCertName,
|
||||||
findFreePoolIp,
|
|
||||||
identityDisplayName,
|
identityDisplayName,
|
||||||
ipsecManagedComment,
|
ipsecManagedComment,
|
||||||
ipsecUserComment,
|
ipsecUserComment,
|
||||||
|
isIke2RemoteAccessIdentity,
|
||||||
isIpsecManagedComment,
|
isIpsecManagedComment,
|
||||||
parseIpsecUserComment,
|
parseIpsecUserComment,
|
||||||
poolRangesFromCidr,
|
poolRangesFromCidr,
|
||||||
|
resolveIke2CaName,
|
||||||
|
resolveIke2ServerCert,
|
||||||
|
selectIke2Peers,
|
||||||
userModeConfigName,
|
userModeConfigName,
|
||||||
} from "../services/ipsec-config.js"
|
} from "../services/ipsec-config.js"
|
||||||
import {
|
import {
|
||||||
@@ -47,7 +51,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
ensureCaCertificate,
|
ensureCaCertificate,
|
||||||
ensureServerCertificate,
|
ensureServerCertificate,
|
||||||
exportClientP12,
|
exportCertificateP12ByName,
|
||||||
findCertificate,
|
findCertificate,
|
||||||
issueClientCertificate,
|
issueClientCertificate,
|
||||||
} from "../services/ipsec-ca.js"
|
} from "../services/ipsec-ca.js"
|
||||||
@@ -113,11 +117,11 @@ function takenStaticIps(modeConfigs: Array<{ name?: string; address?: string; "a
|
|||||||
|
|
||||||
async function buildCertBundle(
|
async function buildCertBundle(
|
||||||
client: MikrotikClient,
|
client: MikrotikClient,
|
||||||
args: { userName: string; serverEndpoint: string; passphrase: string; dns?: string },
|
args: { userName: string; certName?: string; serverEndpoint: string; passphrase: string; dns?: string },
|
||||||
): Promise<IpsecCertBundle> {
|
): Promise<IpsecCertBundle> {
|
||||||
const { fileName, content } = await exportClientP12(client, args.userName, args.passphrase)
|
const certName = args.certName?.trim() || clientCertName(args.userName)
|
||||||
|
const { fileName, content } = await exportCertificateP12ByName(client, certName, args.passphrase)
|
||||||
const p12B64 = content.toString("base64")
|
const p12B64 = content.toString("base64")
|
||||||
const certName = clientCertName(args.userName)
|
|
||||||
return {
|
return {
|
||||||
user: args.userName,
|
user: args.userName,
|
||||||
serverEndpoint: args.serverEndpoint,
|
serverEndpoint: args.serverEndpoint,
|
||||||
@@ -245,26 +249,35 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
const client = MikrotikClient.fromServer(server)
|
const client = MikrotikClient.fromServer(server)
|
||||||
try {
|
try {
|
||||||
const state = await fetchIpsecState(server)
|
const state = await fetchIpsecState(server)
|
||||||
|
const ike2Peers = selectIke2Peers(state.peers)
|
||||||
const peer = (body.peerName
|
const peer = (body.peerName
|
||||||
? state.peers.find((p) => (p.name ?? "").trim() === body.peerName!.trim())
|
? state.peers.find((p) => (p.name ?? "").trim() === body.peerName!.trim())
|
||||||
: undefined)
|
: undefined)
|
||||||
?? state.peers.find((p) => isIpsecManagedComment(p.comment) || (p.name ?? "").trim() === IPSEC_COMMON_NAME)
|
?? ike2Peers.find((p) => isIpsecManagedComment(p.comment))
|
||||||
|
?? ike2Peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||||
|
?? ike2Peers[0]
|
||||||
?? state.peers[0]
|
?? state.peers[0]
|
||||||
if (!peer) {
|
if (!peer) {
|
||||||
return reply.status(400).send({ error: "На роутере нет ни одного IPsec peer — создайте peer на вкладке «Сервер»" })
|
return reply.status(400).send({ error: "На роутере нет ни одного IPsec peer — создайте peer на вкладке «Сервер»" })
|
||||||
}
|
}
|
||||||
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
|
||||||
const caCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_CA_CERT)
|
const ike2Identities = state.identities.filter(
|
||||||
?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("key-cert-sign"))
|
(i) => isIpsecManagedComment(i.comment) || isIke2RemoteAccessIdentity(i, ike2PeerNames),
|
||||||
const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT)
|
)
|
||||||
?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server"))
|
const idMcName = ike2Identities
|
||||||
if (body.authMethod === "certificate" && (!caCert || !serverCert)) {
|
.map((i) => (i["mode-config"] ?? "").trim())
|
||||||
|
.find((n) => n && !n.startsWith("mc-ipsec-"))
|
||||||
|
const sharedMc = (idMcName ? state.modeConfigs.find((m) => (m.name ?? "").trim() === idMcName) : undefined)
|
||||||
|
?? state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||||
|
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers)
|
||||||
|
const caName = resolveIke2CaName(state.certs, serverCertRow)
|
||||||
|
if (body.authMethod === "certificate" && (!caName || !serverCertRow)) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: "Нет CA/серверного сертификата — для сертификатного клиента запустите мастер или используйте PSK",
|
error: "Нет CA/серверного сертификата — для сертификатного клиента запустите мастер или используйте PSK",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const peerName = (peer.name ?? "").trim()
|
const peerName = (peer.name ?? "").trim()
|
||||||
const serverEndpoint = String(serverCert?.["common-name"] ?? "").trim() || server.host
|
const serverEndpoint = String(serverCertRow?.["common-name"] ?? "").trim() || server.host
|
||||||
const dns = sharedMc?.["static-dns"]?.trim() || undefined
|
const dns = sharedMc?.["static-dns"]?.trim() || undefined
|
||||||
|
|
||||||
if (body.authMethod === "pre-shared-key" && !body.psk) {
|
if (body.authMethod === "pre-shared-key" && !body.psk) {
|
||||||
@@ -287,7 +300,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
if (body.authMethod === "certificate") {
|
if (body.authMethod === "certificate") {
|
||||||
const issued = await issueClientCertificate(client, {
|
const issued = await issueClientCertificate(client, {
|
||||||
userName: body.name,
|
userName: body.name,
|
||||||
caCertName: String(caCert!.name ?? IPSEC_CA_CERT),
|
caCertName: caName!,
|
||||||
daysValid: body.daysValid ?? 1825,
|
daysValid: body.daysValid ?? 1825,
|
||||||
})
|
})
|
||||||
if (issued.existed) {
|
if (issued.existed) {
|
||||||
@@ -301,7 +314,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
modeConfig,
|
modeConfig,
|
||||||
comment: ipsecUserComment(body.name),
|
comment: ipsecUserComment(body.name),
|
||||||
authMethod: body.authMethod,
|
authMethod: body.authMethod,
|
||||||
certificate: body.authMethod === "certificate" ? String(serverCert?.name ?? IPSEC_SERVER_CERT) : undefined,
|
certificate: body.authMethod === "certificate" ? String(serverCertRow?.name ?? IPSEC_SERVER_CERT) : undefined,
|
||||||
remoteCertificate: certName,
|
remoteCertificate: certName,
|
||||||
secret: body.psk,
|
secret: body.psk,
|
||||||
remoteId: body.authMethod === "pre-shared-key" ? (body.remoteId ?? body.name) : undefined,
|
remoteId: body.authMethod === "pre-shared-key" ? (body.remoteId ?? body.name) : undefined,
|
||||||
@@ -572,6 +585,39 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Экспорт .p12 существующего клиентского сертификата по имени (client1/anakondra и т.п.). */
|
||||||
|
app.post("/ipsec/certs/:serverId/export", async (req, reply) => {
|
||||||
|
const { serverId } = req.params as { serverId: string }
|
||||||
|
const parsed = ipsecCertExportByNameRequestSchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const { name, passphrase } = parsed.data
|
||||||
|
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||||||
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
try {
|
||||||
|
const state = await fetchIpsecState(server)
|
||||||
|
const cert = state.certs.find((c) => String(c.name ?? "").trim() === name)
|
||||||
|
if (!cert?.[".id"]) return reply.status(404).send({ error: `Сертификат ${name} не найден` })
|
||||||
|
const serverCertRow = resolveIke2ServerCert(state.certs, selectIke2Peers(state.peers))
|
||||||
|
const serverEndpoint = String(serverCertRow?.["common-name"] ?? "").trim() || server.host
|
||||||
|
const userName = String(cert["common-name"] ?? "").trim() || name
|
||||||
|
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||||
|
const dns = sharedMc?.["static-dns"]?.trim() || undefined
|
||||||
|
const bundle = await buildCertBundle(client, {
|
||||||
|
userName,
|
||||||
|
certName: name,
|
||||||
|
serverEndpoint,
|
||||||
|
passphrase,
|
||||||
|
dns,
|
||||||
|
})
|
||||||
|
return reply.send(bundle)
|
||||||
|
} catch (e) {
|
||||||
|
return errReply(reply, e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.get("/ipsec/revisions", async (req, reply) => {
|
app.get("/ipsec/revisions", async (req, reply) => {
|
||||||
const q = req.query as { serverId?: string | number }
|
const q = req.query as { serverId?: string | number }
|
||||||
const serverId = parseDbServerId(q.serverId)
|
const serverId = parseDbServerId(q.serverId)
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {
|
||||||
|
certificateRole,
|
||||||
|
isCaCertificate,
|
||||||
|
mapRosCertificateRow,
|
||||||
|
type RosCertificateRow,
|
||||||
|
} from "./certificate-parse.js"
|
||||||
|
|
||||||
|
// Реальные серты пользователя: MyCA (root) → vpn-server, client1, anakondra.
|
||||||
|
const myCa: RosCertificateRow = {
|
||||||
|
name: "MyCA",
|
||||||
|
"common-name": "MyCA",
|
||||||
|
"key-usage": "key-cert-sign,crl-sign",
|
||||||
|
ca: "",
|
||||||
|
flags: "KAT",
|
||||||
|
authority: "true",
|
||||||
|
}
|
||||||
|
const vpnServer: RosCertificateRow = {
|
||||||
|
name: "vpn-server",
|
||||||
|
"common-name": "vpn.example.com",
|
||||||
|
"key-usage": "digital-signature,key-encipherment,key-cert-sign,crl-sign,tls-server,tls-client",
|
||||||
|
ca: "MyCA",
|
||||||
|
flags: "KLAT",
|
||||||
|
}
|
||||||
|
const client1: RosCertificateRow = {
|
||||||
|
name: "client1",
|
||||||
|
"common-name": "client1",
|
||||||
|
"key-usage": "digital-signature,key-encipherment,key-cert-sign,crl-sign,tls-server,tls-client",
|
||||||
|
ca: "MyCA",
|
||||||
|
flags: "KLAT",
|
||||||
|
}
|
||||||
|
const anakondra: RosCertificateRow = { ...client1, name: "anakondra", "common-name": "anakondra" }
|
||||||
|
|
||||||
|
{
|
||||||
|
assert.ok(isCaCertificate(myCa))
|
||||||
|
// default key-usage содержит key-cert-sign, но ca заполнен → не CA
|
||||||
|
assert.ok(!isCaCertificate(vpnServer))
|
||||||
|
assert.ok(!isCaCertificate(client1))
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const ctx = {
|
||||||
|
peerCertNames: ["vpn-server"],
|
||||||
|
identityCertNames: ["client1", "anakondra"],
|
||||||
|
}
|
||||||
|
assert.equal(certificateRole(myCa, ctx), "ca")
|
||||||
|
assert.equal(certificateRole(vpnServer, ctx), "server")
|
||||||
|
// reference-based важнее key-usage: у client1 в key-usage есть tls-server, но он client
|
||||||
|
assert.equal(certificateRole(client1, ctx), "client")
|
||||||
|
assert.equal(certificateRole(anakondra, ctx), "client")
|
||||||
|
// без контекста — по key-usage (default содержит оба, поэтому server)
|
||||||
|
assert.equal(certificateRole({ name: "x", "key-usage": "tls-client" }), "client")
|
||||||
|
assert.equal(certificateRole({ name: "y", "key-usage": "digital-signature" }), "other")
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const dto = mapRosCertificateRow(1, "mt", client1)
|
||||||
|
assert.equal(dto?.signedByCertName, "MyCA")
|
||||||
|
assert.equal(mapRosCertificateRow(1, "mt", myCa)?.signedByCertName, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("certificate-parse.test.ts: ok")
|
||||||
@@ -2,6 +2,60 @@ import type { CertificateDto } from "@mmapp/contracts/certificates"
|
|||||||
|
|
||||||
export type RosCertificateRow = Record<string, string | undefined>
|
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"
|
||||||
|
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 {
|
function parseRosDate(raw: string | undefined): Date | null {
|
||||||
if (!raw?.trim()) return null
|
if (!raw?.trim()) return null
|
||||||
const d = new Date(raw)
|
const d = new Date(raw)
|
||||||
@@ -88,6 +142,8 @@ export function mapRosCertificateRow(
|
|||||||
trusted: row.trusted === "true",
|
trusted: row.trusted === "true",
|
||||||
status,
|
status,
|
||||||
acmeStatus: row["acme-status"]?.trim() || undefined,
|
acmeStatus: row["acme-status"]?.trim() || undefined,
|
||||||
|
/** Имя CA, которым серт подписан на устройстве (read-only поле `ca`). */
|
||||||
|
signedByCertName: (row.ca ?? "").trim() || undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,15 +107,24 @@ export async function issueClientCertificate(
|
|||||||
return { certName, commonName: args.userName.trim(), existed: false }
|
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 }> {
|
||||||
|
const name = certName.trim()
|
||||||
|
const cert = await findCertificate(client, name)
|
||||||
|
if (!cert) throw new Error(`Сертификат ${name} не найден на роутере`)
|
||||||
|
const { fileName, content } = await client.exportCertificatePkcs12({ name, passphrase })
|
||||||
|
return { fileName, content, certName: name }
|
||||||
|
}
|
||||||
|
|
||||||
/** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */
|
/** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */
|
||||||
export async function exportClientP12(
|
export async function exportClientP12(
|
||||||
client: MikrotikClient,
|
client: MikrotikClient,
|
||||||
userName: string,
|
userName: string,
|
||||||
passphrase: string,
|
passphrase: string,
|
||||||
): Promise<{ fileName: string; content: Buffer; certName: string }> {
|
): Promise<{ fileName: string; content: Buffer; certName: string }> {
|
||||||
const certName = clientCertName(userName)
|
return exportCertificateP12ByName(client, clientCertName(userName), passphrase)
|
||||||
const cert = await findCertificate(client, certName)
|
|
||||||
if (!cert) throw new Error(`Сертификат ${certName} не найден на роутере`)
|
|
||||||
const { fileName, content } = await client.exportCertificatePkcs12({ name: certName, passphrase })
|
|
||||||
return { fileName, content, certName }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,14 @@ import {
|
|||||||
ipsecManagedComment,
|
ipsecManagedComment,
|
||||||
ipsecSlug,
|
ipsecSlug,
|
||||||
ipsecUserComment,
|
ipsecUserComment,
|
||||||
|
isIke2Peer,
|
||||||
|
isIke2RemoteAccessIdentity,
|
||||||
isIpsecManagedComment,
|
isIpsecManagedComment,
|
||||||
parseIpsecUserComment,
|
parseIpsecUserComment,
|
||||||
poolRangesFromCidr,
|
poolRangesFromCidr,
|
||||||
|
resolveIke2CaName,
|
||||||
|
resolveIke2ServerCert,
|
||||||
|
selectIke2Peers,
|
||||||
userModeConfigName,
|
userModeConfigName,
|
||||||
} from "./ipsec-config.js"
|
} from "./ipsec-config.js"
|
||||||
|
|
||||||
@@ -110,4 +115,45 @@ import {
|
|||||||
assert.ok(text.includes("strongSwan"))
|
assert.ok(text.includes("strongSwan"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── IKEv2-only отбор ────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const grePeer = { name: "gre-tunnel", "exchange-mode": "ike2", address: "1.2.3.4", passive: "no" }
|
||||||
|
const s2sPeer = { name: "s2s", "exchange-mode": "main", address: "5.6.7.8", certificate: "vpn-server" }
|
||||||
|
const rwPeer = { name: "vpn-server", "exchange-mode": "ike2", address: "0.0.0.0/0", passive: "yes", certificate: "vpn-server" }
|
||||||
|
|
||||||
|
assert.ok(isIke2Peer(rwPeer))
|
||||||
|
assert.ok(!isIke2Peer(s2sPeer))
|
||||||
|
|
||||||
|
const selected = selectIke2Peers([grePeer, s2sPeer, rwPeer])
|
||||||
|
assert.deepEqual(selected.map((p) => p.name), ["vpn-server"])
|
||||||
|
// fallback: нет строгого набора → ike2 + сертификат
|
||||||
|
const fallback = selectIke2Peers([grePeer, { name: "ike2-cert", "exchange-mode": "ike2", certificate: "c" }])
|
||||||
|
assert.deepEqual(fallback.map((p) => p.name), ["ike2-cert"])
|
||||||
|
|
||||||
|
const names = ["vpn-server"]
|
||||||
|
assert.ok(isIke2RemoteAccessIdentity(
|
||||||
|
{ peer: "vpn-server", "mode-config": "ipsec-vpn", "auth-method": "rsa-key", certificate: "vpn-server", "remote-certificate": "client1" },
|
||||||
|
names,
|
||||||
|
))
|
||||||
|
assert.ok(isIke2RemoteAccessIdentity({ peer: "vpn-server", "auth-method": "rsa-key", certificate: "vpn-server" }, names))
|
||||||
|
// GRE/site-to-site PSK-туннели отсеиваются
|
||||||
|
assert.ok(!isIke2RemoteAccessIdentity({ peer: "s2s", "auth-method": "pre-shared-key" }, names))
|
||||||
|
assert.ok(!isIke2RemoteAccessIdentity({ peer: "vpn-server", "auth-method": "pre-shared-key" }, names))
|
||||||
|
// без ограничения по peer (набор пуст) — mode-config/серт всё равно нужен
|
||||||
|
assert.ok(isIke2RemoteAccessIdentity({ peer: "x", "mode-config": "mc" }, []))
|
||||||
|
assert.ok(!isIke2RemoteAccessIdentity({ peer: "x", "auth-method": "pre-shared-key" }, []))
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const myCa = { name: "MyCA", "key-usage": "key-cert-sign,crl-sign", ca: "", flags: "KAT", authority: "true" }
|
||||||
|
const vpnServer = { name: "vpn-server", "key-usage": "digital-signature,key-encipherment,tls-server,tls-client", ca: "MyCA" }
|
||||||
|
const client1 = { name: "client1", "key-usage": "digital-signature,tls-client,tls-server", ca: "MyCA" }
|
||||||
|
const certs = [myCa, vpnServer, client1]
|
||||||
|
|
||||||
|
assert.equal(resolveIke2ServerCert(certs, [{ name: "vpn-server", certificate: "vpn-server" }])?.name, "vpn-server")
|
||||||
|
assert.equal(resolveIke2ServerCert([myCa], [])?.name, undefined)
|
||||||
|
assert.equal(resolveIke2CaName(certs, vpnServer), "MyCA")
|
||||||
|
assert.equal(resolveIke2CaName([vpnServer, client1], vpnServer), undefined)
|
||||||
|
}
|
||||||
|
|
||||||
console.log("ipsec-config.test.ts: ok")
|
console.log("ipsec-config.test.ts: ok")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import { PRODUCT_NAME } from "../managed-markers.js"
|
import { PRODUCT_NAME } from "../managed-markers.js"
|
||||||
|
import { isCaCertificate } from "./certificate-parse.js"
|
||||||
|
|
||||||
// ── managed-маркеры (как у filters/recursive, см. managed-markers.ts) ─────────
|
// ── managed-маркеры (как у filters/recursive, см. managed-markers.ts) ─────────
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ export function parseIpsecUserComment(comment: string | undefined | null): strin
|
|||||||
return m?.[1] ?? null
|
return m?.[1] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
type IdentityLike = {
|
export type IdentityLike = {
|
||||||
".id"?: string
|
".id"?: string
|
||||||
comment?: string
|
comment?: string
|
||||||
"remote-id"?: string
|
"remote-id"?: string
|
||||||
@@ -32,7 +33,37 @@ type IdentityLike = {
|
|||||||
peer?: string
|
peer?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type CertLike = { name?: string; "common-name"?: string }
|
/** Identity + поля, нужные для отбора IKEv2 remote-access. */
|
||||||
|
export interface Ike2IdentityLike extends IdentityLike {
|
||||||
|
certificate?: string
|
||||||
|
"auth-method"?: string
|
||||||
|
"mode-config"?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Peer (/ip/ipsec/peer). */
|
||||||
|
export interface PeerLike {
|
||||||
|
".id"?: string
|
||||||
|
name?: string
|
||||||
|
address?: string
|
||||||
|
"exchange-mode"?: string
|
||||||
|
passive?: string
|
||||||
|
certificate?: string
|
||||||
|
comment?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CertLike {
|
||||||
|
name?: string
|
||||||
|
"common-name"?: string
|
||||||
|
"key-usage"?: string
|
||||||
|
/** read-only: имя CA, которым подписан серт. */
|
||||||
|
ca?: string
|
||||||
|
/** флаги RouterOS (строка вида "KLAT"). */
|
||||||
|
flags?: string
|
||||||
|
authority?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строка сертификата RouterOS (raw). */
|
||||||
|
type RosRow = Record<string, string | undefined>
|
||||||
|
|
||||||
/** Отображаемое имя identity: managed user= → сырой comment → remote-id → CN сертификата → peer#id. */
|
/** Отображаемое имя identity: managed user= → сырой comment → remote-id → CN сертификата → peer#id. */
|
||||||
export function identityDisplayName(i: IdentityLike, certs: CertLike[] = []): string {
|
export function identityDisplayName(i: IdentityLike, certs: CertLike[] = []): string {
|
||||||
@@ -76,6 +107,70 @@ export function userModeConfigName(userName: string): string {
|
|||||||
return `mc-ipsec-${ipsecSlug(userName) || "client"}`
|
return `mc-ipsec-${ipsecSlug(userName) || "client"}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── отбор IKEv2 remote-access (без GRE/site-to-site) ────────────────────────
|
||||||
|
|
||||||
|
function rosBool(v: string | undefined): boolean {
|
||||||
|
return v === "true" || v === "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAnyAddress(a: string | undefined): boolean {
|
||||||
|
const v = (a ?? "").trim()
|
||||||
|
return v === "" || v === "0.0.0.0/0" || v === "::/0"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Peer с IKEv2 (exchange-mode ike2). */
|
||||||
|
export function isIke2Peer(p: PeerLike): boolean {
|
||||||
|
return (p["exchange-mode"] ?? "").trim().toLowerCase() === "ike2"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remote-access IKEv2 peers. Строгий набор: ike2 + passive + any-address + сертификат.
|
||||||
|
* Fallback → ike2 + сертификат → любые ike2 (чтобы не потерять существующий сервер).
|
||||||
|
*/
|
||||||
|
export function selectIke2Peers<T extends PeerLike>(peers: T[]): T[] {
|
||||||
|
const ike2 = peers.filter(isIke2Peer)
|
||||||
|
const strict = ike2.filter(
|
||||||
|
(p) => rosBool(p.passive) && isAnyAddress(p.address) && Boolean((p.certificate ?? "").trim()),
|
||||||
|
)
|
||||||
|
if (strict.length > 0) return strict
|
||||||
|
const withCert = ike2.filter((p) => Boolean((p.certificate ?? "").trim()))
|
||||||
|
return withCert.length > 0 ? withCert : ike2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity участвует в IKEv2 remote-access: привязан к IKEv2 peer И имеет mode-config
|
||||||
|
* либо сертификат клиента. PSK-туннели без mode-config/сертификата отсеиваются.
|
||||||
|
* Пустой набор peer-имён → не ограничиваем по peer.
|
||||||
|
*/
|
||||||
|
export function isIke2RemoteAccessIdentity(
|
||||||
|
i: Ike2IdentityLike,
|
||||||
|
ike2PeerNames?: Iterable<string>,
|
||||||
|
): boolean {
|
||||||
|
const names = new Set(Array.from(ike2PeerNames ?? [], (n) => n.trim()).filter(Boolean))
|
||||||
|
if (names.size > 0 && !names.has((i.peer ?? "").trim())) return false
|
||||||
|
const hasModeConfig = Boolean((i["mode-config"] ?? "").trim())
|
||||||
|
const hasRemoteCert = Boolean((i["remote-certificate"] ?? "").trim())
|
||||||
|
const rsaKey = (i["auth-method"] ?? "").trim().toLowerCase() === "rsa-key"
|
||||||
|
return hasModeConfig || hasRemoteCert || (rsaKey && Boolean((i.certificate ?? "").trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Серверный серт IKEv2 — из `peer.certificate` (напр. vpn-server), fallback по имени/usage. */
|
||||||
|
export function resolveIke2ServerCert<T extends RosRow>(certs: T[], ike2Peers: PeerLike[]): T | undefined {
|
||||||
|
const names = new Set(ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean))
|
||||||
|
return certs.find((c) => names.has(String(c.name ?? "").trim()))
|
||||||
|
?? certs.find((c) => String(c.name ?? "").trim() === IPSEC_SERVER_CERT)
|
||||||
|
?? certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CA для подписи новых клиентов: `ca` серверного серта (MyCA) → managed ipsec-ca → первый CA. */
|
||||||
|
export function resolveIke2CaName<T extends RosRow>(certs: T[], serverCert?: T | null): string | undefined {
|
||||||
|
const signed = String(serverCert?.ca ?? "").trim()
|
||||||
|
if (signed && certs.some((c) => String(c.name ?? "").trim() === signed)) return signed
|
||||||
|
if (certs.some((c) => String(c.name ?? "").trim() === IPSEC_CA_CERT)) return IPSEC_CA_CERT
|
||||||
|
const ca = certs.find((c) => isCaCertificate(c))
|
||||||
|
return ca ? String(ca.name ?? "").trim() || undefined : undefined
|
||||||
|
}
|
||||||
|
|
||||||
// ── пул адресов клиентов ────────────────────────────────────────────────────
|
// ── пул адресов клиентов ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ipToInt(b: Array<number | undefined>): number {
|
function ipToInt(b: Array<number | undefined>): number {
|
||||||
|
|||||||
@@ -17,8 +17,13 @@ import {
|
|||||||
IPSEC_SERVER_CERT,
|
IPSEC_SERVER_CERT,
|
||||||
clientCertName,
|
clientCertName,
|
||||||
identityDisplayName,
|
identityDisplayName,
|
||||||
|
isIke2RemoteAccessIdentity,
|
||||||
isIpsecManagedComment,
|
isIpsecManagedComment,
|
||||||
|
resolveIke2CaName,
|
||||||
|
resolveIke2ServerCert,
|
||||||
|
selectIke2Peers,
|
||||||
} from "./ipsec-config.js"
|
} from "./ipsec-config.js"
|
||||||
|
import { certificateRole, type CertificateRoleContext } from "./certificate-parse.js"
|
||||||
import {
|
import {
|
||||||
canonicalIpsecSnapshot,
|
canonicalIpsecSnapshot,
|
||||||
type IpsecLiveIdentity,
|
type IpsecLiveIdentity,
|
||||||
@@ -107,13 +112,13 @@ function asBool(v: string | undefined): boolean {
|
|||||||
return v === "true" || v === "yes"
|
return v === "true" || v === "yes"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapCertificate(c: RosCertRow): IpsecCertInfoDto {
|
export function mapCertificate(c: RosCertRow, ctx: CertificateRoleContext = {}): IpsecCertInfoDto {
|
||||||
const name = String(c.name ?? "")
|
const name = String(c.name ?? "")
|
||||||
const keyUsage = String(c["key-usage"] ?? "")
|
|
||||||
const isCa = keyUsage.includes("key-cert-sign")
|
|
||||||
const isServer = keyUsage.includes("tls-server")
|
|
||||||
const isClient = keyUsage.includes("tls-client")
|
|
||||||
const isUser = name.startsWith("ipsec-user-")
|
const isUser = name.startsWith("ipsec-user-")
|
||||||
|
const role = certificateRole(c, {
|
||||||
|
peerCertNames: ctx.peerCertNames,
|
||||||
|
identityCertNames: ctx.identityCertNames,
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
commonName: c["common-name"] || undefined,
|
commonName: c["common-name"] || undefined,
|
||||||
@@ -122,8 +127,9 @@ export function mapCertificate(c: RosCertRow): IpsecCertInfoDto {
|
|||||||
expiresAt: c["invalid-after"] || undefined,
|
expiresAt: c["invalid-after"] || undefined,
|
||||||
trusted: asBool(c.trusted),
|
trusted: asBool(c.trusted),
|
||||||
hasPrivateKey: asBool(c["private-key"]),
|
hasPrivateKey: asBool(c["private-key"]),
|
||||||
role: isCa ? "ca" : name === IPSEC_SERVER_CERT || isServer ? "server" : isUser || isClient ? "client" : "other",
|
role,
|
||||||
managed: isCa && name === IPSEC_CA_CERT
|
signedBy: (c.ca ?? "").trim() || undefined,
|
||||||
|
managed: (name === IPSEC_CA_CERT && role === "ca")
|
||||||
|| name === IPSEC_SERVER_CERT
|
|| name === IPSEC_SERVER_CERT
|
||||||
|| isUser
|
|| isUser
|
||||||
|| isIpsecManagedComment(c.comment),
|
|| isIpsecManagedComment(c.comment),
|
||||||
@@ -204,9 +210,10 @@ export async function fetchIpsecState(server: ServerRow): Promise<IpsecServerSta
|
|||||||
return { server, client, peers, identities, modeConfigs, pools, policies, nat, active, certs }
|
return { server, client, peers, identities, modeConfigs, pools, policies, nat, active, certs }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Клиенты = все identity роутера (managed + существующие RouterOS). */
|
/** Клиенты = identity, участвующие в IKEv2 remote-access (managed + существующие RouterOS). */
|
||||||
export function mapClients(state: IpsecServerState): IpsecClientDto[] {
|
export function mapClients(state: IpsecServerState): IpsecClientDto[] {
|
||||||
const server = state.server
|
const server = state.server
|
||||||
|
const ike2PeerNames = selectIke2Peers(state.peers).map((p) => (p.name ?? "").trim()).filter(Boolean)
|
||||||
const mcByName = new Map(state.modeConfigs.map((m) => [(m.name ?? "").trim(), m]))
|
const mcByName = new Map(state.modeConfigs.map((m) => [(m.name ?? "").trim(), m]))
|
||||||
const activeByRemote = new Map<string, RosIpsecActivePeer>()
|
const activeByRemote = new Map<string, RosIpsecActivePeer>()
|
||||||
for (const a of state.active) {
|
for (const a of state.active) {
|
||||||
@@ -215,6 +222,7 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return state.identities
|
return state.identities
|
||||||
|
.filter((i) => isIpsecManagedComment(i.comment) || isIke2RemoteAccessIdentity(i, ike2PeerNames))
|
||||||
.map((i): IpsecClientDto => {
|
.map((i): IpsecClientDto => {
|
||||||
const comment = i.comment ?? ""
|
const comment = i.comment ?? ""
|
||||||
const managed = isIpsecManagedComment(comment)
|
const managed = isIpsecManagedComment(comment)
|
||||||
@@ -257,38 +265,70 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
|
|||||||
|
|
||||||
export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto {
|
export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto {
|
||||||
const server = state.server
|
const server = state.server
|
||||||
const managedPeer = state.peers.find((p) => isIpsecManagedComment(p.comment))
|
const ike2Peers = selectIke2Peers(state.peers)
|
||||||
?? state.peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME)
|
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
|
||||||
/** Для отображения: managed peer, иначе первый существующий peer роутера. */
|
const ike2Identities = state.identities.filter(
|
||||||
const primaryPeer = managedPeer ?? state.peers[0]
|
(i) => isIpsecManagedComment(i.comment) || isIke2RemoteAccessIdentity(i, ike2PeerNames),
|
||||||
const sharedMc = state.modeConfigs.find(
|
|
||||||
(m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME || (isIpsecManagedComment(m.comment) && !(m.name ?? "").startsWith("mc-ipsec-")),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const managedPeer = ike2Peers.find((p) => isIpsecManagedComment(p.comment))
|
||||||
|
?? ike2Peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||||
|
/** Для отображения: managed IKEv2 peer, иначе первый существующий IKEv2 peer. */
|
||||||
|
const primaryPeer = managedPeer ?? ike2Peers[0]
|
||||||
|
|
||||||
|
// mode-config, на который ссылаются IKEv2 identity (не персональный mc-ipsec-*), fallback managed shared
|
||||||
|
const idMcName = ike2Identities
|
||||||
|
.map((i) => (i["mode-config"] ?? "").trim())
|
||||||
|
.find((n) => n && !n.startsWith("mc-ipsec-"))
|
||||||
|
const sharedMc = (idMcName ? state.modeConfigs.find((m) => (m.name ?? "").trim() === idMcName) : undefined)
|
||||||
|
?? state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||||
|
?? state.modeConfigs.find((m) => isIpsecManagedComment(m.comment) && !(m.name ?? "").startsWith("mc-ipsec-"))
|
||||||
const pool = sharedMc
|
const pool = sharedMc
|
||||||
? state.pools.find((p) => (p.name ?? "").trim() === (sharedMc["address-pool"] ?? "").trim())
|
? state.pools.find((p) => (p.name ?? "").trim() === (sharedMc["address-pool"] ?? "").trim())
|
||||||
?? state.pools.find((p) => isIpsecManagedComment(p.comment))
|
?? state.pools.find((p) => isIpsecManagedComment(p.comment))
|
||||||
: undefined
|
: undefined
|
||||||
const caCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_CA_CERT)
|
|
||||||
?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("key-cert-sign"))
|
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers)
|
||||||
const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT)
|
const caName = resolveIke2CaName(state.certs, serverCertRow)
|
||||||
?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server"))
|
const caCertRow = caName ? state.certs.find((c) => String(c.name ?? "").trim() === caName) : undefined
|
||||||
|
|
||||||
|
const roleCtx: CertificateRoleContext = {
|
||||||
|
peerCertNames: ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean),
|
||||||
|
identityCertNames: ike2Identities.map((i) => (i["remote-certificate"] ?? "").trim()).filter(Boolean),
|
||||||
|
}
|
||||||
|
const referencedCertNames = new Set<string>([
|
||||||
|
...(roleCtx.peerCertNames ?? []),
|
||||||
|
...(roleCtx.identityCertNames ?? []),
|
||||||
|
...ike2Identities.map((i) => (i.certificate ?? "").trim()).filter(Boolean),
|
||||||
|
])
|
||||||
|
// только IKEv2-релевантные серты: CA сервера, referenced peer/identity и подписанные этим CA
|
||||||
|
const relevantCerts = state.certs.filter((c) => {
|
||||||
|
const n = String(c.name ?? "").trim()
|
||||||
|
if (caName && n === caName) return true
|
||||||
|
if (referencedCertNames.has(n)) return true
|
||||||
|
return Boolean(caName) && (c.ca ?? "").trim() === caName
|
||||||
|
})
|
||||||
|
|
||||||
|
const caCert = caCertRow ? mapCertificate(caCertRow, roleCtx) : undefined
|
||||||
|
const serverCert = serverCertRow ? mapCertificate(serverCertRow, roleCtx) : undefined
|
||||||
const natRuleManaged = state.nat.some((r) => isIpsecManagedComment(r.comment) && r.chain === "srcnat")
|
const natRuleManaged = state.nat.some((r) => isIpsecManagedComment(r.comment) && r.chain === "srcnat")
|
||||||
return {
|
return {
|
||||||
serverId: String(server.id),
|
serverId: String(server.id),
|
||||||
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
|
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
|
||||||
serverCountry: server.country ?? undefined,
|
serverCountry: server.country ?? undefined,
|
||||||
initialized: Boolean(managedPeer && caCert && serverCert),
|
initialized: Boolean(managedPeer && caCert && serverCert),
|
||||||
serverEndpoint: serverCert ? String(serverCert["common-name"] ?? "") || undefined : undefined,
|
ike2Ready: Boolean(ike2Peers.length > 0 && caCert && serverCert),
|
||||||
|
serverEndpoint: serverCertRow ? String(serverCertRow["common-name"] ?? "") || undefined : undefined,
|
||||||
peer: primaryPeer ? mapPeer(server, primaryPeer) : undefined,
|
peer: primaryPeer ? mapPeer(server, primaryPeer) : undefined,
|
||||||
peers: state.peers.map((p) => mapPeer(server, p)),
|
peers: ike2Peers.map((p) => mapPeer(server, p)),
|
||||||
pool: pool ? mapPool(server, pool) : undefined,
|
pool: pool ? mapPool(server, pool) : undefined,
|
||||||
sharedModeConfig: sharedMc ? mapModeConfig(server, sharedMc) : undefined,
|
sharedModeConfig: sharedMc ? mapModeConfig(server, sharedMc) : undefined,
|
||||||
caCert: caCert ? mapCertificate(caCert) : undefined,
|
caCert,
|
||||||
serverCert: serverCert ? mapCertificate(serverCert) : undefined,
|
serverCert,
|
||||||
natRuleManaged,
|
natRuleManaged,
|
||||||
clientsTotal: clients.length,
|
clientsTotal: clients.length,
|
||||||
clientsOnline: clients.filter((c) => c.online).length,
|
clientsOnline: clients.filter((c) => c.online).length,
|
||||||
certs: state.certs.map(mapCertificate),
|
certs: relevantCerts.map((c) => mapCertificate(c, roleCtx)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
|
DownloadIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
KeyRoundIcon,
|
KeyRoundIcon,
|
||||||
NetworkIcon,
|
NetworkIcon,
|
||||||
@@ -91,9 +92,11 @@ function PeerRow({
|
|||||||
function CertRow({
|
function CertRow({
|
||||||
cert,
|
cert,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onExport,
|
||||||
}: {
|
}: {
|
||||||
cert: IpsecCertInfoDto
|
cert: IpsecCertInfoDto
|
||||||
onDelete?: (cert: IpsecCertInfoDto) => void
|
onDelete?: (cert: IpsecCertInfoDto) => void
|
||||||
|
onExport?: (cert: IpsecCertInfoDto) => void
|
||||||
}) {
|
}) {
|
||||||
const roleLabel = cert.role === "ca" ? "CA" : cert.role === "server" ? "сервер" : cert.role === "client" ? "клиент" : "прочий"
|
const roleLabel = cert.role === "ca" ? "CA" : cert.role === "server" ? "сервер" : cert.role === "client" ? "клиент" : "прочий"
|
||||||
return (
|
return (
|
||||||
@@ -107,22 +110,41 @@ function CertRow({
|
|||||||
{cert.trusted === false ? <Badge variant="outline" className="text-[10px]">не trusted</Badge> : null}
|
{cert.trusted === false ? <Badge variant="outline" className="text-[10px]">не trusted</Badge> : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="truncate font-mono text-[10px] text-muted-foreground">
|
<p className="truncate font-mono text-[10px] text-muted-foreground">
|
||||||
{[cert.commonName, cert.expiresAt ? `до ${cert.expiresAt}` : undefined].filter(Boolean).join(" · ") || "—"}
|
{[
|
||||||
|
cert.commonName,
|
||||||
|
cert.signedBy ? `подписан ${cert.signedBy}` : undefined,
|
||||||
|
cert.expiresAt ? `до ${cert.expiresAt}` : undefined,
|
||||||
|
].filter(Boolean).join(" · ") || "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{onDelete ? (
|
<div className="flex shrink-0 gap-0.5">
|
||||||
<Button
|
{onExport && cert.role === "client" ? (
|
||||||
type="button"
|
<Button
|
||||||
size="icon"
|
type="button"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
className="size-7 shrink-0 text-destructive"
|
variant="ghost"
|
||||||
aria-label="Удалить сертификат"
|
className="size-7"
|
||||||
title="Удалить сертификат"
|
aria-label="Скачать .p12"
|
||||||
onClick={() => onDelete(cert)}
|
title="Скачать .p12 (сертификат + ключ)"
|
||||||
>
|
onClick={() => onExport(cert)}
|
||||||
<Trash2Icon className="size-3.5" />
|
>
|
||||||
</Button>
|
<DownloadIcon className="size-3.5" />
|
||||||
) : null}
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{onDelete ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="size-7 shrink-0 text-destructive"
|
||||||
|
aria-label="Удалить сертификат"
|
||||||
|
title="Удалить сертификат"
|
||||||
|
onClick={() => onDelete(cert)}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -134,6 +156,7 @@ export interface IpsecServerGridProps {
|
|||||||
onEditPeer?: (serverId: string, peer: IpsecPeerDto) => void
|
onEditPeer?: (serverId: string, peer: IpsecPeerDto) => void
|
||||||
onDeletePeer?: (serverId: string, peer: IpsecPeerDto) => void
|
onDeletePeer?: (serverId: string, peer: IpsecPeerDto) => void
|
||||||
onDeleteCert?: (serverId: string, cert: IpsecCertInfoDto) => void
|
onDeleteCert?: (serverId: string, cert: IpsecCertInfoDto) => void
|
||||||
|
onExportCert?: (serverId: string, cert: IpsecCertInfoDto) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function IpsecServerGrid({
|
function IpsecServerGrid({
|
||||||
@@ -143,6 +166,7 @@ function IpsecServerGrid({
|
|||||||
onEditPeer,
|
onEditPeer,
|
||||||
onDeletePeer,
|
onDeletePeer,
|
||||||
onDeleteCert,
|
onDeleteCert,
|
||||||
|
onExportCert,
|
||||||
}: IpsecServerGridProps) {
|
}: IpsecServerGridProps) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
@@ -157,14 +181,21 @@ function IpsecServerGrid({
|
|||||||
{s.serverEndpoint ?? "endpoint не определён"}
|
{s.serverEndpoint ?? "endpoint не определён"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{s.peers.length > 0 ? (
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
<Badge className="gap-1">
|
{s.ike2Ready ? (
|
||||||
<ShieldCheckIcon className="size-3" />
|
<Badge className="gap-1">
|
||||||
{s.peers.length} peer
|
<ShieldCheckIcon className="size-3" />
|
||||||
</Badge>
|
IKEv2 готов
|
||||||
) : (
|
</Badge>
|
||||||
<Badge variant="outline">нет peer</Badge>
|
) : null}
|
||||||
)}
|
{s.peers.length > 0 ? (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
{s.peers.length} peer
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">нет peer</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
@@ -198,6 +229,7 @@ function IpsecServerGrid({
|
|||||||
key={c.name}
|
key={c.name}
|
||||||
cert={c}
|
cert={c}
|
||||||
onDelete={onDeleteCert ? (cert) => onDeleteCert(s.serverId, cert) : undefined}
|
onDelete={onDeleteCert ? (cert) => onDeleteCert(s.serverId, cert) : undefined}
|
||||||
|
onExport={onExportCert ? (cert) => onExportCert(s.serverId, cert) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -151,7 +151,13 @@ function IpsecUserSheet({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
{!editing ? (
|
{!editing ? (
|
||||||
<FormField label="Аутентификация" required>
|
<FormField
|
||||||
|
label="Аутентификация"
|
||||||
|
required
|
||||||
|
hint={form.authMethod === "certificate"
|
||||||
|
? "CA и серверный серт берутся из уже настроенного IKEv2 (напр. MyCA + vpn-server); новый клиент подписывается этим CA"
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export const certificateDtoSchema = z.object({
|
|||||||
trusted: z.boolean(),
|
trusted: z.boolean(),
|
||||||
status: certStatusSchema,
|
status: certStatusSchema,
|
||||||
acmeStatus: z.string().optional(),
|
acmeStatus: z.string().optional(),
|
||||||
|
/** Имя CA, которым серт подписан на устройстве (read-only поле `ca` RouterOS). */
|
||||||
|
signedByCertName: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const certificatesListResponseSchema = z.object({
|
export const certificatesListResponseSchema = z.object({
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export const ipsecCertInfoDtoSchema = z.object({
|
|||||||
trusted: z.boolean().optional(),
|
trusted: z.boolean().optional(),
|
||||||
hasPrivateKey: z.boolean().optional(),
|
hasPrivateKey: z.boolean().optional(),
|
||||||
role: z.enum(["ca", "server", "client", "other"]).optional(),
|
role: z.enum(["ca", "server", "client", "other"]).optional(),
|
||||||
|
/** Имя CA, которым серт подписан на устройстве (read-only поле `ca` RouterOS). */
|
||||||
|
signedBy: z.string().optional(),
|
||||||
managed: z.boolean(),
|
managed: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -87,6 +89,8 @@ export const ipsecServerSummaryDtoSchema = z.object({
|
|||||||
serverCountry: z.string().optional(),
|
serverCountry: z.string().optional(),
|
||||||
/** Managed-набор (CA + серверный серт + peer + mode-config) инициализирован. */
|
/** Managed-набор (CA + серверный серт + peer + mode-config) инициализирован. */
|
||||||
initialized: z.boolean(),
|
initialized: z.boolean(),
|
||||||
|
/** Существующий IKEv2-сервер пригоден: есть peer + CA + серверный серт. */
|
||||||
|
ike2Ready: z.boolean(),
|
||||||
serverEndpoint: z.string().optional(),
|
serverEndpoint: z.string().optional(),
|
||||||
/** Primary managed peer (обратная совместимость); для полного списка — `peers`. */
|
/** Primary managed peer (обратная совместимость); для полного списка — `peers`. */
|
||||||
peer: ipsecPeerDtoSchema.optional(),
|
peer: ipsecPeerDtoSchema.optional(),
|
||||||
@@ -176,6 +180,12 @@ export const ipsecCertExportRequestSchema = z.object({
|
|||||||
passphrase: z.string().min(4),
|
passphrase: z.string().min(4),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Экспорт .p12 существующего клиентского сертификата по имени (client1/anakondra и т.п.). */
|
||||||
|
export const ipsecCertExportByNameRequestSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
passphrase: z.string().min(4),
|
||||||
|
})
|
||||||
|
|
||||||
/** Бандл для авторизации клиента: .p12 (+ strongSwan .sswan + инструкция). */
|
/** Бандл для авторизации клиента: .p12 (+ strongSwan .sswan + инструкция). */
|
||||||
export const ipsecCertBundleSchema = z.object({
|
export const ipsecCertBundleSchema = z.object({
|
||||||
user: z.string(),
|
user: z.string(),
|
||||||
@@ -209,5 +219,6 @@ export type IpsecInitRequest = z.infer<typeof ipsecInitRequestSchema>
|
|||||||
export type IpsecUserCreateRequest = z.infer<typeof ipsecUserCreateRequestSchema>
|
export type IpsecUserCreateRequest = z.infer<typeof ipsecUserCreateRequestSchema>
|
||||||
export type IpsecUserPatch = z.infer<typeof ipsecUserPatchSchema>
|
export type IpsecUserPatch = z.infer<typeof ipsecUserPatchSchema>
|
||||||
export type IpsecCertExportRequest = z.infer<typeof ipsecCertExportRequestSchema>
|
export type IpsecCertExportRequest = z.infer<typeof ipsecCertExportRequestSchema>
|
||||||
|
export type IpsecCertExportByNameRequest = z.infer<typeof ipsecCertExportByNameRequestSchema>
|
||||||
export type IpsecCertBundle = z.infer<typeof ipsecCertBundleSchema>
|
export type IpsecCertBundle = z.infer<typeof ipsecCertBundleSchema>
|
||||||
export type IpsecUserCreated = z.infer<typeof ipsecUserCreatedSchema>
|
export type IpsecUserCreated = z.infer<typeof ipsecUserCreatedSchema>
|
||||||
|
|||||||
@@ -97,6 +97,19 @@ export async function exportIpsecUserCert(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function exportIpsecCertByName(
|
||||||
|
baseUrl: string,
|
||||||
|
serverId: string,
|
||||||
|
name: string,
|
||||||
|
passphrase: string,
|
||||||
|
): Promise<IpsecCertBundle> {
|
||||||
|
return requestJson<IpsecCertBundle>(
|
||||||
|
baseUrl,
|
||||||
|
`/api/ipsec/certs/${encodeURIComponent(serverId)}/export`,
|
||||||
|
{ method: "POST", body: JSON.stringify({ name, passphrase }) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export async function restoreIpsecRevision(
|
export async function restoreIpsecRevision(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
revisionId: string,
|
revisionId: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user