feat(ipsec): управление IKEv2/IPsec VPN и клиентами из одного окна
Docker images / prepare-release (push) Successful in 15s
Docker images / backend-test (push) Successful in 2m32s
Docker images / frontend-image (push) Successful in 4m19s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 8s
Docker images / prepare-release (push) Successful in 15s
Docker images / backend-test (push) Successful in 2m32s
Docker images / frontend-image (push) Successful in 4m19s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 8s
- мастер инициализации сервера: CA и серверный сертификаты, peer/profile/proposal, пул, mode-config, policy-template, managed NAT masquerade - клиенты по сертификату (RSA) и PSK: статический IP или из пула, онлайн-статус по active-peers - скачивание .p12 и strongSwan .sswan с инструкцией, перекачка с новой passphrase - история изменений (config_revisions, секция ipsec) и restore только managed-объектов - привязка IPsec-клиентов к пользователям приложения по Common Name - страница /ipsec с KPI и вкладками Клиенты/Сервер/CLI, сайдбар, command palette
This commit is contained in:
1 parent
564aae21f0
commit
7755d77340
35 files changed
+4154
-13
No files matched your search
@@ -267,6 +267,57 @@ function rosDelete(
|
||||
})
|
||||
}
|
||||
|
||||
/** GET бинарного содержимого (файлы RouterOS): без utf8-декодирования, JSON-ответ = ошибка. */
|
||||
function rosDownload(
|
||||
params: MikrotikConnectParams,
|
||||
path: 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 options: https.RequestOptions = {
|
||||
hostname: params.host,
|
||||
port: params.port,
|
||||
path: basePath + path,
|
||||
method: "GET",
|
||||
headers: { Authorization: authHeader },
|
||||
rejectUnauthorized: params.useSsl ? params.verifySsl : undefined,
|
||||
}
|
||||
|
||||
const lib = params.useSsl ? https : http
|
||||
|
||||
const req = lib.request(options, (res) => {
|
||||
const chunks: Buffer[] = []
|
||||
res.on("data", (chunk: Buffer) => { chunks.push(chunk) })
|
||||
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}`))
|
||||
return
|
||||
}
|
||||
resolve(buf)
|
||||
})
|
||||
})
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
|
||||
}, timeoutMs)
|
||||
req.on("close", () => clearTimeout(timer))
|
||||
req.on("error", (err) => {
|
||||
clearTimeout(timer)
|
||||
reject(err)
|
||||
})
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
function rosPatch(
|
||||
params: MikrotikConnectParams,
|
||||
path: string,
|
||||
@@ -621,6 +672,92 @@ 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) {
|
||||
try {
|
||||
return await rosDownload(this.params, `/file/${encodeURIComponent(name)}`, timeoutMs)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error(`Не удалось скачать файл ${normalized} с RouterOS`)
|
||||
}
|
||||
|
||||
/** Создание ключевой пары + заявки: /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)
|
||||
}
|
||||
|
||||
/** Подпись сертификата локальным CA; sign небыстрый — увеличенный таймаут. */
|
||||
async signCertificate(params: {
|
||||
name: string
|
||||
ca?: string
|
||||
daysValid?: number
|
||||
}, timeoutMs = 120_000): Promise<unknown> {
|
||||
const body: Record<string, string> = { name: params.name }
|
||||
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)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
/** Экспорт сертификата в файл на роутере (pkcs12/pem); возвращает имя созданного файла. */
|
||||
async exportCertificate(params: {
|
||||
name: string
|
||||
type: "pkcs12" | "pem"
|
||||
passphrase?: string
|
||||
}, timeoutMs = 60_000): Promise<string> {
|
||||
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)
|
||||
} 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)
|
||||
}
|
||||
void raw
|
||||
// RouterOS создаёт cert_export_<name>.p12 либо <name>.p12 — ищем по списку файлов.
|
||||
const ext = params.type === "pkcs12" ? "p12" : "crt"
|
||||
const wanted = [`${params.name}.${ext}`, `cert_export_${params.name}.${ext}`]
|
||||
const files = await this.listFiles()
|
||||
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
|
||||
}
|
||||
|
||||
/** Скачивание .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 removeCertificate(nameOrId: string, timeoutMs = 30_000): Promise<void> {
|
||||
const certs = await this.getCertificates()
|
||||
const row = certs.find((c) => String(c.name ?? "") === nameOrId || c[".id"] === nameOrId)
|
||||
const id = row?.[".id"]
|
||||
if (!id) return
|
||||
await this.delete(`/certificate/${encodeURIComponent(id)}`, timeoutMs)
|
||||
}
|
||||
|
||||
private async patchIpService(serviceName: string, body: Record<string, string>): Promise<void> {
|
||||
const pathByName = `/ip/service/${encodeURIComponent(serviceName)}`
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user