diff --git a/apps/dokploy/__test__/traefik/certificate.test.ts b/apps/dokploy/__test__/traefik/certificate.test.ts new file mode 100644 index 0000000000..3d8112c03f --- /dev/null +++ b/apps/dokploy/__test__/traefik/certificate.test.ts @@ -0,0 +1,39 @@ +import path from "node:path"; +import { getCertificateConfigPath } from "@dokploy/server/services/certificate"; +import { describe, expect, test } from "vitest"; + +describe("getCertificateConfigPath", () => { + const dynamicTraefikPath = path.join("/etc", "dokploy", "traefik", "dynamic"); + + test("writes the certificate config at the TOP LEVEL of the dynamic dir", () => { + const certificatePath = "my-cert"; + const result = getCertificateConfigPath(dynamicTraefikPath, certificatePath); + + // Must be the top-level file, NOT nested under a per-cert subdirectory. + expect(result).toBe( + path.join(dynamicTraefikPath, `${certificatePath}-certificate.yml`), + ); + }); + + test("does NOT place the config inside the certificates/ subdirectory", () => { + const result = getCertificateConfigPath(dynamicTraefikPath, "my-cert"); + + // Traefik's file.directory provider is non-recursive, so the config must + // not live under a subdirectory like /certificates//. + expect(result).not.toContain(`${path.sep}certificates${path.sep}`); + expect(path.dirname(result)).toBe(dynamicTraefikPath); + }); + + test("produces a unique filename per certificatePath", () => { + const a = getCertificateConfigPath(dynamicTraefikPath, "cert-a"); + const b = getCertificateConfigPath(dynamicTraefikPath, "cert-b"); + + expect(a).not.toBe(b); + expect(a).toBe( + path.join(dynamicTraefikPath, "cert-a-certificate.yml"), + ); + expect(b).toBe( + path.join(dynamicTraefikPath, "cert-b-certificate.yml"), + ); + }); +}); diff --git a/packages/server/src/services/certificate.ts b/packages/server/src/services/certificate.ts index aa5c3983c2..0e02b27f06 100644 --- a/packages/server/src/services/certificate.ts +++ b/packages/server/src/services/certificate.ts @@ -16,6 +16,19 @@ import { execAsyncRemote } from "../utils/process/execAsync"; export type Certificate = typeof certificates.$inferSelect; +/** + * Returns the path to the per-certificate Traefik registration YAML. + * + * The file MUST live at the TOP LEVEL of the dynamic Traefik directory, because + * Traefik's `file.directory` provider is non-recursive and never loads files + * nested in subdirectories. The PEM files referenced by this YAML can still live + * in a per-certificate subdirectory since they are referenced by absolute path. + */ +export const getCertificateConfigPath = ( + dynamicTraefikPath: string, + certificatePath: string, +) => path.join(dynamicTraefikPath, `${certificatePath}-certificate.yml`); + export const findCertificateById = async (certificateId: string) => { const certificate = await db.query.certificates.findFirst({ where: eq(certificates.certificateId, certificateId), @@ -59,13 +72,25 @@ export const createCertificate = async ( export const removeCertificateById = async (certificateId: string) => { const certificate = await findCertificateById(certificateId); - const { CERTIFICATES_PATH } = paths(!!certificate.serverId); + const { CERTIFICATES_PATH, DYNAMIC_TRAEFIK_PATH } = paths( + !!certificate.serverId, + ); const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath); + const configFile = getCertificateConfigPath( + DYNAMIC_TRAEFIK_PATH, + certificate.certificatePath, + ); if (certificate.serverId) { - await execAsyncRemote(certificate.serverId, `rm -rf ${certDir}`); + await execAsyncRemote( + certificate.serverId, + `rm -rf "${certDir}"; rm -f "${configFile}"`, + ); } else { await removeDirectoryIfExistsContent(certDir); + if (fs.existsSync(configFile)) { + fs.rmSync(configFile); + } } const result = await db @@ -84,7 +109,9 @@ export const removeCertificateById = async (certificateId: string) => { }; const createCertificateFiles = async (certificate: Certificate) => { - const { CERTIFICATES_PATH } = paths(!!certificate.serverId); + const { CERTIFICATES_PATH, DYNAMIC_TRAEFIK_PATH } = paths( + !!certificate.serverId, + ); const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath); const crtPath = path.join(certDir, "chain.crt"); const keyPath = path.join(certDir, "privkey.key"); @@ -102,13 +129,19 @@ const createCertificateFiles = async (certificate: Certificate) => { }, }; const yamlConfig = stringify(traefikConfig); - const configFile = path.join(certDir, "certificate.yml"); + // The registration YAML must live at the top level of the dynamic Traefik + // directory; Traefik's file.directory provider does not recurse into the + // per-certificate subdirectory where the PEM files live. + const configFile = getCertificateConfigPath( + DYNAMIC_TRAEFIK_PATH, + certificate.certificatePath, + ); if (certificate.serverId) { const certificateData = encodeBase64(certificate.certificateData); const privateKey = encodeBase64(certificate.privateKey); const command = ` - mkdir -p ${certDir}; + mkdir -p "${certDir}"; echo "${certificateData}" | base64 -d > "${crtPath}"; echo "${privateKey}" | base64 -d > "${keyPath}"; echo "${yamlConfig}" > "${configFile}";