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 }; +};