Compare commits

..
11 Commits
Author SHA1 Message Date
DenozordecandCursor fbd1be8952 test(ipsec): покрыть формы ret и print в декодере файла RouterOS
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m28s
Docker images / frontend-image (push) Successful in 3m4s
Docker images / updater-image (push) Successful in 51s
Docker images / backend-image (push) Successful in 2m25s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 9s
Co-authored-by: Cursor <[email protected]>
2026-09-13 01:01:42 +07:00
DenozordecandCursor 0bf5d6065c fix(ipsec): читать содержимое .p12 из поля ret ответа /file/get
Co-authored-by: Cursor <[email protected]>
2026-09-13 01:01:31 +07:00
DenozordecandCursor 67c3a4fd73 test(ipsec): покрыть декодер содержимого файла RouterOS
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-test (push) Successful in 1m53s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 49s
Docker images / backend-image (push) Successful in 3m19s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
Co-authored-by: Cursor <[email protected]>
2026-09-13 00:44:53 +07:00
DenozordecandCursor 9beb24273b fix(ipsec): скачивать .p12 через /file/get вместо несуществующего REST-пути
Co-authored-by: Cursor <[email protected]>
2026-09-13 00:44:44 +07:00
DenozordecandCursor 5cc6128086 fix(ipsec): уточнить подсказку про политику sensitive у группы пользователя
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m13s
Docker images / frontend-image (push) Successful in 3m13s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 7s
Co-authored-by: Cursor <[email protected]>
2026-09-13 00:15:33 +07:00
DenozordecandCursor 569b6be2b4 fix(ipsec): не блокироваться на пароле .p12 и подсказать политику sensitive
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m18s
Docker images / frontend-image (push) Successful in 2m54s
Docker images / updater-image (push) Successful in 45s
Docker images / backend-image (push) Successful in 2m43s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 14s
Co-authored-by: Cursor <[email protected]>
2026-09-13 00:15:05 +07:00
DenozordecandCursor 752256f12e fix(ipsec): требовать пароль .p12 не короче 8 символов
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m51s
Docker images / frontend-image (push) Successful in 2m19s
Docker images / updater-image (push) Successful in 39s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
Co-authored-by: Cursor <[email protected]>
2026-09-13 00:10:10 +07:00
DenozordecandCursor a3502b9755 fix(ipsec): убрать неподдерживаемый параметр при подписи сертификата
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-test (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 2m17s
Docker images / updater-image (push) Successful in 38s
Docker images / backend-image (push) Successful in 2m20s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 9s
Co-authored-by: Cursor <[email protected]>
2026-09-12 22:21:19 +07:00
DenozordecandCursor 0e1524c600 fix(ipsec): клиенты через общий IKEv2-peer и отдельный клиентский сертификат
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m51s
Docker images / frontend-image (push) Successful in 2m54s
Docker images / updater-image (push) Successful in 40s
Docker images / backend-image (push) Successful in 2m11s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
Co-authored-by: Cursor <[email protected]>
2026-09-12 22:06:35 +07:00
DenozordecandCursor 11ae7593f4 fix(ipsec): убрать неподдерживаемый параметр при добавлении сертификата
Co-authored-by: Cursor <[email protected]>
2026-09-12 22:06:27 +07:00
Denozordec 3eb75ea0b8 feat(ipsec): add certificate export functionality by name
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m41s
Docker images / frontend-image (push) Successful in 2m57s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m48s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 13s
- Implemented a new backend route for exporting existing client certificates in .p12 format by name.
- Enhanced the frontend to support exporting certificates directly from the IPsec server grid.
- Updated the IPsec page to manage certificate states and handle exports effectively.
- Introduced new utility functions for certificate handling and improved data structures to accommodate the changes.
- Added tests to ensure the reliability of the new certificate export feature.
2026-09-12 21:39:14 +07:00
19 changed files with 1305 additions and 221 deletions
+41 -9
View File
@@ -40,6 +40,7 @@ import {
deleteIpsecPeer,
deleteIpsecServer,
deleteIpsecUser,
exportIpsecCertByName,
exportIpsecUserCert,
initIpsecServer,
listIpsec,
@@ -97,6 +98,7 @@ const MOCK_IPSEC_SERVERS: IpsecServerSummaryDto[] = [
serverId: "srv2",
serverName: "mt-spb-edge-01",
initialized: true,
ike2Ready: true,
serverEndpoint: "vpn.example.com",
pool: { id: "srv2:p1", rosId: "*P1", serverId: "srv2", name: "ipsec-vpn", ranges: "10.77.0.2-10.77.0.254", managed: true },
sharedModeConfig: { id: "srv2:m1", rosId: "*M1", serverId: "srv2", name: "ipsec-vpn", addressPool: "ipsec-vpn", staticDns: "10.77.0.1", managed: true },
@@ -114,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,
},
]
@@ -181,6 +183,7 @@ export default function IpsecPage() {
const [certOpen, setCertOpen] = useState(false)
const [certBundle, setCertBundle] = useState<IpsecCertBundle | null>(null)
const [certClient, setCertClient] = useState<IpsecClientDto | null>(null)
const [certByName, setCertByName] = useState<{ serverId: string; name: string } | null>(null)
const [peerOpen, setPeerOpen] = useState(false)
const [editingPeer, setEditingPeer] = useState<{ serverId: string; peer: IpsecPeerDto } | null>(null)
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
@@ -318,7 +321,7 @@ export default function IpsecPage() {
/** Подсказка свободного IP из пула выбранного сервера. */
const freeIpHint = useMemo(() => {
const summary = scopedSummaries.find((s) => s.initialized && s.pool)
const summary = scopedSummaries.find((s) => s.ike2Ready && s.pool)
if (!summary?.pool) return undefined
const taken = scopedClients.map((c) => c.staticIp).filter(Boolean) as string[]
return findFreePoolIp(summary.pool.ranges, taken) ?? undefined
@@ -388,6 +391,7 @@ export default function IpsecPage() {
if (res.bundle) {
setCertBundle(res.bundle)
setCertClient(res.client ?? null)
setCertByName(null)
setCertOpen(true)
}
} catch (e) {
@@ -420,13 +424,38 @@ export default function IpsecPage() {
}
const openCert = async (client: IpsecClientDto) => {
setCertClient(client)
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("Ошибка экспорта сертификата", {
description: e instanceof Error ? e.message : String(e),
})
setCertOpen(false)
} finally {
setCertBusy(false)
}
}
/** Экспорт .p12 существующего клиентского серта по имени (client1/anakondra и т.п.). */
const openCertByName = async (serverId: string, cert: IpsecCertInfoDto) => {
setCertByName({ serverId, name: cert.name })
setCertClient(null)
setCertBundle(null)
setCertOpen(true)
setCertBusy(true)
try {
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
const bundle = await exportIpsecCertByName(backendUrl, serverId, cert.name, passphrase)
setCertBundle(bundle)
} catch (e) {
toast.error("Ошибка экспорта сертификата", {
@@ -439,10 +468,12 @@ export default function IpsecPage() {
}
const reexportCert = async (passphrase: string) => {
if (!certClient) return
if (!certClient && !certByName) return
setCertBusy(true)
try {
const bundle = await exportIpsecUserCert(backendUrl, certClient.serverId, certClient.rosId, passphrase)
const bundle = certByName
? await exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase)
: await exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase)
setCertBundle(bundle)
} catch (e) {
toast.error("Ошибка экспорта", { description: e instanceof Error ? e.message : String(e) })
@@ -718,6 +749,7 @@ export default function IpsecPage() {
onEditPeer={isLive ? openEditPeer : undefined}
onDeletePeer={isLive ? (serverId, peer) => setPendingDelete({ kind: "peer", serverId, peer }) : undefined}
onDeleteCert={isLive ? (serverId, cert) => setPendingDelete({ kind: "cert", serverId, cert }) : undefined}
onExportCert={isLive ? (serverId, cert) => void openCertByName(serverId, cert) : undefined}
/>
</TabsContent>
@@ -820,7 +852,7 @@ export default function IpsecPage() {
<IpsecCertSheet
open={certOpen}
onOpenChange={setCertOpen}
onOpenChange={(v) => { setCertOpen(v); if (!v) setCertByName(null) }}
bundle={certBundle}
busy={certBusy}
onReexport={reexportCert}
+2 -1
View File
@@ -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": {
+199 -59
View File
@@ -1,7 +1,9 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import type { FastifyReply } from "fastify"
import {
IPSEC_MIN_PASSPHRASE,
ipsecCertDeleteRequestSchema,
ipsecCertExportByNameRequestSchema,
ipsecCertExportRequestSchema,
ipsecInitRequestSchema,
ipsecPeerPatchSchema,
@@ -18,13 +20,17 @@ import {
buildClientInstructions,
buildSswanConfig,
clientCertName,
findFreePoolIp,
identityDisplayName,
ipsecManagedComment,
ipsecUserComment,
isIke2RemoteAccessIdentity,
isIpsecManagedComment,
parseIpsecUserComment,
poolRangesFromCidr,
resolveIke2CaName,
resolveIke2ServerCert,
selectIke2Peers,
selectSharedIke2Identity,
userModeConfigName,
} from "../services/ipsec-config.js"
import {
@@ -42,12 +48,13 @@ import {
deleteIdentity,
patchPeer,
deletePeer,
ensureSharedIke2Identity,
listByPath,
} from "../services/ipsec-ros.js"
import {
ensureCaCertificate,
ensureServerCertificate,
exportClientP12,
exportCertificateP12ByName,
findCertificate,
issueClientCertificate,
} from "../services/ipsec-ca.js"
@@ -91,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,
@@ -113,18 +136,18 @@ function takenStaticIps(modeConfigs: Array<{ name?: string; address?: string; "a
async function buildCertBundle(
client: MikrotikClient,
args: { userName: string; serverEndpoint: string; passphrase: string; dns?: string },
args: { userName: string; certName?: string; serverEndpoint: string; passphrase: string; dns?: string },
): Promise<IpsecCertBundle> {
const { fileName, content } = await exportClientP12(client, args.userName, args.passphrase)
const certName = args.certName?.trim() || clientCertName(args.userName)
const { fileName, content, passphrase } = await exportCertificateP12ByName(client, certName, args.passphrase)
const p12B64 = content.toString("base64")
const certName = clientCertName(args.userName)
return {
user: args.userName,
serverEndpoint: args.serverEndpoint,
filename: fileName,
contentB64: p12B64,
mime: "application/x-pkcs12",
passphrase: args.passphrase,
passphrase,
sswanFilename: `${certName}.sswan`,
sswanContent: buildSswanConfig({
name: `IKEv2 ${args.serverEndpoint}`,
@@ -136,7 +159,7 @@ async function buildCertBundle(
userName: args.userName,
serverEndpoint: args.serverEndpoint,
p12Filename: fileName,
passphrase: args.passphrase,
passphrase,
dns: args.dns,
}),
}
@@ -237,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)
@@ -245,32 +268,44 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server)
try {
const state = await fetchIpsecState(server)
const ike2Peers = selectIke2Peers(state.peers)
const peer = (body.peerName
? state.peers.find((p) => (p.name ?? "").trim() === body.peerName!.trim())
: undefined)
?? state.peers.find((p) => isIpsecManagedComment(p.comment) || (p.name ?? "").trim() === IPSEC_COMMON_NAME)
?? ike2Peers.find((p) => isIpsecManagedComment(p.comment))
?? ike2Peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME)
?? ike2Peers[0]
?? state.peers[0]
if (!peer) {
return reply.status(400).send({ error: "На роутере нет ни одного IPsec peer — создайте peer на вкладке «Сервер»" })
}
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
const caCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_CA_CERT)
?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("key-cert-sign"))
const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT)
?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server"))
if (body.authMethod === "certificate" && (!caCert || !serverCert)) {
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
const ike2Identities = state.identities.filter(
(i) => isIpsecManagedComment(i.comment) || isIke2RemoteAccessIdentity(i, ike2PeerNames),
)
const idMcName = ike2Identities
.map((i) => (i["mode-config"] ?? "").trim())
.find((n) => n && !n.startsWith("mc-ipsec-"))
const sharedMc = (idMcName ? state.modeConfigs.find((m) => (m.name ?? "").trim() === idMcName) : undefined)
?? state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers)
const caName = resolveIke2CaName(state.certs, serverCertRow)
if (body.authMethod === "certificate" && (!caName || !serverCertRow)) {
return reply.status(400).send({
error: "Нет CA/серверного сертификата — для сертификатного клиента запустите мастер или используйте PSK",
})
}
const peerName = (peer.name ?? "").trim()
const serverEndpoint = String(serverCert?.["common-name"] ?? "").trim() || server.host
const serverEndpoint = String(serverCertRow?.["common-name"] ?? "").trim() || server.host
const dns = sharedMc?.["static-dns"]?.trim() || undefined
if (body.authMethod === "pre-shared-key" && !body.psk) {
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()
@@ -287,7 +322,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
if (body.authMethod === "certificate") {
const issued = await issueClientCertificate(client, {
userName: body.name,
caCertName: String(caCert!.name ?? IPSEC_CA_CERT),
caCertName: caName!,
daysValid: body.daysValid ?? 1825,
})
if (issued.existed) {
@@ -296,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(serverCert?.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) {
@@ -342,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) {
// вернуть выдачу из пула
@@ -370,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 })
@@ -399,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 })
@@ -518,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))
@@ -539,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,
})
@@ -550,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}`,
@@ -562,7 +669,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
userName,
serverEndpoint,
p12Filename: fileName,
passphrase: body.passphrase,
passphrase,
dns,
}),
}
@@ -572,6 +679,39 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
}
})
/** Экспорт .p12 существующего клиентского сертификата по имени (client1/anakondra и т.п.). */
app.post("/ipsec/certs/:serverId/export", async (req, reply) => {
const { serverId } = req.params as { serverId: string }
const parsed = ipsecCertExportByNameRequestSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return badBodyReply(reply, parsed.error)
}
const { name, passphrase } = parsed.data
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
const state = await fetchIpsecState(server)
const cert = state.certs.find((c) => String(c.name ?? "").trim() === name)
if (!cert?.[".id"]) return reply.status(404).send({ error: `Сертификат ${name} не найден` })
const serverCertRow = resolveIke2ServerCert(state.certs, selectIke2Peers(state.peers))
const serverEndpoint = String(serverCertRow?.["common-name"] ?? "").trim() || server.host
const userName = String(cert["common-name"] ?? "").trim() || name
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
const dns = sharedMc?.["static-dns"]?.trim() || undefined
const bundle = await buildCertBundle(client, {
userName,
certName: name,
serverEndpoint,
passphrase,
dns,
})
return reply.send(bundle)
} catch (e) {
return errReply(reply, e)
}
})
app.get("/ipsec/revisions", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId)
@@ -0,0 +1,65 @@
import assert from "node:assert/strict"
import {
certificateRole,
isCaCertificate,
mapRosCertificateRow,
type RosCertificateRow,
} from "./certificate-parse.js"
// Реальные серты пользователя: MyCA (root) → vpn-server, client1, anakondra.
const myCa: RosCertificateRow = {
name: "MyCA",
"common-name": "MyCA",
"key-usage": "key-cert-sign,crl-sign",
ca: "",
flags: "KAT",
authority: "true",
}
const vpnServer: RosCertificateRow = {
name: "vpn-server",
"common-name": "vpn.example.com",
"key-usage": "digital-signature,key-encipherment,key-cert-sign,crl-sign,tls-server,tls-client",
ca: "MyCA",
flags: "KLAT",
}
const client1: RosCertificateRow = {
name: "client1",
"common-name": "client1",
"key-usage": "digital-signature,key-encipherment,key-cert-sign,crl-sign,tls-server,tls-client",
ca: "MyCA",
flags: "KLAT",
}
const anakondra: RosCertificateRow = { ...client1, name: "anakondra", "common-name": "anakondra" }
{
assert.ok(isCaCertificate(myCa))
// default key-usage содержит key-cert-sign, но ca заполнен → не CA
assert.ok(!isCaCertificate(vpnServer))
assert.ok(!isCaCertificate(client1))
}
{
const ctx = {
peerCertNames: ["vpn-server"],
identityCertNames: ["client1", "anakondra"],
}
assert.equal(certificateRole(myCa, ctx), "ca")
assert.equal(certificateRole(vpnServer, ctx), "server")
// 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")
}
{
const dto = mapRosCertificateRow(1, "mt", client1)
assert.equal(dto?.signedByCertName, "MyCA")
assert.equal(mapRosCertificateRow(1, "mt", myCa)?.signedByCertName, undefined)
}
console.log("certificate-parse.test.ts: ok")
+60
View File
@@ -2,6 +2,64 @@ import type { CertificateDto } from "@mmapp/contracts/certificates"
export type RosCertificateRow = Record<string, string | undefined>
/** Роль сертификата в контексте IPsec/IKEv2. */
export type CertRole = "ca" | "server" | "client" | "other"
/** Контекст для reference-based определения роли (peer/identity ссылки точнее, чем key-usage). */
export interface CertificateRoleContext {
/** Имена сертификатов из `peer.certificate`. */
peerCertNames?: Iterable<string>
/** Имена сертификатов из `identity.remote-certificate`. */
identityCertNames?: Iterable<string>
}
function rosTrue(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
/** Флаги RouterOS (`flags` = строка вида "KLAT"). */
function certFlags(row: RosCertificateRow): string {
return (row.flags ?? "").toUpperCase()
}
/**
* CA: `key-cert-sign`, серт self-signed (read-only `ca` пуст).
* Только флаг authority/`A` не годится: RouterOS ставит `A` и серверным сертам,
* т.к. дефолтный `key-usage` содержит `key-cert-sign`. Реальный признак — отсутствие подписавшего.
*/
export function isCaCertificate(row: RosCertificateRow): boolean {
const usage = row["key-usage"] ?? ""
const signer = (row.ca ?? "").trim()
if (signer !== "") return false
return certFlags(row).includes("A") || rosTrue(row.authority) || usage.includes("key-cert-sign")
}
/**
* Роль сертификата. Приоритет:
* 1. CA (authority / key-cert-sign без подписавшего);
* 2. серт из `peer.certificate` → server;
* 3. серт из `identity.remote-certificate` → client;
* 4. `key-usage` tls-server → server, tls-client → client (reference-based важнее: RouterOS
* дефолт содержит и tls-server, и tls-client).
*/
export function certificateRole(
row: RosCertificateRow,
ctx: CertificateRoleContext = {},
): CertRole {
const name = (row.name ?? "").trim()
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"
return "other"
}
function parseRosDate(raw: string | undefined): Date | null {
if (!raw?.trim()) return null
const d = new Date(raw)
@@ -88,6 +146,8 @@ export function mapRosCertificateRow(
trusted: row.trusted === "true",
status,
acmeStatus: row["acme-status"]?.trim() || undefined,
/** Имя CA, которым серт подписан на устройстве (read-only поле `ca`). */
signedByCertName: (row.ca ?? "").trim() || undefined,
}
}
+15 -6
View File
@@ -107,15 +107,24 @@ export async function issueClientCertificate(
return { certName, commonName: args.userName.trim(), existed: false }
}
/** Экспорт произвольного сертификата по имени (существующие client1/anakondra и т.п.). */
export async function exportCertificateP12ByName(
client: MikrotikClient,
certName: string,
passphrase: 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, passphrase: effective } = await client.exportCertificatePkcs12({ name, passphrase })
return { fileName, content, certName: name, passphrase: effective }
}
/** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */
export async function exportClientP12(
client: MikrotikClient,
userName: string,
passphrase: string,
): Promise<{ fileName: string; content: Buffer; certName: string }> {
const certName = clientCertName(userName)
const cert = await findCertificate(client, certName)
if (!cert) throw new Error(`Сертификат ${certName} не найден на роутере`)
const { fileName, content } = await client.exportCertificatePkcs12({ name: certName, passphrase })
return { fileName, content, certName }
): Promise<{ fileName: string; content: Buffer; certName: string; passphrase: string }> {
return exportCertificateP12ByName(client, clientCertName(userName), passphrase)
}
+78
View File
@@ -9,11 +9,18 @@ import {
ipsecManagedComment,
ipsecSlug,
ipsecUserComment,
isIke2Peer,
isIke2RemoteAccessIdentity,
isIpsecManagedComment,
parseIpsecUserComment,
poolRangesFromCidr,
resolveIke2CaName,
resolveIke2ServerCert,
selectIke2Peers,
selectSharedIke2Identity,
userModeConfigName,
} from "./ipsec-config.js"
import { identityRosBody } from "./ipsec-ros.js"
{
assert.equal(ipsecSlug("Alice Cooper"), "alice-cooper")
@@ -110,4 +117,75 @@ import {
assert.ok(text.includes("strongSwan"))
}
// ── IKEv2-only отбор ────────────────────────────────────────────────────────
{
const grePeer = { name: "gre-tunnel", "exchange-mode": "ike2", address: "1.2.3.4", passive: "no" }
const s2sPeer = { name: "s2s", "exchange-mode": "main", address: "5.6.7.8", certificate: "vpn-server" }
const rwPeer = { name: "vpn-server", "exchange-mode": "ike2", address: "0.0.0.0/0", passive: "yes", certificate: "vpn-server" }
assert.ok(isIke2Peer(rwPeer))
assert.ok(!isIke2Peer(s2sPeer))
const selected = selectIke2Peers([grePeer, s2sPeer, rwPeer])
assert.deepEqual(selected.map((p) => p.name), ["vpn-server"])
// fallback: нет строгого набора → ike2 + сертификат
const fallback = selectIke2Peers([grePeer, { name: "ike2-cert", "exchange-mode": "ike2", certificate: "c" }])
assert.deepEqual(fallback.map((p) => p.name), ["ike2-cert"])
const names = ["vpn-server"]
assert.ok(isIke2RemoteAccessIdentity(
{ peer: "vpn-server", "mode-config": "ipsec-vpn", "auth-method": "rsa-key", certificate: "vpn-server", "remote-certificate": "client1" },
names,
))
assert.ok(isIke2RemoteAccessIdentity({ peer: "vpn-server", "auth-method": "rsa-key", certificate: "vpn-server" }, names))
// GRE/site-to-site PSK-туннели отсеиваются
assert.ok(!isIke2RemoteAccessIdentity({ peer: "s2s", "auth-method": "pre-shared-key" }, names))
assert.ok(!isIke2RemoteAccessIdentity({ peer: "vpn-server", "auth-method": "pre-shared-key" }, names))
// без ограничения по peer (набор пуст) — mode-config/серт всё равно нужен
assert.ok(isIke2RemoteAccessIdentity({ peer: "x", "mode-config": "mc" }, []))
assert.ok(!isIke2RemoteAccessIdentity({ peer: "x", "auth-method": "pre-shared-key" }, []))
}
{
const myCa = { name: "MyCA", "key-usage": "key-cert-sign,crl-sign", ca: "", flags: "KAT", authority: "true" }
const vpnServer = { name: "vpn-server", "key-usage": "digital-signature,key-encipherment,tls-server,tls-client", ca: "MyCA" }
const client1 = { name: "client1", "key-usage": "digital-signature,tls-client,tls-server", ca: "MyCA" }
const certs = [myCa, vpnServer, client1]
assert.equal(resolveIke2ServerCert(certs, [{ name: "vpn-server", certificate: "vpn-server" }])?.name, "vpn-server")
assert.equal(resolveIke2ServerCert([myCa], [])?.name, undefined)
assert.equal(resolveIke2CaName(certs, vpnServer), "MyCA")
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")
+118 -2
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto"
import { PRODUCT_NAME } from "../managed-markers.js"
import { isCaCertificate } from "./certificate-parse.js"
// ── managed-маркеры (как у filters/recursive, см. managed-markers.ts) ─────────
@@ -24,7 +25,7 @@ export function parseIpsecUserComment(comment: string | undefined | null): strin
return m?.[1] ?? null
}
type IdentityLike = {
export type IdentityLike = {
".id"?: string
comment?: string
"remote-id"?: string
@@ -32,7 +33,37 @@ type IdentityLike = {
peer?: string
}
type CertLike = { name?: string; "common-name"?: string }
/** Identity + поля, нужные для отбора IKEv2 remote-access. */
export interface Ike2IdentityLike extends IdentityLike {
certificate?: string
"auth-method"?: string
"mode-config"?: string
}
/** Peer (/ip/ipsec/peer). */
export interface PeerLike {
".id"?: string
name?: string
address?: string
"exchange-mode"?: string
passive?: string
certificate?: string
comment?: string
}
export interface CertLike {
name?: string
"common-name"?: string
"key-usage"?: string
/** read-only: имя CA, которым подписан серт. */
ca?: string
/** флаги RouterOS (строка вида "KLAT"). */
flags?: string
authority?: string
}
/** Строка сертификата RouterOS (raw). */
type RosRow = Record<string, string | undefined>
/** Отображаемое имя identity: managed user= → сырой comment → remote-id → CN сертификата → peer#id. */
export function identityDisplayName(i: IdentityLike, certs: CertLike[] = []): string {
@@ -76,6 +107,91 @@ export function userModeConfigName(userName: string): string {
return `mc-ipsec-${ipsecSlug(userName) || "client"}`
}
// ── отбор IKEv2 remote-access (без GRE/site-to-site) ────────────────────────
function rosBool(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
function isAnyAddress(a: string | undefined): boolean {
const v = (a ?? "").trim()
return v === "" || v === "0.0.0.0/0" || v === "::/0"
}
/** Peer с IKEv2 (exchange-mode ike2). */
export function isIke2Peer(p: PeerLike): boolean {
return (p["exchange-mode"] ?? "").trim().toLowerCase() === "ike2"
}
/**
* Remote-access IKEv2 peers. Строгий набор: ike2 + passive + any-address + сертификат.
* Fallback → ike2 + сертификат → любые ike2 (чтобы не потерять существующий сервер).
*/
export function selectIke2Peers<T extends PeerLike>(peers: T[]): T[] {
const ike2 = peers.filter(isIke2Peer)
const strict = ike2.filter(
(p) => rosBool(p.passive) && isAnyAddress(p.address) && Boolean((p.certificate ?? "").trim()),
)
if (strict.length > 0) return strict
const withCert = ike2.filter((p) => Boolean((p.certificate ?? "").trim()))
return withCert.length > 0 ? withCert : ike2
}
/**
* Identity участвует в IKEv2 remote-access: привязан к IKEv2 peer И имеет mode-config
* либо сертификат клиента. PSK-туннели без mode-config/сертификата отсеиваются.
* Пустой набор peer-имён → не ограничиваем по peer.
*/
export function isIke2RemoteAccessIdentity(
i: Ike2IdentityLike,
ike2PeerNames?: Iterable<string>,
): boolean {
const names = new Set(Array.from(ike2PeerNames ?? [], (n) => n.trim()).filter(Boolean))
if (names.size > 0 && !names.has((i.peer ?? "").trim())) return false
const hasModeConfig = Boolean((i["mode-config"] ?? "").trim())
const hasRemoteCert = Boolean((i["remote-certificate"] ?? "").trim())
const rsaKey = (i["auth-method"] ?? "").trim().toLowerCase() === "rsa-key"
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))
return certs.find((c) => names.has(String(c.name ?? "").trim()))
?? certs.find((c) => String(c.name ?? "").trim() === IPSEC_SERVER_CERT)
?? certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server"))
}
/** CA для подписи новых клиентов: `ca` серверного серта (MyCA) → managed ipsec-ca → первый CA. */
export function resolveIke2CaName<T extends RosRow>(certs: T[], serverCert?: T | null): string | undefined {
const signed = String(serverCert?.ca ?? "").trim()
if (signed && certs.some((c) => String(c.name ?? "").trim() === signed)) return signed
if (certs.some((c) => String(c.name ?? "").trim() === IPSEC_CA_CERT)) return IPSEC_CA_CERT
const ca = certs.find((c) => isCaCertificate(c))
return ca ? String(ca.name ?? "").trim() || undefined : undefined
}
// ── пул адресов клиентов ────────────────────────────────────────────────────
function ipToInt(b: Array<number | undefined>): number {
+165 -61
View File
@@ -17,8 +17,14 @@ import {
IPSEC_SERVER_CERT,
clientCertName,
identityDisplayName,
isIke2RemoteAccessIdentity,
isIpsecManagedComment,
resolveIke2CaName,
resolveIke2ServerCert,
selectIke2Peers,
selectSharedIke2Identity,
} from "./ipsec-config.js"
import { certificateRole, type CertificateRoleContext } from "./certificate-parse.js"
import {
canonicalIpsecSnapshot,
type IpsecLiveIdentity,
@@ -107,13 +113,13 @@ function asBool(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
export function mapCertificate(c: RosCertRow): IpsecCertInfoDto {
export function mapCertificate(c: RosCertRow, ctx: CertificateRoleContext = {}): IpsecCertInfoDto {
const name = String(c.name ?? "")
const keyUsage = String(c["key-usage"] ?? "")
const isCa = keyUsage.includes("key-cert-sign")
const isServer = keyUsage.includes("tls-server")
const isClient = keyUsage.includes("tls-client")
const isUser = name.startsWith("ipsec-user-")
const role = certificateRole(c, {
peerCertNames: ctx.peerCertNames,
identityCertNames: ctx.identityCertNames,
})
return {
name,
commonName: c["common-name"] || undefined,
@@ -122,8 +128,9 @@ export function mapCertificate(c: RosCertRow): IpsecCertInfoDto {
expiresAt: c["invalid-after"] || undefined,
trusted: asBool(c.trusted),
hasPrivateKey: asBool(c["private-key"]),
role: isCa ? "ca" : name === IPSEC_SERVER_CERT || isServer ? "server" : isUser || isClient ? "client" : "other",
managed: isCa && name === IPSEC_CA_CERT
role,
signedBy: (c.ca ?? "").trim() || undefined,
managed: (name === IPSEC_CA_CERT && role === "ca")
|| name === IPSEC_SERVER_CERT
|| isUser
|| isIpsecManagedComment(c.comment),
@@ -204,91 +211,188 @@ export async function fetchIpsecState(server: ServerRow): Promise<IpsecServerSta
return { server, client, peers, identities, modeConfigs, pools, policies, nat, active, certs }
}
/** Клиенты = все identity роутера (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 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
.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 {
const server = state.server
const managedPeer = state.peers.find((p) => isIpsecManagedComment(p.comment))
?? state.peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME)
/** Для отображения: managed peer, иначе первый существующий peer роутера. */
const primaryPeer = managedPeer ?? state.peers[0]
const sharedMc = state.modeConfigs.find(
(m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME || (isIpsecManagedComment(m.comment) && !(m.name ?? "").startsWith("mc-ipsec-")),
const ike2Peers = selectIke2Peers(state.peers)
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
const ike2Identities = state.identities.filter(
(i) => isIpsecManagedComment(i.comment) || isIke2RemoteAccessIdentity(i, ike2PeerNames),
)
const managedPeer = ike2Peers.find((p) => isIpsecManagedComment(p.comment))
?? ike2Peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME)
/** Для отображения: managed IKEv2 peer, иначе первый существующий IKEv2 peer. */
const primaryPeer = managedPeer ?? ike2Peers[0]
// mode-config, на который ссылаются IKEv2 identity (не персональный mc-ipsec-*), fallback managed shared
const idMcName = ike2Identities
.map((i) => (i["mode-config"] ?? "").trim())
.find((n) => n && !n.startsWith("mc-ipsec-"))
const sharedMc = (idMcName ? state.modeConfigs.find((m) => (m.name ?? "").trim() === idMcName) : undefined)
?? state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
?? state.modeConfigs.find((m) => isIpsecManagedComment(m.comment) && !(m.name ?? "").startsWith("mc-ipsec-"))
const pool = sharedMc
? state.pools.find((p) => (p.name ?? "").trim() === (sharedMc["address-pool"] ?? "").trim())
?? state.pools.find((p) => isIpsecManagedComment(p.comment))
: undefined
const caCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_CA_CERT)
?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("key-cert-sign"))
const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT)
?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server"))
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers)
const caName = resolveIke2CaName(state.certs, serverCertRow)
const caCertRow = caName ? state.certs.find((c) => String(c.name ?? "").trim() === caName) : undefined
const roleCtx: CertificateRoleContext = {
peerCertNames: ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean),
identityCertNames: ike2Identities.map((i) => (i["remote-certificate"] ?? "").trim()).filter(Boolean),
}
const referencedCertNames = new Set<string>([
...(roleCtx.peerCertNames ?? []),
...(roleCtx.identityCertNames ?? []),
...ike2Identities.map((i) => (i.certificate ?? "").trim()).filter(Boolean),
])
// только IKEv2-релевантные серты: CA сервера, referenced peer/identity и подписанные этим CA
const relevantCerts = state.certs.filter((c) => {
const n = String(c.name ?? "").trim()
if (caName && n === caName) return true
if (referencedCertNames.has(n)) return true
return Boolean(caName) && (c.ca ?? "").trim() === caName
})
const caCert = caCertRow ? mapCertificate(caCertRow, roleCtx) : undefined
const serverCert = serverCertRow ? mapCertificate(serverCertRow, roleCtx) : undefined
const natRuleManaged = state.nat.some((r) => isIpsecManagedComment(r.comment) && r.chain === "srcnat")
return {
serverId: String(server.id),
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
serverCountry: server.country ?? undefined,
initialized: Boolean(managedPeer && caCert && serverCert),
serverEndpoint: serverCert ? String(serverCert["common-name"] ?? "") || undefined : undefined,
ike2Ready: Boolean(ike2Peers.length > 0 && caCert && serverCert),
serverEndpoint: serverCertRow ? String(serverCertRow["common-name"] ?? "") || undefined : undefined,
peer: primaryPeer ? mapPeer(server, primaryPeer) : undefined,
peers: state.peers.map((p) => mapPeer(server, p)),
peers: ike2Peers.map((p) => mapPeer(server, p)),
pool: pool ? mapPool(server, pool) : undefined,
sharedModeConfig: sharedMc ? mapModeConfig(server, sharedMc) : undefined,
caCert: caCert ? mapCertificate(caCert) : undefined,
serverCert: serverCert ? mapCertificate(serverCert) : undefined,
caCert,
serverCert,
natRuleManaged,
clientsTotal: clients.length,
clientsOnline: clients.filter((c) => c.online).length,
certs: state.certs.map(mapCertificate),
certs: relevantCerts.map((c) => mapCertificate(c, roleCtx)),
}
}
+37 -1
View File
@@ -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)
}
+284 -41
View File
@@ -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 | 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,102 @@ function matchesUploadedFile(entryName: string, requested: string): boolean {
|| entryName.endsWith(`/${base}`)
}
/** Лимит команды `/file/get`: выше 61439 байт RouterOS молча отдаёт 0 байт. */
const MAX_FILE_GET_BYTES = 61439
const RET_MARKER = Buffer.from('"ret":', "latin1")
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 для диагностики: непечатаемые байты → `\uXXXX`. */
function rosResponsePreview(raw: Buffer, limit = 200): string {
const slice = raw.subarray(0, limit)
let out = ""
for (const byte of slice) {
if (byte >= 0x20 && byte < 0x7f) out += String.fromCharCode(byte)
else out += `\\u${byte.toString(16).padStart(4, "0")}`
}
return out
}
/**
* RouterOS REST отдаёт содержимое файла в JSON-подобной обёртке, но строку — в single-byte
* кодировке и экранирует лишь часть символов, из-за чего `JSON.parse` падает
* (см. https://forum.mikrotik.com/t/bug-rest-endpoint-producing-invalid-json/177486).
* Значение извлекаем напрямую из сырых байтов, без парсинга всего ответа.
* Команда `get` кладёт результат в поле `ret` (`!done=ret=...`), команда `print` — в `contents`.
* @see https://help.mikrotik.com/docs/spaces/ROS/pages/47579160/API
* @see https://help.mikrotik.com/docs/spaces/ROS/pages/2555971/Files
*/
export function extractRosContentsField(raw: Buffer): Buffer {
const start = findRosStringField(raw, RET_MARKER) ?? findRosStringField(raw, CONTENTS_MARKER)
if (start == null) {
throw new Error(`ответ не содержит поле ret/contents (${rosResponsePreview(raw)})`)
}
const out: number[] = []
let i = start
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("некорректный \\u-escape в содержимом файла")
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("строка содержимого файла не закрыта")
}
/**
* Ищет ключ JSON и возвращает индекс первого байта строкового значения (после открывающей кавычки).
* Возвращает null, если ключа нет или за ним не строка.
*/
function findRosStringField(raw: Buffer, marker: Buffer): number | null {
let from = 0
for (;;) {
const at = raw.indexOf(marker, from)
if (at < 0) return null
let i = at + marker.length
while (i < raw.length && isRosJsonSpace(raw[i])) i += 1
if (raw[i] === 0x22) return i + 1
from = at + marker.length
}
}
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 +513,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 +722,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 +816,104 @@ 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. Путь `GET /file/<name>` не существует — RouterOS
* отвечает `no such command prefix`; используются команды `get`/`print`.
* @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 readOnce = async (id?: string, name?: string): Promise<Buffer> => {
const strategies: Array<{ path: string; body: Record<string, string | string[]> }> = []
if (id) {
strategies.push({ path: "/file/get", body: { ".id": id, "value-name": "contents" } })
strategies.push({ path: "/file/get", body: { ".id": id, ".proplist": "contents" } })
}
if (name) {
strategies.push({ path: "/file/print", body: { ".proplist": "contents", ".query": [`name=${name}`] } })
}
if (strategies.length === 0) throw new Error("не задан файл для чтения")
let lastError: unknown
for (const strategy of strategies) {
try {
const raw = await rosRawRequest(this.params, {
method: "POST",
path: strategy.path,
body: strategy.body,
timeoutMs,
})
const content = extractRosContentsField(raw)
if (content.length > 0) return content
lastError = new Error("RouterOS вернул пустое содержимое файла")
} catch (error) {
lastError = error
}
}
throw lastError instanceof Error ? lastError : new Error("не удалось прочитать содержимое файла")
}
try {
return await readOnce(target.id, target.name)
} catch (error) {
// `.id` мог устареть (файл пересоздан) — пробуем один раз найти его заново по имени.
if (!target.name) throw error
const fresh = await this.findFileEntry(target.name)
if (!fresh || fresh.id === target.id) throw error
return readOnce(fresh.id, fresh.name)
}
}
/** Скачивание содержимого файла 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 +926,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 +962,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,90 @@
import assert from "node:assert/strict"
import { extractRosContentsField } from "./mikrotik.js"
/** Экранирование строки ответа RouterOS: только кавычка и обратный слэш. */
function escapeRosString(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.from(escaped)
}
/** Ответ команды get: `{"ret":"<содержимое>"}` (!done с данными). */
function rosGetResponse(contents: Buffer): Buffer {
return Buffer.concat([
Buffer.from('{"ret":"', "latin1"),
escapeRosString(contents),
Buffer.from('"}', "latin1"),
])
}
/** Ответ команды print: `[{".id":"*A","contents":"<содержимое>"}]` (!re-запись). */
function rosPrintResponse(contents: Buffer): Buffer {
return Buffer.concat([
Buffer.from('[{".id":"*A","contents":"', "latin1"),
escapeRosString(contents),
Buffer.from('"}]', "latin1"),
])
}
{
// get: ASCII-содержимое
const content = Buffer.from("hello p12", "latin1")
assert.deepEqual(extractRosContentsField(rosGetResponse(content)), content)
}
{
// print: ASCII-содержимое (обратная совместимость)
const content = Buffer.from("hello p12", "latin1")
assert.deepEqual(extractRosContentsField(rosPrintResponse(content)), content)
}
{
// get: экранированные кавычки, обратный слэш и \u-escape
const content = Buffer.from('a"b\\c"d', "latin1")
assert.deepEqual(extractRosContentsField(rosGetResponse(content)), content)
assert.deepEqual(
extractRosContentsField(Buffer.from('{"ret":"a\\nb\\u0041"}', "latin1")),
Buffer.from("a\nbA", "latin1"),
)
}
{
// print: экранированные кавычки, обратный слэш и \u-escape
assert.deepEqual(
extractRosContentsField(Buffer.from('[{"contents":"a\\"b\\\\c\\u0044"}]', "latin1")),
Buffer.from('a"b\\cD', "latin1"),
)
}
{
// Пустой файл — пустой Buffer
assert.equal(extractRosContentsField(rosGetResponse(Buffer.alloc(0))).length, 0)
assert.equal(extractRosContentsField(rosPrintResponse(Buffer.alloc(0))).length, 0)
}
{
// Все 256 байт: single-byte кодировка не должна терять значения 0x80–0xFF и NUL
const content = Buffer.from(Array.from({ length: 256 }, (_, i) => i))
for (const raw of [rosGetResponse(content), rosPrintResponse(content)]) {
const decoded = extractRosContentsField(raw)
assert.equal(decoded.length, 256)
assert.deepEqual(decoded, content)
}
}
{
// Нет полей ret/contents — понятная ошибка с превью ответа
assert.throws(
() => extractRosContentsField(Buffer.from('{".id":"*A"}', "latin1")),
/не содержит поле ret\/contents/,
)
assert.throws(
() => extractRosContentsField(Buffer.from('{"error":400,"detail":"Bad Request"}', "latin1")),
/Bad Request/,
)
}
console.log("ros-file-contents.test.ts: ok")
+16 -4
View File
@@ -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"
+54 -22
View File
@@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
DownloadIcon,
GlobeIcon,
KeyRoundIcon,
NetworkIcon,
@@ -91,9 +92,11 @@ function PeerRow({
function CertRow({
cert,
onDelete,
onExport,
}: {
cert: IpsecCertInfoDto
onDelete?: (cert: IpsecCertInfoDto) => void
onExport?: (cert: IpsecCertInfoDto) => void
}) {
const roleLabel = cert.role === "ca" ? "CA" : cert.role === "server" ? "сервер" : cert.role === "client" ? "клиент" : "прочий"
return (
@@ -107,22 +110,41 @@ function CertRow({
{cert.trusted === false ? <Badge variant="outline" className="text-[10px]">не trusted</Badge> : null}
</div>
<p className="truncate font-mono text-[10px] text-muted-foreground">
{[cert.commonName, cert.expiresAt ? `до ${cert.expiresAt}` : undefined].filter(Boolean).join(" · ") || "—"}
{[
cert.commonName,
cert.signedBy ? `подписан ${cert.signedBy}` : undefined,
cert.expiresAt ? `до ${cert.expiresAt}` : undefined,
].filter(Boolean).join(" · ") || "—"}
</p>
</div>
{onDelete ? (
<Button
type="button"
size="icon"
variant="ghost"
className="size-7 shrink-0 text-destructive"
aria-label="Удалить сертификат"
title="Удалить сертификат"
onClick={() => onDelete(cert)}
>
<Trash2Icon className="size-3.5" />
</Button>
) : null}
<div className="flex shrink-0 gap-0.5">
{onExport && cert.role === "client" ? (
<Button
type="button"
size="icon"
variant="ghost"
className="size-7"
aria-label="Скачать .p12"
title="Скачать .p12 (сертификат + ключ)"
onClick={() => onExport(cert)}
>
<DownloadIcon className="size-3.5" />
</Button>
) : null}
{onDelete ? (
<Button
type="button"
size="icon"
variant="ghost"
className="size-7 shrink-0 text-destructive"
aria-label="Удалить сертификат"
title="Удалить сертификат"
onClick={() => onDelete(cert)}
>
<Trash2Icon className="size-3.5" />
</Button>
) : null}
</div>
</div>
)
}
@@ -134,6 +156,7 @@ export interface IpsecServerGridProps {
onEditPeer?: (serverId: string, peer: IpsecPeerDto) => void
onDeletePeer?: (serverId: string, peer: IpsecPeerDto) => void
onDeleteCert?: (serverId: string, cert: IpsecCertInfoDto) => void
onExportCert?: (serverId: string, cert: IpsecCertInfoDto) => void
}
function IpsecServerGrid({
@@ -143,6 +166,7 @@ function IpsecServerGrid({
onEditPeer,
onDeletePeer,
onDeleteCert,
onExportCert,
}: IpsecServerGridProps) {
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
@@ -157,14 +181,21 @@ function IpsecServerGrid({
{s.serverEndpoint ?? "endpoint не определён"}
</p>
</div>
{s.peers.length > 0 ? (
<Badge className="gap-1">
<ShieldCheckIcon className="size-3" />
{s.peers.length} peer
</Badge>
) : (
<Badge variant="outline">нет peer</Badge>
)}
<div className="flex shrink-0 flex-col items-end gap-1">
{s.ike2Ready ? (
<Badge className="gap-1">
<ShieldCheckIcon className="size-3" />
IKEv2 готов
</Badge>
) : null}
{s.peers.length > 0 ? (
<Badge variant="outline" className="gap-1">
{s.peers.length} peer
</Badge>
) : (
<Badge variant="outline">нет peer</Badge>
)}
</div>
</div>
<div className="flex flex-col gap-1.5">
@@ -198,6 +229,7 @@ function IpsecServerGrid({
key={c.name}
cert={c}
onDelete={onDeleteCert ? (cert) => onDeleteCert(s.serverId, cert) : undefined}
onExport={onExportCert ? (cert) => onExportCert(s.serverId, cert) : undefined}
/>
))}
</div>
+20 -3
View File
@@ -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])
@@ -151,7 +154,13 @@ function IpsecUserSheet({
/>
</FormField>
{!editing ? (
<FormField label="Аутентификация" required>
<FormField
label="Аутентификация"
required
hint={form.authMethod === "certificate"
? "По умолчанию клиент подключается через общий IKEv2-peer — выпускается только отдельный клиентский сертификат (CA и серверный серт берутся из уже настроенного IKEv2, напр. MyCA + vpn-server)"
: undefined}
>
<div className="flex gap-1.5">
<Button
type="button"
@@ -195,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"
@@ -214,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)} />
+20 -9
View File
@@ -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
View File
@@ -25,6 +25,8 @@ export const certificateDtoSchema = z.object({
trusted: z.boolean(),
status: certStatusSchema,
acmeStatus: z.string().optional(),
/** Имя CA, которым серт подписан на устройстве (read-only поле `ca` RouterOS). */
signedByCertName: z.string().optional(),
})
export const certificatesListResponseSchema = z.object({
+26 -3
View File
@@ -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 — из общего пула. */
@@ -77,6 +89,8 @@ export const ipsecCertInfoDtoSchema = z.object({
trusted: z.boolean().optional(),
hasPrivateKey: z.boolean().optional(),
role: z.enum(["ca", "server", "client", "other"]).optional(),
/** Имя CA, которым серт подписан на устройстве (read-only поле `ca` RouterOS). */
signedBy: z.string().optional(),
managed: z.boolean(),
})
@@ -87,6 +101,8 @@ export const ipsecServerSummaryDtoSchema = z.object({
serverCountry: z.string().optional(),
/** Managed-набор (CA + серверный серт + peer + mode-config) инициализирован. */
initialized: z.boolean(),
/** Существующий IKEv2-сервер пригоден: есть peer + CA + серверный серт. */
ike2Ready: z.boolean(),
serverEndpoint: z.string().optional(),
/** Primary managed peer (обратная совместимость); для полного списка — `peers`. */
peer: ipsecPeerDtoSchema.optional(),
@@ -156,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(),
})
@@ -173,7 +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(IPSEC_MIN_PASSPHRASE),
})
/** Бандл для авторизации клиента: .p12 (+ strongSwan .sswan + инструкция). */
@@ -209,5 +231,6 @@ export type IpsecInitRequest = z.infer<typeof ipsecInitRequestSchema>
export type IpsecUserCreateRequest = z.infer<typeof ipsecUserCreateRequestSchema>
export type IpsecUserPatch = z.infer<typeof ipsecUserPatchSchema>
export type IpsecCertExportRequest = z.infer<typeof ipsecCertExportRequestSchema>
export type IpsecCertExportByNameRequest = z.infer<typeof ipsecCertExportByNameRequestSchema>
export type IpsecCertBundle = z.infer<typeof ipsecCertBundleSchema>
export type IpsecUserCreated = z.infer<typeof ipsecUserCreatedSchema>
+13
View File
@@ -97,6 +97,19 @@ export async function exportIpsecUserCert(
)
}
export async function exportIpsecCertByName(
baseUrl: string,
serverId: string,
name: string,
passphrase: string,
): Promise<IpsecCertBundle> {
return requestJson<IpsecCertBundle>(
baseUrl,
`/api/ipsec/certs/${encodeURIComponent(serverId)}/export`,
{ method: "POST", body: JSON.stringify({ name, passphrase }) },
)
}
export async function restoreIpsecRevision(
baseUrl: string,
revisionId: string,