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
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]>
This commit is contained in:
@@ -17,8 +17,10 @@ import {
|
||||
resolveIke2CaName,
|
||||
resolveIke2ServerCert,
|
||||
selectIke2Peers,
|
||||
selectSharedIke2Identity,
|
||||
userModeConfigName,
|
||||
} from "./ipsec-config.js"
|
||||
import { identityRosBody } from "./ipsec-ros.js"
|
||||
|
||||
{
|
||||
assert.equal(ipsecSlug("Alice Cooper"), "alice-cooper")
|
||||
@@ -156,4 +158,34 @@ import {
|
||||
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")
|
||||
|
||||
@@ -154,6 +154,27 @@ export function isIke2RemoteAccessIdentity(
|
||||
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. */
|
||||
export function resolveIke2ServerCert<T extends RosRow>(certs: T[], ike2Peers: PeerLike[]): T | undefined {
|
||||
const names = new Set(ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean))
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
resolveIke2CaName,
|
||||
resolveIke2ServerCert,
|
||||
selectIke2Peers,
|
||||
selectSharedIke2Identity,
|
||||
} from "./ipsec-config.js"
|
||||
import { certificateRole, type CertificateRoleContext } from "./certificate-parse.js"
|
||||
import {
|
||||
@@ -210,57 +211,120 @@ export async function fetchIpsecState(server: ServerRow): Promise<IpsecServerSta
|
||||
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[] {
|
||||
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 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>()
|
||||
for (const a of state.active) {
|
||||
const rid = String(a["remote-id"] ?? "").trim()
|
||||
if (rid) activeByRemote.set(rid, a)
|
||||
}
|
||||
|
||||
return state.identities
|
||||
.filter((i) => isIpsecManagedComment(i.comment) || isIke2RemoteAccessIdentity(i, ike2PeerNames))
|
||||
.map((i): IpsecClientDto => {
|
||||
const comment = i.comment ?? ""
|
||||
const managed = isIpsecManagedComment(comment)
|
||||
const name = identityDisplayName(i, state.certs)
|
||||
const psk = (i["auth-method"] ?? "") === "pre-shared-key"
|
||||
const certName = (i["remote-certificate"] ?? "").trim()
|
||||
const cn = certName
|
||||
? String(state.certs.find((c) => String(c.name ?? "") === certName)?.["common-name"] ?? "")
|
||||
: ""
|
||||
const mcName = (i["mode-config"] ?? "").trim()
|
||||
const mc = mcByName.get(mcName)
|
||||
const staticIp = mc
|
||||
? ((mc.address ?? mc["address-prefix"] ?? "").replace(/\/\d+$/, "").trim() || undefined)
|
||||
: undefined
|
||||
const active = (cn ? activeByRemote.get(cn) : undefined)
|
||||
?? (i["remote-id"] ? activeByRemote.get(i["remote-id"]) : undefined)
|
||||
return {
|
||||
id: `${server.id}:${String(i[".id"] ?? "identity")}`,
|
||||
rosId: String(i[".id"] ?? "identity"),
|
||||
serverId: String(server.id),
|
||||
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
|
||||
name,
|
||||
authMethod: psk ? "pre-shared-key" : "certificate",
|
||||
certificateName: certName || undefined,
|
||||
commonName: cn || undefined,
|
||||
remoteId: (i["remote-id"] ?? "").trim() || undefined,
|
||||
staticIp,
|
||||
modeConfigName: mcName || undefined,
|
||||
peerName: (i.peer ?? "").trim() || undefined,
|
||||
online: Boolean(active),
|
||||
activeAddress: active?.address || undefined,
|
||||
activeSince: active?.established || undefined,
|
||||
disabled: asBool(i.disabled),
|
||||
comment: comment || undefined,
|
||||
managed,
|
||||
}
|
||||
const identityByRemoteCert = new Map<string, RosIpsecIdentity>()
|
||||
for (const i of state.identities) {
|
||||
const rc = (i["remote-certificate"] ?? "").trim()
|
||||
if (rc) identityByRemoteCert.set(rc, i)
|
||||
}
|
||||
|
||||
const personalStaticIp = (mcNameRaw: string): string | undefined => {
|
||||
const mc = mcByName.get(mcNameRaw)
|
||||
if (!mc) return undefined
|
||||
return (mc.address ?? mc["address-prefix"] ?? "").replace(/\/\d+$/, "").trim() || undefined
|
||||
}
|
||||
|
||||
const out: IpsecClientDto[] = []
|
||||
|
||||
// 1. Клиентские сертификаты (роль client), подписанные нашим CA / выданные менеджером / referenced identity.
|
||||
for (const cert of state.certs) {
|
||||
const certName = String(cert.name ?? "").trim()
|
||||
if (!certName) continue
|
||||
if (certificateRole(cert, roleCtx) !== "client") continue
|
||||
const signedBy = (cert.ca ?? "").trim()
|
||||
const isUser = certName.startsWith("ipsec-user-")
|
||||
const referenced = identityByRemoteCert.has(certName)
|
||||
if (!isUser && !referenced && !(caName && signedBy === caName)) continue
|
||||
|
||||
const identity = identityByRemoteCert.get(certName)
|
||||
const comment = identity?.comment ?? ""
|
||||
const mcName = (identity?.["mode-config"] ?? "").trim()
|
||||
const cn = String(cert["common-name"] ?? "").trim()
|
||||
const active = (cn ? activeByRemote.get(cn) : undefined)
|
||||
?? (identity?.["remote-id"] ? activeByRemote.get(String(identity["remote-id"]).trim()) : undefined)
|
||||
out.push({
|
||||
id: `${server.id}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
|
||||
rosId: String(identity?.[".id"] ?? cert[".id"] ?? certName),
|
||||
serverId: String(server.id),
|
||||
serverName,
|
||||
name: cn || certName,
|
||||
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 {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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> {
|
||||
const out: Record<string, string> = {}
|
||||
@@ -177,6 +183,8 @@ export interface IdentityFields {
|
||||
/** psk. */
|
||||
secret?: string
|
||||
remoteId?: string
|
||||
/** Вставить identity перед указанной (per-client match-by=certificate должен идти раньше общей). */
|
||||
placeBefore?: 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,
|
||||
"mode-config": f.modeConfig,
|
||||
"generate-policy": "port-strict",
|
||||
"place-before": f.placeBefore,
|
||||
comment: f.comment,
|
||||
})
|
||||
}
|
||||
@@ -198,6 +207,33 @@ export async function putIdentity(client: MikrotikClient, fields: IdentityFields
|
||||
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> {
|
||||
await client.patch(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`, body)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user