Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d4dbeb2
fix(traefik): override entrypoint certResolver for non-letsencrypt do…
onlyilkr Aug 3, 2026
448900e
fix(traefik): emit tls=true for custom certificates without a resolver
onlyilkr Aug 3, 2026
301c15b
feat(traefik): add helper to remove certificates from the ACME store
onlyilkr Aug 3, 2026
ca1af29
fix(traefik): remove the stale ACME certificate when a domain leaves …
onlyilkr Aug 3, 2026
1db7ddb
fix(traefik): reconcile router TLS config for existing domains on sta…
onlyilkr Aug 3, 2026
6c2f646
test(traefik): cover startup TLS reconciliation behaviour
onlyilkr Aug 3, 2026
2b6e3db
fix(traefik): write acme.json atomically when purging certificates
onlyilkr Aug 4, 2026
efc8103
refactor(traefik): move purgeStaleCertificate into the server package
onlyilkr Aug 4, 2026
c72f652
fix(traefik): guard and reorder the startup TLS reconciliation
onlyilkr Aug 4, 2026
a1b20e5
feat(ui): tell the user when Traefik must be restarted after a cert c…
onlyilkr Aug 4, 2026
1348e92
test(traefik): split the reconciliation test per behaviour
onlyilkr Aug 4, 2026
40816d3
fix(ui): show the Traefik restart hint inside the success toast
onlyilkr Aug 4, 2026
2f47efd
fix(traefik): serialise ACME purges per server
onlyilkr Aug 4, 2026
bc84642
fix(traefik): stop a failed reconciliation from aborting startup
onlyilkr Aug 4, 2026
d46f6e4
fix(traefik): re-check acme.json before swapping the purged store in
onlyilkr Aug 4, 2026
9db157c
fix(traefik): purge stale acme.json entries independent of router state
onlyilkr Aug 6, 2026
b32617f
Merge branch 'canary' into fix/4949-certificate-none-letsencrypt
onlyilkr Aug 16, 2026
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
16 changes: 16 additions & 0 deletions apps/dokploy/__test__/compose/domain/labels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,4 +544,20 @@ describe("createDomainLabels", () => {
// Should not contain redirect-to-https since there's only one router
expect(middlewareLabel).toBeUndefined();
});

it("should add tls=true for certificateType custom without a resolver", async () => {
const customDomain = {
...baseDomain,
https: true,
certificateType: "custom" as const,
customCertResolver: null,
};
const labels = await createDomainLabels(appName, customDomain, "websecure");
expect(labels).toContain(
"traefik.http.routers.test-app-1-websecure.tls=true",
);
expect(labels).not.toContain(
"traefik.http.routers.test-app-1-websecure.tls.certresolver=letsencrypt",
);
});
});
164 changes: 164 additions & 0 deletions apps/dokploy/__test__/traefik/acme-purge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { purgeAcmeCertificates } from "@dokploy/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

const execAsyncRemoteMock = vi.fn();
vi.mock("@dokploy/server/utils/process/execAsync", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/utils/process/execAsync")
>("@dokploy/server/utils/process/execAsync");
return {
...actual,
execAsyncRemote: (serverId: string, command: string) =>
execAsyncRemoteMock(serverId, command),
};
});

const certificatesFor = (mains: string[]) =>
JSON.stringify({
letsencrypt: {
Account: { Email: "test@localhost.com" },
Certificates: mains.map((main) => ({
domain: { main },
certificate: "cert",
key: "key",
})),
},
});

const mainsIn = (raw: string): string[] =>
(JSON.parse(raw).letsencrypt.Certificates ?? []).map(
(certificate: { domain: { main: string } }) => certificate.domain.main,
);

