108 lines
8.3 KiB
TypeScript
108 lines
8.3 KiB
TypeScript
import type { IpsecPeerDeleteState } from "./ipsec-peer-delete.js"
|
|
import { McpError } from "../modules/mcp/security.js"
|
|
import { observedBoolean } from "./ospf-observed-values.js"
|
|
|
|
type Row = Record<string, string>
|
|
export interface CertificateUpdateObject { kind: "certificate" | "ipsec_identity" | "ipsec_peer"; id: string }
|
|
const paths = ["/ip/ipsec/peer", "/ip/ipsec/identity", "/ip/ipsec/mode-config", "/ip/pool", "/ip/ipsec/policy", "/ip/firewall/nat", "/certificate", "/ip/ipsec/profile", "/ip/ipsec/proposal", "/ip/ipsec/policy/group", "/ip/ipsec/active-peers"]
|
|
const fail = (code: string, message: string) => new McpError(code, message, code === "INVALID_INPUT" ? 400 : code === "NOT_FOUND" ? 404 : code === "STATE_UNAVAILABLE" ? 502 : 409)
|
|
const exactId = (id: unknown): id is string => typeof id === "string" && /^\*[a-z0-9]+$/i.test(id) && id.length <= 100
|
|
|
|
/** Input is the generic action's typed fields, never an arbitrary RouterOS patch. */
|
|
export function ipsecCertificateUpdateNativePatch(raw: unknown): Record<string, string> {
|
|
if (!raw || typeof raw !== "object" || Array.isArray(raw) || ![Object.prototype, null].includes(Object.getPrototypeOf(raw))) throw fail("INVALID_INPUT", "Некорректные поля сертификата")
|
|
const body: Record<string, string> = {}
|
|
for (const [key, value] of Object.entries(raw)) {
|
|
if (key === "name") {
|
|
if (typeof value !== "string" || !value.trim() || value.trim().length > 100 || /[\x00-\x1f\x7f]/.test(value)) throw fail("INVALID_INPUT", "Некорректное имя сертификата")
|
|
body.name = value.trim()
|
|
} else if (key === "trusted") {
|
|
if (typeof value !== "boolean") throw fail("INVALID_INPUT", "trusted должен быть boolean")
|
|
body.trusted = String(value)
|
|
} else throw fail("INVALID_INPUT", "Неизвестное поле сертификата")
|
|
}
|
|
return body
|
|
}
|
|
|
|
function validateState(state: IpsecPeerDeleteState) {
|
|
if (!state || typeof state.source !== "string" || !state.source || !Array.isArray(state.protections) || !state.catalogs) throw fail("STATE_UNAVAILABLE", "Неполное состояние сертификатов")
|
|
for (const path of paths) {
|
|
const rows = state.catalogs[path]
|
|
if (!Array.isArray(rows) || rows.length > 2000 || rows.some(row => !row || typeof row !== "object" || Array.isArray(row) || !exactId(row[".id"]) || Object.values(row).some(value => typeof value !== "string")) || new Set(rows.map(row => row[".id"])).size !== rows.length) throw fail("STATE_UNAVAILABLE", "Неполный или неоднозначный native-каталог")
|
|
}
|
|
}
|
|
|
|
/** No native/SQL calls. All opaque fields, catalog order, source and protections survive. */
|
|
export function planIpsecCertificateUpdate(state: IpsecPeerDeleteState, id: string, raw: unknown) {
|
|
const body = ipsecCertificateUpdateNativePatch(raw)
|
|
if (!exactId(id)) throw fail("INVALID_INPUT", "Требуется точный native ID сертификата")
|
|
validateState(state)
|
|
const certs = state.catalogs["/certificate"], cert = certs.find(row => row[".id"] === id)
|
|
const certificateNames = certs.map(row => row.name).filter(name => name !== undefined)
|
|
if (new Set(certificateNames).size !== certificateNames.length) throw fail("STATE_CONFLICT", "Неоднозначное имя сертификата")
|
|
if (!cert) throw fail("NOT_FOUND", "Сертификат не найден")
|
|
for (const flag of ["dynamic", "builtin", "default"]) if (cert[flag] !== undefined && observedBoolean(cert[flag]) !== false) throw fail("PROTECTED_OBJECT", "Системный или неподтверждённый сертификат нельзя изменять")
|
|
const oldName = cert.name
|
|
const byName = (name: string): Row | undefined => {
|
|
const matches = certs.filter(row => row.name === name)
|
|
if (matches.length > 1) throw fail("STATE_CONFLICT", "Неоднозначное имя сертификата")
|
|
return matches[0]
|
|
}
|
|
if (!oldName || oldName !== oldName.trim() || byName(oldName) !== cert) throw fail("STATE_CONFLICT", "Не подтверждено точное имя сертификата")
|
|
const rename = body.name !== undefined && body.name !== oldName
|
|
if (rename && byName(body.name)) throw fail("STATE_CONFLICT", "Имя сертификата уже используется")
|
|
const byReference = (reference: string) => {
|
|
const matches = certs.filter(row => row.name === reference || row[".id"] === reference)
|
|
if (matches.length > 1) throw fail("STATE_CONFLICT", "Неоднозначная ссылка сертификата")
|
|
return matches[0]
|
|
}
|
|
if (byReference(oldName) !== cert || (rename && byReference(body.name))) throw fail("STATE_CONFLICT", "Неоднозначное имя или native ID сертификата")
|
|
const referenceRows: Array<{ row: Row; fields: string[] }> = [
|
|
...certs.map(row => ({ row, fields: ["ca"] })),
|
|
...["/ip/ipsec/identity", "/ip/ipsec/peer"].flatMap(path => state.catalogs[path].map(row => ({ row, fields: ["certificate", "remote-certificate"] }))),
|
|
]
|
|
for (const { row, fields } of referenceRows) for (const field of fields) {
|
|
const reference = row[field]
|
|
if (!reference) continue
|
|
if (reference !== oldName && reference !== id && !byReference(reference) && (reference.includes(oldName) || reference.includes(id))) throw fail("STATE_UNAVAILABLE", "Не подтверждён scalar-контракт ссылки сертификата")
|
|
if (rename && reference.includes(body.name)) throw fail("STATE_CONFLICT", "Новое имя сертификата уже встречается в ссылках")
|
|
}
|
|
const target: CertificateUpdateObject = { kind: "certificate", id }
|
|
const companions: CertificateUpdateObject[] = [], seen = new Set([`certificate:${id}`])
|
|
const add = (kind: CertificateUpdateObject["kind"], row: Row) => { const key = `${kind}:${row[".id"]}`; if (!seen.has(key)) { seen.add(key); companions.push({ kind, id: row[".id"] }) } }
|
|
// Traverse the entire issuer/child component; trust and rename can affect a chain.
|
|
const queue = [cert]
|
|
for (let index = 0; index < queue.length; index++) {
|
|
const current = queue[index], issuer = current.ca ? byReference(current.ca) : undefined
|
|
if (current.ca && !issuer) throw fail("STATE_CONFLICT", "Не подтверждён CA-издатель сертификата")
|
|
for (const related of [...(issuer ? [issuer] : []), ...certs.filter(row => row.ca === current.name || row.ca === current[".id"])]) {
|
|
if (queue.some(row => row[".id"] === related[".id"])) continue
|
|
if (!related.name || byName(related.name) !== related) throw fail("STATE_CONFLICT", "Неоднозначная цепочка сертификатов")
|
|
queue.push(related); add("certificate", related)
|
|
}
|
|
}
|
|
const chainNames = new Set(queue.flatMap(row => [row.name, row[".id"]]))
|
|
const identities = state.catalogs["/ip/ipsec/identity"].filter(row => chainNames.has(row.certificate) || chainNames.has(row["remote-certificate"]))
|
|
for (const identity of identities) {
|
|
add("ipsec_identity", identity)
|
|
if (identity.peer) {
|
|
const peers = state.catalogs["/ip/ipsec/peer"].filter(row => row.name === identity.peer || row[".id"] === identity.peer)
|
|
if (peers.length !== 1) throw fail("STATE_CONFLICT", "Не подтверждён peer зависимой identity")
|
|
add("ipsec_peer", peers[0])
|
|
}
|
|
}
|
|
for (const peer of state.catalogs["/ip/ipsec/peer"]) if (chainNames.has(peer.certificate) || chainNames.has(peer["remote-certificate"])) add("ipsec_peer", peer)
|
|
const projected = structuredClone(state)
|
|
Object.assign(projected.catalogs["/certificate"].find(row => row[".id"] === id)!, body)
|
|
// Expected native rename effects, never companion writes. Real RouterOS acceptance
|
|
// remains unproven until a live fixture; whole-state read-back must match exactly.
|
|
// Only explicitly modeled scalar reference fields participate. ID aliases remain stable.
|
|
if (rename) {
|
|
for (const row of projected.catalogs["/certificate"]) if (row.ca === oldName) row.ca = body.name
|
|
for (const path of ["/ip/ipsec/identity", "/ip/ipsec/peer"]) for (const row of projected.catalogs[path]) {
|
|
for (const field of ["certificate", "remote-certificate"]) if (row[field] === oldName) row[field] = body.name
|
|
}
|
|
}
|
|
return { body, projected, target, companions, rename, commandCount: Object.keys(body).length ? 1 : 0 }
|
|
}
|