diff --git a/backend/src/services/acme-cloudflare.ts b/backend/src/services/acme-cloudflare.ts index 2ea0ba0..5aab41d 100644 --- a/backend/src/services/acme-cloudflare.ts +++ b/backend/src/services/acme-cloudflare.ts @@ -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 { } async function resolveZoneId(token: string, domain: string, defaultZoneId?: string): Promise { - 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 { return key } +async function ensureAcmeAccount(client: acme.Client): Promise { + 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(","), diff --git a/backend/src/services/mikrotik.ts b/backend/src/services/mikrotik.ts index 9cfe357..6a69c4a 100644 --- a/backend/src/services/mikrotik.ts +++ b/backend/src/services/mikrotik.ts @@ -156,6 +156,62 @@ function rosPost( }) } +function rosPut( + params: MikrotikConnectParams, + path: string, + body: Record, + timeoutMs: number, +): Promise { + 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(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 } + async put(path: string, body: Record, timeoutMs = 15_000): Promise { + return rosPut(this.params, path, body, timeoutMs) as Promise + } + async delete(path: string, timeoutMs = 10_000): Promise { return rosDelete(this.params, path, timeoutMs) } @@ -437,8 +509,49 @@ export class MikrotikClient { return raw.filter((row): row is Record => row != null && typeof row === "object") } - async uploadTextFile(fileName: string, contents: string, timeoutMs = 30_000): Promise { - await this.post("/file", { name: fileName, contents }, timeoutMs) + async listFiles(): Promise> { + const raw = await this.get("/file") + if (!Array.isArray(raw)) return [] + return raw + .filter((row): row is Record => row != null && typeof row === "object") + .map((row) => ({ name: String(row.name ?? "") })) + .filter((row) => row.name.length > 0) + } + + private async resolveUploadedFileName(requested: string): Promise { + 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 { + 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: {