Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/dokploy/__test__/traefik/certificate.test.ts
Original file line number Diff line number Diff line change
@@ -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/<id>/.
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"),
);
});
});
43 changes: 38 additions & 5 deletions packages/server/src/services/certificate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand All @@ -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");
Expand All @@ -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}";
Expand Down