fix(ipsec): учитывать уникальный DNS SAN в активных сессиях

This commit is contained in:
Denozordec committed 2026-10-11 02:18:09 +07:00
1 parent b4e56d1998
commit d4f5746325
12 files changed
+308 -19

No files matched your search

+25 -6
View File
@@ -261,11 +261,18 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
const subjectCounts = new Map<string, number>()
const cnCounts = new Map<string, number>()
// RouterOS reports the received ID without its type. Only exact DNS SANs
// are usable here; wildcards and other SAN namespaces are not IKE aliases.
const dnsSans = (cert: RosCertRow) => [...new Set((cert["subject-alt-name"] ?? "").split(",")
.map(value => value.trim()).filter(value => /^DNS:[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(value))
.map(value => value.slice(4)))]
const sanCounts = new Map<string, number>()
for (const cert of state.certs) {
const subject = routerOsCertificateDn(cert)
if (subject) subjectCounts.set(subject, (subjectCounts.get(subject) ?? 0) + 1)
const cn = cert["common-name"]
if (cn) cnCounts.set(cn, (cnCounts.get(cn) ?? 0) + 1)
for (const san of dnsSans(cert)) sanCounts.set(san, (sanCounts.get(san) ?? 0) + 1)
}
// RouterOS 7 active-peers.id contains the received value without its configured type prefix.
@@ -285,12 +292,14 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
}
const out: IpsecClientDto[] = []
const sessionClaims = new Map<RosIpsecActivePeer[], IpsecClientDto[]>()
const sessionClaims = new Map<RosIpsecActivePeer, IpsecClientDto[]>()
const claimSession = (active: RosIpsecActivePeer[] | undefined) => {
if (!active) return
const claims = sessionClaims.get(active) ?? []
claims.push(out[out.length - 1]!)
sessionClaims.set(active, claims)
for (const session of active) {
const claims = sessionClaims.get(session) ?? []
claims.push(out[out.length - 1]!)
sessionClaims.set(session, claims)
}
}
// 1. Клиентские сертификаты (роль client), подписанные нашим CA / выданные менеджером / referenced identity.
@@ -314,8 +323,18 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
// A literal CN is not a typed remote-id. It may also resemble another
// certificate's complete DN, in which case the bare-CN alias is unsafe.
const byCommonName = uniqueCn && !subjectCounts.has(cn) ? activeByRemote.get(cn) : undefined
const active = bySubject ?? byCommonName
?? (identity?.["remote-id"] ? findActive(String(identity["remote-id"]).trim()) : undefined)
const remoteId = (identity?.["remote-id"] ?? "").trim()
// SAN inference requires a directly bound certificate and no explicit ID.
// Count every certificate, including non-catalog certificates, to avoid
// attributing a shared alias. DN/CN namespaces take precedence over SANs.
const bySan = identity && (!remoteId || remoteId === "auto")
? dnsSans(cert).filter(san => sanCounts.get(san) === 1 && !subjectCounts.has(san) && !cnCounts.has(san))
.flatMap(san => activeByRemote.get(san) ?? []) : []
const sessions = [...new Set([
...(bySubject ?? []), ...(byCommonName ?? []), ...bySan,
...(remoteId ? findActive(remoteId) ?? [] : []),
])]
const active = sessions.length ? sessions : undefined
// Multiple established sessions confirm presence, but no single lease/uptime.
const single = active?.length === 1 ? active[0] : undefined
out.push({
+31
View File
@@ -213,4 +213,35 @@ sharedCertState.active[0]!.id="client0.example"
const distinctRows=mapClients(sharedCertState)
assert.equal(distinctRows.filter(c => c.online).length,1)
assert(distinctRows.every(c => !c.sessionAmbiguous)); checks++
// A real certificate-authenticated FQDN SAN session may omit remote-id.
const sanState = { ...sharedCertState, certs: [{ ...sharedCertState.certs[0]!, "subject-alt-name": "DNS:vpn.example,DNS:other.example,DNS:vpn.example" }], identities: [{ ...sharedCertState.identities[0]!, "remote-id": "" }], active: [{ ".id": "*SAN", id: "vpn.example", state: "established", "dynamic-address": "198.18.121.250", uptime: "1m" }] } as typeof state
assert.equal(mapClients(sanState)[0]?.online,true)
assert.equal(mapClients(sanState)[0]?.activeAddress,"198.18.121.250"); checks++
sanState.active.push({ ...sanState.active[0]!, ".id": "*DN", id: "CN=alice", "dynamic-address": "198.18.121.251" })
for (const reverse of [false,true]) {
if(reverse) sanState.active.reverse()
const row=mapClients(sanState)[0]!
assert(row.online && !row.sessionAmbiguous && !row.activeAddress && !row.activeSince); checks++
}
sanState.active = sanState.active.filter(a=>a.id==="vpn.example")
sanState.certs.push({ ...sanState.certs[0]!, ".id":"*FOREIGN", name:"foreign", ca:"other", "key-usage":"tls-server" })
assert.equal(mapClients(sanState)[0]?.online,false); checks++
sanState.certs.pop()
sanState.identities[0]!["remote-id"]="fqdn:unrelated.example"
assert.equal(mapClients(sanState)[0]?.online,false); checks++
sanState.identities[0]!["remote-id"]="auto"
assert.equal(mapClients(sanState)[0]?.online,true); checks++
sanState.identities.push({ ".id":"*PSK-SAN", peer:"ike", "auth-method":"pre-shared-key", "remote-id":"key-id:vpn.example", comment:ipsecManagedComment("user=collision") })
assert(mapClients(sanState).every(c=>c.sessionAmbiguous && !c.online && !c.activeAddress && !c.activeSince)); checks++
sanState.identities.pop()
for(const alias of ["DNS:*.example", "IP:198.18.121.250", "email:vpn.example", "DNS:bad\n.example"]) {
sanState.certs[0]!["subject-alt-name"]=alias
assert.equal(mapClients(sanState)[0]?.online,false); checks++
}
sanState.certs[0]!["subject-alt-name"]="DNS:vpn.example"
sanState.certs.push({ ".id":"*CN-SHADOW",name:"foreign-cn", "common-name":"vpn.example" })
assert.equal(mapClients(sanState)[0]?.online,false); checks++
sanState.certs.pop()
sanState.identities=[]
assert.equal(mapClients(sanState)[0]?.online,false); checks++
console.log(`ipsec-state: ${checks} checks passed`)