From 01ddf6c78cf54b856c83aca64925431e13c17721 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:15:05 -0400 Subject: [PATCH] feat(domains): show how each server renews certificates on the Domains page Certificates are renewed only through port 443 (tlsChallenge), but the panel never showed it. An old server could still have httpChallenge in traefik.yml or publish port 80, and nothing pointed that out until a renewal failed. The Domains page now opens with a "Certificate renewal" table with one row for the panel and one for each server: - Method: the ACME challenge in traefik.yml (tlsChallenge on 443, httpChallenge that needs port 80, dnsChallenge, or none). - Published ports: the host ports of the dokploy-traefik container or swarm service, and whether port 80 is open. The configured bindings are read, so a stopped container still shows them. - What to do: httpChallenge, an open port 80 or a missing 443/tcp are listed with the fix (save the Traefik port mappings once, which converts the config and recreates Traefik). There is no switch: the only other method needs port 80, which stays closed. The check runs as one more stage of the existing per-server read and does not affect whether unused certificates are listed. Co-Authored-By: Claude Opus 5.5 --- .../certificate-renewal.test.ts | 159 ++++++++++++++++++ .../domain-overview-unused.test.ts | 20 +++ .../dashboard/domains/certificate-renewal.tsx | 129 ++++++++++++++ .../domains/show-domain-overview.tsx | 4 + docs/certificates-and-ports.md | 10 ++ .../server/src/services/domain-overview.ts | 25 +++ .../domain-overview/certificate-renewal.ts | 128 ++++++++++++++ 7 files changed, 475 insertions(+) create mode 100644 apps/dokploy/__test__/domain-overview/certificate-renewal.test.ts create mode 100644 apps/dokploy/components/dashboard/domains/certificate-renewal.tsx create mode 100644 packages/server/src/utils/domain-overview/certificate-renewal.ts diff --git a/apps/dokploy/__test__/domain-overview/certificate-renewal.test.ts b/apps/dokploy/__test__/domain-overview/certificate-renewal.test.ts new file mode 100644 index 00000000000..2e81167283a --- /dev/null +++ b/apps/dokploy/__test__/domain-overview/certificate-renewal.test.ts @@ -0,0 +1,159 @@ +import { execSync, spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + buildCertificateRenewalCommand, + parseCertificateRenewal, +} from "@dokploy/server/utils/domain-overview/certificate-renewal"; +import { describe, expect, it } from "vitest"; + +const BASH = execSync("command -v bash", { encoding: "utf8" }).trim(); + +type Fake = { + config?: string; + kind: "service" | "container" | "none"; + ports?: string; +}; + +// Runs the check with a fake docker and a temporary traefik.yml. +const runCheck = ({ config, kind, ports = "" }: Fake) => { + const dir = mkdtempSync(path.join(tmpdir(), "dokploy-renewal-")); + try { + const configPath = path.join(dir, "traefik.yml"); + if (config !== undefined) { + writeFileSync(configPath, config); + } + const docker = path.join(dir, "docker"); + writeFileSync( + docker, + `#!/bin/sh +if [ "$1" = service ] && [ "$2" = inspect ]; then + [ "$FAKE_KIND" = service ] || exit 1 + [ "$3" = -f ] && printf '%s' "$FAKE_PORTS" + exit 0 +fi +if [ "$1" = inspect ]; then + [ "$FAKE_KIND" = container ] || exit 1 + [ "$2" = -f ] && printf '%s' "$FAKE_PORTS" + exit 0 +fi +exit 1 +`, + ); + chmodSync(docker, 0o755); + const result = spawnSync( + BASH, + ["-c", buildCertificateRenewalCommand(configPath)], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `${dir}:${process.env.PATH}`, + FAKE_KIND: kind, + FAKE_PORTS: ports, + }, + }, + ); + expect(result.status).toBe(0); + return parseCertificateRenewal(result.stdout); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}; + +const TLS_CONFIG = `certificatesResolvers: + letsencrypt: + acme: + email: ops@example.com + storage: /etc/dokploy/traefik/dynamic/acme.json + tlsChallenge: {} +`; + +const HTTP_CONFIG = `certificatesResolvers: + letsencrypt: + acme: + httpChallenge: + entryPoint: web +`; + +describe("certificate renewal check", () => { + it("reports a standalone Traefik on 443 with tlsChallenge as ok", () => { + const renewal = runCheck({ + config: TLS_CONFIG, + kind: "container", + ports: "443/443/tcp 443/443/udp ", + }); + + expect(renewal).toEqual({ + status: "ok", + challenge: "tls", + traefikFound: true, + publishedPorts: ["443/tcp", "443/udp"], + problems: [], + }); + }); + + it("reads the published ports of a swarm service", () => { + const renewal = runCheck({ + config: TLS_CONFIG, + kind: "service", + ports: "443/tcp 443/udp ", + }); + + expect(renewal.status).toBe("ok"); + expect(renewal.publishedPorts).toEqual(["443/tcp", "443/udp"]); + }); + + it("flags httpChallenge and a published port 80", () => { + const renewal = runCheck({ + config: HTTP_CONFIG, + kind: "container", + ports: "80/80/tcp 443/443/tcp ", + }); + + expect(renewal.status).toBe("problem"); + expect(renewal.challenge).toBe("http"); + expect(renewal.problems.join("\n")).toContain("httpChallenge"); + expect(renewal.problems.join("\n")).toContain("publishes port 80"); + }); + + it("flags a Traefik that does not publish 443/tcp", () => { + const renewal = runCheck({ + config: TLS_CONFIG, + kind: "container", + ports: "8443/8443/tcp ", + }); + + expect(renewal.status).toBe("problem"); + expect(renewal.problems.join("\n")).toContain("443/tcp"); + }); + + it("reports unknown when traefik.yml or Traefik is missing", () => { + expect( + runCheck({ kind: "container", ports: "443/443/tcp " }), + ).toMatchObject({ status: "unknown", challenge: "missing" }); + expect(runCheck({ config: TLS_CONFIG, kind: "none" })).toMatchObject({ + status: "unknown", + traefikFound: false, + }); + }); + + it("does not treat a commented-out httpChallenge as in use", () => { + const renewal = runCheck({ + config: `${TLS_CONFIG} # httpChallenge:\n`, + kind: "container", + ports: "443/443/tcp ", + }); + + expect(renewal.challenge).toBe("tls"); + }); + + it("ignores port bindings without a host port", () => { + expect( + parseCertificateRenewal( + "DOKPLOY_RENEWAL_CHALLENGE tls\nDOKPLOY_RENEWAL_PORTS /80/tcp 443/443/tcp\n", + ).publishedPorts, + ).toEqual(["443/tcp"]); + }); +}); diff --git a/apps/dokploy/__test__/domain-overview/domain-overview-unused.test.ts b/apps/dokploy/__test__/domain-overview/domain-overview-unused.test.ts index 45ecbca3808..43e1f5a5852 100644 --- a/apps/dokploy/__test__/domain-overview/domain-overview-unused.test.ts +++ b/apps/dokploy/__test__/domain-overview/domain-overview-unused.test.ts @@ -134,6 +134,7 @@ const STATUS = "DOKPLOY_LOCK"; const LAUNCH = "prune_logs"; const ACME_READ = "jq -c"; const LOGS = "__DOKPLOY_LOG_RC"; +const RENEWAL = "DOKPLOY_RENEWAL_CHALLENGE"; type Machine = { routes?: string; @@ -170,6 +171,13 @@ const commandHandler = (machine: Machine) => async (command: string) => { stderr: "", }; } + if (command.includes(RENEWAL)) { + return { + stdout: + "DOKPLOY_RENEWAL_CHALLENGE tls\nDOKPLOY_RENEWAL_PORTS 443/443/tcp 443/443/udp\n", + stderr: "", + }; + } if (command.includes(STATUS)) { return { stdout: machine.status ?? "DOKPLOY_LOCK 0\n", stderr: "" }; } @@ -413,6 +421,18 @@ describe("defect 1: the Traefik log", () => { expect(overview.serverErrors).toEqual([]); }); + it("reports how each server renews certificates", async () => { + useLocal({ routes: routeLine("web", "live.example.com") }); + + const overview = await overviewFor(); + + expect(overview.servers[0]?.certificateRenewal).toMatchObject({ + status: "ok", + challenge: "tls", + publishedPorts: ["443/tcp", "443/udp"], + }); + }); + it("takes the last error from the log", async () => { useLocal({ routes: routeLine("web", "live.example.com"), diff --git a/apps/dokploy/components/dashboard/domains/certificate-renewal.tsx b/apps/dokploy/components/dashboard/domains/certificate-renewal.tsx new file mode 100644 index 00000000000..713bb14b59b --- /dev/null +++ b/apps/dokploy/components/dashboard/domains/certificate-renewal.tsx @@ -0,0 +1,129 @@ +import { CheckCircle2, CircleHelp, ShieldAlert } from "lucide-react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { RouterOutputs } from "@/utils/api"; + +type Server = RouterOutputs["domainOverview"]["list"]["servers"][number]; +type Renewal = NonNullable; + +const CHALLENGE_LABELS: Record = { + tls: "TLS-ALPN-01 on port 443", + http: "HTTP-01, needs port 80", + dns: "DNS-01, no inbound port", + none: "No ACME challenge", + missing: "traefik.yml not found", + unreadable: "traefik.yml could not be read", +}; + +const StatusIcon = ({ renewal }: { renewal: Server["certificateRenewal"] }) => { + if (renewal?.status === "ok") { + return ; + } + if (renewal?.status === "problem") { + return ; + } + return ; +}; + +const Ports = ({ renewal }: { renewal: Renewal }) => { + if (!renewal.traefikFound) { + return ( + Traefik container not found + ); + } + const port80 = renewal.publishedPorts.some((port) => port.startsWith("80/")); + return ( +
+ + {renewal.publishedPorts.length > 0 + ? renewal.publishedPorts.join(", ") + : "none"} + + + {port80 ? "port 80 is open" : "port 80 is closed"} + +
+ ); +}; + +export const CertificateRenewal = ({ servers }: { servers: Server[] }) => ( +
+
+

Certificate renewal

+

+ Traefik issues and renews Let's Encrypt certificates through port + 443 (TLS-ALPN-01). Port 80 is never opened, so HTTP-01 is not used. This + applies to every domain you connect, with nothing to switch on. +

+
+
+ + + + Server + Method + Published ports + What to do + + + + {servers.map((server) => { + const renewal = server.certificateRenewal; + return ( + + +
+ + {server.name} +
+
+ + {renewal + ? CHALLENGE_LABELS[renewal.challenge] + : "Not checked"} + + + {renewal ? ( + + ) : ( + Not checked + )} + + + {renewal && renewal.problems.length > 0 ? ( +
    + {renewal.problems.map((problem) => ( +
  • {problem}
  • + ))} +
+ ) : renewal?.status === "ok" ? ( + Nothing + ) : ( + + The server could not be checked. See the warnings above. + + )} +
+
+ ); + })} +
+
+
+
+); diff --git a/apps/dokploy/components/dashboard/domains/show-domain-overview.tsx b/apps/dokploy/components/dashboard/domains/show-domain-overview.tsx index 9b328e81175..e89395479b8 100644 --- a/apps/dokploy/components/dashboard/domains/show-domain-overview.tsx +++ b/apps/dokploy/components/dashboard/domains/show-domain-overview.tsx @@ -14,6 +14,7 @@ import { } from "@/components/ui/table"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; +import { CertificateRenewal } from "./certificate-renewal"; import { InterruptedDeletion } from "./interrupted-deletion"; import { CountBadge, @@ -314,6 +315,9 @@ export const ShowDomainOverview = () => { session can read. )} + {data && data.servers.length > 0 && ( + + )} {data && ( { } }; +const readRenewal = async (target: ServerTarget, timeoutMs?: number) => { + const { stdout } = await runOnServer( + target, + buildCertificateRenewalCommand(staticConfigFor(target)), + timeoutMs, + ); + return parseCertificateRenewal(stdout); +}; + const readCertificates = async (target: ServerTarget, timeoutMs?: number) => { try { const { stdout } = await runOnServer( @@ -732,6 +748,7 @@ const readServer = async ( certificates: [] as AcmeCertificate[], logLines: [] as string[], logAvailable: false, + renewal: null, }; const inMemory = @@ -794,6 +811,12 @@ const readServer = async ( if (logs.status === "failed" && !connectionLost) { errors.push(`Traefik logs: ${buildFailureReason({ text: logs.reason })}`); } + const renewal = await stage( + "Certificate renewal", + null, + (timeoutMs) => readRenewal(target, timeoutMs), + false, + ); return { target, @@ -807,6 +830,7 @@ const readServer = async ( usageVerified, deletion, deletionRunning: false, + renewal, errors, }; }; @@ -1183,6 +1207,7 @@ const buildOverview = async ( !snapshot.deletionRunning ? snapshot.deletion.marker : null, + certificateRenewal: snapshot.renewal, })), serverErrors: analysis.servers.flatMap(({ snapshot }) => snapshot.errors.map((message) => ({ diff --git a/packages/server/src/utils/domain-overview/certificate-renewal.ts b/packages/server/src/utils/domain-overview/certificate-renewal.ts new file mode 100644 index 00000000000..dae592192bc --- /dev/null +++ b/packages/server/src/utils/domain-overview/certificate-renewal.ts @@ -0,0 +1,128 @@ +import { TRAEFIK_CONTAINER_NAME } from "./traefik-logs"; + +const CHALLENGE_MARKER = "DOKPLOY_RENEWAL_CHALLENGE"; +const PORTS_MARKER = "DOKPLOY_RENEWAL_PORTS"; + +export type RenewalChallenge = + | "tls" + | "http" + | "dns" + | "none" + | "missing" + | "unreadable"; + +export type CertificateRenewalStatus = "ok" | "problem" | "unknown"; + +export type CertificateRenewalView = { + status: CertificateRenewalStatus; + challenge: RenewalChallenge; + traefikFound: boolean; + // Host ports Traefik publishes, as "443/tcp". + publishedPorts: string[]; + problems: string[]; +}; + +// Reads the configured port bindings rather than `docker port`, which shows +// nothing for a stopped container. The static config is checked the same way +// the TLS migration step does, so both agree on what counts as httpChallenge. +export const buildCertificateRenewalCommand = (staticConfig: string) => { + const name = TRAEFIK_CONTAINER_NAME; + return [ + `config='${staticConfig}'`, + 'if [ -f "$config" ] && [ -r "$config" ]; then', + ` if grep -Eq '^[[:space:]]*httpChallenge:' "$config"; then echo "${CHALLENGE_MARKER} http"`, + ` elif grep -Eq '^[[:space:]]*tlsChallenge:' "$config"; then echo "${CHALLENGE_MARKER} tls"`, + ` elif grep -Eq '^[[:space:]]*dnsChallenge:' "$config"; then echo "${CHALLENGE_MARKER} dns"`, + ` else echo "${CHALLENGE_MARKER} none"; fi`, + 'elif [ -e "$config" ]; then', + ` echo "${CHALLENGE_MARKER} unreadable"`, + "else", + ` echo "${CHALLENGE_MARKER} missing"`, + "fi", + `if docker service inspect ${name} >/dev/null 2>&1; then`, + ` echo "${PORTS_MARKER} $(docker service inspect -f '{{range .Endpoint.Spec.Ports}}{{.PublishedPort}}/{{.Protocol}} {{end}}' ${name} 2>/dev/null)"`, + `elif docker inspect ${name} >/dev/null 2>&1; then`, + ` echo "${PORTS_MARKER} $(docker inspect -f '{{range $port, $bindings := .HostConfig.PortBindings}}{{range $bindings}}{{.HostPort}}/{{$port}} {{end}}{{end}}' ${name} 2>/dev/null)"`, + "fi", + ].join("\n"); +}; + +const CHALLENGES: RenewalChallenge[] = [ + "tls", + "http", + "dns", + "none", + "missing", + "unreadable", +]; + +// A container binding prints as "443/443/tcp" (host port, then the container +// port with its protocol); a swarm service prints as "443/tcp". +const normalizePort = (item: string) => { + const parts = item.split("/"); + if (parts.length === 3) { + return `${parts[0]}/${parts[2]}`; + } + return item; +}; + +export const parseCertificateRenewal = ( + stdout: string, +): CertificateRenewalView => { + let challenge: RenewalChallenge = "unreadable"; + let traefikFound = false; + const ports = new Set(); + + for (const line of stdout.split("\n")) { + const [marker, ...rest] = line.trim().split(/\s+/); + if (marker === CHALLENGE_MARKER) { + const value = rest[0] as RenewalChallenge; + if (CHALLENGES.includes(value)) { + challenge = value; + } + } else if (marker === PORTS_MARKER) { + traefikFound = true; + for (const item of rest) { + if (/^\d+(\/\d+)?\/[a-z]+$/.test(item)) { + ports.add(normalizePort(item)); + } + } + } + } + + const publishedPorts = [...ports].sort(); + const problems: string[] = []; + if (challenge === "http") { + problems.push( + "traefik.yml uses httpChallenge, which needs port 80. Save the Traefik port mappings of this server once: the panel converts it to tlsChallenge and recreates Traefik.", + ); + } + if (publishedPorts.some((port) => port.startsWith("80/"))) { + problems.push( + "Traefik publishes port 80. Save the Traefik port mappings of this server once to recreate Traefik without it.", + ); + } + if (traefikFound && !publishedPorts.includes("443/tcp")) { + problems.push( + "Traefik does not publish 443/tcp, so certificates cannot be issued or renewed.", + ); + } + if (challenge === "none") { + problems.push( + "traefik.yml has no ACME challenge, so Traefik does not issue certificates.", + ); + } + + let status: CertificateRenewalStatus = "ok"; + if (problems.length > 0) { + status = "problem"; + } else if ( + !traefikFound || + challenge === "missing" || + challenge === "unreadable" + ) { + status = "unknown"; + } + + return { status, challenge, traefikFound, publishedPorts, problems }; +};