Compare commits

...
3 Commits
Author SHA1 Message Date
DenozordecandCursor a3502b9755 fix(ipsec): убрать неподдерживаемый параметр при подписи сертификата
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-test (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 2m17s
Docker images / updater-image (push) Successful in 38s
Docker images / backend-image (push) Successful in 2m20s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 9s
Co-authored-by: Cursor <[email protected]>
2026-09-12 22:21:19 +07:00
DenozordecandCursor 0e1524c600 fix(ipsec): клиенты через общий IKEv2-peer и отдельный клиентский сертификат
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m51s
Docker images / frontend-image (push) Successful in 2m54s
Docker images / updater-image (push) Successful in 40s
Docker images / backend-image (push) Successful in 2m11s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
Co-authored-by: Cursor <[email protected]>
2026-09-12 22:06:35 +07:00
DenozordecandCursor 11ae7593f4 fix(ipsec): убрать неподдерживаемый параметр при добавлении сертификата
Co-authored-by: Cursor <[email protected]>
2026-09-12 22:06:27 +07:00
12 changed files with 389 additions and 101 deletions
+9 -6
View File
@@ -116,18 +116,18 @@ const MOCK_IPSEC_SERVERS: IpsecServerSummaryDto[] = [
const MOCK_IPSEC_CLIENTS: IpsecClientDto[] = [ const MOCK_IPSEC_CLIENTS: IpsecClientDto[] = [
{ {
id: "srv2:*I1", rosId: "*I1", serverId: "srv2", serverName: "mt-spb-edge-01", id: "srv2:*I1", rosId: "*I1", serverId: "srv2", serverName: "mt-spb-edge-01",
name: "alice", authMethod: "certificate", certificateName: "ipsec-user-alice", commonName: "alice", name: "alice", authMethod: "certificate", kind: "cert", certificateName: "ipsec-user-alice", certName: "ipsec-user-alice", signedBy: "MyCA", commonName: "alice",
staticIp: "10.77.0.10", modeConfigName: "mc-ipsec-alice", peerName: "ipsec-vpn", staticIp: "10.77.0.10", modeConfigName: "mc-ipsec-alice", peerName: "ipsec-vpn",
online: true, activeAddress: "10.100.1.7", activeSince: "2h", disabled: false, managed: true, online: true, activeAddress: "10.100.1.7", activeSince: "2h", disabled: false, managed: true,
}, },
{ {
id: "srv2:*I2", rosId: "*I2", serverId: "srv2", serverName: "mt-spb-edge-01", id: "srv2:*I2", rosId: "*I2", serverId: "srv2", serverName: "mt-spb-edge-01",
name: "bob", authMethod: "certificate", certificateName: "ipsec-user-bob", commonName: "bob", name: "bob", authMethod: "certificate", kind: "cert", certificateName: "ipsec-user-bob", certName: "ipsec-user-bob", signedBy: "MyCA", commonName: "bob",
peerName: "ipsec-vpn", online: false, disabled: false, managed: true, peerName: "ipsec-vpn", online: false, disabled: false, managed: true,
}, },
{ {
id: "srv2:*I3", rosId: "*I3", serverId: "srv2", serverName: "mt-spb-edge-01", id: "srv2:*I3", rosId: "*I3", serverId: "srv2", serverName: "mt-spb-edge-01",
name: "tablet-psk", authMethod: "pre-shared-key", remoteId: "tablet", name: "tablet-psk", authMethod: "pre-shared-key", kind: "psk", remoteId: "tablet",
peerName: "ipsec-vpn", online: false, disabled: false, managed: true, peerName: "ipsec-vpn", online: false, disabled: false, managed: true,
}, },
] ]
@@ -424,14 +424,17 @@ export default function IpsecPage() {
} }
const openCert = async (client: IpsecClientDto) => { const openCert = async (client: IpsecClientDto) => {
setCertClient(client) const byName = client.kind === "cert" && Boolean(client.certName)
setCertByName(null) setCertClient(byName ? null : client)
setCertByName(byName ? { serverId: client.serverId, name: client.certName! } : null)
setCertBundle(null) setCertBundle(null)
setCertOpen(true) setCertOpen(true)
setCertBusy(true) setCertBusy(true)
try { try {
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}` const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
const bundle = await exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase) const bundle = byName
? await exportIpsecCertByName(backendUrl, client.serverId, client.certName!, passphrase)
: await exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase)
setCertBundle(bundle) setCertBundle(bundle)
} catch (e) { } catch (e) {
toast.error("Ошибка экспорта сертификата", { toast.error("Ошибка экспорта сертификата", {
+115 -38
View File
@@ -29,6 +29,7 @@ import {
resolveIke2CaName, resolveIke2CaName,
resolveIke2ServerCert, resolveIke2ServerCert,
selectIke2Peers, selectIke2Peers,
selectSharedIke2Identity,
userModeConfigName, userModeConfigName,
} from "../services/ipsec-config.js" } from "../services/ipsec-config.js"
import { import {
@@ -46,6 +47,7 @@ import {
deleteIdentity, deleteIdentity,
patchPeer, patchPeer,
deletePeer, deletePeer,
ensureSharedIke2Identity,
listByPath, listByPath,
} from "../services/ipsec-ros.js" } from "../services/ipsec-ros.js"
import { import {
@@ -284,6 +286,9 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Для PSK-клиента укажите secret (psk)" }) return reply.status(400).send({ error: "Для PSK-клиента укажите secret (psk)" })
} }
const sharedIdentity = selectSharedIke2Identity(state.identities, ike2PeerNames)
const sharedIdentityId = sharedIdentity?.[".id"] ? String(sharedIdentity[".id"]) : undefined
let modeConfig = (sharedMc?.name ?? "").trim() let modeConfig = (sharedMc?.name ?? "").trim()
if (body.staticIp) { if (body.staticIp) {
const ip = body.staticIp.trim() const ip = body.staticIp.trim()
@@ -309,16 +314,30 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
certName = issued.certName certName = issued.certName
} }
await putIdentity(client, { if (body.authMethod === "certificate" && !body.staticIp) {
peerName, // Гибрид: клиент «вешается» на общую identity peer — отдельная identity не нужна,
modeConfig, // RouterOS сам принимает любой клиентский серт, подписанный доверенным CA.
comment: ipsecUserComment(body.name), await ensureSharedIke2Identity(client, {
authMethod: body.authMethod, peerName,
certificate: body.authMethod === "certificate" ? String(serverCertRow?.name ?? IPSEC_SERVER_CERT) : undefined, serverCertName: String(serverCertRow?.name ?? IPSEC_SERVER_CERT),
remoteCertificate: certName, modeConfigName: modeConfig,
secret: body.psk, comment: ipsecManagedComment("IKEv2 listener"),
remoteId: body.authMethod === "pre-shared-key" ? (body.remoteId ?? body.name) : undefined, })
}) } else {
// PSK или персональный статический IP: отдельная identity (match-by=certificate для cert).
// place-before гарантирует матчинг раньше общей listener-identity.
await putIdentity(client, {
peerName,
modeConfig,
comment: ipsecUserComment(body.name),
authMethod: body.authMethod,
certificate: body.authMethod === "certificate" ? String(serverCertRow?.name ?? IPSEC_SERVER_CERT) : undefined,
remoteCertificate: certName,
secret: body.psk,
remoteId: body.authMethod === "pre-shared-key" ? (body.remoteId ?? body.name) : undefined,
placeBefore: sharedIdentityId,
})
}
let bundle: IpsecCertBundle | undefined let bundle: IpsecCertBundle | undefined
if (body.authMethod === "certificate" && certName) { if (body.authMethod === "certificate" && certName) {
@@ -355,21 +374,41 @@ 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 identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId)) const targetRosId = rosIdParam(rosId)
if (!identity) return reply.status(404).send({ error: "Клиент не найден" }) const identity = state.identities.find((i) => String(i[".id"] ?? "") === targetRosId)
const managedIdentity = isIpsecManagedComment(identity.comment) const certRow = identity
const oldName = parseIpsecUserComment(identity.comment) ?? identity.comment ?? "" ? undefined
: state.certs.find((c) => String(c[".id"] ?? "") === targetRosId)
?? state.certs.find((c) => String(c.name ?? "").trim() === targetRosId)
if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" })
const ike2Peers = selectIke2Peers(state.peers)
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers)
const managedIdentity = identity
? isIpsecManagedComment(identity.comment)
: String(certRow?.name ?? "").startsWith("ipsec-user-")
const oldName = identity
? (parseIpsecUserComment(identity.comment) ?? identity.comment ?? "")
: String(certRow?.["common-name"] ?? certRow?.name ?? "").trim()
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME) const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
const sharedMcName = (sharedMc?.name ?? "").trim() const sharedMcName = (sharedMc?.name ?? "").trim()
const newName = d.name?.trim() || oldName
if (d.name && d.name !== oldName) { if (d.name && d.name !== oldName) {
// managed: user=<name>; существующий RouterOS identity: обычный comment if (identity) {
await patchIdentity(client, identity[".id"]!, { // managed: user=<name>; существующий RouterOS identity: обычный comment
comment: managedIdentity ? ipsecUserComment(d.name) : d.name.trim(), await patchIdentity(client, identity[".id"]!, {
}) comment: managedIdentity ? ipsecUserComment(d.name) : d.name.trim(),
})
}
// managed-серт переименовываем на конвенционное имя (client1/anakondra на устройстве не трогаем)
if (certRow?.[".id"] && managedIdentity) {
await client.patch(`/certificate/${encodeURIComponent(String(certRow[".id"]))}`, { name: clientCertName(d.name) })
}
} }
if (d.staticIp !== undefined) { if (identity && d.staticIp !== undefined) {
const currentMc = (identity["mode-config"] ?? "").trim() const currentMc = (identity["mode-config"] ?? "").trim()
if (d.staticIp == null) { if (d.staticIp == null) {
// вернуть выдачу из пула // вернуть выдачу из пула
@@ -383,18 +422,40 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
state.modeConfigs.filter((m) => (m.name ?? "").trim() !== currentMc), state.modeConfigs.filter((m) => (m.name ?? "").trim() !== currentMc),
) )
if (others.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` }) if (others.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` })
const name = userModeConfigName(d.name || oldName) const name = userModeConfigName(newName)
await putUserModeConfig(client, name, ip, ipsecManagedComment(`клиент ${(d.name || oldName).trim()}`)) await putUserModeConfig(client, name, ip, ipsecManagedComment(`клиент ${newName.trim()}`))
await patchIdentity(client, identity[".id"]!, { "mode-config": name }) await patchIdentity(client, identity[".id"]!, { "mode-config": name })
} }
} else if (!identity && certRow && d.staticIp) {
// cert-клиент без identity: персональная identity (match-by=certificate) + статический mode-config
const ip = d.staticIp.trim()
if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
const taken = takenStaticIps(state.modeConfigs)
if (taken.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` })
const peer = ike2Peers.find((p) => isIpsecManagedComment(p.comment)) ?? ike2Peers[0]
if (!peer) return reply.status(400).send({ error: "На роутере нет IKEv2 peer для привязки клиента" })
const shared = selectSharedIke2Identity(state.identities, ike2PeerNames)
const mcName = userModeConfigName(newName)
await putUserModeConfig(client, mcName, ip, ipsecManagedComment(`клиент ${newName.trim()}`))
await putIdentity(client, {
peerName: (peer.name ?? "").trim(),
modeConfig: mcName,
comment: ipsecUserComment(newName),
authMethod: "certificate",
certificate: String(serverCertRow?.name ?? IPSEC_SERVER_CERT),
remoteCertificate: String(certRow.name ?? "").trim(),
placeBefore: shared?.[".id"] ? String(shared[".id"]) : undefined,
})
} }
const patch: Record<string, string> = {} if (identity) {
if (d.psk) patch.secret = d.psk const patch: Record<string, string> = {}
if (d.remoteId !== undefined) patch["remote-id"] = d.remoteId if (d.psk) patch.secret = d.psk
if (d.disabled === true) patch.disabled = "yes" if (d.remoteId !== undefined) patch["remote-id"] = d.remoteId
if (d.disabled === false) patch.disabled = "no" if (d.disabled === true) patch.disabled = "yes"
if (Object.keys(patch).length) await patchIdentity(client, identity[".id"]!, patch) if (d.disabled === false) patch.disabled = "no"
if (Object.keys(patch).length) await patchIdentity(client, identity[".id"]!, patch)
}
await recordIpsec(server, "apply") await recordIpsec(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
@@ -412,20 +473,36 @@ 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 identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId)) const targetRosId = rosIdParam(rosId)
if (!identity) return reply.status(404).send({ error: "Клиент не найден" }) const identity = state.identities.find((i) => String(i[".id"] ?? "") === targetRosId)
const managed = isIpsecManagedComment(identity.comment) const certRow = identity
const userName = parseIpsecUserComment(identity.comment) ?? "" ? undefined
: state.certs.find((c) => String(c[".id"] ?? "") === targetRosId)
?? state.certs.find((c) => String(c.name ?? "").trim() === targetRosId)
if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" })
const managed = identity
? isIpsecManagedComment(identity.comment)
: String(certRow?.name ?? "").startsWith("ipsec-user-")
const userName = identity ? (parseIpsecUserComment(identity.comment) ?? "") : ""
const sharedMcName = (state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)?.name ?? "").trim() const sharedMcName = (state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)?.name ?? "").trim()
const personal = (identity["mode-config"] ?? "").trim() const personal = identity ? (identity["mode-config"] ?? "").trim() : ""
const remoteCert = (identity["remote-certificate"] ?? "").trim() const remoteCert = identity
? (identity["remote-certificate"] ?? "").trim()
: String(certRow?.name ?? "").trim()
await deleteIdentity(client, identity[".id"]!) if (identity) {
if (personal && personal !== sharedMcName) await deleteUserModeConfig(client, personal) await deleteIdentity(client, identity[".id"]!)
if (personal && personal !== sharedMcName) await deleteUserModeConfig(client, personal)
}
if (removeCertificate) { if (removeCertificate) {
// только явный сертификат клиента или managed-identity; чужие сертификаты не трогаем // удаляем только клиентский серт (managed / выбранный явно); CA и серверный не трогаем
if (remoteCert) await client.removeCertificate(remoteCert).catch(() => undefined) const ike2Peers = selectIke2Peers(state.peers)
else if (managed && userName) await client.removeCertificate(clientCertName(userName)).catch(() => undefined) const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers))
const serverCertName = String(resolveIke2ServerCert(state.certs, ike2Peers)?.name ?? "").trim()
const certName = remoteCert || (managed && userName ? clientCertName(userName) : "")
if (certName && certName !== caName && certName !== serverCertName) {
await client.removeCertificate(certName).catch(() => undefined)
}
} }
await recordIpsec(server, "apply") await recordIpsec(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
@@ -48,6 +48,9 @@ const anakondra: RosCertificateRow = { ...client1, name: "anakondra", "common-na
// reference-based важнее key-usage: у client1 в key-usage есть tls-server, но он client // reference-based важнее key-usage: у client1 в key-usage есть tls-server, но он client
assert.equal(certificateRole(client1, ctx), "client") assert.equal(certificateRole(client1, ctx), "client")
assert.equal(certificateRole(anakondra, ctx), "client") assert.equal(certificateRole(anakondra, ctx), "client")
// без reference-контекста: подпись локальным CA делает серт клиентским, несмотря на tls-server в usage
assert.equal(certificateRole(client1), "client")
assert.equal(certificateRole(anakondra), "client")
// без контекста — по key-usage (default содержит оба, поэтому server) // без контекста — по key-usage (default содержит оба, поэтому server)
assert.equal(certificateRole({ name: "x", "key-usage": "tls-client" }), "client") assert.equal(certificateRole({ name: "x", "key-usage": "tls-client" }), "client")
assert.equal(certificateRole({ name: "y", "key-usage": "digital-signature" }), "other") assert.equal(certificateRole({ name: "y", "key-usage": "digital-signature" }), "other")
@@ -50,6 +50,10 @@ export function certificateRole(
if (isCaCertificate(row)) return "ca" if (isCaCertificate(row)) return "ca"
if (name && new Set(ctx.peerCertNames ?? []).has(name)) return "server" if (name && new Set(ctx.peerCertNames ?? []).has(name)) return "server"
if (name && new Set(ctx.identityCertNames ?? []).has(name)) return "client" 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"] ?? "" const usage = row["key-usage"] ?? ""
if (usage.includes("tls-server")) return "server" if (usage.includes("tls-server")) return "server"
if (usage.includes("tls-client")) return "client" if (usage.includes("tls-client")) return "client"
+32
View File
@@ -17,8 +17,10 @@ import {
resolveIke2CaName, resolveIke2CaName,
resolveIke2ServerCert, resolveIke2ServerCert,
selectIke2Peers, selectIke2Peers,
selectSharedIke2Identity,
userModeConfigName, userModeConfigName,
} from "./ipsec-config.js" } from "./ipsec-config.js"
import { identityRosBody } from "./ipsec-ros.js"
{ {
assert.equal(ipsecSlug("Alice Cooper"), "alice-cooper") assert.equal(ipsecSlug("Alice Cooper"), "alice-cooper")
@@ -156,4 +158,34 @@ import {
assert.equal(resolveIke2CaName([vpnServer, client1], vpnServer), undefined) assert.equal(resolveIke2CaName([vpnServer, client1], vpnServer), undefined)
} }
{
// Общая listener-identity: без remote-certificate, сертификатный auth.
const shared = { ".id": "*S", peer: "vpn-server", "auth-method": "rsa-key", certificate: "vpn-server", "mode-config": "ikev2-modeconf" }
const perClient = { ".id": "*C", peer: "vpn-server", "auth-method": "rsa-key", certificate: "vpn-server", "remote-certificate": "client1", "match-by": "certificate" }
const psk = { ".id": "*P", peer: "vpn-server", "auth-method": "pre-shared-key" }
const foreignPeer = { ".id": "*F", peer: "gre-tunnel", "auth-method": "rsa-key", certificate: "vpn-server" }
assert.equal(selectSharedIke2Identity([perClient, psk, foreignPeer, shared], ["vpn-server"])?.[".id"], "*S")
// только per-client identity (с remote-certificate) общей не считается
assert.equal(selectSharedIke2Identity([perClient, psk], ["vpn-server"]), undefined)
// managed-приоритет
const managed = { ".id": "*M", peer: "vpn-server", "auth-method": "rsa-key", certificate: "vpn-server", comment: ipsecManagedComment("listener") }
assert.equal(selectSharedIke2Identity([shared, managed], ["vpn-server"])?.[".id"], "*M")
}
{
const body = identityRosBody({
peerName: "vpn-server",
modeConfig: "mc-ipsec-alice",
comment: ipsecUserComment("alice"),
authMethod: "certificate",
certificate: "vpn-server",
remoteCertificate: "ipsec-user-alice",
placeBefore: "*S",
})
assert.equal(body["place-before"], "*S")
assert.equal(body["remote-certificate"], "ipsec-user-alice")
assert.equal(body["match-by"], "certificate")
}
console.log("ipsec-config.test.ts: ok") console.log("ipsec-config.test.ts: ok")
+21
View File
@@ -154,6 +154,27 @@ export function isIke2RemoteAccessIdentity(
return hasModeConfig || hasRemoteCert || (rsaKey && Boolean((i.certificate ?? "").trim())) return hasModeConfig || hasRemoteCert || (rsaKey && Boolean((i.certificate ?? "").trim()))
} }
/**
* Общая listener-identity на IKEv2 peer: `remote-certificate` пуст, auth-method сертификатный.
* Именно она обслуживает всех клиентов, чей серт подписан доверенным CA (RouterOS валидирует по CA,
* см. docs «If a remote-certificate is not specified then the received certificate is checked against CA»).
* Приоритет managed, иначе первая подходящая (на устройстве это `denozord`/`ikev2-peer`).
*/
export function selectSharedIke2Identity<T extends Ike2IdentityLike>(
identities: T[],
ike2PeerNames?: Iterable<string>,
): T | undefined {
const names = new Set(Array.from(ike2PeerNames ?? [], (n) => n.trim()).filter(Boolean))
const candidates = identities.filter((i) => {
if (names.size > 0 && !names.has((i.peer ?? "").trim())) return false
if ((i["remote-certificate"] ?? "").trim() !== "") return false
const auth = (i["auth-method"] ?? "").trim().toLowerCase()
if (auth !== "rsa-key" && auth !== "digital-signature") return false
return Boolean((i.certificate ?? "").trim())
})
return candidates.find((i) => isIpsecManagedComment(i.comment)) ?? candidates[0]
}
/** Серверный серт IKEv2 — из `peer.certificate` (напр. vpn-server), fallback по имени/usage. */ /** Серверный серт IKEv2 — из `peer.certificate` (напр. vpn-server), fallback по имени/usage. */
export function resolveIke2ServerCert<T extends RosRow>(certs: T[], ike2Peers: PeerLike[]): T | undefined { export function resolveIke2ServerCert<T extends RosRow>(certs: T[], ike2Peers: PeerLike[]): T | undefined {
const names = new Set(ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean)) const names = new Set(ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean))
+105 -41
View File
@@ -22,6 +22,7 @@ import {
resolveIke2CaName, resolveIke2CaName,
resolveIke2ServerCert, resolveIke2ServerCert,
selectIke2Peers, selectIke2Peers,
selectSharedIke2Identity,
} from "./ipsec-config.js" } from "./ipsec-config.js"
import { certificateRole, type CertificateRoleContext } from "./certificate-parse.js" import { certificateRole, type CertificateRoleContext } from "./certificate-parse.js"
import { import {
@@ -210,57 +211,120 @@ 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, участвующие в IKEv2 remote-access (managed + существующие RouterOS). */ /**
* Клиенты IKEv2:
* 1) клиентские сертификаты (роль client) — включая существующие client1/anakondra на устройстве;
* 2) PSK-identity.
* Общая listener-identity (без remote-certificate) клиентом не считается — она обслуживает всех.
*/
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 ike2Peers = selectIke2Peers(state.peers)
const ike2PeerNames = ike2Peers.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 serverName = String(server.name ?? "").trim() || String(server.host ?? server.id)
const roleCtx: CertificateRoleContext = {
peerCertNames: ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean),
identityCertNames: state.identities.map((i) => (i["remote-certificate"] ?? "").trim()).filter(Boolean),
}
const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers))
const shared = selectSharedIke2Identity(state.identities, ike2PeerNames)
const sharedRosId = shared?.[".id"] ? String(shared[".id"]) : undefined
const activeByRemote = new Map<string, RosIpsecActivePeer>() const activeByRemote = new Map<string, RosIpsecActivePeer>()
for (const a of state.active) { for (const a of state.active) {
const rid = String(a["remote-id"] ?? "").trim() const rid = String(a["remote-id"] ?? "").trim()
if (rid) activeByRemote.set(rid, a) if (rid) activeByRemote.set(rid, a)
} }
return state.identities const identityByRemoteCert = new Map<string, RosIpsecIdentity>()
.filter((i) => isIpsecManagedComment(i.comment) || isIke2RemoteAccessIdentity(i, ike2PeerNames)) for (const i of state.identities) {
.map((i): IpsecClientDto => { const rc = (i["remote-certificate"] ?? "").trim()
const comment = i.comment ?? "" if (rc) identityByRemoteCert.set(rc, i)
const managed = isIpsecManagedComment(comment) }
const name = identityDisplayName(i, state.certs)
const psk = (i["auth-method"] ?? "") === "pre-shared-key" const personalStaticIp = (mcNameRaw: string): string | undefined => {
const certName = (i["remote-certificate"] ?? "").trim() const mc = mcByName.get(mcNameRaw)
const cn = certName if (!mc) return undefined
? String(state.certs.find((c) => String(c.name ?? "") === certName)?.["common-name"] ?? "") return (mc.address ?? mc["address-prefix"] ?? "").replace(/\/\d+$/, "").trim() || undefined
: "" }
const mcName = (i["mode-config"] ?? "").trim()
const mc = mcByName.get(mcName) const out: IpsecClientDto[] = []
const staticIp = mc
? ((mc.address ?? mc["address-prefix"] ?? "").replace(/\/\d+$/, "").trim() || undefined) // 1. Клиентские сертификаты (роль client), подписанные нашим CA / выданные менеджером / referenced identity.
: undefined for (const cert of state.certs) {
const active = (cn ? activeByRemote.get(cn) : undefined) const certName = String(cert.name ?? "").trim()
?? (i["remote-id"] ? activeByRemote.get(i["remote-id"]) : undefined) if (!certName) continue
return { if (certificateRole(cert, roleCtx) !== "client") continue
id: `${server.id}:${String(i[".id"] ?? "identity")}`, const signedBy = (cert.ca ?? "").trim()
rosId: String(i[".id"] ?? "identity"), const isUser = certName.startsWith("ipsec-user-")
serverId: String(server.id), const referenced = identityByRemoteCert.has(certName)
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id), if (!isUser && !referenced && !(caName && signedBy === caName)) continue
name,
authMethod: psk ? "pre-shared-key" : "certificate", const identity = identityByRemoteCert.get(certName)
certificateName: certName || undefined, const comment = identity?.comment ?? ""
commonName: cn || undefined, const mcName = (identity?.["mode-config"] ?? "").trim()
remoteId: (i["remote-id"] ?? "").trim() || undefined, const cn = String(cert["common-name"] ?? "").trim()
staticIp, const active = (cn ? activeByRemote.get(cn) : undefined)
modeConfigName: mcName || undefined, ?? (identity?.["remote-id"] ? activeByRemote.get(String(identity["remote-id"]).trim()) : undefined)
peerName: (i.peer ?? "").trim() || undefined, out.push({
online: Boolean(active), id: `${server.id}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
activeAddress: active?.address || undefined, rosId: String(identity?.[".id"] ?? cert[".id"] ?? certName),
activeSince: active?.established || undefined, serverId: String(server.id),
disabled: asBool(i.disabled), serverName,
comment: comment || undefined, name: cn || certName,
managed, authMethod: "certificate",
} kind: "cert",
certificateName: certName,
certName,
signedBy: signedBy || undefined,
commonName: cn || undefined,
remoteId: (identity?.["remote-id"] ?? "").trim() || undefined,
staticIp: personalStaticIp(mcName),
modeConfigName: mcName || undefined,
peerName: (identity?.peer ?? "").trim() || undefined,
online: Boolean(active),
activeAddress: active?.address || undefined,
activeSince: active?.established || undefined,
disabled: asBool(identity?.disabled),
comment: comment || undefined,
managed: isUser || isIpsecManagedComment(comment),
}) })
.sort((a, b) => a.name.localeCompare(b.name)) }
// 2. PSK-identity (managed или IKEv2 remote-access).
for (const i of state.identities) {
if ((i["auth-method"] ?? "").trim().toLowerCase() !== "pre-shared-key") continue
const rosId = String(i[".id"] ?? "")
if (sharedRosId && rosId === sharedRosId) continue
const managed = isIpsecManagedComment(i.comment)
if (!managed && !isIke2RemoteAccessIdentity(i, ike2PeerNames)) continue
const mcName = (i["mode-config"] ?? "").trim()
const remoteId = (i["remote-id"] ?? "").trim()
const active = remoteId ? activeByRemote.get(remoteId) : undefined
out.push({
id: `${server.id}:${rosId || "identity"}`,
rosId: rosId || "identity",
serverId: String(server.id),
serverName,
name: identityDisplayName(i, state.certs),
authMethod: "pre-shared-key",
kind: "psk",
remoteId: remoteId || undefined,
staticIp: personalStaticIp(mcName),
modeConfigName: mcName || undefined,
peerName: (i.peer ?? "").trim() || undefined,
online: Boolean(active),
activeAddress: active?.address || undefined,
activeSince: active?.established || undefined,
disabled: asBool(i.disabled),
comment: i.comment || undefined,
managed,
})
}
return out.sort((a, b) => a.name.localeCompare(b.name))
} }
export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto { export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto {
+37 -1
View File
@@ -1,5 +1,11 @@
import type { MikrotikClient } from "./mikrotik.js" import type { MikrotikClient } from "./mikrotik.js"
import { ipsecManagedComment } from "./ipsec-config.js" import {
ipsecManagedComment,
selectIke2Peers,
selectSharedIke2Identity,
type Ike2IdentityLike,
type PeerLike,
} from "./ipsec-config.js"
export function toRosBody(obj: Record<string, string | number | boolean | undefined | null>): Record<string, string> { export function toRosBody(obj: Record<string, string | number | boolean | undefined | null>): Record<string, string> {
const out: Record<string, string> = {} const out: Record<string, string> = {}
@@ -177,6 +183,8 @@ export interface IdentityFields {
/** psk. */ /** psk. */
secret?: string secret?: string
remoteId?: string remoteId?: string
/** Вставить identity перед указанной (per-client match-by=certificate должен идти раньше общей). */
placeBefore?: string
} }
export function identityRosBody(f: IdentityFields): Record<string, string> { export function identityRosBody(f: IdentityFields): Record<string, string> {
@@ -190,6 +198,7 @@ export function identityRosBody(f: IdentityFields): Record<string, string> {
"remote-id": f.remoteId, "remote-id": f.remoteId,
"mode-config": f.modeConfig, "mode-config": f.modeConfig,
"generate-policy": "port-strict", "generate-policy": "port-strict",
"place-before": f.placeBefore,
comment: f.comment, comment: f.comment,
}) })
} }
@@ -198,6 +207,33 @@ export async function putIdentity(client: MikrotikClient, fields: IdentityFields
await client.put("/ip/ipsec/identity", identityRosBody(fields)) await client.put("/ip/ipsec/identity", identityRosBody(fields))
} }
/**
* Общая listener-identity на IKEv2 peer для всех клиентов (reuse существующей, иначе создать).
* Без `remote-certificate`/`match-by` — RouterOS принимает любой клиентский серт, подписанный доверенным CA.
*/
export async function ensureSharedIke2Identity(
client: MikrotikClient,
args: { peerName: string; serverCertName: string; modeConfigName?: string; comment: string },
): Promise<string | undefined> {
const identities = await listByPath(client, "/ip/ipsec/identity")
const peers = await listByPath(client, "/ip/ipsec/peer")
const peerNames = selectIke2Peers(peers as PeerLike[]).map((p) => (p.name ?? "").trim()).filter(Boolean)
const existing = selectSharedIke2Identity(
identities as Ike2IdentityLike[],
peerNames.length > 0 ? peerNames : [args.peerName],
)
if (existing?.[".id"]) return String(existing[".id"])
await putIdentity(client, {
peerName: args.peerName,
modeConfig: args.modeConfigName ?? "",
comment: args.comment,
authMethod: "certificate",
certificate: args.serverCertName,
})
return undefined
}
export async function patchIdentity(client: MikrotikClient, rosId: string, body: Record<string, string>): Promise<void> { export async function patchIdentity(client: MikrotikClient, rosId: string, body: Record<string, string>): Promise<void> {
await client.patch(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`, body) await client.patch(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`, body)
} }
+35 -5
View File
@@ -415,6 +415,13 @@ export function encodeRosId(rosId: string): string {
// ── MikrotikClient ───────────────────────────────────────────────────────────── // ── MikrotikClient ─────────────────────────────────────────────────────────────
/** Имя параметра из ошибки RouterOS `unknown parameter <name>` (400) — для деградации запроса. */
function unknownParameterName(error: MikrotikError): string | undefined {
if (error.statusCode !== 400) return undefined
const match = error.body.match(/unknown parameter\s+"?([A-Za-z0-9_-]+)"?/i)
return match?.[1]
}
export class MikrotikClient { export class MikrotikClient {
constructor(private readonly params: MikrotikConnectParams) {} constructor(private readonly params: MikrotikConnectParams) {}
@@ -689,9 +696,32 @@ export class MikrotikClient {
: new Error(`Не удалось скачать файл ${normalized} с RouterOS`) : new Error(`Не удалось скачать файл ${normalized} с RouterOS`)
} }
/**
* POST с деградацией по несовместимым параметрам: набор полей `/certificate/*` зависит от версии
* RouterOS (напр. `comment`, `days-valid`). При 400 «unknown parameter X» убираем X и повторяем.
*/
private async postTolerant(
path: string,
body: Record<string, string>,
timeoutMs: number,
maxAttempts = 4,
): Promise<unknown> {
const payload: Record<string, string> = { ...body }
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
return await this.post(path, payload, timeoutMs)
} catch (error) {
const param = error instanceof MikrotikError ? unknownParameterName(error) : undefined
if (!param || !(param in payload)) throw error
delete payload[param]
}
}
throw new Error(`RouterOS: не удалось выполнить ${path} (несовместимые параметры): ${Object.keys(body).join(", ")}`)
}
/** Создание ключевой пары + заявки: /certificate add (поля common-name, key-size, key-usage…). */ /** Создание ключевой пары + заявки: /certificate add (поля common-name, key-size, key-usage…). */
async addCertificate(body: Record<string, string>, timeoutMs = 30_000): Promise<unknown> { async addCertificate(body: Record<string, string>, timeoutMs = 30_000): Promise<unknown> {
return this.post("/certificate/add", body, timeoutMs) return this.postTolerant("/certificate/add", body, timeoutMs)
} }
/** Подпись сертификата локальным CA; sign небыстрый — увеличенный таймаут. */ /** Подпись сертификата локальным CA; sign небыстрый — увеличенный таймаут. */
@@ -704,14 +734,14 @@ export class MikrotikClient {
if (params.ca) body.ca = params.ca if (params.ca) body.ca = params.ca
if (params.daysValid != null) body["days-valid"] = String(params.daysValid) if (params.daysValid != null) body["days-valid"] = String(params.daysValid)
try { try {
return await this.post("/certificate/sign", body, timeoutMs) return await this.postTolerant("/certificate/sign", body, timeoutMs)
} catch (e) { } catch (e) {
// Некоторые версии REST принимают цель подписи только как .id. // Некоторые версии REST принимают цель подписи только как .id.
const certs = await this.getCertificates() const certs = await this.getCertificates()
const row = certs.find((c) => String(c.name ?? "") === params.name) const row = certs.find((c) => String(c.name ?? "") === params.name)
const id = row?.[".id"] const id = row?.[".id"]
if (!id) throw e if (!id) throw e
return await this.post("/certificate/sign", { ".id": id, ...body }, timeoutMs) return await this.postTolerant("/certificate/sign", { ".id": id, ...body }, timeoutMs)
} }
} }
@@ -725,12 +755,12 @@ export class MikrotikClient {
if (params.passphrase?.trim()) body["export-passphrase"] = params.passphrase.trim() if (params.passphrase?.trim()) body["export-passphrase"] = params.passphrase.trim()
let raw: unknown let raw: unknown
try { try {
raw = await this.post("/certificate/export-certificate", body, timeoutMs) raw = await this.postTolerant("/certificate/export-certificate", body, timeoutMs)
} catch (e) { } catch (e) {
const certs = await this.getCertificates() const certs = await this.getCertificates()
const id = certs.find((c) => String(c.name ?? "") === params.name)?.[".id"] const id = certs.find((c) => String(c.name ?? "") === params.name)?.[".id"]
if (!id) throw e if (!id) throw e
raw = await this.post("/certificate/export-certificate", { ".id": id, ...body }, timeoutMs) raw = await this.postTolerant("/certificate/export-certificate", { ".id": id, ...body }, timeoutMs)
} }
void raw void raw
// RouterOS создаёт cert_export_<name>.p12 либо <name>.p12 — ищем по списку файлов. // RouterOS создаёт cert_export_<name>.p12 либо <name>.p12 — ищем по списку файлов.
+2 -1
View File
@@ -155,7 +155,7 @@ function IpsecUserSheet({
label="Аутентификация" label="Аутентификация"
required required
hint={form.authMethod === "certificate" hint={form.authMethod === "certificate"
? "CA и серверный серт берутся из уже настроенного IKEv2 (напр. MyCA + vpn-server); новый клиент подписывается этим CA" ? "По умолчанию клиент подключается через общий IKEv2-peer — выпускается только отдельный клиентский сертификат (CA и серверный серт берутся из уже настроенного IKEv2, напр. MyCA + vpn-server)"
: undefined} : undefined}
> >
<div className="flex gap-1.5"> <div className="flex gap-1.5">
@@ -220,6 +220,7 @@ function IpsecUserSheet({
<p className="text-sm font-medium">Статический IP</p> <p className="text-sm font-medium">Статический IP</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{freeIpHint ? `Свободный из пула: ${freeIpHint}` : "Иначе — выдача из пула"} {freeIpHint ? `Свободный из пула: ${freeIpHint}` : "Иначе — выдача из пула"}
{" "}Статический IP создаёт персональную identity на peer.
</p> </p>
</div> </div>
<FormToggle checked={form.useStaticIp} onChange={(v) => set("useStaticIp", v)} /> <FormToggle checked={form.useStaticIp} onChange={(v) => set("useStaticIp", v)} />
+20 -9
View File
@@ -84,15 +84,26 @@ function IpsecUsersGrid({
accessorKey: "authMethod", accessorKey: "authMethod",
header: ({ column }) => <DataGridSortHeader column={column} title="Аутентификация" />, header: ({ column }) => <DataGridSortHeader column={column} title="Аутентификация" />,
cell: ({ row }) => { cell: ({ row }) => {
const cert = row.original.authMethod === "certificate" const c = row.original
const cert = c.kind === "cert"
return ( return (
<div className="flex items-center gap-1.5 text-xs"> <div className="flex min-w-0 flex-col gap-0.5 text-xs">
{cert ? ( <div className="flex items-center gap-1.5">
<BadgeCheckIcon className="size-3.5 text-info" /> {cert ? (
) : ( <BadgeCheckIcon className="size-3.5 shrink-0 text-info" />
<KeyRoundIcon className="size-3.5 text-muted-foreground" /> ) : (
)} <KeyRoundIcon className="size-3.5 shrink-0 text-muted-foreground" />
{cert ? "Сертификат" : "PSK"} )}
{cert ? "Сертификат" : "PSK"}
</div>
{cert && c.signedBy ? (
<span
className="truncate font-mono text-[10px] text-muted-foreground"
title={`Подписан: ${c.signedBy}`}
>
подписан: {c.signedBy}
</span>
) : null}
</div> </div>
) )
}, },
@@ -175,7 +186,7 @@ function IpsecUsersGrid({
const c = row.original const c = row.original
return ( return (
<div className="flex justify-end gap-0.5"> <div className="flex justify-end gap-0.5">
{c.authMethod === "certificate" && onDownloadCert ? ( {c.kind === "cert" && onDownloadCert ? (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
+6
View File
@@ -11,7 +11,13 @@ export const ipsecClientDtoSchema = z.object({
/** Управляемое имя клиента (из managed-комментария identity). */ /** Управляемое имя клиента (из managed-комментария identity). */
name: z.string(), name: z.string(),
authMethod: ipsecAuthMethodSchema, authMethod: ipsecAuthMethodSchema,
/** cert — клиент по сертификату (может быть без персональной identity); psk — pre-shared-key identity. */
kind: z.enum(["cert", "psk"]).default("cert"),
certificateName: z.string().optional(), certificateName: z.string().optional(),
/** Имя клиентского сертификата (роль client). Для psk — undefined. */
certName: z.string().optional(),
/** CA, которым подписан клиентский сертификат (read-only поле `ca`). */
signedBy: z.string().optional(),
commonName: z.string().optional(), commonName: z.string().optional(),
remoteId: z.string().optional(), remoteId: z.string().optional(),
/** Статический IP клиента (персональный mode-config), undefined — из общего пула. */ /** Статический IP клиента (персональный mode-config), undefined — из общего пула. */