diff --git a/CLAUDE.md b/CLAUDE.md index 654dd27dba..5e9e409f35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,4 +3,12 @@ - Comment only the "why" when something isn't obvious: workarounds, counterintuitive decisions, constraints from an external API. - No section-divider comments like `// --- Helpers ---`. -- Don't leave comments describing the change you just made. \ No newline at end of file +- Don't leave comments describing the change you just made. + +## Ports and certificates + +Порт 80 на серверах не открываем никогда. Сертификаты обновляются только через порт 443 (tlsChallenge). Не добавлять httpChallenge и публикацию порта 80 ни в код, ни в установщик. + +## Pull requests + +Every pull request targets `AgentHits-Dev`, including a PR that builds on another open PR. Checks run only for that base, so a PR stacked on another branch gets none. When one PR depends on another, say so in the description; do not change the base. diff --git a/README.md b/README.md index 6d9ea8fe84..c6dc560b3f 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Rocky, Alma, Arch, openSUSE, Alpine и их производные, x86 и ARM), OrbStack) и Windows (через WSL2). Подробности: [docs/agenthits-install.md](docs/agenthits-install.md). -Требования: свободные порты `80`, `443` и `3000`, доступ к GitHub, GHCR и +Требования: свободные порты `443` и `3000`, доступ к GitHub, GHCR и Docker Hub. Linux, macOS, WSL (на Linux без root скрипт сам вызовет `sudo`, на macOS @@ -152,7 +152,7 @@ Requirements: - fresh Linux VPS with root or sudo access; - `x86_64 / amd64` architecture; -- free ports `80`, `443`, and `3000`; +- free ports `443` and `3000`; - public access to GitHub, GHCR, and Docker Hub. Install as root: diff --git a/apps/dokploy/__test__/server/server-components.test.ts b/apps/dokploy/__test__/server/server-components.test.ts index 83dd9a6315..f44bac7ae6 100644 --- a/apps/dokploy/__test__/server/server-components.test.ts +++ b/apps/dokploy/__test__/server/server-components.test.ts @@ -32,10 +32,23 @@ import { buildTraefikCreateCommand, buildTraefikCreateWithImage, buildTraefikRunCommand, + buildTraefikTlsMigrationStep, TRAEFIK_VERSION, } from "@dokploy/server/setup/traefik-setup"; import { describe, expect, it } from "vitest"; +const PORT_80_PUBLICATION = + /(?:-p|--publish)[=\s]+\S*\b80\b|published=80\b|\b80\/tcp\b/; + +const TRAEFIK_SKIP_CHECK_LINE = + /^if \[ "\$traefik_existed" = 1 \] && \[ "\$\{traefik_old_image%%@\*\}" = .*$/m; + +// The read-only skip check names httpChallenge and 80/tcp; the assertions cover everything else. +const withoutSkipCheck = (script: string) => { + expect(script.match(TRAEFIK_SKIP_CHECK_LINE)).not.toBeNull(); + return script.replace(TRAEFIK_SKIP_CHECK_LINE, ""); +}; + describe("parseComponentVersions", () => { it("extracts the semantic version from each tool's output", () => { const output = [ @@ -125,15 +138,19 @@ describe("buildComponentUpdateScript", () => { expect(script).toContain(`docker pull traefik:v${TRAEFIK_VERSION}`); expect(script).toContain(`traefik_create "traefik:v${TRAEFIK_VERSION}"`); - expect(script).toContain( - buildTraefikCreateWithImage('"$1"', "$traefik_http_publish").trim(), - ); - expect(script).toContain('traefik_http_publish="-p 80:80"'); + expect(script).toContain(buildTraefikCreateWithImage('"$1"').trim()); expect(script).toContain( "docker network disconnect bridge dokploy-traefik", ); expect(script).not.toContain("docker rename"); }); + it("never publishes host port 80 when it replaces the Traefik container", () => { + const script = withoutSkipCheck(buildComponentUpdateScript(["traefik"])); + + expect(script).not.toMatch(PORT_80_PUBLICATION); + expect(script).toMatch(/-p 443:443\s/); + expect(script).toContain("-p 443:443/udp"); + }); it("stops and removes the old Traefik, creates the new one, attaches its networks, and starts it last", () => { const script = buildComponentUpdateScript(["traefik"]); const order = [ @@ -177,6 +194,48 @@ describe("buildComponentUpdateScript", () => { expect(check).toBeGreaterThanOrEqual(0); expect(raise).toBeGreaterThan(check); }); + it("converts the Traefik config to tlsChallenge before the old container is stopped", () => { + const script = buildComponentUpdateScript(["traefik"]); + const step = buildTraefikTlsMigrationStep(); + const at = script.indexOf(step); + + expect(at).toBeGreaterThanOrEqual(0); + expect(script.slice(0, at)).toMatch(/if ! \{\n$/); + expect(script.slice(at + step.length)).toMatch( + /^\n\s*\}; then\n\s*traefik_restore "converting the Traefik config to tlsChallenge"\n/, + ); + expect(at).toBeLessThan( + script.indexOf("if ! $SUDO_CMD docker stop dokploy-traefik >/dev/null;"), + ); + }); + it("keeps the converted Traefik config on rollback, names the backup, and never restores httpChallenge", () => { + const script = buildComponentUpdateScript(["traefik"]); + const kept = script.indexOf( + 'if [ "$traefik_tls_converted" = 1 ]; then echo "Kept the converted Traefik config (tlsChallenge). Port 443 is published, so the restored container works with it. Previous config saved to $traefik_acme_backup." >&2; fi', + ); + const trapSet = script.indexOf( + "trap 'traefik_restore \"being interrupted\"' HUP INT TERM", + ); + + const definition = script.indexOf("traefik_restore() {"); + + expect(kept).toBeGreaterThan(definition); + expect(kept).toBeLessThan(trapSet); + expect(kept).toBeLessThan( + script.indexOf('echo "Restoring the previous Traefik container from'), + ); + expect(script.slice(definition, trapSet)).not.toContain( + 'cat "$traefik_acme_backup"', + ); + expect(script.indexOf('traefik_tls_converted=""')).toBeLessThan(trapSet); + }); + it("keeps httpChallenge out of the Traefik step apart from the conversion", () => { + const script = withoutSkipCheck(buildComponentUpdateScript(["traefik"])); + + expect(script.replace(buildTraefikTlsMigrationStep(), "")).not.toMatch( + /httpchallenge/i, + ); + }); it("passes the pinned versions into the installers through env", () => { const script = buildComponentUpdateScript(["nixpacks", "railpack"]); @@ -407,6 +466,7 @@ case "$1" in case "$*" in *Config.Image*) echo "$FAKE_OLD_IMAGE" ;; *NetworkSettings*) echo "$FAKE_TRAEFIK_NETWORKS" ;; + *PortBindings*) echo "$FAKE_PORT_BINDINGS" ;; *RestartCount*) traefik_now="$(cat "$FAKE_STATE/traefik-image" 2>/dev/null || echo "$FAKE_OLD_IMAGE")" if [ "$FAKE_NOT_RUNNING" = 1 ] && [ "$traefik_now" != "$FAKE_OLD_IMAGE" ]; then echo "false 0"; else echo "true 0"; fi @@ -545,6 +605,7 @@ const SYSTEM_TOOLS = [ "cut", "wc", "sort", + "test", ]; const linkSystemTools = (binDir: string) => { @@ -553,8 +614,13 @@ const linkSystemTools = (binDir: string) => { const found = spawnSync("sh", ["-c", `command -v ${name}`], { encoding: "utf8", }).stdout.trim(); - if (found) { - symlinkSync(found, path.join(binDir, name)); + const executable = found.startsWith("/") + ? found + : ["/usr/bin", "/bin"] + .map((dir) => path.join(dir, name)) + .find((candidate) => existsSync(candidate)); + if (executable) { + symlinkSync(executable, path.join(binDir, name)); } } return binDir; @@ -573,6 +639,7 @@ type ScriptOptions = { sourceFiles?: Record; withoutApt?: boolean; preexistingKeyring?: string; + traefikConfig?: string; }; const listBackups = (backups: string) => { @@ -612,7 +679,17 @@ const runComponentScript = ( const sources = path.join(dir, "sources"); const keyring = path.join(dir, "keyrings", "docker.asc"); const backups = path.join(dir, "backups"); + const traefikConfigPath = path.join( + dir, + "etc-root", + "traefik", + "traefik.yml", + ); mkdirSync(sources); + if (options.traefikConfig !== undefined) { + mkdirSync(path.dirname(traefikConfigPath), { recursive: true }); + writeFileSync(traefikConfigPath, options.traefikConfig); + } if (options.preexistingKeyring !== undefined) { mkdirSync(path.dirname(keyring)); writeFileSync(keyring, options.preexistingKeyring); @@ -636,8 +713,12 @@ const runComponentScript = ( const pathValue = options.withoutApt ? `${dir}:${linkSystemTools(path.join(dir, "bin"))}` : `${dir}:${process.env.PATH}`; + // Config paths point into the temp dir, so a run never touches the host's real config. const result = spawnSync(BASH, [], { - input: buildComponentUpdateScript(components), + input: buildComponentUpdateScript(components).replaceAll( + "/etc/dokploy", + path.join(dir, "etc-root"), + ), encoding: "utf8", env: { ...process.env, @@ -666,6 +747,14 @@ const runComponentScript = ( keyring: existsSync(keyring) ? readFileSync(keyring, "utf8") : null, keyringDirExists: existsSync(path.dirname(keyring)), sources: readSources(sources), + traefikConfigFile: existsSync(traefikConfigPath) + ? readFileSync(traefikConfigPath, "utf8") + : null, + traefikBackups: existsSync(path.dirname(traefikConfigPath)) + ? readdirSync(path.dirname(traefikConfigPath)).filter((name) => + name.includes(".bak-"), + ) + : [], }; } finally { rmSync(dir, { force: true, recursive: true }); @@ -675,6 +764,12 @@ const runComponentScript = ( const SPAWN_TEST_TIMEOUT_MS = 30_000; const DOCKER_OLD = "5:28.3.0-1~ubuntu.24.04~noble"; const DOCKER_NEW = "5:29.8.2-1~ubuntu.24.04~noble"; +const HTTP_CHALLENGE_CONFIG = + "certificatesResolvers:\n letsencrypt:\n acme:\n email: admin@example.com\n httpChallenge:\n entryPoint: web\n"; +const TLS_CHALLENGE_CONFIG = + "certificatesResolvers:\n letsencrypt:\n acme:\n email: admin@example.com\n tlsChallenge: {}\n"; +const UNCONVERTIBLE_CONFIG = + "certificatesResolvers:\n letsencrypt:\n acme:\n httpChallenge: # HTTP-01\n entryPoint: web\n"; describe("buildComponentUpdateScript run order", () => { it( @@ -807,6 +902,117 @@ describe("buildComponentUpdateScript run order", () => { "docker update --restart always dokploy-traefik", ); }); + it( + "converts a Traefik config that still uses httpChallenge before the old container is stopped", + () => { + const run = runComponentScript(["traefik"], { + traefikConfig: HTTP_CHALLENGE_CONFIG, + }); + + expect(run.status).toBe(0); + expect(run.traefikConfigFile).toBe(TLS_CHALLENGE_CONFIG); + expect(run.traefikBackups).toHaveLength(1); + expect(run.calls).toContain("docker stop dokploy-traefik"); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + it( + "leaves the old Traefik running when its config cannot be converted", + () => { + const run = runComponentScript(["traefik"], { + traefikConfig: UNCONVERTIBLE_CONFIG, + }); + + expect(run.status).not.toBe(0); + expect(run.stderr).toContain("could not convert"); + expect(run.traefikConfigFile).toBe(UNCONVERTIBLE_CONFIG); + expect(run.calls.some((call) => call.startsWith("docker stop"))).toBe( + false, + ); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + it( + "keeps the converted Traefik config when a later step fails, without restoring httpChallenge", + () => { + const run = runComponentScript(["traefik"], { + traefikConfig: HTTP_CHALLENGE_CONFIG, + env: { FAKE_CREATE_FAILS: "1" }, + }); + + expect(run.status).not.toBe(0); + expect(run.stderr).toContain("Kept the converted Traefik config"); + expect(run.stderr).not.toContain("Restored the previous Traefik config"); + expect(run.traefikConfigFile).toBe(TLS_CHALLENGE_CONFIG); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + it( + "converts and replaces Traefik when its image is the target but the config still uses httpChallenge", + () => { + const run = runComponentScript(["traefik"], { + traefikConfig: HTTP_CHALLENGE_CONFIG, + env: { + FAKE_OLD_IMAGE: `traefik:v${TRAEFIK_VERSION}`, + FAKE_PORT_BINDINGS: "{}", + }, + }); + + expect(run.status).toBe(0); + expect(run.stdout).not.toContain("Traefik already runs"); + expect(run.traefikConfigFile).toBe(TLS_CHALLENGE_CONFIG); + expect(run.calls).toContain("docker stop dokploy-traefik"); + expect(run.stdout).toContain( + `Traefik version ${TRAEFIK_VERSION} installed`, + ); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + it( + "leaves Traefik running when its image is the target and neither the config nor port 80 needs a change", + () => { + const run = runComponentScript(["traefik"], { + traefikConfig: TLS_CHALLENGE_CONFIG, + env: { + FAKE_OLD_IMAGE: `traefik:v${TRAEFIK_VERSION}`, + FAKE_PORT_BINDINGS: "{}", + }, + }); + + expect(run.status).toBe(0); + expect(run.stdout).toContain( + `Traefik already runs traefik:v${TRAEFIK_VERSION}`, + ); + expect(run.calls.some((call) => call.startsWith("docker stop"))).toBe( + false, + ); + expect(run.calls.some((call) => call.startsWith("docker create"))).toBe( + false, + ); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + it( + "replaces Traefik when its image is the target but port 80 is still published", + () => { + const run = runComponentScript(["traefik"], { + traefikConfig: TLS_CHALLENGE_CONFIG, + env: { + FAKE_OLD_IMAGE: `traefik:v${TRAEFIK_VERSION}`, + FAKE_PORT_BINDINGS: + '{"80/tcp":[{"HostIp":"0.0.0.0","HostPort":"80"}]}', + }, + }); + + expect(run.status).toBe(0); + expect(run.stdout).not.toContain("Traefik already runs"); + expect(run.calls).toContain("docker stop dokploy-traefik"); + expect(run.stdout).toContain( + `Traefik version ${TRAEFIK_VERSION} installed`, + ); + }, + SPAWN_TEST_TIMEOUT_MS, + ); it("creates the new Traefik with --restart no and raises the policy only after the start", () => { const run = runComponentScript(["traefik"]); @@ -1470,14 +1676,18 @@ describe("buildTraefikCreateCommand", () => { expect(command).toContain("-p 443:443/udp"); expect(command).toContain("traefik:v3.7.5"); }); + + it("never publishes host port 80", () => { + expect(buildTraefikCreateCommand("3.7.5")).not.toMatch(PORT_80_PUBLICATION); + }); }); describe("buildTraefikRunCommand", () => { - it("publishes the HTTP, HTTPS and HTTP/3 ports with the requested image", () => { + it("publishes HTTPS and HTTP/3 with the requested image and never port 80", () => { const command = buildTraefikRunCommand("3.7.5"); expect(command).toMatch(/-p 443:443\s/); - expect(command).toMatch(/-p 80:80\s/); + expect(command).not.toMatch(PORT_80_PUBLICATION); expect(command).toContain("-p 443:443/udp"); expect(command).toContain("traefik:v3.7.5"); }); diff --git a/apps/dokploy/__test__/server/server-setup.test.ts b/apps/dokploy/__test__/server/server-setup.test.ts index 0a7d1c13df..e3b7ebdc72 100644 --- a/apps/dokploy/__test__/server/server-setup.test.ts +++ b/apps/dokploy/__test__/server/server-setup.test.ts @@ -9,11 +9,20 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { defaultCommand, reportDockerVersion } from "@dokploy/server"; +import { + createTraefikInstance, + defaultCommand, + reportDockerVersion, +} from "@dokploy/server"; import { NIXPACKS_VERSION, RAILPACK_VERSION, } from "@dokploy/server/setup/component-versions"; +import { + buildTraefikRunCommand, + buildTraefikTlsMigrationStep, + TRAEFIK_VERSION, +} from "@dokploy/server/setup/traefik-setup"; import { describe, expect, it } from "vitest"; const resolveBin = (name: string) => @@ -163,6 +172,19 @@ printf '%s\\n' 'echo fake installer "$NIXPACKS_VERSION$RAILPACK_VERSION"' > "$ou } }; +describe("createTraefikInstance", () => { + it("converts the Traefik config before a missing container is created, and stops if that fails", () => { + const command = createTraefikInstance(); + const migration = command.indexOf(buildTraefikTlsMigrationStep()); + + expect(migration).toBeGreaterThanOrEqual(0); + expect(migration).toBeLessThan( + command.indexOf(buildTraefikRunCommand(TRAEFIK_VERSION)), + ); + expect(command).toMatch(/\}; then\s+echo "[^"]*" >&2\s+exit 1\s+fi\s/); + }); +}); + describe("install scripts in the server setup", () => { it.each([false, true])( "downloads the Nixpacks and Railpack install scripts to files and checks them before running them (isBuildServer=%s)", diff --git a/apps/dokploy/__test__/server/traefik-setup.test.ts b/apps/dokploy/__test__/server/traefik-setup.test.ts new file mode 100644 index 0000000000..2b8e7ba77c --- /dev/null +++ b/apps/dokploy/__test__/server/traefik-setup.test.ts @@ -0,0 +1,862 @@ +import { spawnSync } from "node:child_process"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { wrapComponentUpdateSteps } from "@dokploy/server/setup/server-components"; +import { + buildTraefikTlsMigrationStep, + getDefaultServerTraefikConfig, + getDefaultTraefikConfig, + HTTP_CHALLENGE_TO_TLS_AWK, + initializeStandaloneTraefik, + initializeTraefikService, + type TraefikOptions, +} from "@dokploy/server/setup/traefik-setup"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getRemoteDocker: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({ + getRemoteDocker: mocks.getRemoteDocker, +})); + +const storedPorts: NonNullable = [ + { targetPort: 80, publishedPort: 80, protocol: "tcp" }, + { targetPort: 8081, publishedPort: 80, protocol: "tcp" }, + { targetPort: 9000, publishedPort: 9000, protocol: "tcp" }, +]; + +const createStandaloneContainer = async ( + additionalPorts: TraefikOptions["additionalPorts"], +) => { + const createContainer = vi.fn().mockResolvedValue(undefined); + const container = { + remove: vi.fn().mockResolvedValue(undefined), + start: vi.fn().mockResolvedValue(undefined), + }; + mocks.getRemoteDocker.mockResolvedValue({ + createContainer, + getContainer: () => container, + pull: vi.fn().mockResolvedValue(undefined), + }); + + const started = initializeStandaloneTraefik({ additionalPorts }); + await vi.runAllTimersAsync(); + await started; + + return createContainer.mock.calls[0]?.[0]; +}; + +describe("initializeStandaloneTraefik", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("never binds host port 80, even when a stored port list still contains it", async () => { + const settings = await createStandaloneContainer(storedPorts); + const hostPorts = Object.values(settings.HostConfig.PortBindings).flatMap( + (bindings) => + (bindings as { HostPort: string }[]).map((binding) => binding.HostPort), + ); + + expect(hostPorts).not.toContain("80"); + expect(hostPorts).toContain("9000"); + expect(settings.HostConfig.PortBindings).not.toHaveProperty("80/tcp"); + expect(settings.ExposedPorts).not.toHaveProperty("80/tcp"); + }); + + it("keeps HTTPS and HTTP/3 on port 443 over TCP and UDP", async () => { + const settings = await createStandaloneContainer([]); + + expect(settings.HostConfig.PortBindings["443/tcp"]).toEqual([ + { HostPort: "443" }, + ]); + expect(settings.HostConfig.PortBindings["443/udp"]).toEqual([ + { HostPort: "443" }, + ]); + }); +}); + +describe("initializeTraefikService", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("never publishes host port 80 in the service endpoint", async () => { + const createService = vi.fn().mockResolvedValue(undefined); + mocks.getRemoteDocker.mockResolvedValue({ + createService, + getService: () => ({ + inspect: vi.fn().mockRejectedValue(new Error("service not found")), + }), + }); + + await initializeTraefikService({ additionalPorts: storedPorts }); + + const endpointPorts = createService.mock.calls[0]?.[0].EndpointSpec.Ports; + expect( + endpointPorts.map( + (port: { PublishedPort: number }) => port.PublishedPort, + ), + ).not.toContain(80); + expect(endpointPorts).toContainEqual( + expect.objectContaining({ + TargetPort: 443, + PublishedPort: 443, + Protocol: "tcp", + }), + ); + expect(endpointPorts).toContainEqual( + expect.objectContaining({ + TargetPort: 443, + PublishedPort: 443, + Protocol: "udp", + }), + ); + expect(endpointPorts).toContainEqual( + expect.objectContaining({ + TargetPort: 9000, + PublishedPort: 9000, + Protocol: "tcp", + }), + ); + }); +}); + +describe("buildTraefikTlsMigrationStep", () => { + const isRoot = process.getuid?.() === 0; + const SPAWN_TEST_TIMEOUT_MS = 30_000; + let dir = ""; + + beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), "traefik-tls-")); + }); + + afterEach(() => { + rmSync(dir, { force: true, recursive: true }); + }); + + const lines = (...rows: string[]) => `${rows.join("\n")}\n`; + + const filesInDir = () => readdirSync(dir).sort(); + + const backupFile = () => { + const backups = filesInDir().filter((name) => name.includes(".bak-")); + expect(backups).toHaveLength(1); + return path.join(dir, backups[0] ?? ""); + }; + + const runStep = (configPath: string) => + spawnSync( + "bash", + [ + "-c", + [ + "set -eu", + "if ! {", + buildTraefikTlsMigrationStep(configPath), + "}; then", + "\texit 1", + "fi", + ].join("\n"), + ], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `/bin:/usr/bin:${process.env.PATH ?? ""}`, + SUDO_CMD: "", + }, + }, + ); + + const runWrapped = (configPath: string) => { + const shims = path.join(dir, "shims"); + mkdirSync(shims); + writeFileSync(path.join(shims, "sudo"), '#!/bin/sh\nexec "$@"\n'); + chmodSync(path.join(shims, "sudo"), 0o755); + return spawnSync( + "bash", + [ + "-c", + wrapComponentUpdateSteps(buildTraefikTlsMigrationStep(configPath)), + ], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `${shims}:/bin:/usr/bin:${process.env.PATH ?? ""}`, + }, + }, + ); + }; + + it( + "does not stop the settings script when the config has no httpChallenge", + () => { + const configPath = path.join(dir, "traefik.yml"); + const original = lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " tlsChallenge: {}", + ); + writeFileSync(configPath, original); + + const run = runWrapped(configPath); + + expect(run.status).toBe(0); + expect(readFileSync(configPath, "utf8")).toBe(original); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it( + "stops the settings script when the config cannot be converted", + () => { + const configPath = path.join(dir, "traefik.yml"); + const original = lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " httpChallenge: # HTTP-01 needs port 80", + " entryPoint: web", + ); + writeFileSync(configPath, original); + + const run = runWrapped(configPath); + + expect(run.status).not.toBe(0); + expect(readFileSync(configPath, "utf8")).toBe(original); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it("builds a step that rewrites httpChallenge and keeps a dated backup", () => { + const step = buildTraefikTlsMigrationStep(); + + expect(step.split("\n")[0]).toBe('traefik_acme_backup=""'); + expect(step).toContain(HTTP_CHALLENGE_TO_TLS_AWK); + expect(step).toContain("tlsChallenge: {}"); + expect(step).toContain('.bak-$(date -u +%Y%m%d%H%M%S)"'); + }); + + it("turns the default block form into tlsChallenge at the same indentation", () => { + const configPath = path.join(dir, "traefik.yml"); + const original = lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: admin@example.com", + " storage: /letsencrypt/acme.json", + " httpChallenge:", + " entryPoint: web", + ); + writeFileSync(configPath, original); + + const run = runStep(configPath); + const backup = backupFile(); + + expect(run.status).toBe(0); + expect(run.stdout).toContain(`Previous config saved to ${backup}`); + expect(readFileSync(configPath, "utf8")).toBe( + lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: admin@example.com", + " storage: /letsencrypt/acme.json", + " tlsChallenge: {}", + ), + ); + expect(readFileSync(backup)).toEqual(Buffer.from(original)); + expect(filesInDir()).toEqual([path.basename(backup), "traefik.yml"].sort()); + }); + + it("keeps the sibling keys around an httpChallenge block unchanged", () => { + const configPath = path.join(dir, "traefik.yml"); + const original = lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: admin@example.com", + " httpChallenge:", + " entryPoint: web", + " storage: /letsencrypt/acme.json", + "api:", + " insecure: true", + ); + writeFileSync(configPath, original); + + const run = runStep(configPath); + + expect(run.status).toBe(0); + expect(readFileSync(configPath, "utf8")).toBe( + lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: admin@example.com", + " tlsChallenge: {}", + " storage: /letsencrypt/acme.json", + "api:", + " insecure: true", + ), + ); + expect(readFileSync(backupFile())).toEqual(Buffer.from(original)); + }); + + it("skips the children of an httpChallenge block that follow a blank line", () => { + const configPath = path.join(dir, "traefik.yml"); + const original = lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " httpChallenge:", + " entryPoint: web", + "", + " domains: example.com", + " storage: /letsencrypt/acme.json", + ); + writeFileSync(configPath, original); + + const run = runStep(configPath); + + expect(run.status).toBe(0); + expect(readFileSync(configPath, "utf8")).toBe( + lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " tlsChallenge: {}", + "", + " storage: /letsencrypt/acme.json", + ), + ); + expect(readFileSync(backupFile())).toEqual(Buffer.from(original)); + }); + it("converts the inline httpChallenge form", () => { + const configPath = path.join(dir, "traefik.yml"); + const original = lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: admin@example.com", + " httpChallenge: {entryPoint: web}", + " storage: /letsencrypt/acme.json", + ); + writeFileSync(configPath, original); + + const run = runStep(configPath); + + expect(run.status).toBe(0); + expect(readFileSync(configPath, "utf8")).toBe( + lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: admin@example.com", + " tlsChallenge: {}", + " storage: /letsencrypt/acme.json", + ), + ); + expect(readFileSync(backupFile())).toEqual(Buffer.from(original)); + }); + + it("leaves a config that already uses tlsChallenge untouched", () => { + const configPath = path.join(dir, "traefik.yml"); + const original = lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " tlsChallenge: {}", + ); + writeFileSync(configPath, original); + + const run = runStep(configPath); + + expect(run.status).toBe(0); + expect(readFileSync(configPath, "utf8")).toBe(original); + expect(filesInDir()).toEqual(["traefik.yml"]); + }); + + it.each(["bash", "/bin/sh"])( + "fails before touching the config when sudo cannot run commands (%s)", + (shell) => { + const configPath = path.join(dir, "traefik.yml"); + const original = lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " httpChallenge:", + " entryPoint: web", + ); + writeFileSync(configPath, original); + const shims = path.join(dir, "shims"); + const tmp = path.join(dir, "tmp"); + mkdirSync(shims); + mkdirSync(tmp); + writeFileSync(path.join(shims, "sudo"), "#!/bin/sh\nexit 1\n"); + chmodSync(path.join(shims, "sudo"), 0o755); + + const run = spawnSync( + shell, + ["-c", buildTraefikTlsMigrationStep(configPath)], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `${shims}:/bin:/usr/bin:${process.env.PATH ?? ""}`, + SUDO_CMD: "sudo", + TMPDIR: tmp, + }, + }, + ); + + expect(run.status).toBe(1); + expect(run.stderr).toContain("could not run test as root"); + expect(readFileSync(configPath, "utf8")).toBe(original); + expect(filesInDir()).toEqual(["shims", "tmp", "traefik.yml"].sort()); + expect(readdirSync(tmp)).toEqual([]); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it.each(["bash", "/bin/sh"])( + "fails without changing anything when the existence check cannot run (%s)", + (shell) => { + const configPath = path.join(dir, "traefik.yml"); + const original = lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " httpChallenge:", + " entryPoint: web", + ); + writeFileSync(configPath, original); + const shims = path.join(dir, "shims"); + const tmp = path.join(dir, "tmp"); + mkdirSync(shims); + mkdirSync(tmp); + writeFileSync(path.join(shims, "sudo"), '#!/bin/sh\nexec "$@"\n'); + chmodSync(path.join(shims, "sudo"), 0o755); + // The probe on "/" must pass, so the run reaches the existence check that this test targets. + writeFileSync( + path.join(shims, "test"), + '#!/bin/sh\n[ "$2" = / ] && exit 0\nexit 127\n', + ); + chmodSync(path.join(shims, "test"), 0o755); + + const run = spawnSync( + shell, + ["-c", buildTraefikTlsMigrationStep(configPath)], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `${shims}:/bin:/usr/bin:${process.env.PATH ?? ""}`, + SUDO_CMD: "sudo", + TMPDIR: tmp, + }, + }, + ); + + expect(run.status).toBe(1); + expect(run.stderr).toContain(`could not check ${configPath}`); + expect(readFileSync(configPath, "utf8")).toBe(original); + expect(filesInDir()).toEqual(["shims", "tmp", "traefik.yml"].sort()); + expect(readdirSync(tmp)).toEqual([]); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it.each(["bash", "/bin/sh"])( + "fails without changing anything when test cannot run as root (%s)", + (shell) => { + const configPath = path.join(dir, "traefik.yml"); + const original = lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " httpChallenge:", + " entryPoint: web", + ); + writeFileSync(configPath, original); + const shims = path.join(dir, "shims"); + const tmp = path.join(dir, "tmp"); + mkdirSync(shims); + mkdirSync(tmp); + writeFileSync(path.join(shims, "sudo"), '#!/bin/sh\nexec "$@"\n'); + chmodSync(path.join(shims, "sudo"), 0o755); + writeFileSync(path.join(shims, "test"), "#!/bin/sh\nexit 1\n"); + chmodSync(path.join(shims, "test"), 0o755); + + const run = spawnSync( + shell, + ["-c", buildTraefikTlsMigrationStep(configPath)], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `${shims}:/bin:/usr/bin:${process.env.PATH ?? ""}`, + SUDO_CMD: "sudo", + TMPDIR: tmp, + }, + }, + ); + + expect(run.status).toBe(1); + expect(run.stderr).toContain("could not run test as root"); + expect(readFileSync(configPath, "utf8")).toBe(original); + expect(filesInDir()).toEqual(["shims", "tmp", "traefik.yml"].sort()); + expect(readdirSync(tmp)).toEqual([]); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it("keeps a comment inside an httpChallenge block from ending the skip", () => { + const configPath = path.join(dir, "traefik.yml"); + const original = lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " httpChallenge:", + " # note", + " entryPoint: web", + ); + writeFileSync(configPath, original); + + const run = runStep(configPath); + + expect(run.status).toBe(0); + expect(readFileSync(configPath, "utf8")).toBe( + lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " tlsChallenge: {}", + ), + ); + expect(readFileSync(backupFile())).toEqual(Buffer.from(original)); + }); + + it.each(["bash", "/bin/sh"])( + "fails when a line under the converted tlsChallenge is more indented (%s)", + (shell) => { + const configPath = path.join(dir, "traefik.yml"); + const original = lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " httpChallenge: {entryPoint: web}", + " extra: 1", + ); + writeFileSync(configPath, original); + const tmp = path.join(dir, "tmp"); + mkdirSync(tmp); + + const run = spawnSync( + shell, + ["-c", buildTraefikTlsMigrationStep(configPath)], + { + encoding: "utf8", + env: { ...process.env, SUDO_CMD: "", TMPDIR: tmp }, + }, + ); + const backup = backupFile(); + + expect(run.status).toBe(1); + expect(run.stderr).toContain("has lines under tlsChallenge"); + expect(run.stderr).toContain(backup); + expect(readFileSync(configPath, "utf8")).toBe(original); + expect(readFileSync(backup)).toEqual(Buffer.from(original)); + expect(readdirSync(tmp)).toEqual([]); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it.each(["bash", "/bin/sh"])( + "keeps saving when sudo cannot run test and the config needs no conversion (%s)", + (shell) => { + const configPath = path.join(dir, "traefik.yml"); + const original = lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " tlsChallenge: {}", + ); + writeFileSync(configPath, original); + const shims = path.join(dir, "shims"); + const tmp = path.join(dir, "tmp"); + mkdirSync(shims); + mkdirSync(tmp); + writeFileSync(path.join(shims, "sudo"), "#!/bin/sh\nexit 1\n"); + chmodSync(path.join(shims, "sudo"), 0o755); + + const run = spawnSync( + shell, + ["-c", buildTraefikTlsMigrationStep(configPath)], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `${shims}:/bin:/usr/bin:${process.env.PATH ?? ""}`, + SUDO_CMD: "sudo", + TMPDIR: tmp, + }, + }, + ); + + expect(run.status).toBe(0); + expect(readFileSync(configPath, "utf8")).toBe(original); + expect(filesInDir()).toEqual(["shims", "tmp", "traefik.yml"].sort()); + expect(readdirSync(tmp)).toEqual([]); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it.each(["bash", "/bin/sh"])( + "fails closed when sudo cannot run test and the config is missing (%s)", + (shell) => { + const configPath = path.join(dir, "traefik.yml"); + const shims = path.join(dir, "shims"); + const tmp = path.join(dir, "tmp"); + mkdirSync(shims); + mkdirSync(tmp); + writeFileSync(path.join(shims, "sudo"), "#!/bin/sh\nexit 1\n"); + chmodSync(path.join(shims, "sudo"), 0o755); + + const run = spawnSync( + shell, + ["-c", buildTraefikTlsMigrationStep(configPath)], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `${shims}:/bin:/usr/bin:${process.env.PATH ?? ""}`, + SUDO_CMD: "sudo", + TMPDIR: tmp, + }, + }, + ); + + expect(run.status).toBe(1); + expect(run.stderr).toContain("could not run test as root"); + expect(filesInDir()).toEqual(["shims", "tmp"].sort()); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it("does nothing when there is no config file", () => { + const run = runStep(path.join(dir, "traefik.yml")); + + expect(run.status).toBe(0); + expect(filesInDir()).toEqual([]); + }); + + it("fails and keeps the original config when the conversion cannot be verified", () => { + const configPath = path.join(dir, "traefik.yml"); + const original = lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " httpChallenge: # HTTP-01 needs port 80", + " entryPoint: web", + ); + writeFileSync(configPath, original); + + const run = runStep(configPath); + const backup = backupFile(); + + expect(run.status).toBe(1); + expect(run.stderr).toContain(configPath); + expect(run.stderr).toContain(backup); + expect(readFileSync(configPath, "utf8")).toBe(original); + expect(readFileSync(backup)).toEqual(Buffer.from(original)); + expect(filesInDir()).toEqual([path.basename(backup), "traefik.yml"].sort()); + }); + + it.each(["bash", "/bin/sh"])( + "fails with the original config untouched when awk exits non-zero after writing output (%s)", + (shell) => { + const configPath = path.join(dir, "traefik.yml"); + const original = lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " httpChallenge:", + " entryPoint: web", + ); + writeFileSync(configPath, original); + const shims = path.join(dir, "shims"); + const tmp = path.join(dir, "tmp"); + mkdirSync(shims); + mkdirSync(tmp); + writeFileSync( + path.join(shims, "awk"), + '#!/bin/sh\necho "tlsChallenge: {}"\nexit 2\n', + ); + chmodSync(path.join(shims, "awk"), 0o755); + + const run = spawnSync( + shell, + ["-c", buildTraefikTlsMigrationStep(configPath)], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `${shims}:/bin:/usr/bin:${process.env.PATH ?? ""}`, + SUDO_CMD: "", + TMPDIR: tmp, + }, + }, + ); + const backup = backupFile(); + + expect(run.status).toBe(1); + expect(run.stderr).toContain("awk exited with status 2"); + expect(run.stderr).toContain(configPath); + expect(run.stderr).toContain(backup); + expect(readFileSync(configPath, "utf8")).toBe(original); + expect(readdirSync(tmp)).toEqual([]); + expect(filesInDir()).toEqual( + ["shims", "tmp", path.basename(backup), "traefik.yml"].sort(), + ); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it( + "converts a block-form config when run by /bin/sh, the shell the local settings path uses", + () => { + const configPath = path.join(dir, "traefik.yml"); + const original = lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " httpChallenge:", + " entryPoint: web", + ); + writeFileSync(configPath, original); + + const run = spawnSync( + "/bin/sh", + ["-c", buildTraefikTlsMigrationStep(configPath)], + { encoding: "utf8", env: { ...process.env, SUDO_CMD: "" } }, + ); + + expect(run.status).toBe(0); + expect(readFileSync(configPath, "utf8")).toBe( + lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " tlsChallenge: {}", + ), + ); + expect(readFileSync(backupFile())).toEqual(Buffer.from(original)); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it("fails without changing anything when the config path is a directory", () => { + const configPath = path.join(dir, "traefik.yml"); + mkdirSync(configPath); + + const run = runStep(configPath); + + expect(run.status).toBe(1); + expect(run.stderr).toContain("is not a regular file"); + expect(filesInDir()).toEqual(["traefik.yml"]); + }); + + it.skipIf(isRoot)( + "fails without changing anything when the config cannot be read", + () => { + const configPath = path.join(dir, "traefik.yml"); + const original = lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " httpChallenge:", + " entryPoint: web", + ); + writeFileSync(configPath, original); + chmodSync(configPath, 0o000); + + const run = runStep(configPath); + chmodSync(configPath, 0o600); + + expect(run.status).toBe(1); + expect(run.stderr).toContain("could not read"); + expect(readFileSync(configPath, "utf8")).toBe(original); + expect(filesInDir()).toEqual(["traefik.yml"]); + }, + ); + + it.skipIf(isRoot)( + "reports a failed write and keeps the backup in place", + () => { + const configPath = path.join(dir, "traefik.yml"); + const original = lines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " httpChallenge:", + " entryPoint: web", + ); + writeFileSync(configPath, original); + chmodSync(configPath, 0o444); + + const run = runStep(configPath); + const backup = backupFile(); + + expect(run.status).toBe(1); + expect(run.stderr).toContain(`could not write ${configPath}`); + expect(run.stderr).toContain(`restoring it from ${backup} failed`); + expect(readFileSync(configPath, "utf8")).toBe(original); + expect(readFileSync(backup)).toEqual(Buffer.from(original)); + expect(filesInDir()).toEqual( + [path.basename(backup), "traefik.yml"].sort(), + ); + }, + ); +}); + +describe("Traefik ACME challenge", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("uses the TLS-ALPN challenge in the server config and keeps the web entrypoint", () => { + const config = getDefaultServerTraefikConfig(); + + expect(config).toContain("tlsChallenge: {}"); + expect(config).not.toContain("httpChallenge"); + expect(config).toMatch(/^\s+web:$/m); + }); + + it("uses the TLS-ALPN challenge in the production config and keeps the web entrypoint", () => { + vi.stubEnv("NODE_ENV", "production"); + const config = getDefaultTraefikConfig(); + + expect(config).toContain("tlsChallenge: {}"); + expect(config).not.toContain("httpChallenge"); + expect(config).toMatch(/^\s+web:$/m); + }); +}); diff --git a/apps/dokploy/__test__/settings/docker-server-boundary.test.ts b/apps/dokploy/__test__/settings/docker-server-boundary.test.ts index 0d67e55e12..2e888487e8 100644 --- a/apps/dokploy/__test__/settings/docker-server-boundary.test.ts +++ b/apps/dokploy/__test__/settings/docker-server-boundary.test.ts @@ -412,6 +412,20 @@ describe("settings Docker server boundary", () => { expect(mocks.writeTraefikSetup).not.toHaveBeenCalled(); }); + it("rejects a Traefik port that publishes host port 80 before Traefik is rewritten", async () => { + await expect( + createCaller().updateTraefikPorts({ + additionalPorts: [ + { protocol: "tcp", publishedPort: 80, targetPort: 8081 }, + ], + serverId: "server-1", + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + + expect(mocks.checkPortInUse).not.toHaveBeenCalled(); + expect(mocks.writeTraefikSetup).not.toHaveBeenCalled(); + }); + it("denies inaccessible Traefik port reads before Traefik reads", async () => { mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-2"])); diff --git a/apps/dokploy/__test__/settings/install-agenthits-update.test.ts b/apps/dokploy/__test__/settings/install-agenthits-update.test.ts index b9195d8dc7..2bd728baa0 100644 --- a/apps/dokploy/__test__/settings/install-agenthits-update.test.ts +++ b/apps/dokploy/__test__/settings/install-agenthits-update.test.ts @@ -1,6 +1,7 @@ import { spawnSync } from "node:child_process"; import { chmodSync, + existsSync, mkdirSync, mkdtempSync, readdirSync, @@ -14,6 +15,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -74,6 +76,9 @@ case "$1" in case "$*" in *Config.Image*) echo "$FAKE_TRAEFIK_IMAGE" ;; *NetworkSettings*) echo "$FAKE_TRAEFIK_NETWORKS" ;; + *HostConfig.PortBindings*) + if [ "$FAKE_TRAEFIK_PORT80" = "1" ]; then echo '{"80/tcp":[{"HostIp":"","HostPort":"80"}],"443/tcp":[{"HostIp":"","HostPort":"443"}]}'; fi + ;; *RestartCount*) traefik_now="$(cat "$DOCKER_TRAEFIK_IMAGE_FILE" 2>/dev/null || echo "$FAKE_TRAEFIK_IMAGE")" if [ "$FAKE_TRAEFIK_RUNNING" = "false" ] && [ "$traefik_now" != "$FAKE_TRAEFIK_IMAGE" ]; then echo "false 0"; else echo "true 0"; fi @@ -243,6 +248,25 @@ fi exec ${realPath("uname")} "$@" `; +const textLines = (...rows: string[]) => `${rows.join("\n")}\n`; + +const TLS_TRAEFIK_CONFIG = textLines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: ops@example.com", + " tlsChallenge: {}", +); + +const HTTP_TRAEFIK_CONFIG = textLines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: ops@example.com", + " httpChallenge:", + " entryPoint: web", +); + type OperatorScenario = { redisImage?: string; postgresImage?: string; @@ -250,6 +274,8 @@ type OperatorScenario = { panelImage?: string; traefikRunning?: "true" | "false"; traefikNetworks?: string; + traefikPublishesPort80?: boolean; + traefikConfig?: string; missingNetworks?: string; connectFails?: string; updateState?: string; @@ -280,6 +306,8 @@ const runInstaller = ( const backupDir = path.join(dir, "backups"); const sourcesDir = path.join(dir, "sources"); const osRelease = path.join(dir, "os-release"); + const traefikConfig = path.join(dir, "traefik.yml"); + writeFileSync(traefikConfig, scenario.traefikConfig ?? TLS_TRAEFIK_CONFIG); writeFileSync(callLog, ""); writeFileSync(indexFile, "1"); writeFileSync(engineFile, `${scenario.engineVersion ?? "29.8.2"}\n`); @@ -325,6 +353,7 @@ const runInstaller = ( DOKPLOY_BACKUP_DIR: backupDir, DOKPLOY_APT_SOURCES_DIR: sourcesDir, DOKPLOY_OS_RELEASE: osRelease, + TRAEFIK_CONFIG_FILE: traefikConfig, DOCKER_ENGINE_UPGRADE: scenario.upgradeEngine ? "1" : "0", AGENTHITS_PULL_RETRY_DELAY: "0", DOKPLOY_HEALTH_INTERVAL: "0", @@ -344,6 +373,7 @@ const runInstaller = ( FAKE_TRAEFIK_RUNNING: scenario.traefikRunning ?? "true", FAKE_TRAEFIK_NETWORKS: scenario.traefikNetworks ?? "bridge dokploy-network", + FAKE_TRAEFIK_PORT80: scenario.traefikPublishesPort80 ? "1" : "0", FAKE_MISSING_NETWORKS: scenario.missingNetworks ?? "", FAKE_CONNECT_FAILS: scenario.connectFails ?? "", FAKE_UPDATE_STATE: scenario.updateState ?? "completed", @@ -384,7 +414,20 @@ const runInstaller = ( const backupModes = backups.map( (name) => statSync(path.join(backupDir, name)).mode & 0o777, ); - return { result, calls, backups, backupModes, backupEntries }; + const configBackup = readdirSync(dir).find((name) => + name.includes(".bak-"), + ); + return { + result, + calls, + backups, + backupModes, + backupEntries, + configText: readFileSync(traefikConfig, "utf8"), + configBackupText: configBackup + ? readFileSync(path.join(dir, configBackup), "utf8") + : undefined, + }; } finally { rmSync(dir, { force: true, recursive: true }); } @@ -393,6 +436,13 @@ const runInstaller = ( const firstIndex = (calls: string[], prefix: string) => calls.findIndex((call) => call.startsWith(prefix)); +const shellFunctionText = (script: string, name: string) => { + const lines = script.split("\n"); + const start = lines.indexOf(`${name}() {`); + const end = lines.indexOf("}", start); + return lines.slice(start, end + 1).join("\n"); +}; + const DOCKER_NEW = "5:29.8.2-1~ubuntu.24.04~noble"; const DOCKER_OLD = "5:28.3.0-1~ubuntu.24.04~noble"; @@ -1050,6 +1100,8 @@ printf '%s\\n' 'echo fake nixpacks "$NIXPACKS_VERSION"' > "$out" const curl = path.join(dir, "curl"); writeFileSync(curl, FAKE_INSTALL_CURL); chmodSync(curl, 0o755); + const traefikConfig = path.join(dir, "traefik.yml"); + writeFileSync(traefikConfig, TLS_TRAEFIK_CONFIG); const script = [ "set -euo pipefail", helperText(), @@ -1062,6 +1114,7 @@ printf '%s\\n' 'echo fake nixpacks "$NIXPACKS_VERSION"' > "$out" ...process.env, PATH: `${dir}:${binDir}`, FAKE_CURL_MODE: curlMode, + TRAEFIK_CONFIG_FILE: traefikConfig, }, }); } finally { @@ -1177,6 +1230,66 @@ describe("install-agenthits.sh Docker verification window", () => { }); }); +const PORT_80_PUBLICATION = + /(?:-p|--publish)[=\s]+\S*\b80\b|published=80\b|\b80\/tcp\b/; + +describe("install-agenthits.sh publishes no port 80", () => { + it( + "creates the Traefik container with HTTPS and HTTP/3 and never port 80", + () => { + const { result, calls } = runInstaller({ + traefikImage: "traefik:v3.6.25", + }); + + expect(result.status).toBe(0); + const created = calls.filter((call) => + call.startsWith("create --name dokploy-traefik"), + ); + expect(created).not.toHaveLength(0); + for (const call of created) { + expect(call).not.toMatch(PORT_80_PUBLICATION); + expect(call).toContain("-p 443:443/tcp"); + expect(call).toContain("-p 443:443/udp"); + } + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it( + "replaces Traefik with the same image when the running one still publishes port 80", + () => { + const { result, calls } = runInstaller({ + traefikImage: "traefik:v3.7.14", + traefikPublishesPort80: true, + }); + + expect(result.status).toBe(0); + expect(result.stdout).not.toContain("Traefik already runs"); + expect(result.stdout).toContain("Traefik runs traefik:v3.7.14"); + const created = calls.filter((call) => + call.startsWith("create --name dokploy-traefik"), + ); + expect(created).toHaveLength(1); + expect(created[0]).not.toMatch(PORT_80_PUBLICATION); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it("keeps port 80 out of the static Traefik config, the firewall and the preflight checks", () => { + const installer = readFileSync(installerScript, "utf8"); + const defaults = shellFunctionText( + installer, + "create_default_traefik_files", + ); + + expect(defaults).toContain("tlsChallenge: {}"); + expect(defaults).not.toContain("httpChallenge"); + expect(installer).not.toContain("TRAEFIK_HTTP_PUBLISH"); + expect(installer).not.toMatch(/ufw allow 80\//); + expect(installer).not.toMatch(/require_free_port 80\b/); + }); +}); + describe("install-agenthits.sh Traefik settle check", () => { it("polls the new Traefik once a second over the settle time, then checks it once more", () => { const installer = readFileSync(installerScript, "utf8"); @@ -1228,3 +1341,536 @@ describe("install-agenthits.sh Traefik settle check", () => { SPAWN_TEST_TIMEOUT_MS, ); }); + +const runMigrateTraefikAcme = ( + config: string | null, + { + passPath = true, + failWrite = false, + }: { passPath?: boolean; failWrite?: boolean } = {}, +) => { + const installer = readFileSync(installerScript, "utf8"); + const dir = mkdtempSync(path.join(tmpdir(), "traefik-acme-")); + try { + const configPath = path.join(dir, "traefik.yml"); + if (config !== null) { + writeFileSync(configPath, config); + } + const scriptPath = path.join(dir, "migrate.sh"); + const failingCat = failWrite + ? 'cat() {\n\tcase "$1" in\n\t\t*.tmp) return 1 ;;\n\t\t*) command cat "$@" ;;\n\tesac\n}\n' + : ""; + writeFileSync( + scriptPath, + `set -eu\n${failingCat}${shellFunctionText(installer, "migrate_traefik_acme_to_tls")}\nmigrate_traefik_acme_to_tls${passPath ? ' "$1"' : ""}\n`, + ); + const result = spawnSync("/bin/bash", [scriptPath, configPath], { + encoding: "utf8", + env: { + ...process.env, + PATH: `/bin:/usr/bin:${process.env.PATH}`, + TRAEFIK_CONFIG_FILE: configPath, + }, + }); + const entries = readdirSync(dir).sort(); + const backupName = entries.find((name) => name.includes(".bak-")); + return { + result, + configText: existsSync(configPath) + ? readFileSync(configPath, "utf8") + : null, + entries, + backupName, + backupBytes: backupName ? readFileSync(path.join(dir, backupName)) : null, + }; + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}; + +const runDirectoryGuard = (target: string) => { + const installer = readFileSync(installerScript, "utf8"); + const lines = installer.split("\n"); + const start = lines.indexOf('\tif [ -d "$TRAEFIK_CONFIG_FILE" ]; then'); + const end = lines.indexOf("\tfi", start); + const dir = mkdtempSync(path.join(tmpdir(), "traefik-guard-")); + try { + const scriptPath = path.join(dir, "guard.sh"); + writeFileSync( + scriptPath, + `set -eu\nTRAEFIK_CONFIG_FILE="$1"\n${lines.slice(start, end + 1).join("\n")}\n`, + ); + return spawnSync("/bin/bash", [scriptPath, target], { + encoding: "utf8", + env: { ...process.env, PATH: `/bin:/usr/bin:${process.env.PATH}` }, + }); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}; + +describe("install-agenthits.sh Traefik ACME migration", () => { + it("runs the migration after the trap and before the old Traefik container is stopped", () => { + const installer = readFileSync(installerScript, "utf8"); + const swap = shellFunctionText(installer, "swap_dokploy_traefik"); + const trap = swap.indexOf( + "trap 'traefik_restore \"being interrupted\"' HUP INT TERM", + ); + const migrate = swap.indexOf( + 'if ! migrate_traefik_acme_to_tls; then traefik_restore "converting the Traefik config to tlsChallenge"; fi', + ); + const stop = swap.indexOf("docker stop dokploy-traefik"); + + expect(trap).toBeGreaterThanOrEqual(0); + expect(migrate).toBeGreaterThan(trap); + expect(stop).toBeGreaterThan(migrate); + }); + + it("skips the early return when the config still has httpChallenge or the container publishes port 80", () => { + const installer = readFileSync(installerScript, "utf8"); + const swap = shellFunctionText(installer, "swap_dokploy_traefik"); + + expect(swap).toContain('TRAEFIK_ACME_BACKUP=""'); + expect(swap).toContain( + "! grep -q '^[[:space:]]*httpChallenge:' \"$TRAEFIK_CONFIG_FILE\" 2>/dev/null", + ); + expect(swap).toContain( + `! docker inspect -f '{{json .HostConfig.PortBindings}}' dokploy-traefik 2>/dev/null | grep -q '"80/tcp"'`, + ); + }); + + it("names the backup with a UTC timestamp and writes the converted file in place", () => { + const installer = readFileSync(installerScript, "utf8"); + const migrate = shellFunctionText(installer, "migrate_traefik_acme_to_tls"); + + expect(installer).toContain("migrate_traefik_acme_to_tls() {"); + expect(migrate).toContain('backup="$config.bak-$(date -u +%Y%m%d%H%M%S)"'); + expect(migrate).toContain('cp -p "$config" "$backup"'); + expect(migrate).toContain('print substr($0, 1, indent) "tlsChallenge: {}"'); + expect(migrate).toContain('cat "$config.tmp" >"$config"'); + }); + + it("keeps the converted config and reports the backup before any container is recreated", () => { + const installer = readFileSync(installerScript, "utf8"); + const restore = shellFunctionText(installer, "traefik_restore"); + const failed = restore.indexOf( + 'echo "Error: Traefik update failed while $failed_step." >&2', + ); + const kept = restore.indexOf( + 'echo "Kept the converted Traefik config (tlsChallenge). Previous config saved to $TRAEFIK_ACME_BACKUP." >&2', + ); + const containerRestore = restore.indexOf( + 'if [ "$TRAEFIK_OLD_STOPPED" = "1" ]; then', + ); + + expect(failed).toBeGreaterThanOrEqual(0); + expect(kept).toBeGreaterThan(failed); + expect(containerRestore).toBeGreaterThan(kept); + expect(restore).not.toContain('cat "$TRAEFIK_ACME_BACKUP"'); + }); + + it("converts a block-form httpChallenge and keeps a byte-exact backup", () => { + const original = textLines( + "global:", + " sendAnonymousUsage: false", + "entryPoints:", + " web:", + " address: :80", + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: ops@example.com", + " storage: /etc/traefik/acme.json", + " httpChallenge:", + " entryPoint: web", + ); + const run = runMigrateTraefikAcme(original); + + expect(run.result.status).toBe(0); + expect(run.result.stdout).toMatch( + /^Certificate renewal now uses port 443 \(tlsChallenge\)\. Previous config saved to \S+traefik\.yml\.bak-\d{14}\n$/, + ); + expect(run.configText).toBe( + textLines( + "global:", + " sendAnonymousUsage: false", + "entryPoints:", + " web:", + " address: :80", + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: ops@example.com", + " storage: /etc/traefik/acme.json", + " tlsChallenge: {}", + ), + ); + expect(run.backupName).toMatch(/^traefik\.yml\.bak-\d{14}$/); + expect(run.backupBytes).toEqual(Buffer.from(original)); + expect(run.entries).not.toContain("traefik.yml.tmp"); + }); + + it("keeps the keys that follow a block-form httpChallenge", () => { + const original = textLines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: ops@example.com", + " httpChallenge:", + " entryPoint: web", + " storage: /etc/traefik/acme.json", + " staging:", + " acme:", + " email: ops@example.com", + "dynamic:", + " directory: /etc/traefik/dynamic", + ); + const run = runMigrateTraefikAcme(original); + + expect(run.result.status).toBe(0); + expect(run.configText).toBe( + textLines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: ops@example.com", + " tlsChallenge: {}", + " storage: /etc/traefik/acme.json", + " staging:", + " acme:", + " email: ops@example.com", + "dynamic:", + " directory: /etc/traefik/dynamic", + ), + ); + expect(run.backupBytes).toEqual(Buffer.from(original)); + }); + + it("converts an inline httpChallenge in place", () => { + const original = textLines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: ops@example.com", + " httpChallenge: {entryPoint: web}", + " storage: /etc/traefik/acme.json", + ); + const run = runMigrateTraefikAcme(original); + + expect(run.result.status).toBe(0); + expect(run.configText).toBe( + textLines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: ops@example.com", + " tlsChallenge: {}", + " storage: /etc/traefik/acme.json", + ), + ); + expect(run.backupBytes).toEqual(Buffer.from(original)); + }); + + it("leaves a config already on tlsChallenge untouched and makes no backup", () => { + const original = textLines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: ops@example.com", + " tlsChallenge: {}", + ); + const run = runMigrateTraefikAcme(original); + + expect(run.result.status).toBe(0); + expect(run.result.stdout).toBe(""); + expect(run.configText).toBe(original); + expect(run.backupName).toBeUndefined(); + }); + + it("does nothing and creates no file when the config is missing", () => { + const run = runMigrateTraefikAcme(null); + + expect(run.result.status).toBe(0); + expect(run.configText).toBeNull(); + expect(run.backupName).toBeUndefined(); + }); + + it("keeps the original config when the converted file fails verification", () => { + const original = textLines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " httpChallenge: {entryPoint: web} # old", + ); + const run = runMigrateTraefikAcme(original); + + expect(run.result.status).toBe(1); + expect(run.result.stderr).toMatch( + /could not convert \S+traefik\.yml to tlsChallenge\. The previous config is saved at \S+traefik\.yml\.bak-\d{14}\./, + ); + expect(run.configText).toBe(original); + expect(run.backupBytes).toEqual(Buffer.from(original)); + expect(run.entries).not.toContain("traefik.yml.tmp"); + }); + + it("keeps a blank line inside a block-form httpChallenge and drops only its children", () => { + const original = textLines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: ops@example.com", + " httpChallenge:", + "", + " entryPoint: web", + "providers:", + " docker:", + " exposedByDefault: false", + ); + const run = runMigrateTraefikAcme(original); + + expect(run.result.status).toBe(0); + expect(run.configText).toBe( + textLines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: ops@example.com", + " tlsChallenge: {}", + "", + "providers:", + " docker:", + " exposedByDefault: false", + ), + ); + expect(parse(run.configText ?? "")).toEqual({ + certificatesResolvers: { + letsencrypt: { + acme: { email: "ops@example.com", tlsChallenge: {} }, + }, + }, + providers: { docker: { exposedByDefault: false } }, + }); + expect(run.backupBytes).toEqual(Buffer.from(original)); + }); + + it("uses TRAEFIK_CONFIG_FILE when it is called without a path", () => { + const original = textLines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " httpChallenge: {entryPoint: web}", + ); + const run = runMigrateTraefikAcme(original, { passPath: false }); + + expect(run.result.status).toBe(0); + expect(run.configText).toBe( + textLines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " tlsChallenge: {}", + ), + ); + expect(run.backupBytes).toEqual(Buffer.from(original)); + }); + + it("reads and writes the Traefik config only through TRAEFIK_CONFIG_FILE", () => { + const installer = readFileSync(installerScript, "utf8"); + const migrate = shellFunctionText(installer, "migrate_traefik_acme_to_tls"); + const swap = shellFunctionText(installer, "swap_dokploy_traefik"); + const restore = shellFunctionText(installer, "traefik_restore"); + + expect(installer).toContain( + 'TRAEFIK_CONFIG_FILE="${TRAEFIK_CONFIG_FILE:-/etc/dokploy/traefik/traefik.yml}"', + ); + expect(migrate).toContain('local config="${1:-$TRAEFIK_CONFIG_FILE}"'); + expect(swap).toContain('"$TRAEFIK_CONFIG_FILE" 2>/dev/null'); + for (const text of [migrate, swap, restore]) { + expect(text).not.toContain("/etc/dokploy/traefik/traefik.yml"); + } + }); + + it("converts the Traefik config right after the default file is written, before any secret or service is created", () => { + const installer = readFileSync(installerScript, "utf8"); + const install = shellFunctionText(installer, "install_agenthits_dokploy"); + const defaults = install.indexOf("\tcreate_default_traefik_files\n"); + const migrate = install.indexOf( + '\tif ! migrate_traefik_acme_to_tls "$TRAEFIK_CONFIG_FILE"; then echo "Error: could not convert the Traefik config to tlsChallenge. Check the file named above, then run the installer again." >&2; exit 1; fi\n', + ); + const firstSecret = install.indexOf("create_secret_if_missing "); + const firstService = install.indexOf("docker service create"); + + expect(defaults).toBeGreaterThanOrEqual(0); + expect(migrate).toBeGreaterThan(defaults); + expect(firstSecret).toBeGreaterThan(migrate); + expect(firstService).toBeGreaterThan(migrate); + }); + + it( + "converts an httpChallenge config during an update and keeps the previous file", + () => { + const { result, configText, configBackupText } = runInstaller({ + traefikImage: "traefik:v3.6.25", + traefikConfig: HTTP_TRAEFIK_CONFIG, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Certificate renewal now uses port 443"); + expect(configText).toBe(TLS_TRAEFIK_CONFIG); + expect(configBackupText).toBe(HTTP_TRAEFIK_CONFIG); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it( + "keeps the converted Traefik config when the new Traefik does not start", + () => { + const { result, configText, configBackupText } = runInstaller({ + traefikImage: "traefik:v3.6.25", + traefikRunning: "false", + traefikConfig: HTTP_TRAEFIK_CONFIG, + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch( + /Kept the converted Traefik config \(tlsChallenge\)\. Previous config saved to \S+traefik\.yml\.bak-\d{14}\./, + ); + expect(configText).toBe(TLS_TRAEFIK_CONFIG); + expect(configBackupText).toBe(HTTP_TRAEFIK_CONFIG); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it("writes and mounts the static Traefik config through TRAEFIK_CONFIG_FILE", () => { + const installer = readFileSync(installerScript, "utf8"); + const defaults = shellFunctionText( + installer, + "create_default_traefik_files", + ); + const container = shellFunctionText(installer, "create_dokploy_traefik"); + + expect(defaults).toContain('if [ -d "$TRAEFIK_CONFIG_FILE" ]; then'); + expect(defaults).toContain('rm -rf "$TRAEFIK_CONFIG_FILE"'); + expect(defaults).toContain('if [ ! -f "$TRAEFIK_CONFIG_FILE" ]; then'); + expect(defaults).toContain("cat >\"$TRAEFIK_CONFIG_FILE\" <<'EOF'"); + expect(defaults).toContain("mkdir -p /etc/dokploy/traefik/dynamic"); + expect(container).toContain( + '-v "$TRAEFIK_CONFIG_FILE:/etc/traefik/traefik.yml" \\', + ); + expect(container).toContain( + "-v /etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic \\", + ); + expect(defaults).toContain('case "$TRAEFIK_CONFIG_FILE" in'); + expect(defaults).toContain( + "/etc/dokploy/traefik/traefik.yml | */traefik.yml)", + ); + expect(defaults).not.toMatch( + /(?:-[df]|rm -rf|cat >)\s*\/etc\/dokploy\/traefik\/traefik\.yml/, + ); + expect(container).not.toContain("/etc/dokploy/traefik/traefik.yml"); + }); + + it("keeps a comment line inside a block-form httpChallenge without ending the skip", () => { + const original = textLines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: ops@example.com", + " httpChallenge:", + "# note", + " entryPoint: web", + ); + const run = runMigrateTraefikAcme(original); + + expect(run.result.status).toBe(0); + expect(run.configText).toBe( + textLines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: ops@example.com", + " tlsChallenge: {}", + ), + ); + expect(parse(run.configText ?? "")).toEqual({ + certificatesResolvers: { + letsencrypt: { + acme: { email: "ops@example.com", tlsChallenge: {} }, + }, + }, + }); + expect(run.backupBytes).toEqual(Buffer.from(original)); + }); + + it("refuses a conversion that would leave a line indented under tlsChallenge", () => { + const original = textLines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " httpChallenge: {entryPoint: web}", + " extra: true", + ); + const run = runMigrateTraefikAcme(original); + + expect(run.result.status).toBe(1); + expect(run.result.stderr).toMatch( + /would leave output line 5 indented under tlsChallenge\. The config is unchanged/, + ); + expect(run.configText).toBe(original); + expect(run.entries).not.toContain("traefik.yml.tmp"); + }); + + it("restores the original config when the in-place write fails", () => { + const original = textLines( + "certificatesResolvers:", + " letsencrypt:", + " acme:", + " email: ops@example.com", + " httpChallenge:", + " entryPoint: web", + ); + const run = runMigrateTraefikAcme(original, { failWrite: true }); + + expect(run.result.status).toBe(1); + expect(run.result.stderr).toMatch( + /could not write \S+traefik\.yml\. The previous config was restored from \S+traefik\.yml\.bak-\d{14}\./, + ); + expect(run.configText).toBe(original); + expect(run.backupBytes).toEqual(Buffer.from(original)); + expect(run.entries).not.toContain("traefik.yml.tmp"); + }); + + it("removes a directory only when its path ends in traefik.yml", () => { + const root = mkdtempSync(path.join(tmpdir(), "traefik-guard-target-")); + try { + const target = path.join(root, "traefik.yml"); + mkdirSync(target); + writeFileSync(path.join(target, "stale"), "x"); + + const result = runDirectoryGuard(target); + + expect(result.status).toBe(0); + expect(existsSync(target)).toBe(false); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("refuses to remove a directory at any other path", () => { + const root = mkdtempSync(path.join(tmpdir(), "traefik-guard-target-")); + try { + const target = path.join(root, "other.yml"); + mkdirSync(target); + + const result = runDirectoryGuard(target); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "is a directory, not the Traefik config file", + ); + expect(existsSync(target)).toBe(true); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); +}); diff --git a/apps/dokploy/__test__/settings/write-traefik-setup.test.ts b/apps/dokploy/__test__/settings/write-traefik-setup.test.ts new file mode 100644 index 0000000000..31f1b80ebb --- /dev/null +++ b/apps/dokploy/__test__/settings/write-traefik-setup.test.ts @@ -0,0 +1,148 @@ +import { writeTraefikSetup } from "@dokploy/server/services/settings"; +import { TRAEFIK_MAIN_CONFIG_PATH } from "@dokploy/server/setup/traefik-setup"; +import { ExecError } from "@dokploy/server/utils/process/ExecError"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), + findMany: vi.fn(), + initializeStandaloneTraefik: vi.fn(), + initializeTraefikService: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + compose: { + findMany: mocks.findMany, + }, + }, + }, +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: mocks.execAsync, + execAsyncRemote: mocks.execAsyncRemote, +})); + +vi.mock("@dokploy/server/setup/traefik-setup", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@dokploy/server/setup/traefik-setup") + >()), + initializeStandaloneTraefik: mocks.initializeStandaloneTraefik, + initializeTraefikService: mocks.initializeTraefikService, +})); + +const CONVERSION = "traefik_convert_acme_to_tls"; +const RESOURCE_CHECK = "RESOURCE_NAME="; +const NO_OUTPUT = { stdout: "", stderr: "" }; + +const answerResourceCheck = (resourceType: string, command: string) => + command.includes(RESOURCE_CHECK) + ? { stdout: `${resourceType}\n`, stderr: "" } + : NO_OUTPUT; + +const conversionCallIndex = (calls: unknown[][]) => + calls.findIndex((args) => String(args[args.length - 1]).includes(CONVERSION)); + +describe("writeTraefikSetup", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.findMany.mockResolvedValue([]); + }); + + it("converts the Traefik config on the server before the standalone Traefik is recreated", async () => { + mocks.execAsyncRemote.mockImplementation( + async (_serverId: string, command: string) => + answerResourceCheck("standalone", command), + ); + + await writeTraefikSetup({ serverId: "server-1" }); + + const conversion = conversionCallIndex(mocks.execAsyncRemote.mock.calls); + expect(conversion).toBeGreaterThanOrEqual(0); + expect(mocks.execAsyncRemote.mock.calls[conversion]?.[0]).toBe("server-1"); + expect( + mocks.execAsyncRemote.mock.invocationCallOrder[conversion], + ).toBeLessThan( + mocks.initializeStandaloneTraefik.mock.invocationCallOrder[0] ?? 0, + ); + expect(mocks.initializeTraefikService).not.toHaveBeenCalled(); + }); + + it("converts the Traefik config on the server before the Traefik service is updated", async () => { + mocks.execAsyncRemote.mockImplementation( + async (_serverId: string, command: string) => + answerResourceCheck("service", command), + ); + + await writeTraefikSetup({ serverId: "server-1" }); + + const conversion = conversionCallIndex(mocks.execAsyncRemote.mock.calls); + expect(conversion).toBeGreaterThanOrEqual(0); + expect( + mocks.execAsyncRemote.mock.invocationCallOrder[conversion], + ).toBeLessThan( + mocks.initializeTraefikService.mock.invocationCallOrder[0] ?? 0, + ); + expect(mocks.initializeStandaloneTraefik).not.toHaveBeenCalled(); + }); + + it("throws before any Traefik container is changed when the conversion fails", async () => { + mocks.execAsyncRemote.mockImplementation( + async (_serverId: string, command: string) => { + if (command.includes(RESOURCE_CHECK)) { + return answerResourceCheck("standalone", command); + } + throw new Error("Remote command failed with exit code 1"); + }, + ); + + await expect(writeTraefikSetup({ serverId: "server-1" })).rejects.toThrow( + "exit code 1", + ); + expect(mocks.initializeStandaloneTraefik).not.toHaveBeenCalled(); + expect(mocks.initializeTraefikService).not.toHaveBeenCalled(); + }); + + it("names the config and the backup in the error when the conversion fails", async () => { + mocks.execAsyncRemote.mockImplementation( + async (_serverId: string, command: string) => { + if (command.includes(RESOURCE_CHECK)) { + return answerResourceCheck("standalone", command); + } + throw new ExecError("Remote command failed with exit code 1", { + command, + serverId: "server-1", + exitCode: 1, + stderr: `Error: could not convert ${TRAEFIK_MAIN_CONFIG_PATH} to tlsChallenge. The original config was left unchanged. Previous config saved to ${TRAEFIK_MAIN_CONFIG_PATH}.bak-20261009000000\n`, + }); + }, + ); + + const failure = writeTraefikSetup({ serverId: "server-1" }); + + await expect(failure).rejects.toThrow( + `Traefik config ${TRAEFIK_MAIN_CONFIG_PATH} could not be converted to tlsChallenge`, + ); + await expect(failure).rejects.toThrow( + `${TRAEFIK_MAIN_CONFIG_PATH}.bak-20261009000000`, + ); + expect(mocks.initializeStandaloneTraefik).not.toHaveBeenCalled(); + }); + + it("runs the conversion on this host, not over SSH, when no server is given", async () => { + mocks.execAsync.mockImplementation(async (command: string) => + answerResourceCheck("standalone", command), + ); + + await writeTraefikSetup({}); + + expect( + conversionCallIndex(mocks.execAsync.mock.calls), + ).toBeGreaterThanOrEqual(0); + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + expect(mocks.initializeStandaloneTraefik).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index 61c6e3122e..39e1f2b2b9 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -1278,6 +1278,13 @@ export const settingsRouter = createTRPCRouter({ }); } await assertSettingsServerAccess(ctx, input.serverId); + if (input.additionalPorts.some((port) => port.publishedPort === 80)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Port 80 cannot be published. Certificates renew only over port 443.", + }); + } try { const env = await readEnvironmentVariables( diff --git a/docs/agenthits-install.md b/docs/agenthits-install.md index 58516ee23b..ae2adb7d29 100644 --- a/docs/agenthits-install.md +++ b/docs/agenthits-install.md @@ -38,7 +38,7 @@ Installer сам определяет систему: Общие требования: -- Свободные порты `80`, `443` и `3000`. +- Свободные порты `443` и `3000`. - Доступ к GitHub, GHCR и Docker Hub. - На Linux-хосте не должно быть важного Docker Swarm: installer выполняет `docker swarm leave --force` и создает новый single-node Swarm. На macOS и @@ -96,7 +96,7 @@ irm https://raw.githubusercontent.com/agentHits/dokploy/AgentHits-Dev/install-ag - На Apple Silicon панель (`amd64`) работает через Rosetta, а Postgres, Redis и Traefik — нативно. Холодный старт панели медленнее, чем на VPS. -- Порты `80`, `443` и `3000` OrbStack пробрасывает на Mac и в локальную сеть +- Порты `443` и `3000` OrbStack пробрасывает на Mac и в локальную сеть (`machines.expose_ports_to_lan`, включено по умолчанию). Если из сети панель не открывается, а на самом Mac открывается, выполните `orb restart dokploy`. Если включен файрвол macOS, OrbStack должен быть в @@ -119,7 +119,7 @@ irm https://raw.githubusercontent.com/agentHits/dokploy/AgentHits-Dev/install-ag `networkingMode=mirrored` (WSL получает IP Windows, панель видна в локальной сети; нужен Windows 11 22H2+) и `vmIdleTimeout=-1`. Уже заданные вами значения не меняются, installer только предупредит. -3. Открывает в Hyper-V firewall для WSL входящие TCP `80`, `443`, `3000` и +3. Открывает в Hyper-V firewall для WSL входящие TCP `443`, `3000` и UDP `443`. 4. Проверяет, что эти порты не заняты программами Windows. 5. Создает задачу планировщика «AgentHits Dokploy WSL», которая держит WSL diff --git a/docs/certificates-and-ports.md b/docs/certificates-and-ports.md new file mode 100644 index 0000000000..99a220bee5 --- /dev/null +++ b/docs/certificates-and-ports.md @@ -0,0 +1,56 @@ +# Порты и сертификаты + +Документ для оператора сервера: почему сертификаты обновляются только через порт 443, что значит строка об ошибке продления в логе Traefik и что делать, если продление домена не проходит. + +## Почему только порт 443 + +Traefik выпускает и продлевает сертификаты Let's Encrypt методом TLS-ALPN-01 (`tlsChallenge`). Центр сертификации подключается к домену на порт 443 и проверяет ответ внутри TLS-рукопожатия. Порт 80 для этого не нужен. + +Поэтому: + +- порт 80 не публикуется на хосте ни панелью, ни установщиком, и UFW его не открывает; +- метод HTTP-01 (`httpChallenge`) в конфигурации не используется, потому что он требует порта 80; +- Traefik публикует только 443/tcp и 443/udp (HTTP/3). + +Последствие: домены, которые работают только по HTTP без HTTPS, снаружи не открываются. Такие домены нужно перевести на HTTPS. + +## Что значит строка об ошибке продления + +Traefik проверяет сертификаты заранее. По умолчанию продление начинается примерно за 30 дней до окончания срока, а проверка идет раз в сутки. Если попытка не удалась, в логе появляется запись уровня error: + +``` +Error renewing ACME certificate: <причина> +``` + +В записи указан домен, для которого продление не прошло, и текст ошибки. Пока срок действия сертификата не истек, сайт продолжает работать на старом сертификате. Traefik повторяет попытку сам, перезапускать его не нужно. + +Частые причины в конце записи: + +- `Cannot negotiate ALPN protocol` или ошибка `tls-alpn-01`: на 443 при подключении к домену отвечает не Traefik. Обычно перед сервером стоит CDN или прокси, который завершает TLS; +- `connection refused` или `timeout`: порт 443 закрыт файрволом или не ведет на Traefik; +- `Incorrect validation certificate`: на 443 отвечает другой TLS-сервер; +- `429`: слишком много неудачных попыток у Let's Encrypt. Сначала устраните причину, и только потом ждите новую попытку. + +## Что делать, если у домена ошибка продления + +1. Проверьте DNS-запись домена. Запись A (и AAAA, если она есть) должна указывать на публичный IP этого сервера. Если домен ведет на другой сервер или стоит за CDN или прокси с включенным проксированием, отключите проксирование для этой записи. +2. Проверьте, используется ли домен приложением. Если приложение удалено или домен переехал, удалите домен в панели: Traefik перестанет его продлевать, и ошибка уйдет. Если приложение работает, исправьте DNS или доступ к порту 443 (пункты 1 и 3). +3. Проверьте доступность порта 443 извне: файрвол хостинга, UFW и правила облачной сети. На самом сервере порт 443 должен занимать Traefik. Команда `docker port dokploy-traefik` покажет публикацию 443/tcp и 443/udp. +4. Если причина устранена, следующая попытка пройдет по расписанию. Ручной перезапуск не нужен. + +## Старые серверы и конфиг Traefik + +На старом сервере в `/etc/dokploy/traefik/traefik.yml` может остаться метод `httpChallenge`, который требует порта 80. Файл целиком не переписывается, поэтому перевод на `tlsChallenge` выполняется перед каждым созданием или пересозданием контейнера Traefik: + +- установка установщиком с нуля и обновление Traefik установщиком; +- обновление компонента Traefik в панели; +- сохранение портов Traefik в панели; +- настройка сервера, если контейнера Traefik еще нет. + +Меняется только блок `httpChallenge` в `certificatesResolvers.letsencrypt.acme`, остальные настройки остаются как были. Перед заменой файл копируется рядом: `/etc/dokploy/traefik/traefik.yml.bak-<дата и время UTC>`. Если замена или запуск нового контейнера не удались, файл возвращается из копии, а при обновлении старый контейнер поднимается снова. + +Если путь занят каталогом или файл нельзя прочитать, перевод не выполняется, и обновление останавливается: ничего не меняется. Тогда проверьте файл вручную. + +Если перевод не прошел, ошибка назовет файл и его копию. Тогда замените блок `httpChallenge` на строку `tlsChallenge: {}` вручную и повторите обновление. + +После обновления проверьте публикацию портов: `docker port dokploy-traefik` должен показать только 443/tcp и 443/udp. diff --git a/docs/server-components-update.md b/docs/server-components-update.md index 993a606b27..6811b1e2d6 100644 --- a/docs/server-components-update.md +++ b/docs/server-components-update.md @@ -31,7 +31,7 @@ Postgres и Redis относятся к базе панели. На сервер Кнопка **Components** на карточке сервера сравнивает установленные версии с закреплёнными и обновляет отличающиеся. Сначала скачивается всё, что нужно выбранным компонентам: образ Traefik, скрипт get.docker.com и (на хостах с apt) пакеты Docker. Если какая-либо загрузка не удалась, команда завершается с сообщением `Nothing was changed`, и ни один сервис не трогается: - Docker: пакеты закреплённой версии уже скачаны, затем она устанавливается через get.docker.com. Демон Docker перезапускается, и сервисы swarm на сервере перезапускаются вместе с ним. Live-restore это не предотвращает: по документации Docker он относится только к обычным контейнерам, а не к сервисам swarm, поэтому `daemon.json` не меняется. На хостах без apt пакеты загружает менеджер пакетов во время шага Docker. -- Traefik: образ скачивается до первого шага. Старый контейнер `dokploy-traefik` останавливается и переименовывается в `dokploy-traefik-previous`, новый запускается и подключается к сети `dokploy-network`. Если он не работает через 10 секунд (`TRAEFIK_SETTLE_SECONDS`), старый контейнер возвращается и команда завершается с ошибкой. Порты 80 и 443 освобождаются только после остановки старого контейнера, поэтому простой примерно равен времени запуска нового. После успеха старый контейнер удаляется. +- Traefik: образ скачивается до первого шага. Старый контейнер `dokploy-traefik` останавливается и переименовывается в `dokploy-traefik-previous`, новый запускается и подключается к сети `dokploy-network`. Если он не работает через 10 секунд (`TRAEFIK_SETTLE_SECONDS`), старый контейнер возвращается и команда завершается с ошибкой. Порт 443 освобождается только после остановки старого контейнера, поэтому простой примерно равен времени запуска нового. После успеха старый контейнер удаляется. - RClone, Nixpacks, Railpack, Buildpacks: переустанавливаются закреплённые версии, работающие сервисы не трогаются. Установка сервера (Setup Server) уже установленное не обновляет. Поэтому после смены версии нужна именно эта кнопка. diff --git a/docs/server-hardening.md b/docs/server-hardening.md index cce63f414d..89d9d09b08 100644 --- a/docs/server-hardening.md +++ b/docs/server-hardening.md @@ -7,7 +7,7 @@ | Флаг установщика | Переменная окружения | Что делает | |---|---|---| | `--ssh-keys-only` | `HARDEN_SSH=1` | Вход только по ключу: `PasswordAuthentication no`, `UsePAM no`, `PermitRootLogin prohibit-password`. Требует валидный ключ в `/root/.ssh/authorized_keys`. Пропускается, если на хосте нет sshd. | -| `--ufw` | `HARDEN_UFW=1` | UFW: входящие запрещены, разрешены SSH, 80 и 443 (tcp, и 443 udp для HTTP/3). Пересылка для Docker разрешена. Только Debian и Ubuntu. | +| `--ufw` | `HARDEN_UFW=1` | UFW: входящие запрещены, разрешены SSH, 443 (tcp, и 443 udp для HTTP/3). Пересылка для Docker разрешена. Только Debian и Ubuntu. | | `--fail2ban` | `HARDEN_FAIL2BAN=1` | Fail2Ban: защита sshd в режиме `aggressive`, настройка в `/etc/fail2ban/jail.local`. Только Debian и Ubuntu. | | `--harden` | все три | Все три флага сразу. | | — | `DOKPLOY_SERVER_HARDENING=ssh,ufw,fail2ban` | То же самое панель применяет к новым серверам в «Setup Server». Если задан флаг установки, переменная выставляется автоматически. | diff --git a/install-agenthits.ps1 b/install-agenthits.ps1 index 28459c5171..230643573c 100644 --- a/install-agenthits.ps1 +++ b/install-agenthits.ps1 @@ -126,7 +126,7 @@ function Set-WslFirewall { if (-not (Get-Command New-NetFirewallHyperVRule -ErrorAction SilentlyContinue)) { return } $rules = @( - @{ Name = 'AgentHitsDokploy-TCP'; Protocol = 'TCP'; Ports = @('80', '443', '3000') }, + @{ Name = 'AgentHitsDokploy-TCP'; Protocol = 'TCP'; Ports = @('443', '3000') }, @{ Name = 'AgentHitsDokploy-UDP'; Protocol = 'UDP'; Ports = @('443') } ) foreach ($rule in $rules) { @@ -139,7 +139,7 @@ function Set-WslFirewall { function Test-PortsFree { $busy = Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | - Where-Object { $_.LocalPort -in 80, 443, 3000 } | + Where-Object { $_.LocalPort -in 443, 3000 } | ForEach-Object { $process = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue if ($process -and $process.ProcessName -notmatch '^(wslrelay|wslservice|vmmem|vmmemWSL)$') { diff --git a/install-agenthits.sh b/install-agenthits.sh index 6ffe26fbec..048b7531ee 100755 --- a/install-agenthits.sh +++ b/install-agenthits.sh @@ -55,13 +55,13 @@ DOKPLOY_TRAEFIK_SETTLE="${DOKPLOY_TRAEFIK_SETTLE:-10}" AGENTHITS_PULL_RETRY_DELAY="${AGENTHITS_PULL_RETRY_DELAY:-10}" DOKPLOY_APT_SOURCES_DIR="${DOKPLOY_APT_SOURCES_DIR:-/etc/apt/sources.list.d}" DOKPLOY_OS_RELEASE="${DOKPLOY_OS_RELEASE:-/etc/os-release}" +TRAEFIK_CONFIG_FILE="${TRAEFIK_CONFIG_FILE:-/etc/dokploy/traefik/traefik.yml}" DOCKER_PACKAGE_VERSION="" DOCKER_ENGINE_UPGRADE_NEEDED=0 DOCKER_ENGINE_PREVIOUS_VERSION="" DOCKER_ROLLBACK_DIR="" DOCKER_ROLLBACK_DIR_CREATED=0 TRAEFIK_EXTRA_NETWORKS="" -TRAEFIK_HTTP_PUBLISH=1 TRAEFIK_OLD_IMAGE="" TRAEFIK_OLD_STOPPED=0 POSTGRES_KEPT=0 @@ -137,12 +137,20 @@ get_postgres_data_target() { create_default_traefik_files() { mkdir -p /etc/dokploy/traefik/dynamic - if [ -d /etc/dokploy/traefik/traefik.yml ]; then - rm -rf /etc/dokploy/traefik/traefik.yml + if [ -d "$TRAEFIK_CONFIG_FILE" ]; then + case "$TRAEFIK_CONFIG_FILE" in + /etc/dokploy/traefik/traefik.yml | */traefik.yml) + rm -rf "$TRAEFIK_CONFIG_FILE" + ;; + *) + echo "Error: $TRAEFIK_CONFIG_FILE is a directory, not the Traefik config file. Remove it by hand, then run the installer again." >&2 + exit 1 + ;; + esac fi - if [ ! -f /etc/dokploy/traefik/traefik.yml ]; then - cat >/etc/dokploy/traefik/traefik.yml <<'EOF' + if [ ! -f "$TRAEFIK_CONFIG_FILE" ]; then + cat >"$TRAEFIK_CONFIG_FILE" <<'EOF' global: sendAnonymousUsage: false providers: @@ -173,8 +181,7 @@ certificatesResolvers: acme: email: test@localhost.com storage: /etc/dokploy/traefik/dynamic/acme.json - httpChallenge: - entryPoint: web + tlsChallenge: {} EOF fi @@ -804,12 +811,11 @@ harden_ufw() { sed -i 's/^DEFAULT_FORWARD_POLICY=.*/DEFAULT_FORWARD_POLICY="ACCEPT"/' /etc/default/ufw # SSH must be allowed before enabling, or the next login fails. ufw allow "${ssh_port}/tcp" comment 'SSH' >/dev/null - ufw allow 80/tcp comment 'HTTP' >/dev/null ufw allow 443/tcp comment 'HTTPS' >/dev/null ufw allow 443/udp comment 'HTTP/3' >/dev/null ufw allow 3000/tcp comment 'Dokploy panel' >/dev/null ufw --force enable >/dev/null - echo "UFW: enabled. Incoming is denied except SSH (${ssh_port}), 80, 443 and 3000." + echo "UFW: enabled. Incoming is denied except SSH (${ssh_port}), 443 and 3000." } harden_fail2ban() { @@ -888,7 +894,6 @@ install_agenthits_dokploy() { if [ "$HARDEN_SSH" = "1" ] && command_exists sshd; then require_root_public_key fi - require_free_port 80 require_free_port 443 require_free_port 3000 install_docker_if_missing @@ -927,6 +932,7 @@ install_agenthits_dokploy() { mkdir -p /etc/dokploy chmod 755 /etc/dokploy create_default_traefik_files + if ! migrate_traefik_acme_to_tls "$TRAEFIK_CONFIG_FILE"; then echo "Error: could not convert the Traefik config to tlsChallenge. Check the file named above, then run the installer again." >&2; exit 1; fi create_secret_if_missing dokploy_postgres_password "$(generate_random_secret)" create_secret_if_missing dokploy_auth_secret "$(generate_random_secret)" @@ -1341,17 +1347,12 @@ traefik_extra_networks() { create_dokploy_traefik() { local image="$1" local restart="$2" - local http_publish=() - if [ "${TRAEFIK_HTTP_PUBLISH:-1}" = "1" ]; then - http_publish=(-p 80:80/tcp) - fi docker create \ --name dokploy-traefik \ --restart "$restart" \ - -v /etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml \ + -v "$TRAEFIK_CONFIG_FILE:/etc/traefik/traefik.yml" \ -v /etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic \ -v /var/run/docker.sock:/var/run/docker.sock:ro \ - ${http_publish[@]+"${http_publish[@]}"} \ -p 443:443/tcp \ -p 443:443/udp \ "$image" >/dev/null || return 1 @@ -1392,6 +1393,7 @@ traefik_restore() { local failed_step="$1" trap - HUP INT TERM echo "Error: Traefik update failed while $failed_step." >&2 + if [ -n "${TRAEFIK_ACME_BACKUP:-}" ] && [ -f "$TRAEFIK_ACME_BACKUP" ]; then echo "Kept the converted Traefik config (tlsChallenge). Previous config saved to $TRAEFIK_ACME_BACKUP." >&2; fi if [ "$TRAEFIK_OLD_STOPPED" = "1" ]; then echo "Restoring the previous Traefik container from $TRAEFIK_OLD_IMAGE." >&2 docker rm -f dokploy-traefik >/dev/null 2>&1 || true @@ -1404,10 +1406,69 @@ traefik_restore() { exit 1 } +# HTTP-01 needs port 80, which the Traefik container does not publish, so renewal +# runs over 443 with tlsChallenge. +migrate_traefik_acme_to_tls() { + local config="${1:-$TRAEFIK_CONFIG_FILE}" + if [ ! -f "$config" ] || ! grep -q '^[[:space:]]*httpChallenge:' "$config"; then + return 0 + fi + + local backup="" + backup="$config.bak-$(date -u +%Y%m%d%H%M%S)" + if ! cp -p "$config" "$backup"; then + rm -f "$backup" + return 1 + fi + + local awk_status=0 + awk ' + /^[[:space:]]*httpChallenge:[[:space:]]*$/ { match($0, /[^[:space:]]/); indent = RSTART - 1; print substr($0, 1, indent) "tlsChallenge: {}"; skipping = 1; next } + /^[[:space:]]*httpChallenge:[[:space:]]*\{.*\}[[:space:]]*$/ { match($0, /[^[:space:]]/); print substr($0, 1, RSTART - 1) "tlsChallenge: {}"; next } + skipping && /^[[:space:]]*$/ { print; next } + skipping && /^[[:space:]]*#/ { next } + skipping { if (match($0, /[^[:space:]]/) && RSTART - 1 > indent) next; skipping = 0 } + { print } + ' "$config" >"$config.tmp" || awk_status=$? + if [ "$awk_status" -ne 0 ] || grep -q 'httpChallenge' "$config.tmp" || ! grep -q 'tlsChallenge: {}' "$config.tmp"; then + rm -f "$config.tmp" + echo "Error: could not convert $config to tlsChallenge. The previous config is saved at $backup." >&2 + return 1 + fi + + local orphan_line="" + orphan_line="$(awk ' + /^[[:space:]]*tlsChallenge:[[:space:]]*\{\}[[:space:]]*$/ { match($0, /[^[:space:]]/); level = RSTART - 1; armed = 1; next } + armed && /^[[:space:]]*(#.*)?$/ { next } + armed { armed = 0; match($0, /[^[:space:]]/); if (RSTART - 1 > level) { print NR; exit } } + ' "$config.tmp")" + if [ -n "$orphan_line" ]; then + rm -f "$config.tmp" + echo "Error: converting $config would leave output line $orphan_line indented under tlsChallenge. The config is unchanged; the previous config is saved at $backup." >&2 + return 1 + fi + + if ! cat "$config.tmp" >"$config"; then + rm -f "$config.tmp" + if cat "$backup" >"$config"; then + echo "Error: could not write $config. The previous config was restored from $backup." >&2 + else + echo "Error: could not write $config, and restoring it from $backup failed. Copy $backup back over $config." >&2 + fi + return 1 + fi + rm -f "$config.tmp" + TRAEFIK_ACME_BACKUP="$backup" + echo "Certificate renewal now uses port 443 (tlsChallenge). Previous config saved to $backup" +} + swap_dokploy_traefik() { + TRAEFIK_ACME_BACKUP="" local current="" current="$(docker inspect --format '{{.Config.Image}}' dokploy-traefik 2>/dev/null || true)" - if [ "$(image_without_digest "$current")" = "$(image_without_digest "$TRAEFIK_IMAGE")" ]; then + if [ "$(image_without_digest "$current")" = "$(image_without_digest "$TRAEFIK_IMAGE")" ] && + ! grep -q '^[[:space:]]*httpChallenge:' "$TRAEFIK_CONFIG_FILE" 2>/dev/null && + ! docker inspect -f '{{json .HostConfig.PortBindings}}' dokploy-traefik 2>/dev/null | grep -q '"80/tcp"'; then echo "Traefik already runs $TRAEFIK_IMAGE" return 0 fi @@ -1418,12 +1479,9 @@ swap_dokploy_traefik() { fi TRAEFIK_OLD_IMAGE="$current" TRAEFIK_EXTRA_NETWORKS="$(traefik_extra_networks)" - TRAEFIK_HTTP_PUBLISH=1 - if [ "$existed" = "1" ] && ! docker inspect -f '{{json .HostConfig.PortBindings}}' dokploy-traefik | grep -q '"80/tcp"'; then - TRAEFIK_HTTP_PUBLISH=0 - fi TRAEFIK_OLD_STOPPED=0 trap 'traefik_restore "being interrupted"' HUP INT TERM + if ! migrate_traefik_acme_to_tls; then traefik_restore "converting the Traefik config to tlsChallenge"; fi docker rm -f dokploy-traefik-previous >/dev/null 2>&1 || true if [ "$existed" = "1" ]; then if ! docker stop dokploy-traefik >/dev/null; then diff --git a/packages/server/src/services/settings.ts b/packages/server/src/services/settings.ts index 291d8f81c4..570831e482 100644 --- a/packages/server/src/services/settings.ts +++ b/packages/server/src/services/settings.ts @@ -1,6 +1,7 @@ import { readdirSync } from "node:fs"; import { join, posix } from "node:path"; import { quoteShellArg } from "@dokploy/server/utils/filesystem/safe-path"; +import { ExecError } from "@dokploy/server/utils/process/ExecError"; import { execAsync, execAsyncRemote, @@ -11,8 +12,10 @@ import semver from "semver"; import { db } from "../db"; import { compose } from "../db/schema"; import { + buildTraefikTlsMigrationStep, initializeStandaloneTraefik, initializeTraefikService, + TRAEFIK_MAIN_CONFIG_PATH, type TraefikOptions, } from "../setup/traefik-setup"; import { UPDATE_IMAGE_PULLED_MARKER } from "./web-server-update"; @@ -1128,6 +1131,29 @@ export const checkPortInUse = async ( } }; +const convertTraefikConfigToTls = async (serverId?: string) => { + // Loaded lazily: a static import would close a cycle through server-setup and monitoring-setup, which import this module. + const { wrapComponentUpdateSteps } = await import( + "../setup/server-components" + ); + const script = wrapComponentUpdateSteps(buildTraefikTlsMigrationStep()); + try { + if (serverId) { + await execAsyncRemote(serverId, script); + } else { + await execAsync(script); + } + } catch (error) { + const stderr = + error instanceof ExecError ? error.stderr?.trim() : undefined; + const detail = + stderr || (error instanceof Error ? error.message : String(error)); + throw new Error( + `Traefik config ${TRAEFIK_MAIN_CONFIG_PATH} could not be converted to tlsChallenge. ${detail}`, + ); + } +}; + export const writeTraefikSetup = async (input: TraefikOptions) => { const resourceType = await getDockerResourceType( "dokploy-traefik", @@ -1135,6 +1161,7 @@ export const writeTraefikSetup = async (input: TraefikOptions) => { ); if (resourceType === "service") { + await convertTraefikConfigToTls(input.serverId); await initializeTraefikService({ env: input.env, additionalPorts: input.additionalPorts, @@ -1142,6 +1169,7 @@ export const writeTraefikSetup = async (input: TraefikOptions) => { }); await reconnectServicesToTraefik(input.serverId); } else if (resourceType === "standalone") { + await convertTraefikConfigToTls(input.serverId); await initializeStandaloneTraefik({ env: input.env, additionalPorts: input.additionalPorts, diff --git a/packages/server/src/setup/server-components.ts b/packages/server/src/setup/server-components.ts index 9eb7cd9f04..bbcbac7aca 100644 --- a/packages/server/src/setup/server-components.ts +++ b/packages/server/src/setup/server-components.ts @@ -8,7 +8,7 @@ import { PINNED_VERSIONS } from "./component-versions"; import { rcloneInstallCommand } from "./server-setup"; import { buildTraefikCreateWithImage, - TRAEFIK_PORT, + buildTraefikTlsMigrationStep, TRAEFIK_VERSION, } from "./traefik-setup"; @@ -457,6 +457,8 @@ traefik_existed=0 traefik_old_stopped=0 traefik_old_image="" traefik_extra_networks="" +traefik_acme_backup="" +traefik_tls_converted="" if $SUDO_CMD docker inspect dokploy-traefik >/dev/null 2>&1; then traefik_existed=1 traefik_old_image="$($SUDO_CMD docker inspect -f '{{.Config.Image}}' dokploy-traefik 2>/dev/null || true)" @@ -466,12 +468,8 @@ traefik_keep_bridge=1 if [ "$traefik_existed" = 1 ] && ! echo " $traefik_extra_networks " | grep -q " bridge "; then traefik_keep_bridge=0 fi -traefik_http_publish="-p ${TRAEFIK_PORT}:${TRAEFIK_PORT}" -if [ "$traefik_existed" = 1 ] && ! $SUDO_CMD docker inspect -f '{{json .HostConfig.PortBindings}}' dokploy-traefik | grep -q '"${TRAEFIK_PORT}/tcp"'; then - traefik_http_publish="" -fi traefik_create() { -${buildTraefikCreateWithImage('"$1"', "$traefik_http_publish")} +${buildTraefikCreateWithImage('"$1"')} } traefik_connect() { $SUDO_CMD docker network connect dokploy-network dokploy-traefik || return 1 @@ -503,6 +501,7 @@ traefik_restore() { local failed_step="$1" trap - HUP INT TERM echo "Error: Traefik update failed while $failed_step." >&2 + if [ "$traefik_tls_converted" = 1 ]; then echo "Kept the converted Traefik config (tlsChallenge). Port 443 is published, so the restored container works with it. Previous config saved to $traefik_acme_backup." >&2; fi if [ "$traefik_old_stopped" = 1 ]; then echo "Restoring the previous Traefik container from $traefik_old_image." >&2 $SUDO_CMD docker rm -f dokploy-traefik >/dev/null 2>&1 || true @@ -514,10 +513,15 @@ traefik_restore() { fi exit 1 } -if [ "$traefik_existed" = 1 ] && [ "\${traefik_old_image%%@*}" = "traefik:v${TRAEFIK_VERSION}" ] && traefik_check_running; then +if [ "$traefik_existed" = 1 ] && [ "\${traefik_old_image%%@*}" = "traefik:v${TRAEFIK_VERSION}" ] && traefik_check_running && ! $SUDO_CMD grep -q '^[[:space:]]*httpChallenge:' /etc/dokploy/traefik/traefik.yml 2>/dev/null && ! $SUDO_CMD docker inspect -f '{{json .HostConfig.PortBindings}}' dokploy-traefik 2>/dev/null | grep -q '"80/tcp"'; then echo "Traefik already runs traefik:v${TRAEFIK_VERSION}" else trap 'traefik_restore "being interrupted"' HUP INT TERM + if ! { +${buildTraefikTlsMigrationStep()} + }; then + traefik_restore "converting the Traefik config to tlsChallenge" + fi $SUDO_CMD docker rm -f dokploy-traefik-previous >/dev/null 2>&1 || true if [ "$traefik_existed" = 1 ]; then if ! $SUDO_CMD docker stop dokploy-traefik >/dev/null; then diff --git a/packages/server/src/setup/server-setup.ts b/packages/server/src/setup/server-setup.ts index 32b8c8647a..91ba570fce 100644 --- a/packages/server/src/setup/server-setup.ts +++ b/packages/server/src/setup/server-setup.ts @@ -11,10 +11,10 @@ import { } from "@dokploy/server/services/server"; import { buildTraefikRunCommand, + buildTraefikTlsMigrationStep, getDefaultMiddlewares, getDefaultServerTraefikConfig, TRAEFIK_HTTP3_PORT, - TRAEFIK_PORT, TRAEFIK_SSL_PORT, TRAEFIK_VERSION, } from "@dokploy/server/setup/traefik-setup"; @@ -188,7 +188,6 @@ const hardenUfwCommand = () => ` $SUDO_CMD ufw default allow outgoing >/dev/null $SUDO_CMD sed -i 's/^DEFAULT_FORWARD_POLICY=.*/DEFAULT_FORWARD_POLICY="ACCEPT"/' /etc/default/ufw $SUDO_CMD ufw allow "$SSH_PORT/tcp" comment 'SSH' >/dev/null - $SUDO_CMD ufw allow ${TRAEFIK_PORT}/tcp comment 'HTTP' >/dev/null $SUDO_CMD ufw allow ${TRAEFIK_SSL_PORT}/tcp comment 'HTTPS' >/dev/null $SUDO_CMD ufw allow ${TRAEFIK_HTTP3_PORT}/udp comment 'HTTP/3' >/dev/null $SUDO_CMD ufw --force enable >/dev/null @@ -578,11 +577,6 @@ const setupNetwork = () => ` `; const validatePorts = () => ` - # check if something is running on port 80 - if ss -tulnp | grep ':80 ' >/dev/null; then - echo "Something is already running on port 80" >&2 - fi - # check if something is running on port 443 if ss -tulnp | grep ':443 ' >/dev/null; then echo "Something is already running on port 443" >&2 @@ -824,6 +818,12 @@ export const createTraefikInstance = () => { else # Create the dokploy-traefik container TRAEFIK_VERSION=${TRAEFIK_VERSION} + if ! { +${buildTraefikTlsMigrationStep()} +}; then + echo "Error: the Traefik config could not be converted to tlsChallenge, so Traefik was not created." >&2 + exit 1 + fi ${buildTraefikRunCommand(TRAEFIK_VERSION)} echo "Traefik version $TRAEFIK_VERSION installed ✅" fi diff --git a/packages/server/src/setup/traefik-setup.ts b/packages/server/src/setup/traefik-setup.ts index e2e6aa4af8..963a3cdde8 100644 --- a/packages/server/src/setup/traefik-setup.ts +++ b/packages/server/src/setup/traefik-setup.ts @@ -27,14 +27,12 @@ export const TRAEFIK_VERSION = const traefikContainerOptions = ( image: string, restart: string, - httpPublish = `-p ${TRAEFIK_PORT}:${TRAEFIK_PORT}`, ) => `--name dokploy-traefik \ --restart ${restart} \ -v /etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml \ -v /etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic \ -v /var/run/docker.sock:/var/run/docker.sock:ro \ -p ${TRAEFIK_SSL_PORT}:${TRAEFIK_SSL_PORT} \ - ${httpPublish} \ -p ${TRAEFIK_HTTP3_PORT}:${TRAEFIK_HTTP3_PORT}/udp \ ${image}`; @@ -45,16 +43,132 @@ export const buildTraefikRunCommand = (version: string) => ` // Created with no restart policy and not started: networks are attached before the // ports are taken, and the policy is raised only after the new container runs. -export const buildTraefikCreateWithImage = ( - image: string, - httpPublish?: string, -) => ` - $SUDO_CMD docker create ${traefikContainerOptions(image, "no", httpPublish)} +export const buildTraefikCreateWithImage = (image: string) => ` + $SUDO_CMD docker create ${traefikContainerOptions(image, "no")} `; export const buildTraefikCreateCommand = (version: string) => buildTraefikCreateWithImage(`traefik:v${version}`); +export const TRAEFIK_MAIN_CONFIG_PATH = "/etc/dokploy/traefik/traefik.yml"; + +export const HTTP_CHALLENGE_TO_TLS_AWK = String.raw`/^[[:space:]]*httpChallenge:[[:space:]]*$/ { + match($0, /[^[:space:]]/) + indent = RSTART - 1 + print substr($0, 1, indent) "tlsChallenge: {}" + skipping = 1 + next +} +/^[[:space:]]*httpChallenge:[[:space:]]*\{.*\}[[:space:]]*$/ { + match($0, /[^[:space:]]/) + print substr($0, 1, RSTART - 1) "tlsChallenge: {}" + next +} +skipping && /^[[:space:]]*$/ { + print + next +} +skipping && /^[[:space:]]*#/ { + next +} +skipping { + if (match($0, /[^[:space:]]/) && RSTART - 1 > indent) next + skipping = 0 +} +{ print } +`; + +const TLS_CHILD_CHECK_AWK = String.raw`prev_tls && match($0, /[^[:space:]]/) && RSTART - 1 > tls_indent { + bad = 1 +} +{ prev_tls = 0 } +/^[[:space:]]*tlsChallenge: \{\}[[:space:]]*$/ { + match($0, /[^[:space:]]/) + tls_indent = RSTART - 1 + prev_tls = 1 +} +END { exit bad } +`; + +// The step runs as one command so the caller can branch on its status. The config is +// rewritten in place because the Traefik container bind-mounts that file, and the backup +// is recorded before the write so an interrupted or failed write can still be restored. +export const buildTraefikTlsMigrationStep = ( + configPath = TRAEFIK_MAIN_CONFIG_PATH, +) => `traefik_acme_backup="" +traefik_convert_acme_to_tls() { + local config="${configPath}" + local backup converted awk_status=0 + local grep_status=0 exists_status=0 + if ! $SUDO_CMD test -e /; then + grep -Eq '^[[:space:]]*httpChallenge:' "$config" || grep_status=$? + if [ "$grep_status" -eq 1 ]; then + return 0 + fi + echo "Error: could not run test as root. Nothing was changed." >&2 + return 1 + fi + $SUDO_CMD test -e "$config" || exists_status=$? + if [ "$exists_status" -eq 1 ]; then + return 0 + fi + if [ "$exists_status" -ne 0 ]; then + echo "Error: could not check $config. Nothing was changed." >&2 + return 1 + fi + if ! $SUDO_CMD test -f "$config"; then + echo "Error: $config is not a regular file. Nothing was changed." >&2 + return 1 + fi + $SUDO_CMD grep -Eq '^[[:space:]]*httpChallenge:' "$config" || grep_status=$? + if [ "$grep_status" -eq 1 ]; then + return 0 + fi + if [ "$grep_status" -ne 0 ]; then + echo "Error: could not read $config. Nothing was changed." >&2 + return 1 + fi + backup="$config.bak-$(date -u +%Y%m%d%H%M%S)" + if ! $SUDO_CMD cp -p "$config" "$backup"; then + echo "Error: could not back up $config to $backup. The Traefik config was not changed." >&2 + return 1 + fi + traefik_acme_backup="$backup" + if ! converted="$(mktemp "\${TMPDIR:-/tmp}/traefik-tls.XXXXXX")"; then + echo "Error: could not create a temporary file to convert $config. The original config was left unchanged. Previous config saved to $backup" >&2 + return 1 + fi + $SUDO_CMD awk '${HTTP_CHALLENGE_TO_TLS_AWK}' "$config" > "$converted" || awk_status=$? + if [ "$awk_status" -ne 0 ]; then + rm -f "$converted" + echo "Error: awk exited with status $awk_status while converting $config to tlsChallenge. The original config was left unchanged. Previous config saved to $backup" >&2 + return 1 + fi + if grep -Fq httpChallenge "$converted" || ! grep -Fq 'tlsChallenge: {}' "$converted"; then + rm -f "$converted" + echo "Error: could not convert $config to tlsChallenge. The original config was left unchanged. Previous config saved to $backup" >&2 + return 1 + fi + if ! awk '${TLS_CHILD_CHECK_AWK}' "$converted"; then + rm -f "$converted" + echo "Error: the converted config for $config has lines under tlsChallenge. The original config was left unchanged. Previous config saved to $backup" >&2 + return 1 + fi + if ! $SUDO_CMD tee "$config" < "$converted" >/dev/null; then + rm -f "$converted" + if $SUDO_CMD cp -p "$backup" "$config"; then + echo "Error: could not write $config. The previous config was restored from $backup" >&2 + else + echo "Error: could not write $config, and restoring it from $backup failed. Restore it from the backup before starting Traefik." >&2 + fi + return 1 + fi + traefik_tls_converted=1 + rm -f "$converted" + echo "Certificate renewal now uses port 443 (tlsChallenge). Previous config saved to $backup" +} +traefik_convert_acme_to_tls`; + export interface TraefikOptions { env?: string[]; serverId?: string; @@ -65,6 +179,10 @@ export interface TraefikOptions { }[]; } +// Host port 80 is never published, even when a stored port list still contains it. +const withoutHostPort80 = (ports: TraefikOptions["additionalPorts"] = []) => + ports.filter((port) => port.publishedPort !== 80); + export const initializeStandaloneTraefik = async ({ env, serverId, @@ -74,30 +192,28 @@ export const initializeStandaloneTraefik = async ({ const imageName = `traefik:v${TRAEFIK_VERSION}`; const containerName = "dokploy-traefik"; + const publishable = withoutHostPort80(additionalPorts); + const exposedPorts: Record = { - [`${TRAEFIK_PORT}/tcp`]: {}, [`${TRAEFIK_SSL_PORT}/tcp`]: {}, [`${TRAEFIK_HTTP3_PORT}/udp`]: {}, }; const portBindings: Record> = { - [`${TRAEFIK_PORT}/tcp`]: [{ HostPort: TRAEFIK_PORT.toString() }], [`${TRAEFIK_SSL_PORT}/tcp`]: [{ HostPort: TRAEFIK_SSL_PORT.toString() }], [`${TRAEFIK_HTTP3_PORT}/udp`]: [ { HostPort: TRAEFIK_HTTP3_PORT.toString() }, ], }; - const enableDashboard = additionalPorts.some( - (port) => port.targetPort === 8080, - ); + const enableDashboard = publishable.some((port) => port.targetPort === 8080); if (enableDashboard) { exposedPorts["8080/tcp"] = {}; portBindings["8080/tcp"] = [{ HostPort: "8080" }]; } - for (const port of additionalPorts) { + for (const port of publishable) { const portKey = `${port.targetPort}/${port.protocol ?? "tcp"}`; exposedPorts[portKey] = {}; portBindings[portKey] = [{ HostPort: port.publishedPort.toString() }]; @@ -207,14 +323,8 @@ export const initializeTraefikService = async ({ PublishMode: "host", Protocol: "udp", }, - { - TargetPort: 80, - PublishedPort: TRAEFIK_PORT, - PublishMode: "host", - Protocol: "tcp", - }, - ...additionalPorts.map((port) => ({ + ...withoutHostPort80(additionalPorts).map((port) => ({ TargetPort: port.targetPort, PublishedPort: port.publishedPort, Protocol: port.protocol as "tcp" | "udp" | "sctp" | undefined, @@ -339,9 +449,7 @@ export const getDefaultTraefikConfig = () => { acme: { email: "test@localhost.com", storage: "/etc/dokploy/traefik/dynamic/acme.json", - httpChallenge: { - entryPoint: "web", - }, + tlsChallenge: {}, }, }, }, @@ -394,9 +502,7 @@ export const getDefaultServerTraefikConfig = () => { acme: { email: "test@localhost.com", storage: "/etc/dokploy/traefik/dynamic/acme.json", - httpChallenge: { - entryPoint: "web", - }, + tlsChallenge: {}, }, }, },