From 3384de3fb47948fc8a0bdf7d6bc116169e5e23c3 Mon Sep 17 00:00:00 2001 From: ravindu0823 Date: Tue, 9 Jun 2026 14:39:16 +0530 Subject: [PATCH 1/2] fix(traefik): load uploaded custom certificates from top-level dynamic dir Traefik's file.directory provider (directory: /etc/dokploy/traefik/dynamic, watch: true) is non-recursive, so the per-certificate registration YAML written to certificates//certificate.yml was never loaded and uploaded custom certificates were never presented over SNI. Write the registration YAML to the TOP LEVEL of the dynamic Traefik directory as -certificate.yml via a new exported pure helper getCertificateConfigPath. The PEM files (chain.crt/privkey.key) stay in the per-certificate subdirectory since the YAML references them by absolute path. removeCertificateById now also deletes the top-level YAML in addition to the subdirectory. Fixes #4503 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__test__/traefik/certificate.test.ts | 39 ++++++++++++++++++ packages/server/src/services/certificate.ts | 41 +++++++++++++++++-- 2 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 apps/dokploy/__test__/traefik/certificate.test.ts 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..44a9194385 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,7 +129,13 @@ 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); From f891de1c2c54435fe7287dfac742090a71b83dda Mon Sep 17 00:00:00 2001 From: ravindu0823 Date: Tue, 9 Jun 2026 14:58:43 +0530 Subject: [PATCH 2/2] fix: quote interpolated paths in remote certificate shell commands Address review: certificatePath is user-supplied (apiCreateCertificate does not omit it), so interpolating it unquoted into the remote shell commands widened a pre-existing injection/word-splitting surface. Quote ${certDir} and ${configFile} in the create (mkdir) and remove (rm) commands, matching the already-quoted PEM path redirects. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/server/src/services/certificate.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server/src/services/certificate.ts b/packages/server/src/services/certificate.ts index 44a9194385..0e02b27f06 100644 --- a/packages/server/src/services/certificate.ts +++ b/packages/server/src/services/certificate.ts @@ -84,7 +84,7 @@ export const removeCertificateById = async (certificateId: string) => { if (certificate.serverId) { await execAsyncRemote( certificate.serverId, - `rm -rf ${certDir}; rm -f ${configFile}`, + `rm -rf "${certDir}"; rm -f "${configFile}"`, ); } else { await removeDirectoryIfExistsContent(certDir); @@ -141,7 +141,7 @@ const createCertificateFiles = async (certificate: Certificate) => { 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}";