Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
752256f12e |
@@ -1,6 +1,7 @@
|
|||||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
import type { FastifyReply } from "fastify"
|
import type { FastifyReply } from "fastify"
|
||||||
import {
|
import {
|
||||||
|
IPSEC_MIN_PASSPHRASE,
|
||||||
ipsecCertDeleteRequestSchema,
|
ipsecCertDeleteRequestSchema,
|
||||||
ipsecCertExportByNameRequestSchema,
|
ipsecCertExportByNameRequestSchema,
|
||||||
ipsecCertExportRequestSchema,
|
ipsecCertExportRequestSchema,
|
||||||
@@ -97,6 +98,22 @@ function errReply(reply: FastifyReply, e: unknown) {
|
|||||||
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
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(
|
async function recordIpsec(
|
||||||
server: NonNullable<Awaited<ReturnType<typeof getEnabledIpsecServerById>>>,
|
server: NonNullable<Awaited<ReturnType<typeof getEnabledIpsecServerById>>>,
|
||||||
source: ConfigRevisionSource,
|
source: ConfigRevisionSource,
|
||||||
@@ -243,7 +260,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
app.post("/ipsec/users", async (req, reply) => {
|
app.post("/ipsec/users", async (req, reply) => {
|
||||||
const parsed = ipsecUserCreateRequestSchema.safeParse(req.body ?? {})
|
const parsed = ipsecUserCreateRequestSchema.safeParse(req.body ?? {})
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
return badBodyReply(reply, parsed.error)
|
||||||
}
|
}
|
||||||
const body = parsed.data
|
const body = parsed.data
|
||||||
const server = await getEnabledIpsecServerById(body.serverId)
|
const server = await getEnabledIpsecServerById(body.serverId)
|
||||||
@@ -608,7 +625,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||||||
const parsed = ipsecCertExportRequestSchema.safeParse({ ...(req.body as object), serverId, clientId: rosId })
|
const parsed = ipsecCertExportRequestSchema.safeParse({ ...(req.body as object), serverId, clientId: rosId })
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
return badBodyReply(reply, parsed.error)
|
||||||
}
|
}
|
||||||
const body = parsed.data
|
const body = parsed.data
|
||||||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||||||
@@ -667,7 +684,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
const { serverId } = req.params as { serverId: string }
|
const { serverId } = req.params as { serverId: string }
|
||||||
const parsed = ipsecCertExportByNameRequestSchema.safeParse(req.body ?? {})
|
const parsed = ipsecCertExportByNameRequestSchema.safeParse(req.body ?? {})
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
return badBodyReply(reply, parsed.error)
|
||||||
}
|
}
|
||||||
const { name, passphrase } = parsed.data
|
const { name, passphrase } = parsed.data
|
||||||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
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 { FormField, SectionTitle } from "@/components/form-kit"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -33,7 +33,8 @@ function downloadB64(filename: string, b64: string, mime: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function randomPassphrase(): string {
|
function randomPassphrase(): string {
|
||||||
const bytes = new Uint8Array(9)
|
// RouterOS требует ≥ IPSEC_MIN_PASSPHRASE символов
|
||||||
|
const bytes = new Uint8Array(IPSEC_MIN_PASSPHRASE + 1)
|
||||||
crypto.getRandomValues(bytes)
|
crypto.getRandomValues(bytes)
|
||||||
let s = ""
|
let s = ""
|
||||||
for (const b of bytes) s += "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"[b % 56]
|
for (const b of bytes) s += "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"[b % 56]
|
||||||
@@ -62,7 +63,10 @@ function IpsecCertSheet({
|
|||||||
queueMicrotask(() => setPassphrase(initial))
|
queueMicrotask(() => setPassphrase(initial))
|
||||||
}, [open, bundle])
|
}, [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 (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -83,7 +87,15 @@ function IpsecCertSheet({
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Пароль архива .p12</SectionTitle>
|
<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">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
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 { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -91,6 +91,9 @@ function IpsecUserSheet({
|
|||||||
if (!form.name.trim()) return false
|
if (!form.name.trim()) return false
|
||||||
if (!editing && form.authMethod === "pre-shared-key" && form.psk.trim().length < 8) 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
|
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
|
return true
|
||||||
}, [form, editing])
|
}, [form, editing])
|
||||||
|
|
||||||
@@ -201,7 +204,14 @@ function IpsecUserSheet({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
!editing ? (
|
!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
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
placeholder="например MySecret123"
|
placeholder="например MySecret123"
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { z } from "zod"
|
|||||||
|
|
||||||
export const ipsecAuthMethodSchema = z.enum(["certificate", "pre-shared-key"])
|
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). */
|
/** Клиент IKEv2 — /ip/ipsec/identity (+ опциональный персональный mode-config). */
|
||||||
export const ipsecClientDtoSchema = z.object({
|
export const ipsecClientDtoSchema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
@@ -166,8 +172,8 @@ export const ipsecUserCreateRequestSchema = z.object({
|
|||||||
remoteId: z.string().optional(),
|
remoteId: z.string().optional(),
|
||||||
/** Конкретный IP клиента; без — выдаётся из пула. */
|
/** Конкретный IP клиента; без — выдаётся из пула. */
|
||||||
staticIp: z.string().optional(),
|
staticIp: z.string().optional(),
|
||||||
/** Пароль на экспортируемый .p12. */
|
/** Пароль на экспортируемый .p12 (RouterOS требует ≥ IPSEC_MIN_PASSPHRASE). */
|
||||||
passphrase: z.string().min(4).optional(),
|
passphrase: z.string().min(IPSEC_MIN_PASSPHRASE).optional(),
|
||||||
daysValid: z.number().int().positive().optional(),
|
daysValid: z.number().int().positive().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -183,13 +189,13 @@ export const ipsecUserPatchSchema = z.object({
|
|||||||
export const ipsecCertExportRequestSchema = z.object({
|
export const ipsecCertExportRequestSchema = z.object({
|
||||||
serverId: z.union([z.string(), z.number()]),
|
serverId: z.union([z.string(), z.number()]),
|
||||||
clientId: z.string().min(1),
|
clientId: z.string().min(1),
|
||||||
passphrase: z.string().min(4),
|
passphrase: z.string().min(IPSEC_MIN_PASSPHRASE),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Экспорт .p12 существующего клиентского сертификата по имени (client1/anakondra и т.п.). */
|
/** Экспорт .p12 существующего клиентского сертификата по имени (client1/anakondra и т.п.). */
|
||||||
export const ipsecCertExportByNameRequestSchema = z.object({
|
export const ipsecCertExportByNameRequestSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
passphrase: z.string().min(4),
|
passphrase: z.string().min(IPSEC_MIN_PASSPHRASE),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Бандл для авторизации клиента: .p12 (+ strongSwan .sswan + инструкция). */
|
/** Бандл для авторизации клиента: .p12 (+ strongSwan .sswan + инструкция). */
|
||||||
|
|||||||
Reference in New Issue
Block a user