Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67c3a4fd73 | ||
|
|
9beb24273b | ||
|
|
5cc6128086 | ||
|
|
569b6be2b4 | ||
|
|
752256f12e | ||
|
|
a3502b9755 | ||
|
|
0e1524c600 | ||
|
|
11ae7593f4 |
@@ -116,18 +116,18 @@ const MOCK_IPSEC_SERVERS: IpsecServerSummaryDto[] = [
|
||||
const MOCK_IPSEC_CLIENTS: IpsecClientDto[] = [
|
||||
{
|
||||
id: "srv2:*I1", rosId: "*I1", serverId: "srv2", serverName: "mt-spb-edge-01",
|
||||
name: "alice", authMethod: "certificate", certificateName: "ipsec-user-alice", commonName: "alice",
|
||||
name: "alice", authMethod: "certificate", kind: "cert", certificateName: "ipsec-user-alice", certName: "ipsec-user-alice", signedBy: "MyCA", commonName: "alice",
|
||||
staticIp: "10.77.0.10", modeConfigName: "mc-ipsec-alice", peerName: "ipsec-vpn",
|
||||
online: true, activeAddress: "10.100.1.7", activeSince: "2h", disabled: false, managed: true,
|
||||
},
|
||||
{
|
||||
id: "srv2:*I2", rosId: "*I2", serverId: "srv2", serverName: "mt-spb-edge-01",
|
||||
name: "bob", authMethod: "certificate", certificateName: "ipsec-user-bob", commonName: "bob",
|
||||
name: "bob", authMethod: "certificate", kind: "cert", certificateName: "ipsec-user-bob", certName: "ipsec-user-bob", signedBy: "MyCA", commonName: "bob",
|
||||
peerName: "ipsec-vpn", online: false, disabled: false, managed: true,
|
||||
},
|
||||
{
|
||||
id: "srv2:*I3", rosId: "*I3", serverId: "srv2", serverName: "mt-spb-edge-01",
|
||||
name: "tablet-psk", authMethod: "pre-shared-key", remoteId: "tablet",
|
||||
name: "tablet-psk", authMethod: "pre-shared-key", kind: "psk", remoteId: "tablet",
|
||||
peerName: "ipsec-vpn", online: false, disabled: false, managed: true,
|
||||
},
|
||||
]
|
||||
@@ -424,14 +424,17 @@ export default function IpsecPage() {
|
||||
}
|
||||
|
||||
const openCert = async (client: IpsecClientDto) => {
|
||||
setCertClient(client)
|
||||
setCertByName(null)
|
||||
const byName = client.kind === "cert" && Boolean(client.certName)
|
||||
setCertClient(byName ? null : client)
|
||||
setCertByName(byName ? { serverId: client.serverId, name: client.certName! } : null)
|
||||
setCertBundle(null)
|
||||
setCertOpen(true)
|
||||
setCertBusy(true)
|
||||
try {
|
||||
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
|
||||
const bundle = await exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase)
|
||||
const bundle = byName
|
||||
? await exportIpsecCertByName(backendUrl, client.serverId, client.certName!, passphrase)
|
||||
: await exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase)
|
||||
setCertBundle(bundle)
|
||||
} catch (e) {
|
||||
toast.error("Ошибка экспорта сертификата", {
|
||||
|
||||
@@ -20,9 +20,10 @@
|
||||
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts",
|
||||
"test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/services/config-revisions.test.ts",
|
||||
"test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts",
|
||||
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
|
||||
"test:backups": "tsx src/services/s3-backup-client.test.ts",
|
||||
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts",
|
||||
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:config-sync",
|
||||
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:config-sync && npm run test:ros-file",
|
||||
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+141
-47
@@ -1,6 +1,7 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import type { FastifyReply } from "fastify"
|
||||
import {
|
||||
IPSEC_MIN_PASSPHRASE,
|
||||
ipsecCertDeleteRequestSchema,
|
||||
ipsecCertExportByNameRequestSchema,
|
||||
ipsecCertExportRequestSchema,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
resolveIke2CaName,
|
||||
resolveIke2ServerCert,
|
||||
selectIke2Peers,
|
||||
selectSharedIke2Identity,
|
||||
userModeConfigName,
|
||||
} from "../services/ipsec-config.js"
|
||||
import {
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
deleteIdentity,
|
||||
patchPeer,
|
||||
deletePeer,
|
||||
ensureSharedIke2Identity,
|
||||
listByPath,
|
||||
} from "../services/ipsec-ros.js"
|
||||
import {
|
||||
@@ -95,6 +98,22 @@ function errReply(reply: FastifyReply, e: unknown) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
||||
}
|
||||
|
||||
/**
|
||||
* 400 по невалидному телу. Для короткого пароля .p12 — понятный текст вместо generic-сообщения:
|
||||
* RouterOS отклоняет `export-passphrase` короче 8 символов.
|
||||
*/
|
||||
function badBodyReply(reply: FastifyReply, error: z.ZodError) {
|
||||
const shortPassphrase = error.issues.some(
|
||||
(issue) => issue.path[0] === "passphrase" && issue.code === "too_small",
|
||||
)
|
||||
if (shortPassphrase) {
|
||||
return reply.status(400).send({
|
||||
error: `Пароль архива .p12 должен быть не короче ${IPSEC_MIN_PASSPHRASE} символов (требование RouterOS)`,
|
||||
})
|
||||
}
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: error.flatten() })
|
||||
}
|
||||
|
||||
async function recordIpsec(
|
||||
server: NonNullable<Awaited<ReturnType<typeof getEnabledIpsecServerById>>>,
|
||||
source: ConfigRevisionSource,
|
||||
@@ -120,7 +139,7 @@ async function buildCertBundle(
|
||||
args: { userName: string; certName?: string; serverEndpoint: string; passphrase: string; dns?: string },
|
||||
): Promise<IpsecCertBundle> {
|
||||
const certName = args.certName?.trim() || clientCertName(args.userName)
|
||||
const { fileName, content } = await exportCertificateP12ByName(client, certName, args.passphrase)
|
||||
const { fileName, content, passphrase } = await exportCertificateP12ByName(client, certName, args.passphrase)
|
||||
const p12B64 = content.toString("base64")
|
||||
return {
|
||||
user: args.userName,
|
||||
@@ -128,7 +147,7 @@ async function buildCertBundle(
|
||||
filename: fileName,
|
||||
contentB64: p12B64,
|
||||
mime: "application/x-pkcs12",
|
||||
passphrase: args.passphrase,
|
||||
passphrase,
|
||||
sswanFilename: `${certName}.sswan`,
|
||||
sswanContent: buildSswanConfig({
|
||||
name: `IKEv2 ${args.serverEndpoint}`,
|
||||
@@ -140,7 +159,7 @@ async function buildCertBundle(
|
||||
userName: args.userName,
|
||||
serverEndpoint: args.serverEndpoint,
|
||||
p12Filename: fileName,
|
||||
passphrase: args.passphrase,
|
||||
passphrase,
|
||||
dns: args.dns,
|
||||
}),
|
||||
}
|
||||
@@ -241,7 +260,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.post("/ipsec/users", async (req, reply) => {
|
||||
const parsed = ipsecUserCreateRequestSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
return badBodyReply(reply, parsed.error)
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = await getEnabledIpsecServerById(body.serverId)
|
||||
@@ -284,6 +303,9 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
return reply.status(400).send({ error: "Для PSK-клиента укажите secret (psk)" })
|
||||
}
|
||||
|
||||
const sharedIdentity = selectSharedIke2Identity(state.identities, ike2PeerNames)
|
||||
const sharedIdentityId = sharedIdentity?.[".id"] ? String(sharedIdentity[".id"]) : undefined
|
||||
|
||||
let modeConfig = (sharedMc?.name ?? "").trim()
|
||||
if (body.staticIp) {
|
||||
const ip = body.staticIp.trim()
|
||||
@@ -309,16 +331,30 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
certName = issued.certName
|
||||
}
|
||||
|
||||
await putIdentity(client, {
|
||||
peerName,
|
||||
modeConfig,
|
||||
comment: ipsecUserComment(body.name),
|
||||
authMethod: body.authMethod,
|
||||
certificate: body.authMethod === "certificate" ? String(serverCertRow?.name ?? IPSEC_SERVER_CERT) : undefined,
|
||||
remoteCertificate: certName,
|
||||
secret: body.psk,
|
||||
remoteId: body.authMethod === "pre-shared-key" ? (body.remoteId ?? body.name) : undefined,
|
||||
})
|
||||
if (body.authMethod === "certificate" && !body.staticIp) {
|
||||
// Гибрид: клиент «вешается» на общую identity peer — отдельная identity не нужна,
|
||||
// RouterOS сам принимает любой клиентский серт, подписанный доверенным CA.
|
||||
await ensureSharedIke2Identity(client, {
|
||||
peerName,
|
||||
serverCertName: String(serverCertRow?.name ?? IPSEC_SERVER_CERT),
|
||||
modeConfigName: modeConfig,
|
||||
comment: ipsecManagedComment("IKEv2 listener"),
|
||||
})
|
||||
} else {
|
||||
// PSK или персональный статический IP: отдельная identity (match-by=certificate для cert).
|
||||
// place-before гарантирует матчинг раньше общей listener-identity.
|
||||
await putIdentity(client, {
|
||||
peerName,
|
||||
modeConfig,
|
||||
comment: ipsecUserComment(body.name),
|
||||
authMethod: body.authMethod,
|
||||
certificate: body.authMethod === "certificate" ? String(serverCertRow?.name ?? IPSEC_SERVER_CERT) : undefined,
|
||||
remoteCertificate: certName,
|
||||
secret: body.psk,
|
||||
remoteId: body.authMethod === "pre-shared-key" ? (body.remoteId ?? body.name) : undefined,
|
||||
placeBefore: sharedIdentityId,
|
||||
})
|
||||
}
|
||||
|
||||
let bundle: IpsecCertBundle | undefined
|
||||
if (body.authMethod === "certificate" && certName) {
|
||||
@@ -355,21 +391,41 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId))
|
||||
if (!identity) return reply.status(404).send({ error: "Клиент не найден" })
|
||||
const managedIdentity = isIpsecManagedComment(identity.comment)
|
||||
const oldName = parseIpsecUserComment(identity.comment) ?? identity.comment ?? ""
|
||||
const targetRosId = rosIdParam(rosId)
|
||||
const identity = state.identities.find((i) => String(i[".id"] ?? "") === targetRosId)
|
||||
const certRow = identity
|
||||
? undefined
|
||||
: state.certs.find((c) => String(c[".id"] ?? "") === targetRosId)
|
||||
?? state.certs.find((c) => String(c.name ?? "").trim() === targetRosId)
|
||||
if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" })
|
||||
|
||||
const ike2Peers = selectIke2Peers(state.peers)
|
||||
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
|
||||
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers)
|
||||
const managedIdentity = identity
|
||||
? isIpsecManagedComment(identity.comment)
|
||||
: String(certRow?.name ?? "").startsWith("ipsec-user-")
|
||||
const oldName = identity
|
||||
? (parseIpsecUserComment(identity.comment) ?? identity.comment ?? "")
|
||||
: String(certRow?.["common-name"] ?? certRow?.name ?? "").trim()
|
||||
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||
const sharedMcName = (sharedMc?.name ?? "").trim()
|
||||
const newName = d.name?.trim() || oldName
|
||||
|
||||
if (d.name && d.name !== oldName) {
|
||||
// managed: user=<name>; существующий RouterOS identity: обычный comment
|
||||
await patchIdentity(client, identity[".id"]!, {
|
||||
comment: managedIdentity ? ipsecUserComment(d.name) : d.name.trim(),
|
||||
})
|
||||
if (identity) {
|
||||
// managed: user=<name>; существующий RouterOS identity: обычный comment
|
||||
await patchIdentity(client, identity[".id"]!, {
|
||||
comment: managedIdentity ? ipsecUserComment(d.name) : d.name.trim(),
|
||||
})
|
||||
}
|
||||
// managed-серт переименовываем на конвенционное имя (client1/anakondra на устройстве не трогаем)
|
||||
if (certRow?.[".id"] && managedIdentity) {
|
||||
await client.patch(`/certificate/${encodeURIComponent(String(certRow[".id"]))}`, { name: clientCertName(d.name) })
|
||||
}
|
||||
}
|
||||
|
||||
if (d.staticIp !== undefined) {
|
||||
if (identity && d.staticIp !== undefined) {
|
||||
const currentMc = (identity["mode-config"] ?? "").trim()
|
||||
if (d.staticIp == null) {
|
||||
// вернуть выдачу из пула
|
||||
@@ -383,18 +439,40 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
state.modeConfigs.filter((m) => (m.name ?? "").trim() !== currentMc),
|
||||
)
|
||||
if (others.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` })
|
||||
const name = userModeConfigName(d.name || oldName)
|
||||
await putUserModeConfig(client, name, ip, ipsecManagedComment(`клиент ${(d.name || oldName).trim()}`))
|
||||
const name = userModeConfigName(newName)
|
||||
await putUserModeConfig(client, name, ip, ipsecManagedComment(`клиент ${newName.trim()}`))
|
||||
await patchIdentity(client, identity[".id"]!, { "mode-config": name })
|
||||
}
|
||||
} else if (!identity && certRow && d.staticIp) {
|
||||
// cert-клиент без identity: персональная identity (match-by=certificate) + статический mode-config
|
||||
const ip = d.staticIp.trim()
|
||||
if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
|
||||
const taken = takenStaticIps(state.modeConfigs)
|
||||
if (taken.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` })
|
||||
const peer = ike2Peers.find((p) => isIpsecManagedComment(p.comment)) ?? ike2Peers[0]
|
||||
if (!peer) return reply.status(400).send({ error: "На роутере нет IKEv2 peer для привязки клиента" })
|
||||
const shared = selectSharedIke2Identity(state.identities, ike2PeerNames)
|
||||
const mcName = userModeConfigName(newName)
|
||||
await putUserModeConfig(client, mcName, ip, ipsecManagedComment(`клиент ${newName.trim()}`))
|
||||
await putIdentity(client, {
|
||||
peerName: (peer.name ?? "").trim(),
|
||||
modeConfig: mcName,
|
||||
comment: ipsecUserComment(newName),
|
||||
authMethod: "certificate",
|
||||
certificate: String(serverCertRow?.name ?? IPSEC_SERVER_CERT),
|
||||
remoteCertificate: String(certRow.name ?? "").trim(),
|
||||
placeBefore: shared?.[".id"] ? String(shared[".id"]) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const patch: Record<string, string> = {}
|
||||
if (d.psk) patch.secret = d.psk
|
||||
if (d.remoteId !== undefined) patch["remote-id"] = d.remoteId
|
||||
if (d.disabled === true) patch.disabled = "yes"
|
||||
if (d.disabled === false) patch.disabled = "no"
|
||||
if (Object.keys(patch).length) await patchIdentity(client, identity[".id"]!, patch)
|
||||
if (identity) {
|
||||
const patch: Record<string, string> = {}
|
||||
if (d.psk) patch.secret = d.psk
|
||||
if (d.remoteId !== undefined) patch["remote-id"] = d.remoteId
|
||||
if (d.disabled === true) patch.disabled = "yes"
|
||||
if (d.disabled === false) patch.disabled = "no"
|
||||
if (Object.keys(patch).length) await patchIdentity(client, identity[".id"]!, patch)
|
||||
}
|
||||
|
||||
await recordIpsec(server, "apply")
|
||||
return reply.send({ ok: true })
|
||||
@@ -412,20 +490,36 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId))
|
||||
if (!identity) return reply.status(404).send({ error: "Клиент не найден" })
|
||||
const managed = isIpsecManagedComment(identity.comment)
|
||||
const userName = parseIpsecUserComment(identity.comment) ?? ""
|
||||
const targetRosId = rosIdParam(rosId)
|
||||
const identity = state.identities.find((i) => String(i[".id"] ?? "") === targetRosId)
|
||||
const certRow = identity
|
||||
? undefined
|
||||
: state.certs.find((c) => String(c[".id"] ?? "") === targetRosId)
|
||||
?? state.certs.find((c) => String(c.name ?? "").trim() === targetRosId)
|
||||
if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" })
|
||||
const managed = identity
|
||||
? isIpsecManagedComment(identity.comment)
|
||||
: String(certRow?.name ?? "").startsWith("ipsec-user-")
|
||||
const userName = identity ? (parseIpsecUserComment(identity.comment) ?? "") : ""
|
||||
const sharedMcName = (state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)?.name ?? "").trim()
|
||||
const personal = (identity["mode-config"] ?? "").trim()
|
||||
const remoteCert = (identity["remote-certificate"] ?? "").trim()
|
||||
const personal = identity ? (identity["mode-config"] ?? "").trim() : ""
|
||||
const remoteCert = identity
|
||||
? (identity["remote-certificate"] ?? "").trim()
|
||||
: String(certRow?.name ?? "").trim()
|
||||
|
||||
await deleteIdentity(client, identity[".id"]!)
|
||||
if (personal && personal !== sharedMcName) await deleteUserModeConfig(client, personal)
|
||||
if (identity) {
|
||||
await deleteIdentity(client, identity[".id"]!)
|
||||
if (personal && personal !== sharedMcName) await deleteUserModeConfig(client, personal)
|
||||
}
|
||||
if (removeCertificate) {
|
||||
// только явный сертификат клиента или managed-identity; чужие сертификаты не трогаем
|
||||
if (remoteCert) await client.removeCertificate(remoteCert).catch(() => undefined)
|
||||
else if (managed && userName) await client.removeCertificate(clientCertName(userName)).catch(() => undefined)
|
||||
// удаляем только клиентский серт (managed / выбранный явно); CA и серверный не трогаем
|
||||
const ike2Peers = selectIke2Peers(state.peers)
|
||||
const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers))
|
||||
const serverCertName = String(resolveIke2ServerCert(state.certs, ike2Peers)?.name ?? "").trim()
|
||||
const certName = remoteCert || (managed && userName ? clientCertName(userName) : "")
|
||||
if (certName && certName !== caName && certName !== serverCertName) {
|
||||
await client.removeCertificate(certName).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
await recordIpsec(server, "apply")
|
||||
return reply.send({ ok: true })
|
||||
@@ -531,7 +625,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||||
const parsed = ipsecCertExportRequestSchema.safeParse({ ...(req.body as object), serverId, clientId: rosId })
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
return badBodyReply(reply, parsed.error)
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||||
@@ -552,7 +646,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||
const dns = sharedMc?.["static-dns"]?.trim() || undefined
|
||||
// export работает по имени сертификата (certName), не по userName
|
||||
const { fileName, content } = await client.exportCertificatePkcs12({
|
||||
const { fileName, content, passphrase } = await client.exportCertificatePkcs12({
|
||||
name: certName,
|
||||
passphrase: body.passphrase,
|
||||
})
|
||||
@@ -563,7 +657,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
filename: fileName,
|
||||
contentB64: p12B64,
|
||||
mime: "application/x-pkcs12",
|
||||
passphrase: body.passphrase,
|
||||
passphrase,
|
||||
sswanFilename: `${certName}.sswan`,
|
||||
sswanContent: buildSswanConfig({
|
||||
name: `IKEv2 ${serverEndpoint}`,
|
||||
@@ -575,7 +669,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
userName,
|
||||
serverEndpoint,
|
||||
p12Filename: fileName,
|
||||
passphrase: body.passphrase,
|
||||
passphrase,
|
||||
dns,
|
||||
}),
|
||||
}
|
||||
@@ -590,7 +684,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { serverId } = req.params as { serverId: string }
|
||||
const parsed = ipsecCertExportByNameRequestSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
return badBodyReply(reply, parsed.error)
|
||||
}
|
||||
const { name, passphrase } = parsed.data
|
||||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||||
|
||||
@@ -48,6 +48,9 @@ const anakondra: RosCertificateRow = { ...client1, name: "anakondra", "common-na
|
||||
// reference-based важнее key-usage: у client1 в key-usage есть tls-server, но он client
|
||||
assert.equal(certificateRole(client1, ctx), "client")
|
||||
assert.equal(certificateRole(anakondra, ctx), "client")
|
||||
// без reference-контекста: подпись локальным CA делает серт клиентским, несмотря на tls-server в usage
|
||||
assert.equal(certificateRole(client1), "client")
|
||||
assert.equal(certificateRole(anakondra), "client")
|
||||
// без контекста — по key-usage (default содержит оба, поэтому server)
|
||||
assert.equal(certificateRole({ name: "x", "key-usage": "tls-client" }), "client")
|
||||
assert.equal(certificateRole({ name: "y", "key-usage": "digital-signature" }), "other")
|
||||
|
||||
@@ -50,6 +50,10 @@ export function certificateRole(
|
||||
if (isCaCertificate(row)) return "ca"
|
||||
if (name && new Set(ctx.peerCertNames ?? []).has(name)) return "server"
|
||||
if (name && new Set(ctx.identityCertNames ?? []).has(name)) return "client"
|
||||
// Серт, подписанный локальным CA (`ca` заполнен), но не используемый как peer-серт — клиентский.
|
||||
// `client1`/`anakondra` имеют дефолтный key-usage (tls-server+tls-client), поэтому по usage
|
||||
// они выглядели бы «server».
|
||||
if ((row.ca ?? "").trim() !== "") return "client"
|
||||
const usage = row["key-usage"] ?? ""
|
||||
if (usage.includes("tls-server")) return "server"
|
||||
if (usage.includes("tls-client")) return "client"
|
||||
|
||||
@@ -112,12 +112,12 @@ export async function exportCertificateP12ByName(
|
||||
client: MikrotikClient,
|
||||
certName: string,
|
||||
passphrase: string,
|
||||
): Promise<{ fileName: string; content: Buffer; certName: string }> {
|
||||
): Promise<{ fileName: string; content: Buffer; certName: string; passphrase: string }> {
|
||||
const name = certName.trim()
|
||||
const cert = await findCertificate(client, name)
|
||||
if (!cert) throw new Error(`Сертификат ${name} не найден на роутере`)
|
||||
const { fileName, content } = await client.exportCertificatePkcs12({ name, passphrase })
|
||||
return { fileName, content, certName: name }
|
||||
const { fileName, content, passphrase: effective } = await client.exportCertificatePkcs12({ name, passphrase })
|
||||
return { fileName, content, certName: name, passphrase: effective }
|
||||
}
|
||||
|
||||
/** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */
|
||||
@@ -125,6 +125,6 @@ export async function exportClientP12(
|
||||
client: MikrotikClient,
|
||||
userName: string,
|
||||
passphrase: string,
|
||||
): Promise<{ fileName: string; content: Buffer; certName: string }> {
|
||||
): Promise<{ fileName: string; content: Buffer; certName: string; passphrase: string }> {
|
||||
return exportCertificateP12ByName(client, clientCertName(userName), passphrase)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import http from "node:http"
|
||||
import https from "node:https"
|
||||
import { randomBytes } from "node:crypto"
|
||||
import type { Server } from "../db/schema.js"
|
||||
import { parseRosDataSizeBytes } from "./ros-metric-parse.js"
|
||||
import type {
|
||||
@@ -267,22 +268,27 @@ function rosDelete(
|
||||
})
|
||||
}
|
||||
|
||||
/** GET бинарного содержимого (файлы RouterOS): без utf8-декодирования, JSON-ответ = ошибка. */
|
||||
function rosDownload(
|
||||
/** Запрос к RouterOS с сырым (не обязательно JSON) ответом — для содержимого файлов. */
|
||||
function rosRawRequest(
|
||||
params: MikrotikConnectParams,
|
||||
path: string,
|
||||
timeoutMs: number,
|
||||
opts: { method: "GET" | "POST"; path: string; body?: Record<string, string>; timeoutMs: number },
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const basePath = params.apiPath ?? "/rest"
|
||||
const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64")
|
||||
const payload = opts.body ? JSON.stringify(opts.body) : undefined
|
||||
|
||||
const options: https.RequestOptions = {
|
||||
hostname: params.host,
|
||||
port: params.port,
|
||||
path: basePath + path,
|
||||
method: "GET",
|
||||
headers: { Authorization: authHeader },
|
||||
path: basePath + opts.path,
|
||||
method: opts.method,
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
...(payload
|
||||
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
|
||||
: {}),
|
||||
},
|
||||
rejectUnauthorized: params.useSsl ? params.verifySsl : undefined,
|
||||
}
|
||||
|
||||
@@ -294,12 +300,7 @@ function rosDownload(
|
||||
res.on("end", () => {
|
||||
const buf = Buffer.concat(chunks)
|
||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(new MikrotikError(res.statusCode ?? 0, path, buf.toString("utf8").slice(0, 200)))
|
||||
return
|
||||
}
|
||||
const contentType = String(res.headers["content-type"] ?? "")
|
||||
if (contentType.includes("application/json")) {
|
||||
reject(new Error(`RouterOS вернул метаданные вместо содержимого файла ${path}`))
|
||||
reject(new MikrotikError(res.statusCode ?? 0, opts.path, buf.toString("utf8").slice(0, 200)))
|
||||
return
|
||||
}
|
||||
resolve(buf)
|
||||
@@ -307,13 +308,14 @@ function rosDownload(
|
||||
})
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
|
||||
}, timeoutMs)
|
||||
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${opts.timeoutMs / 1000}s`))
|
||||
}, opts.timeoutMs)
|
||||
req.on("close", () => clearTimeout(timer))
|
||||
req.on("error", (err) => {
|
||||
clearTimeout(timer)
|
||||
reject(err)
|
||||
})
|
||||
if (payload) req.write(payload)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
@@ -400,6 +402,74 @@ function matchesUploadedFile(entryName: string, requested: string): boolean {
|
||||
|| entryName.endsWith(`/${base}`)
|
||||
}
|
||||
|
||||
/** Лимит команды `/file/get`: RouterOS отдаёт содержимое файлов не больше 60 KB. */
|
||||
const MAX_FILE_GET_BYTES = 60 * 1024
|
||||
|
||||
const CONTENTS_MARKER = Buffer.from('"contents":', "latin1")
|
||||
|
||||
function assertRosFileReadable(entry: { name: string; size: number }): void {
|
||||
if (entry.size > MAX_FILE_GET_BYTES) {
|
||||
throw new Error(
|
||||
`Файл ${routerFileBasename(entry.name)} больше ${MAX_FILE_GET_BYTES / 1024} КБ — `
|
||||
+ "RouterOS REST отдаёт содержимое только до 60 КБ (используйте SCP/FTP)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RouterOS REST отдаёт содержимое файла в JSON-подобной обёртке, но строку — в single-byte
|
||||
* кодировке и экранирует лишь часть символов, из-за чего `JSON.parse` падает
|
||||
* (см. https://forum.mikrotik.com/t/bug-rest-endpoint-producing-invalid-json/177486).
|
||||
* Поле `contents` извлекаем напрямую из сырых байтов, без парсинга всего ответа.
|
||||
* @see https://help.mikrotik.com/docs/spaces/ROS/pages/2555971/Files
|
||||
*/
|
||||
export function extractRosContentsField(raw: Buffer): Buffer {
|
||||
const marker = raw.indexOf(CONTENTS_MARKER)
|
||||
if (marker < 0) throw new Error("RouterOS: ответ не содержит поле contents")
|
||||
|
||||
let i = marker + CONTENTS_MARKER.length
|
||||
while (i < raw.length && isRosJsonSpace(raw[i])) i += 1
|
||||
if (raw[i] !== 0x22) throw new Error("RouterOS: поле contents не является строкой")
|
||||
i += 1
|
||||
|
||||
const out: number[] = []
|
||||
while (i < raw.length) {
|
||||
const byte = raw[i]
|
||||
if (byte === 0x22) return Buffer.from(out) // закрывающая кавычка
|
||||
if (byte !== 0x5c) { // обычный байт
|
||||
out.push(byte)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
const esc = raw[i + 1]
|
||||
if (esc === undefined) break
|
||||
i += 2
|
||||
switch (esc) {
|
||||
case 0x22: out.push(0x22); break // \"
|
||||
case 0x5c: out.push(0x5c); break // \\
|
||||
case 0x2f: out.push(0x2f); break // \/
|
||||
case 0x62: out.push(0x08); break // \b
|
||||
case 0x66: out.push(0x0c); break // \f
|
||||
case 0x6e: out.push(0x0a); break // \n
|
||||
case 0x72: out.push(0x0d); break // \r
|
||||
case 0x74: out.push(0x09); break // \t
|
||||
case 0x75: { // \uXXXX
|
||||
const hex = raw.subarray(i, i + 4).toString("latin1")
|
||||
if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw new Error("RouterOS: некорректный \\u-escape в contents")
|
||||
i += 4
|
||||
for (const b of Buffer.from(String.fromCharCode(parseInt(hex, 16)), "utf8")) out.push(b)
|
||||
break
|
||||
}
|
||||
default: out.push(esc)
|
||||
}
|
||||
}
|
||||
throw new Error("RouterOS: строка contents не закрыта")
|
||||
}
|
||||
|
||||
function isRosJsonSpace(byte: number): boolean {
|
||||
return byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d
|
||||
}
|
||||
|
||||
export function firewallRestPath(
|
||||
family: FirewallFamily,
|
||||
table: FirewallTable | "address-list",
|
||||
@@ -415,6 +485,38 @@ export function encodeRosId(rosId: string): string {
|
||||
|
||||
// ── MikrotikClient ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Имя параметра из ошибки RouterOS `unknown parameter <name>` (400) — для деградации запроса. */
|
||||
function unknownParameterName(error: MikrotikError): string | undefined {
|
||||
if (error.statusCode !== 400) return undefined
|
||||
const match = error.body.match(/unknown parameter\s+"?([A-Za-z0-9_-]+)"?/i)
|
||||
return match?.[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* RouterOS отклоняет `export-passphrase` короче 8 символов:
|
||||
* `Failure: If used, passphrase must be at least 8 chars long!`.
|
||||
* Та же ошибка приходит, если политика `sensitive` не даёт применить sensitive-параметр.
|
||||
*/
|
||||
export function isPassphraseTooShortError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof MikrotikError &&
|
||||
error.statusCode === 400 &&
|
||||
/passphrase/i.test(error.body) &&
|
||||
/at least\s*8/i.test(error.body)
|
||||
)
|
||||
}
|
||||
|
||||
/** Пароль .p12, который RouterOS примет гарантированно (≥8 символов). */
|
||||
function generateExportPassphrase(): string {
|
||||
return randomBytes(12).toString("base64url")
|
||||
}
|
||||
|
||||
const EXPORT_PASSPHRASE_HINT =
|
||||
"RouterOS отклонил пароль .p12. Экспорт приватного ключа (export-passphrase — sensitive-параметр) " +
|
||||
"разрешён только пользователю, у которого в политике группы есть «sensitive». " +
|
||||
"Проверьте: /user print → группа API-пользователя → /user group print; добавьте sensitive в policy группы " +
|
||||
"(в группе full она уже есть)."
|
||||
|
||||
export class MikrotikClient {
|
||||
constructor(private readonly params: MikrotikConnectParams) {}
|
||||
|
||||
@@ -592,13 +694,27 @@ export class MikrotikClient {
|
||||
return raw.filter((row): row is Record<string, string | undefined> => row != null && typeof row === "object")
|
||||
}
|
||||
|
||||
async listFiles(): Promise<Array<{ name: string }>> {
|
||||
async listFiles(): Promise<Array<{ id: string; name: string; size: number }>> {
|
||||
const raw = await this.get<unknown>("/file")
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.filter((row): row is Record<string, unknown> => row != null && typeof row === "object")
|
||||
.map((row) => ({ name: String(row.name ?? "") }))
|
||||
.filter((row) => row.name.length > 0)
|
||||
.map((row) => ({
|
||||
id: String(row[".id"] ?? ""),
|
||||
name: String(row.name ?? ""),
|
||||
size: Number(row.size ?? 0),
|
||||
}))
|
||||
.filter((row) => row.name.length > 0 && row.id.length > 0)
|
||||
}
|
||||
|
||||
/** Поиск файла в `/file` по полному имени, базовому имени или `flash/<base>`. */
|
||||
private async findFileEntry(name: string): Promise<{ id: string; name: string; size: number } | undefined> {
|
||||
const base = routerFileBasename(name)
|
||||
const files = await this.listFiles()
|
||||
return files.find((file) => file.name === name)
|
||||
?? files.find((file) => file.name === base)
|
||||
?? files.find((file) => file.name === `flash/${base}`)
|
||||
?? files.find((file) => routerFileBasename(file.name) === base)
|
||||
}
|
||||
|
||||
private async resolveUploadedFileName(requested: string): Promise<string> {
|
||||
@@ -672,26 +788,76 @@ export class MikrotikClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Скачивание содержимого файла RouterOS (GET /rest/file/<name>, бинарно). */
|
||||
async downloadFile(fileName: string, timeoutMs = 30_000): Promise<Buffer> {
|
||||
const normalized = routerFileBasename(fileName)
|
||||
const candidates = [normalized, `flash/${normalized}`]
|
||||
let lastError: unknown
|
||||
for (const name of candidates) {
|
||||
/**
|
||||
* Чтение содержимого файла RouterOS командой `/file/get` (REST: POST).
|
||||
* Путь `GET /file/<name>` не существует — RouterOS отвечает `no such command prefix`.
|
||||
* @see https://help.mikrotik.com/docs/spaces/ROS/pages/2555971/Files
|
||||
*/
|
||||
private async readFileContents(
|
||||
target: { id?: string; name?: string },
|
||||
timeoutMs = 30_000,
|
||||
): Promise<Buffer> {
|
||||
const body: Record<string, string> = { ".proplist": "contents" }
|
||||
if (target.id) body[".id"] = target.id
|
||||
else if (target.name) body.name = target.name
|
||||
else throw new Error("RouterOS: не задан файл для чтения")
|
||||
const raw = await rosRawRequest(this.params, {
|
||||
method: "POST",
|
||||
path: "/file/get",
|
||||
body,
|
||||
timeoutMs,
|
||||
})
|
||||
return extractRosContentsField(raw)
|
||||
}
|
||||
|
||||
/** Скачивание содержимого файла RouterOS: `/file/get` + побайтовый разбор (лимит 60 КБ). */
|
||||
async downloadFile(
|
||||
target: { name: string; id?: string; size?: number },
|
||||
timeoutMs = 30_000,
|
||||
): Promise<Buffer> {
|
||||
let entry = target.id
|
||||
? { id: target.id, name: target.name, size: target.size ?? 0 }
|
||||
: await this.findFileEntry(target.name)
|
||||
if (!entry) throw new Error(`Файл ${routerFileBasename(target.name)} не найден на RouterOS`)
|
||||
assertRosFileReadable(entry)
|
||||
return this.readFileContents({ id: entry.id, name: entry.name }, timeoutMs)
|
||||
}
|
||||
|
||||
/** Удаление файла RouterOS по `.id` или имени (для временных артефактов экспорта). */
|
||||
async removeFile(idOrName: string, timeoutMs = 10_000): Promise<void> {
|
||||
const id = idOrName.startsWith("*")
|
||||
? idOrName
|
||||
: (await this.findFileEntry(idOrName))?.id
|
||||
if (!id) return
|
||||
await this.delete(`/file/${encodeURIComponent(id)}`, timeoutMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST с деградацией по несовместимым параметрам: набор полей `/certificate/*` зависит от версии
|
||||
* RouterOS (напр. `comment`, `days-valid`). При 400 «unknown parameter X» убираем X и повторяем.
|
||||
*/
|
||||
private async postTolerant(
|
||||
path: string,
|
||||
body: Record<string, string>,
|
||||
timeoutMs: number,
|
||||
maxAttempts = 4,
|
||||
): Promise<unknown> {
|
||||
const payload: Record<string, string> = { ...body }
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
try {
|
||||
return await rosDownload(this.params, `/file/${encodeURIComponent(name)}`, timeoutMs)
|
||||
return await this.post(path, payload, timeoutMs)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
const param = error instanceof MikrotikError ? unknownParameterName(error) : undefined
|
||||
if (!param || !(param in payload)) throw error
|
||||
delete payload[param]
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error(`Не удалось скачать файл ${normalized} с RouterOS`)
|
||||
throw new Error(`RouterOS: не удалось выполнить ${path} (несовместимые параметры): ${Object.keys(body).join(", ")}`)
|
||||
}
|
||||
|
||||
/** Создание ключевой пары + заявки: /certificate add (поля common-name, key-size, key-usage…). */
|
||||
async addCertificate(body: Record<string, string>, timeoutMs = 30_000): Promise<unknown> {
|
||||
return this.post("/certificate/add", body, timeoutMs)
|
||||
return this.postTolerant("/certificate/add", body, timeoutMs)
|
||||
}
|
||||
|
||||
/** Подпись сертификата локальным CA; sign небыстрый — увеличенный таймаут. */
|
||||
@@ -704,33 +870,33 @@ export class MikrotikClient {
|
||||
if (params.ca) body.ca = params.ca
|
||||
if (params.daysValid != null) body["days-valid"] = String(params.daysValid)
|
||||
try {
|
||||
return await this.post("/certificate/sign", body, timeoutMs)
|
||||
return await this.postTolerant("/certificate/sign", body, timeoutMs)
|
||||
} catch (e) {
|
||||
// Некоторые версии REST принимают цель подписи только как .id.
|
||||
const certs = await this.getCertificates()
|
||||
const row = certs.find((c) => String(c.name ?? "") === params.name)
|
||||
const id = row?.[".id"]
|
||||
if (!id) throw e
|
||||
return await this.post("/certificate/sign", { ".id": id, ...body }, timeoutMs)
|
||||
return await this.postTolerant("/certificate/sign", { ".id": id, ...body }, timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
/** Экспорт сертификата в файл на роутере (pkcs12/pem); возвращает имя созданного файла. */
|
||||
/** Экспорт сертификата в файл на роутере (pkcs12/pem); возвращает созданный файл. */
|
||||
async exportCertificate(params: {
|
||||
name: string
|
||||
type: "pkcs12" | "pem"
|
||||
passphrase?: string
|
||||
}, timeoutMs = 60_000): Promise<string> {
|
||||
}, timeoutMs = 60_000): Promise<{ fileName: string; fileId: string; size: number }> {
|
||||
const body: Record<string, string> = { name: params.name, type: params.type }
|
||||
if (params.passphrase?.trim()) body["export-passphrase"] = params.passphrase.trim()
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = await this.post("/certificate/export-certificate", body, timeoutMs)
|
||||
raw = await this.postTolerant("/certificate/export-certificate", body, timeoutMs)
|
||||
} catch (e) {
|
||||
const certs = await this.getCertificates()
|
||||
const id = certs.find((c) => String(c.name ?? "") === params.name)?.[".id"]
|
||||
if (!id) throw e
|
||||
raw = await this.post("/certificate/export-certificate", { ".id": id, ...body }, timeoutMs)
|
||||
raw = await this.postTolerant("/certificate/export-certificate", { ".id": id, ...body }, timeoutMs)
|
||||
}
|
||||
void raw
|
||||
// RouterOS создаёт cert_export_<name>.p12 либо <name>.p12 — ищем по списку файлов.
|
||||
@@ -740,14 +906,35 @@ export class MikrotikClient {
|
||||
const hit = files.find((f) => wanted.includes(f.name))
|
||||
?? files.find((f) => f.name.endsWith(`.${ext}`) && f.name.includes(params.name))
|
||||
if (!hit) throw new Error(`Файл экспорта ${params.name}.${ext} не найден на RouterOS`)
|
||||
return hit.name
|
||||
return { fileName: hit.name, fileId: hit.id, size: hit.size }
|
||||
}
|
||||
|
||||
/** Скачивание .p12 (сертификат + ключ + цепочка) как бинарный Buffer. */
|
||||
async exportCertificatePkcs12(params: { name: string; passphrase?: string }): Promise<{ fileName: string; content: Buffer }> {
|
||||
const fileName = await this.exportCertificate({ name: params.name, type: "pkcs12", passphrase: params.passphrase })
|
||||
const content = await this.downloadFile(fileName)
|
||||
return { fileName, content }
|
||||
async exportCertificatePkcs12(params: { name: string; passphrase?: string }): Promise<{ fileName: string; content: Buffer; passphrase: string }> {
|
||||
let passphrase = params.passphrase?.trim() || generateExportPassphrase()
|
||||
let exported: { fileName: string; fileId: string; size: number }
|
||||
try {
|
||||
exported = await this.exportCertificate({ name: params.name, type: "pkcs12", passphrase })
|
||||
} catch (error) {
|
||||
if (!isPassphraseTooShortError(error)) throw error
|
||||
// RouterOS мог отклонить пароль пользователя — повторяем со сгенерированным и отдаём его в бандл.
|
||||
const forced = generateExportPassphrase()
|
||||
try {
|
||||
exported = await this.exportCertificate({ name: params.name, type: "pkcs12", passphrase: forced })
|
||||
passphrase = forced
|
||||
} catch (retryError) {
|
||||
if (isPassphraseTooShortError(retryError)) throw new Error(EXPORT_PASSPHRASE_HINT)
|
||||
throw retryError
|
||||
}
|
||||
}
|
||||
const content = await this.downloadFile({
|
||||
name: exported.fileName,
|
||||
id: exported.fileId,
|
||||
size: exported.size,
|
||||
})
|
||||
// Временный файл экспорта на роутере больше не нужен (best-effort, не влияет на результат).
|
||||
await this.removeFile(exported.fileId).catch(() => undefined)
|
||||
return { fileName: exported.fileName, content, passphrase }
|
||||
}
|
||||
|
||||
async removeCertificate(nameOrId: string, timeoutMs = 30_000): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { extractRosContentsField } from "./mikrotik.js"
|
||||
|
||||
/**
|
||||
* Собирает ответ RouterOS на `/file/get` (.proplist=contents) в том виде, в каком его
|
||||
* отдаёт устройство: single-byte строка, экранируются только кавычка и обратный слэш.
|
||||
*/
|
||||
function rosFileGetResponse(contents: Buffer): Buffer {
|
||||
const escaped: number[] = []
|
||||
for (const byte of contents) {
|
||||
if (byte === 0x22 || byte === 0x5c) escaped.push(0x5c, byte)
|
||||
else escaped.push(byte)
|
||||
}
|
||||
return Buffer.concat([
|
||||
Buffer.from('[{".id":"*A","contents":"', "latin1"),
|
||||
Buffer.from(escaped),
|
||||
Buffer.from('"}]', "latin1"),
|
||||
])
|
||||
}
|
||||
|
||||
{
|
||||
// ASCII-содержимое
|
||||
const content = Buffer.from("hello p12", "latin1")
|
||||
assert.deepEqual(extractRosContentsField(rosFileGetResponse(content)), content)
|
||||
}
|
||||
|
||||
{
|
||||
// Экранированные кавычки и обратный слэш
|
||||
const content = Buffer.from('a"b\\c"d', "latin1")
|
||||
assert.deepEqual(extractRosContentsField(rosFileGetResponse(content)), content)
|
||||
}
|
||||
|
||||
{
|
||||
// Пустой файл
|
||||
assert.equal(extractRosContentsField(rosFileGetResponse(Buffer.alloc(0))).length, 0)
|
||||
}
|
||||
|
||||
{
|
||||
// Управляющие escape-последовательности
|
||||
const raw = Buffer.from('{"contents":"a\\nb\\tc\\u0041"}', "latin1")
|
||||
assert.deepEqual(extractRosContentsField(raw), Buffer.from("a\nb\tcA", "latin1"))
|
||||
}
|
||||
|
||||
{
|
||||
// contents не первый ключ в объекте
|
||||
const raw = Buffer.from('{".id":"*B","name":"x.p12","contents":"DATA"}', "latin1")
|
||||
assert.deepEqual(extractRosContentsField(raw), Buffer.from("DATA", "latin1"))
|
||||
}
|
||||
|
||||
{
|
||||
// Все 256 байт: single-byte кодировка не должна терять значения 0x80–0xFF и NUL
|
||||
const content = Buffer.from(Array.from({ length: 256 }, (_, i) => i))
|
||||
const decoded = extractRosContentsField(rosFileGetResponse(content))
|
||||
assert.equal(decoded.length, 256)
|
||||
assert.deepEqual(decoded, content)
|
||||
}
|
||||
|
||||
{
|
||||
// Нет поля contents — понятная ошибка
|
||||
assert.throws(
|
||||
() => extractRosContentsField(Buffer.from('{".id":"*A"}', "latin1")),
|
||||
/не содержит поле contents/,
|
||||
)
|
||||
}
|
||||
|
||||
console.log("ros-file-contents.test.ts: ok")
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import type { IpsecCertBundle } from "@mmapp/contracts/ipsec"
|
||||
import { IPSEC_MIN_PASSPHRASE, type IpsecCertBundle } from "@mmapp/contracts/ipsec"
|
||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -33,7 +33,8 @@ function downloadB64(filename: string, b64: string, mime: string) {
|
||||
}
|
||||
|
||||
function randomPassphrase(): string {
|
||||
const bytes = new Uint8Array(9)
|
||||
// RouterOS требует ≥ IPSEC_MIN_PASSPHRASE символов
|
||||
const bytes = new Uint8Array(IPSEC_MIN_PASSPHRASE + 1)
|
||||
crypto.getRandomValues(bytes)
|
||||
let s = ""
|
||||
for (const b of bytes) s += "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"[b % 56]
|
||||
@@ -62,7 +63,10 @@ function IpsecCertSheet({
|
||||
queueMicrotask(() => setPassphrase(initial))
|
||||
}, [open, bundle])
|
||||
|
||||
const canDownload = useMemo(() => Boolean(bundle && passphrase.trim().length >= 4), [bundle, passphrase])
|
||||
const canDownload = useMemo(
|
||||
() => Boolean(bundle && passphrase.trim().length >= IPSEC_MIN_PASSPHRASE),
|
||||
[bundle, passphrase],
|
||||
)
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
@@ -83,7 +87,15 @@ function IpsecCertSheet({
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Пароль архива .p12</SectionTitle>
|
||||
<FormField label="Passphrase" required hint="Нужна при импорте .p12 на устройстве">
|
||||
<FormField
|
||||
label="Passphrase"
|
||||
required
|
||||
hint={
|
||||
passphrase.trim().length > 0 && passphrase.trim().length < IPSEC_MIN_PASSPHRASE
|
||||
? `Минимум ${IPSEC_MIN_PASSPHRASE} символов — требование RouterOS`
|
||||
: `Нужна при импорте .p12 на устройстве (минимум ${IPSEC_MIN_PASSPHRASE} символов)`
|
||||
}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="font-mono"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import type { IpsecClientDto } from "@mmapp/contracts/ipsec"
|
||||
import { IPSEC_MIN_PASSPHRASE, type IpsecClientDto } from "@mmapp/contracts/ipsec"
|
||||
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -91,6 +91,9 @@ function IpsecUserSheet({
|
||||
if (!form.name.trim()) return false
|
||||
if (!editing && form.authMethod === "pre-shared-key" && form.psk.trim().length < 8) return false
|
||||
if (form.useStaticIp && form.staticIp.trim() && !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(form.staticIp.trim())) return false
|
||||
// RouterOS отклоняет export-passphrase короче 8 символов
|
||||
const passphrase = form.passphrase.trim()
|
||||
if (!editing && passphrase.length > 0 && passphrase.length < IPSEC_MIN_PASSPHRASE) return false
|
||||
return true
|
||||
}, [form, editing])
|
||||
|
||||
@@ -155,7 +158,7 @@ function IpsecUserSheet({
|
||||
label="Аутентификация"
|
||||
required
|
||||
hint={form.authMethod === "certificate"
|
||||
? "CA и серверный серт берутся из уже настроенного IKEv2 (напр. MyCA + vpn-server); новый клиент подписывается этим CA"
|
||||
? "По умолчанию клиент подключается через общий IKEv2-peer — выпускается только отдельный клиентский сертификат (CA и серверный серт берутся из уже настроенного IKEv2, напр. MyCA + vpn-server)"
|
||||
: undefined}
|
||||
>
|
||||
<div className="flex gap-1.5">
|
||||
@@ -201,7 +204,14 @@ function IpsecUserSheet({
|
||||
</>
|
||||
) : (
|
||||
!editing ? (
|
||||
<FormField label="Пароль архива .p12" hint="Пусто — сгенерируем автоматически">
|
||||
<FormField
|
||||
label="Пароль архива .p12"
|
||||
hint={
|
||||
form.passphrase.trim().length > 0 && form.passphrase.trim().length < IPSEC_MIN_PASSPHRASE
|
||||
? `Минимум ${IPSEC_MIN_PASSPHRASE} символов — требование RouterOS`
|
||||
: `Пусто — сгенерируем автоматически (минимум ${IPSEC_MIN_PASSPHRASE} символов)`
|
||||
}
|
||||
>
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="например MySecret123"
|
||||
@@ -220,6 +230,7 @@ function IpsecUserSheet({
|
||||
<p className="text-sm font-medium">Статический IP</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{freeIpHint ? `Свободный из пула: ${freeIpHint}` : "Иначе — выдача из пула"}
|
||||
{" "}Статический IP создаёт персональную identity на peer.
|
||||
</p>
|
||||
</div>
|
||||
<FormToggle checked={form.useStaticIp} onChange={(v) => set("useStaticIp", v)} />
|
||||
|
||||
@@ -84,15 +84,26 @@ function IpsecUsersGrid({
|
||||
accessorKey: "authMethod",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Аутентификация" />,
|
||||
cell: ({ row }) => {
|
||||
const cert = row.original.authMethod === "certificate"
|
||||
const c = row.original
|
||||
const cert = c.kind === "cert"
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{cert ? (
|
||||
<BadgeCheckIcon className="size-3.5 text-info" />
|
||||
) : (
|
||||
<KeyRoundIcon className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
{cert ? "Сертификат" : "PSK"}
|
||||
<div className="flex min-w-0 flex-col gap-0.5 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{cert ? (
|
||||
<BadgeCheckIcon className="size-3.5 shrink-0 text-info" />
|
||||
) : (
|
||||
<KeyRoundIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{cert ? "Сертификат" : "PSK"}
|
||||
</div>
|
||||
{cert && c.signedBy ? (
|
||||
<span
|
||||
className="truncate font-mono text-[10px] text-muted-foreground"
|
||||
title={`Подписан: ${c.signedBy}`}
|
||||
>
|
||||
подписан: {c.signedBy}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@@ -175,7 +186,7 @@ function IpsecUsersGrid({
|
||||
const c = row.original
|
||||
return (
|
||||
<div className="flex justify-end gap-0.5">
|
||||
{c.authMethod === "certificate" && onDownloadCert ? (
|
||||
{c.kind === "cert" && onDownloadCert ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -2,6 +2,12 @@ import { z } from "zod"
|
||||
|
||||
export const ipsecAuthMethodSchema = z.enum(["certificate", "pre-shared-key"])
|
||||
|
||||
/**
|
||||
* Минимальная длина пароля .p12. Ограничение RouterOS REST:
|
||||
* `Failure: If used, passphrase must be at least 8 chars long!` (см. /certificate/export-certificate).
|
||||
*/
|
||||
export const IPSEC_MIN_PASSPHRASE = 8
|
||||
|
||||
/** Клиент IKEv2 — /ip/ipsec/identity (+ опциональный персональный mode-config). */
|
||||
export const ipsecClientDtoSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
@@ -11,7 +17,13 @@ export const ipsecClientDtoSchema = z.object({
|
||||
/** Управляемое имя клиента (из managed-комментария identity). */
|
||||
name: z.string(),
|
||||
authMethod: ipsecAuthMethodSchema,
|
||||
/** cert — клиент по сертификату (может быть без персональной identity); psk — pre-shared-key identity. */
|
||||
kind: z.enum(["cert", "psk"]).default("cert"),
|
||||
certificateName: z.string().optional(),
|
||||
/** Имя клиентского сертификата (роль client). Для psk — undefined. */
|
||||
certName: z.string().optional(),
|
||||
/** CA, которым подписан клиентский сертификат (read-only поле `ca`). */
|
||||
signedBy: z.string().optional(),
|
||||
commonName: z.string().optional(),
|
||||
remoteId: z.string().optional(),
|
||||
/** Статический IP клиента (персональный mode-config), undefined — из общего пула. */
|
||||
@@ -160,8 +172,8 @@ export const ipsecUserCreateRequestSchema = z.object({
|
||||
remoteId: z.string().optional(),
|
||||
/** Конкретный IP клиента; без — выдаётся из пула. */
|
||||
staticIp: z.string().optional(),
|
||||
/** Пароль на экспортируемый .p12. */
|
||||
passphrase: z.string().min(4).optional(),
|
||||
/** Пароль на экспортируемый .p12 (RouterOS требует ≥ IPSEC_MIN_PASSPHRASE). */
|
||||
passphrase: z.string().min(IPSEC_MIN_PASSPHRASE).optional(),
|
||||
daysValid: z.number().int().positive().optional(),
|
||||
})
|
||||
|
||||
@@ -177,13 +189,13 @@ export const ipsecUserPatchSchema = z.object({
|
||||
export const ipsecCertExportRequestSchema = z.object({
|
||||
serverId: z.union([z.string(), z.number()]),
|
||||
clientId: z.string().min(1),
|
||||
passphrase: z.string().min(4),
|
||||
passphrase: z.string().min(IPSEC_MIN_PASSPHRASE),
|
||||
})
|
||||
|
||||
/** Экспорт .p12 существующего клиентского сертификата по имени (client1/anakondra и т.п.). */
|
||||
export const ipsecCertExportByNameRequestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
passphrase: z.string().min(4),
|
||||
passphrase: z.string().min(IPSEC_MIN_PASSPHRASE),
|
||||
})
|
||||
|
||||
/** Бандл для авторизации клиента: .p12 (+ strongSwan .sswan + инструкция). */
|
||||
|
||||
Reference in New Issue
Block a user