fix(backend): улучшить обработку сертификатов и добавить поддержку PUT запросов
This commit is contained in:
@@ -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(","),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user