describe("purgeAcmeCertificates on a remote server", () => {
/** One acme.json per remote host, as on a real fleet. */
let remoteStores: Map<string, string>;
/** Delay applied to the read, to give a concurrent purge room to interleave. */
let readDelayMs: number;

const storeOf = (serverId: string) => remoteStores.get(serverId) ?? "";

beforeEach(() => {
vi.clearAllMocks();
remoteStores = new Map([
["server-one", certificatesFor(["a.example.com", "b.example.com"])],
["server-two", certificatesFor(["a.example.com", "b.example.com"])],
]);
readDelayMs = 0;

execAsyncRemoteMock.mockImplementation(
async (serverId: string, command: string) => {
if (command.startsWith("cat ")) {
// The remote reads the file when the command runs; the contents
// only reach us after the round trip. Snapshotting before the
// delay is what makes a concurrent purge observable.
const snapshot = storeOf(serverId);
await new Promise((resolve) => setTimeout(resolve, readDelayMs));
return { stdout: snapshot, stderr: "" };
}

const payload = command.match(/printf '%s' "([^"]+)"/)?.[1];
if (!payload) throw new Error(`unrecognised command: ${command}`);
remoteStores.set(
serverId,
Buffer.from(payload, "base64").toString("utf8"),
);
return { stdout: "", stderr: "" };
},
);
});

it("removes the requested host and leaves the others", async () => {
const removed = await purgeAcmeCertificates(
["a.example.com"],
"server-one",
);

expect(removed).toEqual(["a.example.com"]);
expect(mainsIn(storeOf("server-one"))).toEqual(["b.example.com"]);
});

// Without serialisation both purges read the original store and the later
// write puts back whatever the earlier one removed.
it("does not lose a removal when two purges run concurrently", async () => {
readDelayMs = 10;

const [firstRemoved, secondRemoved] = await Promise.all([
purgeAcmeCertificates(["a.example.com"], "server-one"),
purgeAcmeCertificates(["b.example.com"], "server-one"),
]);

expect(firstRemoved).toEqual(["a.example.com"]);
expect(secondRemoved).toEqual(["b.example.com"]);
expect(mainsIn(storeOf("server-one"))).toEqual([]);
});

it("keeps serialising after a failed purge", async () => {
readDelayMs = 5;
const failing = execAsyncRemoteMock.getMockImplementation();
execAsyncRemoteMock.mockImplementationOnce(async () => {
throw new Error("ssh dropped");
});

const results = await Promise.allSettled([
purgeAcmeCertificates(["a.example.com"], "server-one"),
purgeAcmeCertificates(["b.example.com"], "server-one"),
]);

expect(failing).toBeDefined();
expect(results[0]?.status).toBe("rejected");
expect(results[1]).toEqual({
status: "fulfilled",
value: ["b.example.com"],
});
expect(mainsIn(storeOf("server-one"))).toEqual(["a.example.com"]);
});

// Traefik owns acme.json and rewrites it in full when it issues or renews a
// certificate. Swapping in a snapshot taken before that write would drop the
// new certificate from disk.
it("does not drop a certificate Traefik writes while the purge is in flight", async () => {
const passthrough = execAsyncRemoteMock.getMockImplementation();
let reads = 0;

execAsyncRemoteMock.mockImplementation(
async (serverId: string, command: string) => {
const result = await passthrough?.(serverId, command);
if (command.startsWith("cat ")) {
reads += 1;
if (reads === 1) {
// Traefik issues a certificate right after our first read.
const store = JSON.parse(storeOf(serverId));
store.letsencrypt.Certificates.push({
domain: { main: "fresh.example.com" },
certificate: "cert",
key: "key",
});
remoteStores.set(serverId, JSON.stringify(store));
}
}
return result;
},
);

const removed = await purgeAcmeCertificates(
["a.example.com"],
"server-one",
);

expect(removed).toEqual(["a.example.com"]);
expect(mainsIn(storeOf("server-one"))).toEqual([
"b.example.com",
"fresh.example.com",
]);
});

it("runs purges for different servers independently", async () => {
const removed = await Promise.all([
purgeAcmeCertificates(["a.example.com"], "server-one"),
purgeAcmeCertificates(["a.example.com"], "server-two"),
]);

expect(removed).toEqual([["a.example.com"], ["a.example.com"]]);
});
});
89 changes: 89 additions & 0 deletions apps/dokploy/__test__/traefik/acme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { AcmeStore } from "@dokploy/server";
import { removeAcmeCertificates } from "@dokploy/server";
import { describe, expect, it } from "vitest";

const buildStore = (mains: string[]): AcmeStore => ({
letsencrypt: {
Account: { Email: "test@example.com" },
Certificates: mains.map((main) => ({
domain: { main },
certificate: "cert",
key: "key",
})),
},
});

describe("removeAcmeCertificates", () => {
it("removes only the requested hosts", () => {
const store = buildStore(["a.example.com", "b.example.com"]);

const result = removeAcmeCertificates(store, ["a.example.com"]);

expect(result.removed).toEqual(["a.example.com"]);
expect(result.store.letsencrypt?.Certificates).toHaveLength(1);
expect(result.store.letsencrypt?.Certificates?.[0]?.domain.main).toBe(
"b.example.com",
);
});

it("keeps the Account object intact", () => {
const store = buildStore(["a.example.com"]);

const result = removeAcmeCertificates(store, ["a.example.com"]);

expect(result.store.letsencrypt?.Account).toEqual({
Email: "test@example.com",
});
expect(result.store.letsencrypt?.Certificates).toEqual([]);
});

it("reports nothing removed when the host is absent", () => {
const store = buildStore(["a.example.com"]);

const result = removeAcmeCertificates(store, ["other.example.com"]);

expect(result.removed).toEqual([]);
expect(result.store.letsencrypt?.Certificates).toHaveLength(1);
});

it("tolerates a resolver with no Certificates array", () => {
const store: AcmeStore = { letsencrypt: { Account: {} } };

const result = removeAcmeCertificates(store, ["a.example.com"]);

expect(result.removed).toEqual([]);
});

it("does not mutate the input store", () => {
const store = buildStore(["a.example.com"]);

removeAcmeCertificates(store, ["a.example.com"]);

expect(store.letsencrypt?.Certificates).toHaveLength(1);
});
});

