fix(backend): улучшить обработку сертификатов и добавить поддержку PUT запросов

This commit is contained in:
Denozordec
2026-05-12 20:29:06 +07:00
parent 53ea0e74a3
commit 60024facab
2 changed files with 132 additions and 15 deletions
+17 -13
View File
@@ -1,4 +1,3 @@
import { createHash } from "node:crypto"
import * as acme from "acme-client"
import type { Server } from "../db/schema.js"
import {
@@ -38,7 +37,6 @@ export async function testCloudflareToken(token: string): Promise<void> {
}
async function resolveZoneId(token: string, domain: string, defaultZoneId?: string): Promise<string> {
if (defaultZoneId?.trim()) return defaultZoneId.trim()
const labels = domain.split(".").filter(Boolean)
for (let i = 0; i < labels.length - 1; i++) {
const guess = labels.slice(i).join(".")
@@ -48,6 +46,7 @@ async function resolveZoneId(token: string, domain: string, defaultZoneId?: stri
)
if (zones[0]?.id) return zones[0].id
}
if (defaultZoneId?.trim()) return defaultZoneId.trim()
throw new Error(`Не удалось определить зону Cloudflare для ${domain}`)
}
@@ -73,10 +72,6 @@ async function deleteTxtRecord(token: string, zoneId: string, recordId: string):
await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${recordId}`, { method: "DELETE" })
}
function dns01Digest(keyAuthorization: string): string {
return createHash("sha256").update(keyAuthorization).digest("base64url")
}
async function sleep(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -89,6 +84,15 @@ async function getOrCreateAccountKey(): Promise<Buffer> {
return key
}
async function ensureAcmeAccount(client: acme.Client): Promise<void> {
try {
client.getAccountUrl()
return
} catch {
await client.createAccount({ termsOfServiceAgreed: true })
}
}
export async function issueCertificateWithCloudflareDns(params: {
server: Server
certName: string
@@ -110,6 +114,7 @@ export async function issueCertificateWithCloudflareDns(params: {
directoryUrl: settings.directoryUrl,
accountKey,
})
await ensureAcmeAccount(client)
const altNames = domains.slice(1)
const privateKey = params.keyType === "ec256"
@@ -129,11 +134,10 @@ export async function issueCertificateWithCloudflareDns(params: {
for (const authz of authorizations) {
const challenge = authz.challenges.find((c) => c.type === "dns-01")
if (!challenge) throw new Error(`Нет DNS-01 challenge для ${authz.identifier.value}`)
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge)
const digest = dns01Digest(keyAuthorization)
const txtValue = await client.getChallengeKeyAuthorization(challenge)
const zoneId = await resolveZoneId(token, authz.identifier.value, settings.defaultZoneId)
const recordName = `_acme-challenge.${authz.identifier.value}`
const recordId = await createTxtRecord(token, zoneId, recordName, digest)
const recordId = await createTxtRecord(token, zoneId, recordName, txtValue)
txtCleanups.push({ zoneId, recordId })
}
@@ -156,16 +160,16 @@ export async function issueCertificateWithCloudflareDns(params: {
const safeBase = params.certName.replace(/[^a-zA-Z0-9._-]+/g, "_")
const certFile = `${safeBase}.crt`
const keyFile = `${safeBase}.key`
await clientRos.uploadTextFile(certFile, certPem)
await clientRos.uploadTextFile(keyFile, privateKey.toString("utf8"))
const certRouterFile = await clientRos.uploadTextFile(certFile, certPem)
const keyRouterFile = await clientRos.uploadTextFile(keyFile, privateKey.toString("utf8"))
await clientRos.importCertificate({
fileName: certFile,
fileName: certRouterFile,
name: params.certName,
trusted: true,
trustStore: params.trustStore.join(","),
})
await clientRos.importCertificate({
fileName: keyFile,
fileName: keyRouterFile,
name: params.certName,
trusted: true,
trustStore: params.trustStore.join(","),
+115 -2
View File
@@ -156,6 +156,62 @@ function rosPost(
})
}
function rosPut(
params: MikrotikConnectParams,
path: string,
body: Record<string, string>,
timeoutMs: number,
): Promise<unknown> {
return new Promise((resolve, reject) => {
const basePath = params.apiPath ?? "/rest"
const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64")
const payload = JSON.stringify(body)
const options: https.RequestOptions = {
hostname: params.host,
port: params.port,
path: basePath + path,
method: "PUT",
headers: {
Authorization: authHeader,
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
rejectUnauthorized: params.useSsl ? params.verifySsl : undefined,
}
const lib = params.useSsl ? https : http
const timer = setTimeout(() => {
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
}, timeoutMs)
const req = lib.request(options, (res) => {
let buf = ""
res.setEncoding("utf8")
res.on("data", (chunk: string) => { buf += chunk })
res.on("end", () => {
clearTimeout(timer)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, buf))
return
}
try {
resolve(buf.trim() ? JSON.parse(buf) : {})
} catch {
reject(new Error(`Invalid JSON from RouterOS: ${buf.slice(0, 200)}`))
}
})
})
req.on("error", (err) => {
clearTimeout(timer)
reject(err)
})
req.write(payload)
req.end()
})
}
function rosDelete(
params: MikrotikConnectParams,
path: string,
@@ -270,6 +326,18 @@ function unwrapRestSingle<T extends object>(raw: unknown): T {
throw new Error("RouterOS REST: неожиданное тело ответа")
}
function routerFileBasename(fileName: string): string {
return fileName.replace(/^\/+/, "").split("/").pop() ?? fileName
}
function matchesUploadedFile(entryName: string, requested: string): boolean {
const base = routerFileBasename(requested)
return entryName === requested
|| entryName === base
|| entryName === `flash/${base}`
|| entryName.endsWith(`/${base}`)
}
// ── MikrotikClient ─────────────────────────────────────────────────────────────
export class MikrotikClient {
@@ -295,6 +363,10 @@ export class MikrotikClient {
return rosPost(this.params, path, body, timeoutMs, signal) as Promise<T>
}
async put<T>(path: string, body: Record<string, string>, timeoutMs = 15_000): Promise<T> {
return rosPut(this.params, path, body, timeoutMs) as Promise<T>
}
async delete(path: string, timeoutMs = 10_000): Promise<void> {
return rosDelete(this.params, path, timeoutMs)
}
@@ -437,8 +509,49 @@ export class MikrotikClient {
return raw.filter((row): row is Record<string, string | undefined> => row != null && typeof row === "object")
}
async uploadTextFile(fileName: string, contents: string, timeoutMs = 30_000): Promise<void> {
await this.post("/file", { name: fileName, contents }, timeoutMs)
async listFiles(): Promise<Array<{ name: string }>> {
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)
}
private async resolveUploadedFileName(requested: string): Promise<string> {
const files = await this.listFiles()
const match = files.find((file) => matchesUploadedFile(file.name, requested))
if (!match) {
throw new Error(`Файл ${routerFileBasename(requested)} не найден на RouterOS после загрузки`)
}
return match.name
}
async uploadTextFile(fileName: string, contents: string, timeoutMs = 30_000): Promise<string> {
const normalized = routerFileBasename(fileName)
const payload = contents.endsWith("\n") ? contents : `${contents}\n`
const candidates = [`flash/${normalized}`, normalized]
let lastError: unknown
for (const name of candidates) {
try {
await this.put("/file", { name, contents: payload }, timeoutMs)
return await this.resolveUploadedFileName(name)
} catch (error) {
lastError = error
}
try {
await this.post("/file/add", { name, contents: payload, type: "file" }, timeoutMs)
return await this.resolveUploadedFileName(name)
} catch (error) {
lastError = error
}
}
throw lastError instanceof Error
? lastError
: new Error(`Не удалось загрузить файл ${normalized} на RouterOS`)
}
async importCertificate(params: {