fix(ipsec): показывать все привязки общего сертификата
This commit is contained in:
1 parent
60b2e099f0
commit
58f5c48a97
17 files changed
+1213
-56
No files matched your search
@@ -6,9 +6,9 @@ import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
|
||||
import { fetchIpsecState } from "../services/ipsec-live.js"
|
||||
const server = { id: 1, name: "test", host: "192.0.2.1", enabled: true } as Parameters<typeof fetchIpsecState>[0]
|
||||
const original = MikrotikClient.fromServer
|
||||
let writes = 0, records = 0, fault = ""
|
||||
const identities = [{ ".id": "*I", peer: "ike", "auth-method": "pre-shared-key", "remote-id": "key-id:alice", "mode-config": "mc-ipsec-alice", comment: "MikrotikManager:ipsec user=Alice" }, { ".id": "*OTHER", peer: "ike", "auth-method": "pre-shared-key", "remote-id": "key-id:other", "mode-config": "mc-ipsec-other", comment: "MikrotikManager:ipsec user=Other" }]
|
||||
MikrotikClient.fromServer = () => ({ get: async (p: string) => p === "/ip/ipsec/peer" ? [{ ".id": "*P", name: "ike", "exchange-mode": "ike2" }] : p === "/ip/ipsec/identity" ? identities : p === "/ip/ipsec/mode-config" ? [{ ".id": "*MC", name: "mc-ipsec-alice", address: "198.18.81.250" }, { ".id": "*OTHERMC", name: "mc-ipsec-other", address: "198.18.81.249" }] : [], put: async () => { writes++ }, post: async () => { writes++ }, delete: async () => { writes++ }, patch: async (path: string, body: Record<string,string>) => { writes++; if (fault === "partial" && writes === 2) throw new MikrotikError(403, path, "rejected"); Object.assign(identities.find(i => path.endsWith(i[".id"]))!, body); if (fault === "lost-ack") throw Error("socket closed after application") } }) as unknown as MikrotikClient
|
||||
let writes = 0, records = 0, fault = "", certificateFixture = false
|
||||
const identities: Record<string,string>[] = [{ ".id": "*I", peer: "ike", "auth-method": "pre-shared-key", "remote-id": "key-id:alice", "mode-config": "mc-ipsec-alice", comment: "MikrotikManager:ipsec user=Alice" }, { ".id": "*OTHER", peer: "ike", "auth-method": "pre-shared-key", "remote-id": "key-id:other", "mode-config": "mc-ipsec-other", comment: "MikrotikManager:ipsec user=Other" }]
|
||||
MikrotikClient.fromServer = () => ({ get: async (p: string) => certificateFixture && p === "/certificate" ? [{ ".id": "*CERT", name: "ipsec-user-shared", "common-name": "shared", "key-usage": "tls-client" }] : p === "/ip/ipsec/peer" ? [{ ".id": "*P", name: "ike", "exchange-mode": "ike2" }] : p === "/ip/ipsec/identity" ? identities : p === "/ip/ipsec/mode-config" ? [{ ".id": "*MC", name: "mc-ipsec-alice", address: "198.18.81.250" }, { ".id": "*OTHERMC", name: "mc-ipsec-other", address: "198.18.81.249" }] : [], put: async () => { writes++ }, post: async () => { writes++ }, delete: async () => { writes++ }, patch: async (path: string, body: Record<string,string>) => { writes++; if (fault === "partial" && writes === 2) throw new MikrotikError(403, path, "rejected"); Object.assign(identities.find(i => path.endsWith(i[".id"]))!, body); if (fault === "lost-ack") throw Error("socket closed after application") } }) as unknown as MikrotikClient
|
||||
const app = Fastify(); app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler)
|
||||
await app.register(createIpsecRoutes({ findServer: async () => server, recordRevision: async () => { records++; return {created:false,revision:{} as never} } }), { prefix: "/api" })
|
||||
const patch = (payload: Record<string, unknown>) => app.inject({ method: "PATCH", url: "/api/ipsec/users/1/identity:*I", payload })
|
||||
@@ -17,6 +17,12 @@ for (const body of [{name:"New",staticIp:"198.18.81.249"},{name:"Other"},{remote
|
||||
writes=records=0; fault="partial"; let r=await patch({name:"New",psk:"new-test-secret"}); assert.equal(r.statusCode,502); assert.equal(r.json().state,"partial"); assert.equal(r.json().confirmedWrites,1); assert.equal(records,0); assert.equal(identities[0]!.comment,"MikrotikManager:ipsec user=New")
|
||||
writes=records=0; fault="lost-ack"; r=await patch({name:"Unknown"}); assert.equal(r.statusCode,502); assert.equal(r.json().state,"unknown"); assert.equal(r.json().confirmedWrites,0); assert.equal(identities[0]!.comment,"MikrotikManager:ipsec user=Unknown"); assert.equal(records,0)
|
||||
writes=records=0; fault=""; r=await patch({remoteId:"fresh"}); assert.equal(r.statusCode,200); assert.equal(identities[0]!["remote-id"],"key-id:fresh"); assert.equal(writes,1); assert.equal(records,1)
|
||||
|
||||
certificateFixture=true;identities.splice(0,identities.length,...["*FIRST","*SECOND"].map((id,n)=>({".id":id,peer:"ike","auth-method":"digital-signature","remote-certificate":"ipsec-user-shared",comment:"MikrotikManager:ipsec user=Shared"+n})));
|
||||
for(const method of ["PATCH","DELETE"] as const){writes=records=0;const r=await app.inject({method,url:"/api/ipsec/users/1/cert:*CERT",...(method==="PATCH"?{payload:{name:"Wrong"}}:{})});assert.equal(r.statusCode,409);assert.equal(writes,0);assert.equal(records,0)}
|
||||
writes=records=0;r=await app.inject({method:"DELETE",url:"/api/ipsec/users/1/identity:*FIRST"});assert.equal(r.statusCode,409);assert.equal(writes,0);assert.equal(records,0);
|
||||
writes=records=0;r=await app.inject({method:"PATCH",url:"/api/ipsec/users/1/identity:*FIRST",payload:{name:"Selected"}});assert.equal(r.statusCode,200);assert.equal(writes,1);assert.equal(identities[0]!.comment,"MikrotikManager:ipsec user=Selected");assert.equal(identities[1]!.comment,"MikrotikManager:ipsec user=Shared1");
|
||||
writes=records=0;r=await app.inject({method:"DELETE",url:"/api/ipsec/users/1/identity:*FIRST?removeCertificate=false"});assert.equal(r.statusCode,200);assert.equal(writes,1);assert.equal(records,1);
|
||||
} finally { await app.close(); MikrotikClient.fromServer=original }
|
||||
console.log("IPsec HTTP mutations: no-write conflicts, acknowledged partial failure, applied-but-lost ack and remote ID normalization")
|
||||
|
||||
@@ -545,6 +545,13 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
? (identity["remote-certificate"] ?? "").trim()
|
||||
: String(certRow?.name ?? "").trim()
|
||||
|
||||
// Refuse before deleting the identity: a shared certificate must survive.
|
||||
const deletionCertName = remoteCert || (managed && userName ? clientCertName(userName) : "")
|
||||
if (removeCertificate && deletionCertName && (
|
||||
state.identities.some(i => i[".id"] !== identity?.[".id"] && ((i["remote-certificate"] ?? "").trim() === deletionCertName || (i.certificate ?? "").trim() === deletionCertName))
|
||||
|| state.peers.some(p => (p.certificate ?? "").trim() === deletionCertName)
|
||||
)) return reply.status(409).send({ error: "Сертификат используется другой identity или peer. Удалите только выбранную identity, сохранив сертификат" })
|
||||
|
||||
if (identity) {
|
||||
await deleteIdentity(client, identity[".id"]!)
|
||||
if (personal && personal !== sharedMcName) await deleteUserModeConfig(client, personal)
|
||||
|
||||
@@ -272,10 +272,10 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
|
||||
const findActive = (remoteId: string) => activeByRemote.get(remoteId)
|
||||
?? activeByRemote.get(remoteId.replace(/^(key-id|fqdn|user-fqdn|address):/, ""))
|
||||
|
||||
const identityByRemoteCert = new Map<string, RosIpsecIdentity>()
|
||||
const identityByRemoteCert = new Map<string, RosIpsecIdentity[]>()
|
||||
for (const i of state.identities) {
|
||||
const rc = (i["remote-certificate"] ?? "").trim()
|
||||
if (rc) identityByRemoteCert.set(rc, i)
|
||||
if (rc) identityByRemoteCert.set(rc, [...(identityByRemoteCert.get(rc) ?? []), i])
|
||||
}
|
||||
|
||||
const personalStaticIp = (mcNameRaw: string): string | undefined => {
|
||||
@@ -303,44 +303,46 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
|
||||
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 subject = routerOsCertificateDn(cert)
|
||||
const bySubject = subject && subjectCounts.get(subject) === 1 ? activeByRemote.get(subject) : undefined
|
||||
const uniqueCn = cn && cnCounts.get(cn) === 1
|
||||
// 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)
|
||||
// Multiple established sessions confirm presence, but no single lease/uptime.
|
||||
const single = active?.length === 1 ? active[0] : undefined
|
||||
out.push({
|
||||
id: `${server.id}:${identity ? "identity" : "cert"}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
|
||||
rosId: `${identity ? "identity" : "cert"}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
|
||||
serverId: String(server.id),
|
||||
serverName,
|
||||
name: identity ? identityDisplayName(identity, state.certs) : (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: single?.["dynamic-address"] || single?.["remote-address"] || single?.address || undefined,
|
||||
activeSince: single?.uptime || single?.established || undefined,
|
||||
disabled: asBool(identity?.disabled),
|
||||
comment: comment || undefined,
|
||||
managed: isUser || isIpsecManagedComment(comment),
|
||||
})
|
||||
claimSession(active)
|
||||
const identities = identityByRemoteCert.get(certName) ?? [undefined]
|
||||
for (const identity of identities) {
|
||||
const comment = identity?.comment ?? ""
|
||||
const mcName = (identity?.["mode-config"] ?? "").trim()
|
||||
const cn = String(cert["common-name"] ?? "").trim()
|
||||
const subject = routerOsCertificateDn(cert)
|
||||
const bySubject = subject && subjectCounts.get(subject) === 1 ? activeByRemote.get(subject) : undefined
|
||||
const uniqueCn = cn && cnCounts.get(cn) === 1
|
||||
// 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)
|
||||
// Multiple established sessions confirm presence, but no single lease/uptime.
|
||||
const single = active?.length === 1 ? active[0] : undefined
|
||||
out.push({
|
||||
id: `${server.id}:${identity ? "identity" : "cert"}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
|
||||
rosId: `${identity ? "identity" : "cert"}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
|
||||
serverId: String(server.id),
|
||||
serverName,
|
||||
name: identity ? identityDisplayName(identity, state.certs) : (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: single?.["dynamic-address"] || single?.["remote-address"] || single?.address || undefined,
|
||||
activeSince: single?.uptime || single?.established || undefined,
|
||||
disabled: asBool(identity?.disabled),
|
||||
comment: comment || undefined,
|
||||
managed: isUser || isIpsecManagedComment(comment),
|
||||
})
|
||||
claimSession(active)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. PSK-identity (managed или IKEv2 remote-access).
|
||||
@@ -397,7 +399,7 @@ export function resolveIpsecClientTarget(state: IpsecServerState, token: string)
|
||||
const clients = mapClients(state)
|
||||
const identity = kind === "cert" ? undefined : state.identities.find(i => i[".id"] === raw && clients.some(c => c.rosId === "identity:" + raw))
|
||||
const certRow = kind === "identity" ? undefined : state.certs.find(c => (c[".id"] === raw || c.name === raw) && clients.some(user => user.kind === "cert" && user.certName === c.name))
|
||||
return { identity, certRow, ambiguous: Boolean(!kind && identity && certRow) }
|
||||
return { identity, certRow, ambiguous: Boolean((!kind && identity && certRow) || (certRow && state.identities.filter(i => (i["remote-certificate"] ?? "").trim() === certRow.name).length > 1)) }
|
||||
}
|
||||
|
||||
export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto {
|
||||
|
||||
@@ -197,4 +197,20 @@ aliasesState.active[1]!.state = "dying"
|
||||
assert.equal(mapClients(aliasesState)[0]?.activeAddress, aliasesState.active[0]!["dynamic-address"]); checks++
|
||||
aliasesState.active[0]!.state = "starting"
|
||||
assert.equal(mapClients(aliasesState)[0]?.online, false); checks++
|
||||
const sharedCertState = { ...namespaceState, certs: [namespaceState.certs[0]!], identities: [0,1].map(n => ({ ".id": "*SHARED" + n, peer: "ike", "auth-method": "digital-signature", "remote-certificate": "ipsec-user-plain", "mode-config": "mc" + n, comment: ipsecManagedComment("user=Client" + n) })), modeConfigs: [0,1].map(n => ({ ".id": "*MC" + n, name: "mc" + n, address: "198.18.114." + (250+n) })), active: [{ ".id": "*SHAREDSESSION", id: "CN=alice", state: "established", "dynamic-address": "198.18.114.250" }] } as typeof state
|
||||
for (const reverse of [false,true]) {
|
||||
if (reverse) sharedCertState.identities.reverse()
|
||||
const rows = mapClients(sharedCertState)
|
||||
assert.equal(rows.length,2)
|
||||
assert(rows.every(c => c.sessionAmbiguous && !c.online && !c.activeAddress))
|
||||
assert.equal(rows.find(c => c.rosId === "identity:*SHARED0")?.staticIp,"198.18.114.250")
|
||||
assert.equal(rows.find(c => c.rosId === "identity:*SHARED1")?.staticIp,"198.18.114.251"); checks++
|
||||
}
|
||||
assert(resolveIpsecClientTarget(sharedCertState,"cert:*PLAIN").ambiguous); checks++
|
||||
for(const id of ["*SHARED0","*SHARED1"]) {assert.equal(resolveIpsecClientTarget(sharedCertState,"identity:"+id).identity?.[".id"],id);checks++}
|
||||
sharedCertState.identities.forEach((i,n) => { i["remote-id"] = "fqdn:client"+n+".example" })
|
||||
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++
|
||||
console.log(`ipsec-state: ${checks} checks passed`)
|
||||
Reference in new issue
Block a user