Skip to content
Merged
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
159 changes: 159 additions & 0 deletions apps/dokploy/__test__/domain-overview/certificate-renewal.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: "" };
}
Expand Down Expand Up @@ -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"),
Expand Down
129 changes: 129 additions & 0 deletions apps/dokploy/components/dashboard/domains/certificate-renewal.tsx
Original file line number Diff line number Diff line change
@@ -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<Server["certificateRenewal"]>;

const CHALLENGE_LABELS: Record<Renewal["challenge"], string> = {
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 <CheckCircle2 className="size-4 shrink-0 text-emerald-500" />;
}
if (renewal?.status === "problem") {
return <ShieldAlert className="size-4 shrink-0 text-red-500" />;
}
return <CircleHelp className="size-4 shrink-0 text-muted-foreground" />;
};

const Ports = ({ renewal }: { renewal: Renewal }) => {
if (!renewal.traefikFound) {
return (
<span className="text-muted-foreground">Traefik container not found</span>
);
}
const port80 = renewal.publishedPorts.some((port) => port.startsWith("80/"));
return (
<div className="flex flex-col gap-1">
<span className="font-mono text-xs">
{renewal.publishedPorts.length > 0
? renewal.publishedPorts.join(", ")
: "none"}
</span>
<span
className={
port80
? "text-xs text-red-600 dark:text-red-400"
: "text-xs text-muted-foreground"
}
>
{port80 ? "port 80 is open" : "port 80 is closed"}
</span>
</div>
);
};

export const CertificateRenewal = ({ servers }: { servers: Server[] }) => (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<h3 className="text-lg font-semibold">Certificate renewal</h3>
<p className="text-sm text-muted-foreground">
Traefik issues and renews Let&apos;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.
</p>
</div>
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Server</TableHead>
<TableHead>Method</TableHead>
<TableHead>Published ports</TableHead>
<TableHead>What to do</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{servers.map((server) => {
const renewal = server.certificateRenewal;
return (
<TableRow
key={`renewal-${server.serverId ?? "local"}`}
className="align-top"
>
<TableCell className="text-sm">
<div className="flex items-center gap-2">
<StatusIcon renewal={renewal} />
<span>{server.name}</span>
</div>
</TableCell>
<TableCell className="text-sm">
{renewal
? CHALLENGE_LABELS[renewal.challenge]
: "Not checked"}
</TableCell>
<TableCell className="text-sm">
{renewal ? (
<Ports renewal={renewal} />
) : (
<span className="text-muted-foreground">Not checked</span>
)}
</TableCell>
<TableCell className="text-sm">
{renewal && renewal.problems.length > 0 ? (
<ul className="flex flex-col gap-1 text-red-600 dark:text-red-400">
{renewal.problems.map((problem) => (
<li key={problem}>{problem}</li>
))}
</ul>
) : renewal?.status === "ok" ? (
<span className="text-muted-foreground">Nothing</span>
) : (
<span className="text-muted-foreground">
The server could not be checked. See the warnings above.
</span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
);
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -314,6 +315,9 @@ export const ShowDomainOverview = () => {
session can read.
</AlertBlock>
)}
{data && data.servers.length > 0 && (
<CertificateRenewal servers={data.servers} />
)}
{data && (
<DomainsTable
rows={data.rows}
Expand Down
10 changes: 10 additions & 0 deletions docs/certificates-and-ports.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ Traefik выпускает и продлевает сертификаты Let's

Последствие: домены, которые работают только по HTTP без HTTPS, снаружи не открываются. Такие домены нужно перевести на HTTPS.

## Блок Certificate renewal на странице Domains

Вверху страницы Domains (`/dashboard/domains`) есть таблица Certificate renewal: по строке на панель и на каждый сервер. Переключателя там нет: другой метод требует порта 80, а его не открываем. Таблица только показывает, что на каждом сервере действует порт 443:

- **Method**: что записано в `traefik.yml` у резолвера: `tlsChallenge` (TLS-ALPN-01 на 443), `httpChallenge` (нужен порт 80, это ошибка), `dnsChallenge` (входящий порт не нужен) или ничего.
- **Published ports**: какие порты хоста публикует контейнер или сервис `dokploy-traefik`, и закрыт ли порт 80.
- **What to do**: если найден `httpChallenge`, открытый порт 80 или не опубликован 443/tcp, здесь написано, что исправить. Для первых двух достаточно один раз сохранить Additional Port Mappings у Traefik этого сервера: панель переводит конфиг на `tlsChallenge` и пересоздаёт Traefik без порта 80.

Это действует для любого домена, который подключат позже, ничего включать не нужно.

## Что значит строка об ошибке продления

Traefik проверяет сертификаты заранее. По умолчанию продление начинается примерно за 30 дней до окончания срока, а проверка идет раз в сутки. Если попытка не удалась, в логе появляется запись уровня error:
Expand Down
Loading