describe("removeAcmeCertificates with multiple resolvers", () => {
it("only touches the resolver that holds the host", () => {
const store: AcmeStore = {
letsencrypt: {
Account: {},
Certificates: [
{ domain: { main: "a.example.com" }, certificate: "c", key: "k" },
],
},
other: {
Account: {},
Certificates: [
{ domain: { main: "b.example.com" }, certificate: "c", key: "k" },
],
},
};

const result = removeAcmeCertificates(store, ["a.example.com"]);

expect(result.removed).toEqual(["a.example.com"]);
expect(result.store.letsencrypt?.Certificates).toEqual([]);
expect(result.store.other?.Certificates).toHaveLength(1);
});
});
100 changes: 100 additions & 0 deletions apps/dokploy/__test__/traefik/domain-certificate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { Domain } from "@dokploy/server";
import { purgeStaleCertificate } from "@dokploy/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

// Stands in for the `domains` lookup that answers "is this host still served
// by another Let's Encrypt domain?".
const hasOtherLetsencryptDomainForHostMock = vi.fn();
vi.mock("@dokploy/server/services/domain", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/services/domain")
>("@dokploy/server/services/domain");
return {
...actual,
hasOtherLetsencryptDomainForHost: (host: string, excludeId: string) =>
hasOtherLetsencryptDomainForHostMock(host, excludeId),
};
});

const purgeAcmeCertificatesMock = vi.fn();
vi.mock("@dokploy/server/utils/traefik/acme", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/utils/traefik/acme")
>("@dokploy/server/utils/traefik/acme");
return {
...actual,
purgeAcmeCertificates: (hosts: string[], serverId?: string | null) =>
purgeAcmeCertificatesMock(hosts, serverId),
};
});

const buildDomain = (overrides: Partial<Domain> = {}): Domain =>
({
domainId: "domain-1",
host: "example.com",
certificateType: "none",
applicationId: "app-1",
uniqueConfigKey: 1,
...overrides,
}) as Domain;

describe("purgeStaleCertificate", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => undefined);
});

it("purges the host when no other Let's Encrypt domain uses it", async () => {
hasOtherLetsencryptDomainForHostMock.mockResolvedValue(false);
purgeAcmeCertificatesMock.mockResolvedValue(["example.com"]);

const result = await purgeStaleCertificate(buildDomain(), "server-1");

expect(purgeAcmeCertificatesMock).toHaveBeenCalledWith(
["example.com"],
"server-1",
);
expect(result).toBe(true);
});

it("keeps the host when another Let's Encrypt domain still uses it", async () => {
hasOtherLetsencryptDomainForHostMock.mockResolvedValue(true);

const result = await purgeStaleCertificate(buildDomain(), "server-1");

expect(purgeAcmeCertificatesMock).not.toHaveBeenCalled();
expect(result).toBe(false);
});

it("never purges a domain that still uses Let's Encrypt", async () => {
const result = await purgeStaleCertificate(
buildDomain({ certificateType: "letsencrypt" }),
"server-1",
);

expect(hasOtherLetsencryptDomainForHostMock).not.toHaveBeenCalled();
expect(purgeAcmeCertificatesMock).not.toHaveBeenCalled();
expect(result).toBe(false);
});

it("reports no reload instead of throwing when the purge fails", async () => {
hasOtherLetsencryptDomainForHostMock.mockResolvedValue(false);
purgeAcmeCertificatesMock.mockRejectedValue(
new Error("ssh connection refused"),
);

await expect(
purgeStaleCertificate(buildDomain(), "server-1"),
).resolves.toBe(false);
});

it("reports no reload instead of throwing when the lookup fails", async () => {
hasOtherLetsencryptDomainForHostMock.mockRejectedValue(
new Error("database unavailable"),
);

await expect(
purgeStaleCertificate(buildDomain(), "server-1"),
).resolves.toBe(false);
});
});
Loading