fix(backend): улучшить обработку сертификатов и добавить поддержку PUT запросов
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
import { createHash } from "node:crypto"
|
|
||||||
import * as acme from "acme-client"
|
import * as acme from "acme-client"
|
||||||
import type { Server } from "../db/schema.js"
|
import type { Server } from "../db/schema.js"
|
||||||
import {
|
import {
|
||||||
@@ -38,7 +37,6 @@ export async function testCloudflareToken(token: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveZoneId(token: string, domain: string, defaultZoneId?: string): Promise<string> {
|
async function resolveZoneId(token: string, domain: string, defaultZoneId?: string): Promise<string> {
|
||||||
if (defaultZoneId?.trim()) return defaultZoneId.trim()
|
|
||||||
const labels = domain.split(".").filter(Boolean)
|
const labels = domain.split(".").filter(Boolean)
|
||||||
for (let i = 0; i < labels.length - 1; i++) {
|
for (let i = 0; i < labels.length - 1; i++) {
|
||||||
const guess = labels.slice(i).join(".")
|
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 (zones[0]?.id) return zones[0].id
|
||||||
}
|
}
|
||||||
|
if (defaultZoneId?.trim()) return defaultZoneId.trim()
|
||||||
throw new Error(`Не удалось определить зону Cloudflare для ${domain}`)
|
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" })
|
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) {
|
async function sleep(ms: number) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, ms))
|
await new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
@@ -89,6 +84,15 @@ async function getOrCreateAccountKey(): Promise<Buffer> {
|
|||||||
return key
|
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: {
|
export async function issueCertificateWithCloudflareDns(params: {
|
||||||
server: Server
|
server: Server
|
||||||
certName: string
|
certName: string
|
||||||
@@ -110,6 +114,7 @@ export async function issueCertificateWithCloudflareDns(params: {
|
|||||||
directoryUrl: settings.directoryUrl,
|
directoryUrl: settings.directoryUrl,
|
||||||
accountKey,
|
accountKey,
|
||||||
})
|
})
|
||||||
|
await ensureAcmeAccount(client)
|
||||||
|
|
||||||
const altNames = domains.slice(1)
|
const altNames = domains.slice(1)
|
||||||
const privateKey = params.keyType === "ec256"
|
const privateKey = params.keyType === "ec256"
|
||||||
@@ -129,11 +134,10 @@ export async function issueCertificateWithCloudflareDns(params: {
|
|||||||
for (const authz of authorizations) {
|
for (const authz of authorizations) {
|
||||||
const challenge = authz.challenges.find((c) => c.type === "dns-01")
|
const challenge = authz.challenges.find((c) => c.type === "dns-01")
|
||||||
if (!challenge) throw new Error(`Нет DNS-01 challenge для ${authz.identifier.value}`)
|
if (!challenge) throw new Error(`Нет DNS-01 challenge для ${authz.identifier.value}`)
|
||||||
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge)
|
const txtValue = await client.getChallengeKeyAuthorization(challenge)
|
||||||
const digest = dns01Digest(keyAuthorization)
|
|
||||||
const zoneId = await resolveZoneId(token, authz.identifier.value, settings.defaultZoneId)
|
const zoneId = await resolveZoneId(token, authz.identifier.value, settings.defaultZoneId)
|
||||||
const recordName = `_acme-challenge.${authz.identifier.value}`
|
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 })
|
txtCleanups.push({ zoneId, recordId })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,16 +160,16 @@ export async function issueCertificateWithCloudflareDns(params: {
|
|||||||
const safeBase = params.certName.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
const safeBase = params.certName.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
||||||
const certFile = `${safeBase}.crt`
|
const certFile = `${safeBase}.crt`
|
||||||
const keyFile = `${safeBase}.key`
|
const keyFile = `${safeBase}.key`
|
||||||
await clientRos.uploadTextFile(certFile, certPem)
|
const certRouterFile = await clientRos.uploadTextFile(certFile, certPem)
|
||||||
await clientRos.uploadTextFile(keyFile, privateKey.toString("utf8"))
|
const keyRouterFile = await clientRos.uploadTextFile(keyFile, privateKey.toString("utf8"))
|
||||||
await clientRos.importCertificate({
|
await clientRos.importCertificate({
|
||||||
fileName: certFile,
|
fileName: certRouterFile,
|
||||||
name: params.certName,
|
name: params.certName,
|
||||||
trusted: true,
|
trusted: true,
|
||||||
trustStore: params.trustStore.join(","),
|
trustStore: params.trustStore.join(","),
|
||||||
})
|
})
|
||||||
await clientRos.importCertificate({
|
await clientRos.importCertificate({
|
||||||
fileName: keyFile,
|
fileName: keyRouterFile,
|
||||||
name: params.certName,
|
name: params.certName,
|
||||||
trusted: true,
|
trusted: true,
|
||||||
trustStore: params.trustStore.join(","),
|
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(
|
function rosDelete(
|
||||||
params: MikrotikConnectParams,
|
params: MikrotikConnectParams,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -270,6 +326,18 @@ function unwrapRestSingle<T extends object>(raw: unknown): T {
|
|||||||
throw new Error("RouterOS REST: неожиданное тело ответа")
|
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 ─────────────────────────────────────────────────────────────
|
// ── MikrotikClient ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class MikrotikClient {
|
export class MikrotikClient {
|
||||||
@@ -295,6 +363,10 @@ export class MikrotikClient {
|
|||||||
return rosPost(this.params, path, body, timeoutMs, signal) as Promise<T>
|
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> {
|
async delete(path: string, timeoutMs = 10_000): Promise<void> {
|
||||||
return rosDelete(this.params, path, timeoutMs)
|
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")
|
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> {
|
async listFiles(): Promise<Array<{ name: string }>> {
|
||||||
await this.post("/file", { name: fileName, contents }, timeoutMs)
|
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: {
|
async importCertificate(params: {
|
||||||
|
|||||||
Reference in New Issue
Block a user