Files
MikrotikManager/backend/src/services/ipsec-state.test.ts
T

248 lines
21 KiB
TypeScript

import assert from "node:assert/strict"
import { fetchIpsecState, getEnabledIpsecServerById, listIpsec, mapClients, mapServerSummary, resolveIpsecClientTarget } from "./ipsec-live.js"
import { removeManagedIpsecServer } from "./ipsec-cleanup.js"
import { deleteUserModeConfig, ensureIpsecPeer, identityRosBody, putOrPatchByName } from "./ipsec-ros.js"
import { ipsecManagedComment, isIpsecManagedComment } from "./ipsec-config.js"
import type { MikrotikClient } from "./mikrotik.js"
const server = { id: 1, name: "test", host: "192.0.2.1", enabled: true } as Parameters<typeof fetchIpsecState>[0]
const paths = ["/ip/ipsec/peer", "/ip/ipsec/identity", "/ip/ipsec/mode-config", "/ip/pool", "/ip/ipsec/policy", "/ip/firewall/nat", "/ip/ipsec/active-peers", "/certificate"]
let checks = 0
for (const path of paths) {
const reader = { get: async (p: string) => { if (p === path) throw Error("upstream failed"); return [] } } as unknown as MikrotikClient
await assert.rejects(fetchIpsecState(server, reader), /upstream failed/); checks++
for (const invalid of [{}, null, [null], [{}]]) {
await assert.rejects(fetchIpsecState(server, { get: async (p: string) => p === path ? invalid : [] } as unknown as MikrotikClient), /Некорректный ответ/); checks++
}
}
let reads = 0
const reader = { get: async () => { reads++; return [] } } as unknown as MikrotikClient
await assert.rejects(fetchIpsecState({ ...server, enabled: false }, reader), /выключен/)
assert.equal(reads, 0); checks++
for (const id of ["1oops", "-1", "0", "srv1"]) {
await assert.equal(await getEnabledIpsecServerById(id, async () => { throw Error("must not look up") }), null); checks++
}
assert.equal(await getEnabledIpsecServerById(1, async () => ({ ...server, enabled: false })), null); checks++
const dependencies = { findServer: async () => server, enabledServers: async () => [server, { ...server, id: 2 }], readState: async (s: typeof server) => { if (s.id === 2) throw Error("second failed"); return fetchIpsecState(s, reader) } }
const partial = await listIpsec(undefined, dependencies)
assert.equal(partial.servers.length, 1); assert.equal(partial.failures?.[0]?.serverId, "2"); checks++
reads = 0
const disabled = await listIpsec({ serverId: "1" }, { ...dependencies, findServer: async () => ({ ...server, enabled: false }) })
assert.equal(disabled.servers.length, 0); assert.match(disabled.failures?.[0]?.error ?? "", /выключен/); assert.equal(reads, 0); checks++
const state = await fetchIpsecState(server, { get: async (p: string) => p === "/ip/ipsec/peer" ? [{ ".id": "*P", name: "ike", "exchange-mode": "ike2" }] : p === "/ip/ipsec/identity" ? [{ ".id": "*I", peer: "ike", "auth-method": "digital-signature", certificate: "server", "mode-config": "mc" }] : p === "/certificate" ? [{ ".id": "*CS", name: "server", ca: "ca", "key-usage": "tls-server,tls-client" }, { ".id": "*CA", name: "ca", "key-usage": "key-cert-sign" }] : [] } as unknown as MikrotikClient)
assert.equal(mapClients(state).length, 0); assert.equal(mapServerSummary(state, []).serverCert?.role, "server"); checks++
assert.equal(identityRosBody({ peerName: "ike", modeConfig: "mc", comment: "test", authMethod: "certificate", certificate: "server" })["auth-method"], "digital-signature")
assert.equal(identityRosBody({ peerName: "ike", modeConfig: "mc", comment: "test", authMethod: "pre-shared-key", remoteId: "alice" })["remote-id"], "key-id:alice"); checks++
assert.equal(isIpsecManagedComment("MikrotikManager:ipsec-foreign"), false); checks++
const writes: Array<{ path: string; body?: Record<string, string> }> = []
await ensureIpsecPeer({ get: async () => [], put: async (path: string, body: Record<string, string>) => writes.push({ path, body }) } as unknown as MikrotikClient, "ike", "server", "profile", "test")
assert.equal(writes[0]?.body?.certificate, undefined); assert.equal(writes[0]?.body?.["send-cert"], undefined); checks++
await assert.rejects(putOrPatchByName({ get: async () => { throw Error("read failed") }, put: async () => { throw Error("must not write") } } as unknown as MikrotikClient, "/ip/pool", "x", {}), /read failed/); checks++
for (const failRead of [false, true]) {
const deletes: string[] = []
const client = { get: async (p: string) => {
if (failRead && p === "/certificate") throw Error("cert unreadable")
return [{ ".id": "*FOREIGN", template: "true", comment: "foreign" }, { ".id": "*OWN", comment: ipsecManagedComment("test") }]
}, delete: async (p: string) => { deletes.push(p) } } as unknown as MikrotikClient
if (failRead) { await assert.rejects(removeManagedIpsecServer(client, true), /cert unreadable/); assert.deepEqual(deletes, []) }
else { await removeManagedIpsecServer(client, true); assert.equal(deletes.length, 7); assert(deletes.every(p => p.endsWith("*OWN"))) }
checks++
}
let deletes = 0
await assert.rejects(removeManagedIpsecServer({ get: async () => [{ ".id": "*OWN", comment: ipsecManagedComment("test") }], delete: async () => { deletes++; throw Error("delete denied") } } as unknown as MikrotikClient, false), /delete denied/)
assert.equal(deletes, 1); checks++
for (const shared of [false, true]) {
const removed: string[] = []
await deleteUserModeConfig({ get: async (p: string) => p.endsWith("mode-config") ? [{ ".id": "*M", name: "mc", comment: shared ? ipsecManagedComment("test") : "foreign" }] : [{ ".id": "*I", "mode-config": "mc" }], delete: async (p: string) => { removed.push(p) } } as unknown as MikrotikClient, "mc")
assert.deepEqual(removed, []); checks++
}
for (const reference of ["identity", "policy"]) {
let writes = 0
await assert.rejects(removeManagedIpsecServer({ get: async (path: string) => path === "/ip/ipsec/peer" ? [{ ".id": "*P", name: "own", comment: ipsecManagedComment("test") }] : path.endsWith(reference) ? [{ ".id": "*F", peer: "own", comment: "foreign" }] : [], delete: async () => { writes++ } } as unknown as MikrotikClient, false), /чужой/)
assert.equal(writes, 0); checks++
}
state.certs.push({ ".id": "*CLIENT", name: "client", "common-name": "Original CN", ca: "ca", "key-usage": "tls-client" })
state.identities.push({ ".id": "*PERSONAL", peer: "ike", "auth-method": "digital-signature", "remote-certificate": "client", certificate: "server", comment: ipsecManagedComment("user=Alice Cooper") })
assert.equal(mapClients(state)[0]?.name, "Alice Cooper"); assert.equal(mapClients(state)[0]?.commonName, "Original CN"); checks++
state.certs.push({ ".id": "*COLLISION", name: "another-client", "common-name": "Certificate", ca: "ca", "key-usage": "tls-client" })
state.identities.push({ ".id": "*COLLISION", peer: "ike", "auth-method": "pre-shared-key", comment: ipsecManagedComment("user=PSK"), "mode-config": "mc" })
assert.equal(resolveIpsecClientTarget(state, "*COLLISION").ambiguous, true); checks++
assert.equal(resolveIpsecClientTarget(state, "identity:*COLLISION").identity?.["auth-method"], "pre-shared-key"); checks++
assert.equal(resolveIpsecClientTarget(state, "cert:*COLLISION").certRow?.name, "another-client"); checks++
assert.equal(resolveIpsecClientTarget(state, "cert:*CA").certRow, undefined); checks++
state.identities.find(i => i[".id"] === "*PERSONAL")!.comment = "Existing identity label"
assert.equal(mapClients(state).find(c => c.certName === "client")?.name, "Existing identity label"); checks++
let foreignCertificateWrites = 0
await assert.rejects(removeManagedIpsecServer({ get: async (path: string) => path === "/certificate" ? [{ ".id": "*CA", name: "ipsec-ca" }, { ".id": "*FOREIGN", name: "external-client", ca: "ipsec-ca" }] : [], delete: async () => { foreignCertificateWrites++ } } as unknown as MikrotikClient, true), /чужой сертификат/)
assert.equal(foreignCertificateWrites, 0); checks++
const sessionState = { ...state, certs: [], identities: [{ ".id": "*SESSION", peer: "ike", "auth-method": "pre-shared-key", "remote-id": "key-id:alice", comment: ipsecManagedComment("user=Alice") }], active: [] } as typeof state
for (const phase of ["starting", "established", "dying", undefined]) {
sessionState.active = [{ ".id": "*ACTIVE", id: "alice", state: phase, "remote-address": "192.0.2.2", "dynamic-address": "198.18.78.10", uptime: "1m" }]
const client = mapClients(sessionState)[0]!
assert.equal(client.online, phase === "established")
assert.equal(client.activeAddress, phase === "established" ? "198.18.78.10" : undefined)
assert.equal(client.activeSince, phase === "established" ? "1m" : undefined); checks++
}
sessionState.active = [{ ".id": "*ACTIVE", id: "key-id:other", state: "established", "remote-address": "192.0.2.2" }]
assert.equal(mapClients(sessionState)[0]?.online, false); checks++
sessionState.active = [{ ".id": "*ACTIVE", "remote-id": "key-id:alice", state: "established", "remote-address": "192.0.2.2", uptime: "2m" }]
assert.equal(mapClients(sessionState)[0]?.activeAddress, "192.0.2.2"); checks++
for (const prefix of ["fqdn", "user-fqdn", "address", "unknown"]) {
sessionState.identities[0]!["remote-id"] = prefix + ":alice"
sessionState.active = [{ ".id": "*ACTIVE", id: "alice", state: "established" }]
assert.equal(mapClients(sessionState)[0]?.online, prefix !== "unknown"); checks++
}
state.active = [{ ".id": "*CERTACTIVE", id: "CN=Original CN", state: "established", "dynamic-address": "198.18.79.250", uptime: "1s" }]
assert.equal(mapClients(state).find(c => c.certName === "client")?.online, true); checks++
state.active[0]!.id = "CN=Original CN,O=Other"
assert.equal(mapClients(state).find(c => c.certName === "client")?.online, false); checks++
state.active[0]!.id = "CN=Original CN"
state.certs.push({ ".id": "*DUPCN", name: "duplicate-cn", "common-name": "Original CN", ca: "ca", "key-usage": "tls-client" })
assert.equal(mapClients(state).find(c => c.certName === "client")?.online, false); checks++
state.certs = state.certs.filter(c => c[".id"] !== "*DUPCN")
const certificate = state.certs.find(c => c.name === "client")!
Object.assign(certificate, { country: "US", organization: "Example, Ltd", unit: "QA+Operations", "common-name": 'QA, Team+VPN="client"\\Lab' })
state.active[0]!.id = 'C=US, O=Example, Ltd, OU=QA+Operations, CN=QA, Team+VPN="client"\\Lab'
assert.equal(mapClients(state).find(c => c.certName === "client")?.online, true); checks++
assert.equal(mapClients(state).find(c => c.certName === "client")?.activeAddress, "198.18.79.250"); checks++
state.active[0]!.id += ", O=Other"
assert.equal(mapClients(state).find(c => c.certName === "client")?.online, false); checks++
state.active[0]!.id = 'C=US, O=Example, Ltd, OU=QA+Operations, CN=QA, Team+VPN="client"\\Lab'
// An exact duplicate subject must never be attributed to either certificate.
state.certs.push({ ...certificate, ".id": "*DUPSUBJECT", name: "duplicate-subject" })
assert.equal(mapClients(state).find(c => c.certName === "client")?.online, false); checks++
state.certs = state.certs.filter(c => c[".id"] !== "*DUPSUBJECT")
// Different attribute tuples can have the same unescaped RouterOS rendering.
Object.assign(certificate, { organization: "Example, OU=QA", unit: "Ops" })
state.active[0]!.id = 'C=US, O=Example, OU=QA, OU=Ops, CN=QA, Team+VPN="client"\\Lab'
assert.equal(mapClients(state).find(c => c.certName === "client")?.online, true); checks++
state.certs.push({ ...certificate, ".id": "*AMBIGUOUS", name: "ambiguous-subject", organization: "Example", unit: "QA, OU=Ops" })
assert.equal(mapClients(state).find(c => c.certName === "client")?.online, false); checks++
state.certs = state.certs.filter(c => c[".id"] !== "*AMBIGUOUS")
Object.assign(certificate, { organization: "Example, Ltd", unit: "QA+Operations" })
state.active[0]!.id = 'C=US, O=Example, Ltd, OU=QA+Operations, CN=QA, Team+VPN="client"\\Lab'
state.active.push({ ...state.active[0]!, ".id": "*DUPSESSION", "dynamic-address": "198.18.79.251" })
const multiCertificate = mapClients(state).find(c => c.certName === "client")!
assert.equal(multiCertificate.online, true)
assert.equal(multiCertificate.activeAddress, undefined)
assert.equal(multiCertificate.activeSince, undefined)
assert.equal(multiCertificate.sessionAmbiguous, undefined); checks++
state.active.pop()
Object.assign(certificate, { state: "New York", locality: "Albany" })
state.active[0]!.id = 'C=US, S=New York, L=Albany, O=Example, Ltd, OU=QA+Operations, CN=QA, Team+VPN="client"\\Lab'
assert.equal(mapClients(state).find(c => c.certName === "client")?.online, true); checks++
certificate.organization = "bad\nsubject"
assert.equal(mapClients(state).find(c => c.certName === "client")?.online, false); checks++
const namespaceState = {
...state,
identities: [],
certs: [
{ ".id": "*PLAIN", name: "ipsec-user-plain", "common-name": "alice", ca: "ca", "key-usage": "tls-client" },
{ ".id": "*DNNAME", name: "ipsec-user-dn-name", "common-name": "CN=alice", ca: "ca", "key-usage": "tls-client" },
{ ".id": "*TYPEDNAME", name: "ipsec-user-typed-name", "common-name": "key-id:alice", ca: "ca", "key-usage": "tls-client" },
],
active: [{ ".id": "*NATIVE", id: "CN=alice", state: "established", "dynamic-address": "198.18.111.250" }],
} as typeof state
assert.equal(mapClients(namespaceState).find(c => c.certName === "ipsec-user-plain")?.online, true); checks++
assert.equal(mapClients(namespaceState).find(c => c.certName === "ipsec-user-dn-name")?.online, false); checks++
namespaceState.active[0]!.id = "alice"
assert.equal(mapClients(namespaceState).find(c => c.certName === "ipsec-user-plain")?.online, true); checks++
assert.equal(mapClients(namespaceState).find(c => c.certName === "ipsec-user-typed-name")?.online, false); checks++
namespaceState.active[0]!.id = "key-id:alice"
assert.equal(mapClients(namespaceState).find(c => c.certName === "ipsec-user-typed-name")?.online, true); checks++
// Type normalization belongs to an explicitly configured remote ID, not a CN.
namespaceState.identities = [{ ".id": "*EXPLICIT", peer: "ike", "auth-method": "digital-signature", "remote-certificate": "ipsec-user-plain", "remote-id": "fqdn:explicit.example", comment: "explicit certificate" }]
namespaceState.active[0]!.id = "explicit.example"
assert.equal(mapClients(namespaceState).find(c => c.certName === "ipsec-user-plain")?.online, true); checks++
namespaceState.identities[0]!["remote-id"] = "unknown:explicit.example"
assert.equal(mapClients(namespaceState).find(c => c.certName === "ipsec-user-plain")?.online, false); checks++
const aliasesState = { ...sessionState, active: [{ ".id": "*ALIASED", id: "shared.example", state: "established", "dynamic-address": "198.18.112.250", uptime: "1m" }] } as typeof state
for (const secondId of ["fqdn:shared.example", "key-id:shared.example"]) {
aliasesState.identities = ["fqdn:shared.example", secondId].map((remoteId, n) => ({ ".id": "*PSK" + n, peer: "ike", "auth-method": "pre-shared-key", "remote-id": remoteId, comment: ipsecManagedComment("user=PSK" + n) }))
const clients = mapClients(aliasesState)
assert.equal(clients.length, 2)
assert(clients.every(c => c.sessionAmbiguous && !c.online && !c.activeAddress && !c.activeSince)); checks++
const summary = mapServerSummary(aliasesState, clients)
assert.equal(summary.clientsOnline, 0); assert.equal(summary.clientsSessionAmbiguous, 2); checks++
}
aliasesState.certs = [{ ".id": "*CERTALIAS", name: "ipsec-user-aliased", "common-name": "certificate", ca: "ca", "key-usage": "tls-client" }]
aliasesState.identities[0] = { ".id": "*CERTIDENTITY", peer: "ike", "auth-method": "digital-signature", "remote-certificate": "ipsec-user-aliased", "remote-id": "fqdn:shared.example" }
assert(mapClients(aliasesState).every(c => c.sessionAmbiguous && !c.online && !c.activeAddress)); checks++
aliasesState.identities[1]!["remote-id"] = "key-id:unrelated"
const uniqueClients = mapClients(aliasesState)
assert.equal(uniqueClients.find(c => c.kind === "cert")?.online, true)
assert.equal(uniqueClients.find(c => c.kind === "psk")?.online, false)
assert(uniqueClients.every(c => !c.sessionAmbiguous)); checks++
// Repeated observed IDs are sessions of one principal, not proof of being offline.
aliasesState.active.push({ ...aliasesState.active[0]!, ".id": "*SECOND", "dynamic-address": "198.18.113.251", uptime: "2m" })
for (const reverse of [false, true]) {
if (reverse) aliasesState.active.reverse()
const clients = mapClients(aliasesState)
const cert = clients.find(c => c.kind === "cert")!
assert.equal(cert.online, true)
assert.equal(cert.activeAddress, undefined)
assert.equal(cert.activeSince, undefined)
assert.equal(mapServerSummary(aliasesState, clients).clientsOnline, 1); checks++
}
aliasesState.identities[1]!["remote-id"] = "key-id:shared.example"
assert(mapClients(aliasesState).every(c => c.sessionAmbiguous && !c.online && !c.activeAddress && !c.activeSince)); checks++
aliasesState.certs = []
aliasesState.identities = [aliasesState.identities[1]!]
const multiplePsk = mapClients(aliasesState)[0]!
assert.equal(multiplePsk.online, true)
assert.equal(multiplePsk.activeAddress, undefined)
assert.equal(multiplePsk.activeSince, undefined)
assert.equal(multiplePsk.sessionAmbiguous, undefined); checks++
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++
// 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`)