From 739e6e12b4b23e595f3475d9ad068a07966511ce Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:03:41 -0400 Subject: [PATCH 01/20] refactor(servers): split the component update script into steps buildComponentUpdateSteps returns the pre-download gate and the steps of the selected components. wrapComponentUpdateSteps adds the shell header and the done marker. buildComponentUpdateScript composes the two with the same output, so the SSH update path is unchanged, and the panel host update can run the Traefik steps as they are. Co-Authored-By: Claude Opus 5.5 --- packages/server/src/setup/server-components.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/server/src/setup/server-components.ts b/packages/server/src/setup/server-components.ts index c4f01d65fe..da31bd696d 100644 --- a/packages/server/src/setup/server-components.ts +++ b/packages/server/src/setup/server-components.ts @@ -579,16 +579,21 @@ echo "Buildpacks version ${PINNED_VERSIONS.buildpacks} installed ✅" } }; -export const buildComponentUpdateScript = ( - components: UpdatableComponent[], -) => ` -set -e -if [ "$(id -u)" -eq 0 ]; then SUDO_CMD=""; else SUDO_CMD="sudo"; fi +export const buildComponentUpdateSteps = (components: UpdatableComponent[]) => ` ${predownloadGate(components)} ${components.map(updateStepFor).join("\n")} +`; + +export const wrapComponentUpdateSteps = (steps: string) => ` +set -e +if [ "$(id -u)" -eq 0 ]; then SUDO_CMD=""; else SUDO_CMD="sudo"; fi +${steps} echo "${COMPONENTS_UPDATE_DONE}" `; +export const buildComponentUpdateScript = (components: UpdatableComponent[]) => + wrapComponentUpdateSteps(buildComponentUpdateSteps(components)); + const runSshCommand = async ( serverId: string, command: string, From 49223ad6e59b01e988c127c732c0cc704eea4e02 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:03:46 -0400 Subject: [PATCH 02/20] feat(servers): compare panel host components with their pinned targets buildHostStackRows turns the versions read on the panel host into one row per component: panel, docker, traefik, postgres and redis. Each row reports its installed and target version, whether it is outdated, and how it can be updated: from the Web Server Update dialog (panel), from the panel itself (traefik, redis), or only in the maintenance window (docker, postgres). Postgres is compared by major version, and a major change is reported as needing a migration. Values that cannot be read are unknown, never outdated. Co-Authored-By: Claude Opus 5.5 --- .../__test__/server/host-stack-rows.test.ts | 250 ++++++++++++++++++ packages/server/src/setup/host-stack-rows.ts | 242 +++++++++++++++++ 2 files changed, 492 insertions(+) create mode 100644 apps/dokploy/__test__/server/host-stack-rows.test.ts create mode 100644 packages/server/src/setup/host-stack-rows.ts diff --git a/apps/dokploy/__test__/server/host-stack-rows.test.ts b/apps/dokploy/__test__/server/host-stack-rows.test.ts new file mode 100644 index 0000000000..821c997d04 --- /dev/null +++ b/apps/dokploy/__test__/server/host-stack-rows.test.ts @@ -0,0 +1,250 @@ +import { PINNED_VERSIONS } from "@dokploy/server/setup/component-versions"; +import { + buildHostStackRows, + HOST_STACK_COMPONENTS, + type HostStackReadings, + type HostStackRow, + hostStackRefusals, + isHostStackUiComponent, +} from "@dokploy/server/setup/host-stack-rows"; +import { TRAEFIK_VERSION } from "@dokploy/server/setup/traefik-setup"; +import { describe, expect, it } from "vitest"; + +const pinnedPostgresMajor = PINNED_VERSIONS.postgres.split(".")[0]; + +const upToDate: HostStackReadings = { + docker: PINNED_VERSIONS.docker, + traefikImage: `traefik:v${TRAEFIK_VERSION}`, + postgresImage: `postgres:${pinnedPostgresMajor}`, + redisImage: `redis:${PINNED_VERSIONS.redis}`, + panel: { + installed: "agenthits-dev", + latest: "agenthits-dev", + updateAvailable: false, + }, +}; + +const rowsFor = (overrides: Partial) => + buildHostStackRows({ ...upToDate, ...overrides }); + +const rowNamed = (rows: HostStackRow[], name: HostStackRow["name"]) => { + const row = rows.find((candidate) => candidate.name === name); + if (!row) { + throw new Error(`no row named ${name}`); + } + return row; +}; + +describe("buildHostStackRows", () => { + it("lists the panel host components in stack order", () => { + expect(rowsFor({}).map((row) => row.name)).toEqual([ + "panel", + "docker", + "traefik", + "postgres", + "redis", + ]); + }); + + it("reports every component as current when the installed versions match the pins", () => { + for (const row of rowsFor({})) { + expect(row.status, row.name).toBe("current"); + expect(row.outdated, row.name).toBe(false); + expect(row.reason, row.name).toBeNull(); + expect(row.command, row.name).toBeNull(); + } + }); + + it("marks Docker Engine behind its pin as a maintenance-window step with the engine upgrade command", () => { + const docker = rowNamed(rowsFor({ docker: "29.4.1" }), "docker"); + + expect(docker).toMatchObject({ + installed: "29.4.1", + target: PINNED_VERSIONS.docker, + outdated: true, + action: "manual", + command: "DOCKER_ENGINE_UPGRADE=1 bash install-agenthits.sh update", + }); + expect(docker.reason).toContain("maintenance window"); + }); + + it("compares Docker Engine versions numerically", () => { + expect(rowNamed(rowsFor({ docker: "29.10.0" }), "docker").outdated).toBe( + false, + ); + expect(rowNamed(rowsFor({ docker: "29.8.1" }), "docker").outdated).toBe( + true, + ); + }); + + it("reads the Traefik version from its image tag and marks it updatable from the panel", () => { + const traefik = rowNamed( + rowsFor({ traefikImage: "traefik:v3.6.1" }), + "traefik", + ); + + expect(traefik).toMatchObject({ + installed: "3.6.1", + target: TRAEFIK_VERSION, + outdated: true, + action: "ui", + reason: null, + command: null, + }); + }); + + it("treats Traefik at the pinned version as current", () => { + expect( + rowNamed( + rowsFor({ traefikImage: `traefik:v${TRAEFIK_VERSION}` }), + "traefik", + ).outdated, + ).toBe(false); + }); + + it("compares Postgres by major version and reports a major change as manual", () => { + const postgres = rowNamed( + rowsFor({ postgresImage: "postgres:16" }), + "postgres", + ); + + expect(postgres).toMatchObject({ + installed: "16", + target: PINNED_VERSIONS.postgres, + outdated: true, + action: "manual", + command: "POSTGRES_IMAGE=postgres:16 bash install-agenthits.sh update", + }); + expect(postgres.reason).toBe( + `Major version change (16 to ${pinnedPostgresMajor}) needs a migration. The panel never changes the Postgres major version.`, + ); + }); + + it("does not flag Postgres whose major version matches the pin, whatever the minor and suffix", () => { + const postgres = rowNamed( + rowsFor({ postgresImage: `postgres:${pinnedPostgresMajor}.5-alpine` }), + "postgres", + ); + + expect(postgres).toMatchObject({ + installed: pinnedPostgresMajor, + status: "current", + outdated: false, + reason: null, + command: null, + }); + }); + + it("keeps the image digest out of the Postgres command", () => { + const postgres = rowNamed( + rowsFor({ postgresImage: "postgres:16@sha256:abcdef0123" }), + "postgres", + ); + + expect(postgres.command).toBe( + "POSTGRES_IMAGE=postgres:16 bash install-agenthits.sh update", + ); + }); + + it("marks Redis behind its pin as updatable from the panel, comparing versions numerically", () => { + expect(rowNamed(rowsFor({ redisImage: "redis:7" }), "redis")).toMatchObject( + { + installed: "7", + target: PINNED_VERSIONS.redis, + outdated: true, + action: "ui", + }, + ); + expect( + rowNamed(rowsFor({ redisImage: "redis:8.9.9" }), "redis").outdated, + ).toBe(true); + expect( + rowNamed(rowsFor({ redisImage: "redis:8.10.2" }), "redis").outdated, + ).toBe(false); + }); + + it("reports components whose version cannot be read as unknown rather than outdated", () => { + const rows = rowsFor({ + docker: null, + traefikImage: null, + postgresImage: "postgres:latest", + redisImage: "redis:latest", + }); + + for (const name of ["docker", "traefik", "postgres", "redis"] as const) { + const row = rowNamed(rows, name); + expect(row.status, name).toBe("unknown"); + expect(row.outdated, name).toBe(false); + expect(row.installed, name).toBeNull(); + expect(row.command, name).toBeNull(); + expect(row.reason, name).not.toBeNull(); + } + }); + + it("marks the panel outdated when its update check reports a newer build", () => { + expect( + rowNamed( + rowsFor({ + panel: { + installed: "agenthits-dev", + latest: "agenthits-dev-2", + updateAvailable: true, + }, + }), + "panel", + ), + ).toMatchObject({ + installed: "agenthits-dev", + target: "agenthits-dev-2", + status: "outdated", + outdated: true, + action: "panel", + }); + }); + + it("reports the panel as unknown when the update check did not return a version", () => { + expect( + rowNamed( + rowsFor({ + panel: { + installed: "agenthits-dev", + latest: null, + updateAvailable: null, + }, + }), + "panel", + ), + ).toMatchObject({ + status: "unknown", + outdated: false, + action: "panel", + target: null, + }); + }); +}); + +describe("host stack update rules", () => { + it("allows the panel to update only Traefik and Redis", () => { + expect(HOST_STACK_COMPONENTS.filter(isHostStackUiComponent)).toEqual([ + "traefik", + "redis", + ]); + }); + + it("refuses docker, postgres and panel with a message that names the next step", () => { + const refusals = hostStackRefusals([ + "traefik", + "docker", + "postgres", + "panel", + ]); + + expect(refusals).toHaveLength(3); + expect(refusals[0]).toContain( + "DOCKER_ENGINE_UPGRADE=1 bash install-agenthits.sh update", + ); + expect(refusals[1]).toContain("POSTGRES_IMAGE"); + expect(refusals[2]).toContain("Web Server Update"); + expect(hostStackRefusals(["traefik", "redis"])).toEqual([]); + }); +}); diff --git a/packages/server/src/setup/host-stack-rows.ts b/packages/server/src/setup/host-stack-rows.ts new file mode 100644 index 0000000000..3217ca81ef --- /dev/null +++ b/packages/server/src/setup/host-stack-rows.ts @@ -0,0 +1,242 @@ +import { PINNED_VERSIONS } from "./component-versions"; +import { TRAEFIK_VERSION } from "./traefik-setup"; + +export const HOST_STACK_COMPONENTS = [ + "panel", + "docker", + "traefik", + "postgres", + "redis", +] as const; + +export type HostStackComponent = (typeof HOST_STACK_COMPONENTS)[number]; + +export const HOST_STACK_UI_COMPONENTS = ["traefik", "redis"] as const; + +export type HostStackUiComponent = (typeof HOST_STACK_UI_COMPONENTS)[number]; + +export type HostStackAction = "panel" | "ui" | "manual"; + +export type HostStackStatus = "current" | "outdated" | "unknown"; + +export type HostStackRow = { + name: HostStackComponent; + installed: string | null; + target: string | null; + status: HostStackStatus; + outdated: boolean; + action: HostStackAction; + reason: string | null; + command: string | null; +}; + +export type HostStackReadings = { + docker: string | null; + traefikImage: string | null; + postgresImage: string | null; + redisImage: string | null; + panel: { + installed: string | null; + latest: string | null; + updateAvailable: boolean | null; + }; +}; + +export const isHostStackUiComponent = ( + name: HostStackComponent, +): name is HostStackUiComponent => + (HOST_STACK_UI_COMPONENTS as readonly string[]).includes(name); + +const INSTALLER_UPDATE = "bash install-agenthits.sh update"; +const DOCKER_UPGRADE_COMMAND = `DOCKER_ENGINE_UPGRADE=1 ${INSTALLER_UPDATE}`; +const UNKNOWN_REASON = + "The installed version could not be read on the panel host."; +const PANEL_UNKNOWN_REASON = + "The update check did not return a version for this panel."; +const DOCKER_REASON = + "Docker Engine upgrades restart the daemon, which stops this panel. Run them only in the maintenance window."; + +const HOST_STACK_REFUSALS: Record< + Exclude, + string +> = { + panel: "The panel is updated from the Web Server Update dialog.", + docker: `Docker Engine is not upgraded from the panel because the daemon restart stops it. In the maintenance window run: ${DOCKER_UPGRADE_COMMAND}`, + postgres: + "Postgres is not updated from the panel, and its major version never changes here. In the maintenance window run the host update with POSTGRES_IMAGE set to the image Postgres runs now (see the Panel host components list).", +}; + +const withoutDigest = (image: string) => image.split("@")[0] ?? image; + +const imageTag = (image: string) => { + const name = withoutDigest(image); + const slash = name.lastIndexOf("/"); + const colon = name.lastIndexOf(":"); + return colon > slash ? name.slice(colon + 1) : null; +}; + +const VERSION_PATTERN = /\d+(?:\.\d+)*/; + +const versionOf = (value: string | null) => + value?.match(VERSION_PATTERN)?.[0] ?? null; + +const imageVersion = (image: string | null) => + versionOf(image ? imageTag(image) : null); + +const postgresMajor = (image: string | null) => { + const major = (image ? imageTag(image) : null)?.match(/^\d+/)?.[0]; + return major === undefined ? null : Number(major); +}; + +const compareVersions = (left: string, right: string) => { + const leftParts = left.split(".").map(Number); + const rightParts = right.split(".").map(Number); + const length = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < length; index += 1) { + const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (difference !== 0) { + return difference; + } + } + return 0; +}; + +const unknownRow = ( + name: HostStackComponent, + target: string | null, + action: HostStackAction, +): HostStackRow => ({ + name, + installed: null, + target, + status: "unknown", + outdated: false, + action, + reason: UNKNOWN_REASON, + command: null, +}); + +const panelRow = ({ + installed, + latest, + updateAvailable, +}: HostStackReadings["panel"]): HostStackRow => { + if (updateAvailable === null) { + return { + name: "panel", + installed, + target: latest, + status: "unknown", + outdated: false, + action: "panel", + reason: PANEL_UNKNOWN_REASON, + command: null, + }; + } + return { + name: "panel", + installed, + target: latest, + status: updateAvailable ? "outdated" : "current", + outdated: updateAvailable, + action: "panel", + reason: null, + command: null, + }; +}; + +const dockerRow = (readout: string | null): HostStackRow => { + const target = PINNED_VERSIONS.docker; + const installed = versionOf(readout); + if (installed === null) { + return unknownRow("docker", target, "manual"); + } + const outdated = compareVersions(installed, target) < 0; + return { + name: "docker", + installed, + target, + status: outdated ? "outdated" : "current", + outdated, + action: "manual", + reason: outdated ? DOCKER_REASON : null, + command: outdated ? DOCKER_UPGRADE_COMMAND : null, + }; +}; + +const traefikRow = (image: string | null): HostStackRow => { + const target = TRAEFIK_VERSION; + const installed = imageVersion(image); + if (installed === null) { + return unknownRow("traefik", target, "ui"); + } + const outdated = compareVersions(installed, target) < 0; + return { + name: "traefik", + installed, + target, + status: outdated ? "outdated" : "current", + outdated, + action: "ui", + reason: null, + command: null, + }; +}; + +const postgresRow = (image: string | null): HostStackRow => { + const target = PINNED_VERSIONS.postgres; + const targetMajor = Number(target.split(".")[0]); + const installedMajor = postgresMajor(image); + if (image === null || installedMajor === null) { + return unknownRow("postgres", target, "manual"); + } + const outdated = installedMajor < targetMajor; + return { + name: "postgres", + installed: String(installedMajor), + target, + status: outdated ? "outdated" : "current", + outdated, + action: "manual", + reason: outdated + ? `Major version change (${installedMajor} to ${targetMajor}) needs a migration. The panel never changes the Postgres major version.` + : null, + command: outdated + ? `POSTGRES_IMAGE=${withoutDigest(image)} ${INSTALLER_UPDATE}` + : null, + }; +}; + +const redisRow = (image: string | null): HostStackRow => { + const target = PINNED_VERSIONS.redis; + const installed = imageVersion(image); + if (installed === null) { + return unknownRow("redis", target, "ui"); + } + const outdated = compareVersions(installed, target) < 0; + return { + name: "redis", + installed, + target, + status: outdated ? "outdated" : "current", + outdated, + action: "ui", + reason: null, + command: null, + }; +}; + +export const buildHostStackRows = ( + readings: HostStackReadings, +): HostStackRow[] => [ + panelRow(readings.panel), + dockerRow(readings.docker), + traefikRow(readings.traefikImage), + postgresRow(readings.postgresImage), + redisRow(readings.redisImage), +]; + +export const hostStackRefusals = (components: HostStackComponent[]) => + components.flatMap((component) => + isHostStackUiComponent(component) ? [] : [HOST_STACK_REFUSALS[component]], + ); From 663c94e5d983d57e11d95d7189621526ea27b92d Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:03:52 -0400 Subject: [PATCH 03/20] feat(servers): update Traefik and Redis on the panel host locally updateHostStackComponents runs one bash script on the panel host. It replaces Traefik with the steps the SSH update path already uses, and updates the Redis service to the pinned image with stop-first and rollback. Redis is pulled before anything changes. The Redis update waits until the swarm reports the update as completed and redis-cli answers PONG, within a timeout; a rollback, a pause, a refused update or a timeout fails the run. A second update is refused while one is running. readHostStackRows reads the installed versions with read-only docker commands and the panel's own update check, which has a timeout. Co-Authored-By: Claude Opus 5.5 --- .../__test__/server/host-stack-script.test.ts | 291 ++++++++++++++++++ packages/server/src/setup/host-stack.ts | 195 ++++++++++++ 2 files changed, 486 insertions(+) create mode 100644 apps/dokploy/__test__/server/host-stack-script.test.ts create mode 100644 packages/server/src/setup/host-stack.ts diff --git a/apps/dokploy/__test__/server/host-stack-script.test.ts b/apps/dokploy/__test__/server/host-stack-script.test.ts new file mode 100644 index 0000000000..b60edf9518 --- /dev/null +++ b/apps/dokploy/__test__/server/host-stack-script.test.ts @@ -0,0 +1,291 @@ +import { spawnSync } from "node:child_process"; +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { PINNED_VERSIONS } from "@dokploy/server/setup/component-versions"; +import { buildHostStackUpdateScript } from "@dokploy/server/setup/host-stack"; +import type { HostStackUiComponent } from "@dokploy/server/setup/host-stack-rows"; +import { + buildComponentUpdateSteps, + COMPONENTS_UPDATE_DONE, +} from "@dokploy/server/setup/server-components"; +import { describe, expect, it } from "vitest"; + +const SPAWN_TEST_TIMEOUT_MS = 30_000; + +const FAKE_DOCKER = `#!/bin/sh +printf 'docker %s\\n' "$*" >> "$CALL_LOG" +case "$1" in + pull) [ "$FAKE_PULL_FAILS" = 1 ] && exit 1 ;; + ps) echo cid-redis ;; + exec) [ "$FAKE_PING_FAILS" = 1 ] || echo PONG ;; + service) + case "$2" in + inspect) + case "$*" in + *ContainerSpec.Image*) cat "$STATE_DIR/image" ;; + *Version.Index*) cat "$STATE_DIR/index" ;; + *UpdateStatus.State*) cat "$STATE_DIR/state" ;; + esac + ;; + update) + [ "$FAKE_UPDATE_REFUSED" = 1 ] && exit 1 + previous="" + for argument in "$@"; do + if [ "$previous" = "--image" ]; then + printf '%s\\n' "$argument" > "$STATE_DIR/image" + fi + previous="$argument" + done + if [ "$FAKE_NEVER_MOVES" != 1 ]; then + index=$(cat "$STATE_DIR/index") + echo $((index + 1)) > "$STATE_DIR/index" + fi + printf '%s\\n' "$FAKE_UPDATE_STATE" > "$STATE_DIR/state" + ;; + esac + ;; +esac +exit 0 +`; + +const FAKE_SUDO = `#!/bin/sh +exec "$@" +`; + +const FAKE_SLEEP = `#!/bin/sh +exit 0 +`; + +type RedisFixture = { + image?: string; + env?: Record; +}; + +const runHostScript = ( + components: HostStackUiComponent[], + fixture: RedisFixture = {}, +) => { + const dir = mkdtempSync(path.join(tmpdir(), "host-stack-")); + try { + const callLog = path.join(dir, "calls.log"); + writeFileSync(callLog, ""); + writeFileSync(path.join(dir, "image"), `${fixture.image ?? "redis:7"}\n`); + writeFileSync(path.join(dir, "index"), "1\n"); + writeFileSync(path.join(dir, "state"), "\n"); + for (const [name, body] of Object.entries({ + docker: FAKE_DOCKER, + sudo: FAKE_SUDO, + sleep: FAKE_SLEEP, + })) { + writeFileSync(path.join(dir, name), body); + chmodSync(path.join(dir, name), 0o755); + } + const result = spawnSync("bash", [], { + input: buildHostStackUpdateScript(components), + encoding: "utf8", + env: { + ...process.env, + PATH: `${dir}:${process.env.PATH}`, + CALL_LOG: callLog, + STATE_DIR: dir, + FAKE_UPDATE_STATE: "completed", + DOKPLOY_REDIS_TIMEOUT: "0", + ...fixture.env, + }, + }); + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + calls: readFileSync(callLog, "utf8").split("\n").filter(Boolean), + }; + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}; + +const REDIS_UPDATE_CALL = `docker service update --detach --update-order stop-first --update-failure-action rollback --image redis:${PINNED_VERSIONS.redis} dokploy-redis`; + +describe("buildHostStackUpdateScript", () => { + it("reuses the component update steps for Traefik unchanged", () => { + expect(buildHostStackUpdateScript(["traefik"])).toContain( + buildComponentUpdateSteps(["traefik"]), + ); + }); + + it("pulls Redis before any change and updates it after Traefik is replaced", () => { + const script = buildHostStackUpdateScript(["redis", "traefik"]); + const redisPull = script.indexOf("redis_pull() {"); + const traefikDownload = script.indexOf("abort_before_change() {"); + const traefikReplace = script.indexOf('if ! traefik_create "traefik:v'); + const redisUpdate = script.indexOf("redis_settled() {"); + + expect(redisPull).toBeGreaterThanOrEqual(0); + expect(redisPull).toBeLessThan(traefikDownload); + expect(traefikDownload).toBeLessThan(traefikReplace); + expect(traefikReplace).toBeLessThan(redisUpdate); + }); + + it("builds the same script whatever order the components are requested in", () => { + expect(buildHostStackUpdateScript(["redis", "traefik"])).toBe( + buildHostStackUpdateScript(["traefik", "redis"]), + ); + }); + + it("prints the done marker once, as the last line", () => { + const script = buildHostStackUpdateScript(["traefik", "redis"]); + + expect(script.split(COMPONENTS_UPDATE_DONE)).toHaveLength(2); + expect(script.trimEnd().endsWith(`echo "${COMPONENTS_UPDATE_DONE}"`)).toBe( + true, + ); + }); + + it("pins Redis to the pinned image and waits for PONG within a timeout", () => { + const script = buildHostStackUpdateScript(["redis"]); + + expect(script).toContain(`redis_image="redis:${PINNED_VERSIONS.redis}"`); + expect(script).toContain( + 'docker service update --detach --update-order stop-first --update-failure-action rollback --image "$redis_image" dokploy-redis', + ); + expect(script).toContain("redis-cli ping"); + expect(script).toContain('= "PONG"'); + expect(script).toContain("DOKPLOY_REDIS_TIMEOUT:-240"); + }); + + it("does not touch the Postgres or panel services", () => { + const script = buildHostStackUpdateScript(["traefik", "redis"]); + + expect(script).not.toContain("dokploy-postgres"); + expect(script).not.toMatch(/service update [^\n]* dokploy\n/); + }); +}); + +describe("Redis update run", () => { + it( + "updates Redis to the pinned image, waits for PONG, and reports done", + () => { + const run = runHostScript(["redis"]); + + expect(run.status).toBe(0); + expect(run.calls).toContain(`docker pull redis:${PINNED_VERSIONS.redis}`); + expect(run.calls).toContain(REDIS_UPDATE_CALL); + expect(run.stdout).toContain( + `Redis version ${PINNED_VERSIONS.redis} installed`, + ); + expect(run.stdout).toContain(COMPONENTS_UPDATE_DONE); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it( + "leaves Redis alone when it already runs the pinned image", + () => { + const run = runHostScript(["redis"], { + image: `redis:${PINNED_VERSIONS.redis}`, + }); + + expect(run.status).toBe(0); + expect(run.calls.some((call) => call.includes("service update"))).toBe( + false, + ); + expect(run.stdout).toContain("already runs"); + expect(run.stdout).toContain(COMPONENTS_UPDATE_DONE); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it( + "fails loudly when the swarm rolls the Redis update back", + () => { + const run = runHostScript(["redis"], { + env: { FAKE_UPDATE_STATE: "rollback_completed" }, + }); + + expect(run.status).toBe(1); + expect(run.stderr).toContain( + `dokploy-redis did not start on redis:${PINNED_VERSIONS.redis}`, + ); + expect(run.stdout).not.toContain(COMPONENTS_UPDATE_DONE); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it( + "fails loudly when the Redis update pauses", + () => { + const run = runHostScript(["redis"], { + env: { FAKE_UPDATE_STATE: "paused" }, + }); + + expect(run.status).toBe(1); + expect(run.stderr).toContain("is paused after its update"); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it( + "fails loudly when Redis never answers PONG", + () => { + const run = runHostScript(["redis"], { + env: { FAKE_PING_FAILS: "1" }, + }); + + expect(run.status).toBe(1); + expect(run.stderr).toContain("was not ready"); + expect(run.stdout).not.toContain(COMPONENTS_UPDATE_DONE); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it( + "fails loudly when the swarm never registers the update", + () => { + const run = runHostScript(["redis"], { + env: { FAKE_NEVER_MOVES: "1" }, + }); + + expect(run.status).toBe(1); + expect(run.stderr).toContain("was not ready"); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it( + "changes nothing when the pinned Redis image cannot be pulled", + () => { + const run = runHostScript(["redis"], { + env: { FAKE_PULL_FAILS: "1" }, + }); + + expect(run.status).toBe(1); + expect(run.stderr).toContain( + `Pre-download failed: redis:${PINNED_VERSIONS.redis}. Nothing was changed.`, + ); + expect(run.calls.some((call) => call.includes("service update"))).toBe( + false, + ); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it( + "fails loudly when the swarm refuses the update", + () => { + const run = runHostScript(["redis"], { + env: { FAKE_UPDATE_REFUSED: "1" }, + }); + + expect(run.status).toBe(1); + expect(run.stderr).toContain("docker service update was refused"); + }, + SPAWN_TEST_TIMEOUT_MS, + ); +}); diff --git a/packages/server/src/setup/host-stack.ts b/packages/server/src/setup/host-stack.ts new file mode 100644 index 0000000000..5caa6454f2 --- /dev/null +++ b/packages/server/src/setup/host-stack.ts @@ -0,0 +1,195 @@ +import { getDokployVersionData, getUpdateData } from "../services/settings"; +import { execAsync } from "../utils/process/execAsync"; +import { spawnAsync } from "../utils/process/spawnAsync"; +import { PINNED_VERSIONS } from "./component-versions"; +import { + buildHostStackRows, + HOST_STACK_UI_COMPONENTS, + type HostStackRow, + type HostStackUiComponent, +} from "./host-stack-rows"; +import { + buildComponentUpdateSteps, + wrapComponentUpdateSteps, +} from "./server-components"; + +const REDIS_IMAGE = `redis:${PINNED_VERSIONS.redis}`; + +const UPDATE_CHECK_TIMEOUT_MS = 15_000; + +const readCommand = async (command: string) => { + try { + const { stdout } = await execAsync(command); + return stdout.trim() || null; + } catch { + return null; + } +}; + +const checkPanelUpdate = async (currentVersion: string) => { + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(null), UPDATE_CHECK_TIMEOUT_MS); + }); + try { + return await Promise.race([ + getUpdateData(currentVersion).catch(() => null), + timeout, + ]); + } finally { + clearTimeout(timer); + } +}; + +export const readHostStackRows = async ( + currentVersion: string, +): Promise => { + const [docker, traefikImage, postgresImage, redisImage, update] = + await Promise.all([ + readCommand("docker version --format '{{.Server.Version}}'"), + readCommand("docker inspect -f '{{.Config.Image}}' dokploy-traefik"), + readCommand( + "docker service inspect dokploy-postgres --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}'", + ), + readCommand( + "docker service inspect dokploy-redis --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}'", + ), + checkPanelUpdate(currentVersion), + ]); + + return buildHostStackRows({ + docker, + traefikImage, + postgresImage, + redisImage, + panel: { + installed: getDokployVersionData(currentVersion).forkVersion, + latest: update?.latestVersion ?? null, + updateAvailable: update?.latestVersion ? update.updateAvailable : null, + }, + }); +}; + +const redisPullStep = () => ` +redis_image="${REDIS_IMAGE}" +redis_pull() { + local attempt=1 + while [ "$attempt" -le 3 ]; do + if $SUDO_CMD docker pull "$redis_image"; then + return 0 + fi + attempt=$((attempt + 1)) + sleep 5 + done + return 1 +} +echo "Downloading $redis_image before anything is changed" +if ! redis_pull; then + echo "Pre-download failed: $redis_image. Nothing was changed." >&2 + exit 1 +fi +`; + +const redisServiceStep = () => ` +echo "Updating Redis to ${PINNED_VERSIONS.redis}" +redis_timeout="\${DOKPLOY_REDIS_TIMEOUT:-240}" +redis_previous_image="$($SUDO_CMD docker service inspect dokploy-redis --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}' 2>/dev/null || true)" +redis_previous_index="$($SUDO_CMD docker service inspect dokploy-redis --format '{{.Version.Index}}' 2>/dev/null || true)" +redis_update_needed=1 +if [ "\${redis_previous_image%%@*}" = "$redis_image" ]; then + redis_update_needed=0 + echo "Redis already runs $redis_image" +elif ! $SUDO_CMD docker service update --detach --update-order stop-first --update-failure-action rollback --image "$redis_image" dokploy-redis >/dev/null; then + echo "Error: docker service update was refused for dokploy-redis. Redis was not changed." >&2 + exit 1 +fi +redis_ready() { + local container="" + container="$($SUDO_CMD docker ps -q --no-trunc --filter label=com.docker.swarm.service.name=dokploy-redis --filter status=running --filter "ancestor=$redis_image" | head -n1)" + [ -n "$container" ] && [ "$($SUDO_CMD docker exec "$container" redis-cli ping 2>/dev/null)" = "PONG" ] +} +redis_settled() { + if [ "$redis_update_needed" = 1 ]; then + [ "\${redis_index:-0}" -gt "\${redis_previous_index:-0}" ] && [ "$redis_state" = "completed" ] || return 1 + fi + redis_ready +} +redis_deadline=$(( $(date +%s) + $redis_timeout )) +while :; do + redis_index="$($SUDO_CMD docker service inspect dokploy-redis --format '{{.Version.Index}}' 2>/dev/null || true)" + redis_state="$($SUDO_CMD docker service inspect dokploy-redis --format '{{.UpdateStatus.State}}' 2>/dev/null || true)" + if [ "$redis_update_needed" = 1 ] && [ "\${redis_index:-0}" -gt "\${redis_previous_index:-0}" ]; then + case "$redis_state" in + rollback_*) + echo "Error: dokploy-redis did not start on $redis_image (swarm state: $redis_state). Previous image: $redis_previous_image." >&2 + exit 1 + ;; + paused) + echo "Error: dokploy-redis is paused after its update to $redis_image. Previous image: $redis_previous_image." >&2 + exit 1 + ;; + esac + fi + if redis_settled; then + break + fi + if [ "$(date +%s)" -ge "$redis_deadline" ]; then + echo "Error: dokploy-redis was not ready on $redis_image within $redis_timeout seconds: the update did not finish or redis-cli ping did not return PONG. Previous image: $redis_previous_image. Check with: docker service ps dokploy-redis --no-trunc" >&2 + exit 1 + fi + sleep 2 +done +echo "Redis version ${PINNED_VERSIONS.redis} installed ✅" +`; + +const hostStackSteps = (components: HostStackUiComponent[]) => { + const selected = HOST_STACK_UI_COMPONENTS.filter((component) => + components.includes(component), + ); + const steps: string[] = []; + if (selected.includes("redis")) { + steps.push(redisPullStep()); + } + if (selected.includes("traefik")) { + steps.push(buildComponentUpdateSteps(["traefik"])); + } + if (selected.includes("redis")) { + steps.push(redisServiceStep()); + } + return steps.join("\n"); +}; + +export const buildHostStackUpdateScript = ( + components: HostStackUiComponent[], +) => wrapComponentUpdateSteps(hostStackSteps(components)); + +let hostStackUpdating = false; + +export const isHostStackUpdateRunning = () => hostStackUpdating; + +export const updateHostStackComponents = async ( + components: HostStackUiComponent[], + onData?: (data: string) => void, +) => { + if (hostStackUpdating) { + throw new Error("Another panel host component update is already running."); + } + hostStackUpdating = true; + try { + await spawnAsync( + "bash", + ["-c", buildHostStackUpdateScript(components)], + onData, + { stdio: ["ignore", "pipe", "pipe"] }, + ); + } catch (error) { + const code = (error as { code?: unknown }).code; + throw new Error( + typeof code === "number" + ? `Panel host update failed with exit code ${code}` + : `Panel host update could not start: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + hostStackUpdating = false; + } +}; From 12a557d4bd97a8f6fd52c8268fe5cc5842d4c6c0 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:07:08 -0400 Subject: [PATCH 04/20] feat(settings): expose the panel host components to the Remote Servers page settings.hostStack returns the rows of the panel host components. It is limited to local host access (owner or admin) and returns nothing on cloud. settings.updateHostStack is a subscription that runs the Traefik and Redis updates on the panel host and streams their logs. It refuses docker, postgres and panel with the next step, refuses while a panel update or another host update is running, and writes an audit entry. Both procedures are classified in the procedure access map; the update is dangerous, like the other host settings changes. Co-Authored-By: Claude Opus 5.5 --- apps/dokploy/server/api/routers/settings.ts | 72 +++++++++++++++++++ .../server/api/utils/procedure-access.ts | 2 + packages/server/src/index.ts | 2 + 3 files changed, 76 insertions(+) diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index 9c21426da1..99a60460e6 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -1,5 +1,6 @@ import { CLEANUP_CRON_JOB, + COMPONENTS_UPDATE_FAILED, checkGPUStatus, checkPortInUse, checkPostgresHealth, @@ -30,7 +31,11 @@ import { getUpdateData, getUpdateDiskSpace, getWebServerSettings, + HOST_STACK_COMPONENTS, + hostStackRefusals, IS_CLOUD, + isHostStackUiComponent, + isHostStackUpdateRunning, isServerUpdateRunning, parseRawConfig, paths, @@ -40,6 +45,7 @@ import { readConfigInPath, readDirectory, readEnvironmentVariables, + readHostStackRows, readMainConfig, readMonitoringConfig, readPorts, @@ -53,6 +59,7 @@ import { startLogCleanup, startServerUpdate, stopLogCleanup, + updateHostStackComponents, updateLetsEncryptEmail, updateServerById, updateServerTraefik, @@ -67,6 +74,7 @@ import { checkPermission } from "@dokploy/server/services/permission"; import { isProtectedTraefikPath } from "@dokploy/server/utils/traefik/application"; import { generateOpenApiDocument } from "@dokploy/trpc-openapi"; import { TRPCError } from "@trpc/server"; +import { observable } from "@trpc/server/observable"; import { eq, sql } from "drizzle-orm"; import { scheduledJobs, scheduleJob } from "node-schedule"; import { parse, stringify } from "yaml"; @@ -193,6 +201,70 @@ export const settingsRouter = createTRPCRouter({ }); return true; }), + hostStack: adminProcedure.query(async ({ ctx }) => { + await assertLocalHostAccess(ctx); + if (IS_CLOUD) { + return null; + } + return await readHostStackRows(packageInfo.version); + }), + updateHostStack: adminProcedure + .meta({ + openapi: { + path: "/settings/update-host-stack", + method: "POST", + override: true, + enabled: false, + }, + }) + .input( + z.object({ + components: z.array(z.enum(HOST_STACK_COMPONENTS)).min(1), + }), + ) + .subscription(async ({ input, ctx }) => { + await assertLocalHostAccess(ctx); + if (IS_CLOUD) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "The panel host components are not managed here.", + }); + } + const refusals = hostStackRefusals(input.components); + if (refusals.length > 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: refusals.join(" "), + }); + } + if (isServerUpdateRunning() || isHostStackUpdateRunning()) { + throw new TRPCError({ + code: "CONFLICT", + message: + "Another update of this panel host is running. Try again when it finishes.", + }); + } + const components = input.components.filter(isHostStackUiComponent); + await audit(ctx, { + action: "update", + resourceType: "settings", + resourceName: "panel-host", + metadata: { components }, + }); + return observable((emit) => { + updateHostStackComponents(components, (log) => { + emit.next(log); + }) + .catch((error) => { + emit.next( + `${error instanceof Error ? error.message : error}\n${COMPONENTS_UPDATE_FAILED}\n`, + ); + }) + .finally(() => { + emit.complete(); + }); + }); + }), toggleDashboard: adminProcedure .input(apiEnableDashboard) .mutation(async ({ input, ctx }) => { diff --git a/apps/dokploy/server/api/utils/procedure-access.ts b/apps/dokploy/server/api/utils/procedure-access.ts index 7b755f4e55..4cc7778f49 100644 --- a/apps/dokploy/server/api/utils/procedure-access.ts +++ b/apps/dokploy/server/api/utils/procedure-access.ts @@ -610,6 +610,7 @@ export const PROCEDURE_ACCESS: Record = { "settings.haveActivateRequests": "read", "settings.haveTraefikDashboardPortEnabled": "read", "settings.health": "read", + "settings.hostStack": "read", "settings.isCloud": "read", "settings.isUserSubscribed": "read", "settings.readDirectories": "read", @@ -629,6 +630,7 @@ export const PROCEDURE_ACCESS: Record = { "settings.updateBuildsConcurrency": "write", "settings.updateDockerCleanup": "write", "settings.updateEnforceSSO": "dangerous", + "settings.updateHostStack": "dangerous", "settings.updateLogCleanup": "write", "settings.updateMiddlewareTraefikConfig": "dangerous", "settings.updateRemoteServersOnly": "write", diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index a9aca1d3f5..c85811c3ab 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -64,6 +64,8 @@ export * from "./services/web-server-settings"; export * from "./services/web-server-update"; export * from "./setup/config-paths"; export * from "./setup/forward-auth-setup"; +export * from "./setup/host-stack"; +export * from "./setup/host-stack-rows"; export * from "./setup/monitoring-setup"; export * from "./setup/postgres-setup"; export * from "./setup/server-audit"; From f54485f4f3e7742b958e58385be3d252110cda45 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:07:37 -0400 Subject: [PATCH 05/20] feat(servers): show the panel host components on the Remote Servers page A "Panel host (this server)" card sits above the server cards. Its Components button opens the panel host rows with the installed and target versions, the status and the action for each component. Traefik and Redis can be updated from the dialog after a confirmation, with a warning that Traefik is recreated and the admin site is unreachable for a few seconds. Docker and Postgres show the maintenance-window command, and the panel row opens the Web Server Update dialog. The card is hidden for users who cannot manage the panel host. Co-Authored-By: Claude Opus 5.5 --- .../settings/servers/panel-host-stack.tsx | 380 ++++++++++++++++++ .../settings/servers/show-servers.tsx | 2 + 2 files changed, 382 insertions(+) create mode 100644 apps/dokploy/components/dashboard/settings/servers/panel-host-stack.tsx diff --git a/apps/dokploy/components/dashboard/settings/servers/panel-host-stack.tsx b/apps/dokploy/components/dashboard/settings/servers/panel-host-stack.tsx new file mode 100644 index 0000000000..004de4c85f --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/servers/panel-host-stack.tsx @@ -0,0 +1,380 @@ +import type { + HostStackComponent, + HostStackRow, +} from "@dokploy/server/setup/host-stack-rows"; +import { + AlertTriangle, + CheckCircle2, + Loader2, + RefreshCw, + Server, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { api } from "@/utils/api"; +import { UpdateServer } from "../web-server/update-server"; + +const UPDATE_DONE = "Components update finished ✅"; +const UPDATE_FAILED = "Components update failed ❌"; +const TRAEFIK_CONNECTION_NOTE = + "If the connection to the panel dropped while Traefik was recreated, the update can still finish on the host. Reload this page to see the result."; + +const LABELS: Record = { + panel: "Panel", + docker: "Docker", + traefik: "Traefik", + postgres: "Postgres", + redis: "Redis", +}; + +const WARNINGS: Partial> = { + traefik: + "Traefik is recreated; the admin site is unreachable for a few seconds.", + redis: "Redis is restarted; it is unavailable for a few seconds.", +}; + +const StatusLine = ({ row }: { row: HostStackRow }) => { + if (row.status === "outdated") { + return ( + + + Outdated + + ); + } + if (row.status === "current") { + return ( + + + Up to date + + ); + } + return Unknown; +}; + +const HostStackRowItem = ({ + row, + checked, + onCheckedChange, + onOpenWebUpdate, +}: { + row: HostStackRow; + checked: boolean; + onCheckedChange: (checked: boolean) => void; + onOpenWebUpdate: () => void; +}) => { + const checkboxId = `host-stack-${row.name}`; + return ( +
+
+ {LABELS[row.name]} + + installed: {row.installed ?? "not found"} + + + target: {row.target ?? "not pinned"} + +
+
+ + {row.action === "panel" && ( + + )} + {row.action === "ui" && row.outdated && ( +
+ onCheckedChange(value === true)} + /> + +
+ )} + {row.action === "manual" && + (row.outdated || row.status === "unknown") && ( +

{row.reason}

+ )} + {row.action === "manual" && row.outdated && row.command && ( + + {row.command} + + )} + {row.action !== "manual" && row.status === "unknown" && row.reason && ( +

{row.reason}

+ )} +
+
+ ); +}; + +const PanelHostCard = ({ + rows, + refetch, +}: { + rows: HostStackRow[]; + refetch: () => unknown; +}) => { + const [isOpen, setIsOpen] = useState(false); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const [isWebUpdateOpen, setIsWebUpdateOpen] = useState(false); + const [chosen, setChosen] = useState< + Partial> + >({}); + const [isUpdating, setIsUpdating] = useState(false); + const [runComponents, setRunComponents] = useState([]); + const [logs, setLogs] = useState(""); + + const outdatedCount = rows.filter((row) => row.outdated).length; + const unknownCount = rows.filter((row) => row.status === "unknown").length; + const selected = rows + .filter( + (row) => + row.action === "ui" && row.outdated && (chosen[row.name] ?? true), + ) + .map((row) => row.name); + const summary = rows + .filter((row) => row.name !== "panel") + .map((row) => `${LABELS[row.name]} ${row.installed ?? "unknown"}`) + .join(" · "); + + api.settings.updateHostStack.useSubscription( + { components: runComponents }, + { + enabled: isUpdating, + onData(log) { + setLogs((prev) => `${prev}${log}`); + if (log.includes(UPDATE_DONE) || log.includes(UPDATE_FAILED)) { + setIsUpdating(false); + if (log.includes(UPDATE_DONE)) { + toast.success("Panel host components updated"); + } else { + toast.error("Panel host update failed, see the log"); + } + void refetch(); + } + }, + onError(err) { + setLogs((prev) => { + const note = runComponents.includes("traefik") + ? `\n${TRAEFIK_CONNECTION_NOTE}` + : ""; + return `${prev}${err.message}${note}\n`; + }); + setIsUpdating(false); + toast.error("Panel host update did not finish"); + void refetch(); + }, + }, + ); + + const startUpdate = () => { + setLogs(""); + setRunComponents(selected); + setIsConfirmOpen(false); + setIsUpdating(true); + }; + + return ( + <> + + +
+ + + Panel host (this server) + + {outdatedCount > 0 ? ( + {outdatedCount} to update + ) : unknownCount > 0 ? ( + Some versions unknown + ) : ( + Up to date + )} +
+ + Docker, Traefik, Postgres and Redis on the server that runs this + panel. + +
+ +

{summary}

+ + + + + + + + Panel host components + + + Versions on the server that runs this panel, compared with the + versions pinned in Dokploy. + + + +
+ {rows.map((row) => ( + + setChosen((prev) => ({ ...prev, [row.name]: value })) + } + onOpenWebUpdate={() => { + setIsOpen(false); + setIsWebUpdateOpen(true); + }} + /> + ))} +
+ + {selected.map((name) => + WARNINGS[name] ? ( +

+ {WARNINGS[name]} +

+ ) : null, + )} + + + + {logs && ( +
+									{logs}
+								
+ )} +
+
+
+
+ + {/* Opened from the row above, so the dialog's own trigger stays hidden. */} + + + + + + + + Update {selected.map((name) => LABELS[name]).join(" and ")} on the + panel host? + + + {selected.map((name) => + WARNINGS[name] ? ( + + {WARNINGS[name]} + + ) : null, + )} + + + + Cancel + Update + + + + + ); +}; + +export const PanelHostStack = () => { + const { data, error, isPending, refetch } = api.settings.hostStack.useQuery( + undefined, + { + refetchOnWindowFocus: false, + staleTime: 60 * 1000, + retry: false, + }, + ); + + if (isPending) { + return ( + + + + Checking the panel host... + + + ); + } + + if (error) { + if ( + error.data?.code === "UNAUTHORIZED" || + error.data?.code === "FORBIDDEN" + ) { + return null; + } + return ( + + + The panel host check failed: {error.message} + + + ); + } + + if (!data) { + return null; + } + + return ; +}; diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx index b6d4cc7fb1..89963993b0 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx @@ -33,6 +33,7 @@ import { TerminalModal } from "../web-server/terminal-modal"; import { ShowServerActions } from "./actions/show-server-actions"; import { DeleteServerModal } from "./delete-server-modal"; import { HandleServers } from "./handle-servers"; +import { PanelHostStack } from "./panel-host-stack"; import { ServerComponentsUpdate } from "./server-components-update"; import { SetupServer } from "./setup-server"; import { ShowMonitoringModal } from "./show-monitoring-modal"; @@ -124,6 +125,7 @@ export const ShowServers = () => { )} + {isPending ? (
Loading... From 5d5a0a5adc8e088c116e10fcad0a3d8135115d54 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:09:46 -0400 Subject: [PATCH 06/20] fix(servers): keep the maintenance reason and command on Docker and Postgres Docker and Postgres are always updated in the maintenance window, so their rows keep the reason and the exact installer command even when they are current. The card can then show the command at any time. Traefik and Redis rows carry neither, since the panel runs them. A Postgres row whose version cannot be read gets no command, because the image it would keep is unknown. Co-Authored-By: Claude Opus 5.5 --- .../__test__/server/host-stack-rows.test.ts | 41 ++++++++++++++++--- packages/server/src/setup/host-stack-rows.ts | 17 ++++---- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/apps/dokploy/__test__/server/host-stack-rows.test.ts b/apps/dokploy/__test__/server/host-stack-rows.test.ts index 821c997d04..0d7bcc26cc 100644 --- a/apps/dokploy/__test__/server/host-stack-rows.test.ts +++ b/apps/dokploy/__test__/server/host-stack-rows.test.ts @@ -50,8 +50,35 @@ describe("buildHostStackRows", () => { for (const row of rowsFor({})) { expect(row.status, row.name).toBe("current"); expect(row.outdated, row.name).toBe(false); - expect(row.reason, row.name).toBeNull(); - expect(row.command, row.name).toBeNull(); + } + }); + + it("keeps the reason and command of the maintenance-window steps even when they are current", () => { + const rows = rowsFor({}); + + expect(rowNamed(rows, "docker")).toMatchObject({ + action: "manual", + command: "DOCKER_ENGINE_UPGRADE=1 bash install-agenthits.sh update", + }); + expect(rowNamed(rows, "docker").reason).toContain("maintenance window"); + expect(rowNamed(rows, "postgres")).toMatchObject({ + action: "manual", + command: `POSTGRES_IMAGE=postgres:${pinnedPostgresMajor} bash install-agenthits.sh update`, + }); + }); + + it("gives Traefik and Redis no reason or command, since the panel runs them", () => { + const rows = rowsFor({ + traefikImage: "traefik:v3.6.1", + redisImage: "redis:7", + }); + + for (const name of ["traefik", "redis"] as const) { + expect(rowNamed(rows, name)).toMatchObject({ + action: "ui", + reason: null, + command: null, + }); } }); @@ -130,9 +157,9 @@ describe("buildHostStackRows", () => { installed: pinnedPostgresMajor, status: "current", outdated: false, - reason: null, - command: null, + command: `POSTGRES_IMAGE=postgres:${pinnedPostgresMajor}.5-alpine bash install-agenthits.sh update`, }); + expect(postgres.reason).toContain("maintenance window"); }); it("keeps the image digest out of the Postgres command", () => { @@ -176,7 +203,11 @@ describe("buildHostStackRows", () => { expect(row.status, name).toBe("unknown"); expect(row.outdated, name).toBe(false); expect(row.installed, name).toBeNull(); - expect(row.command, name).toBeNull(); + expect(row.command, name).toBe( + name === "docker" + ? "DOCKER_ENGINE_UPGRADE=1 bash install-agenthits.sh update" + : null, + ); expect(row.reason, name).not.toBeNull(); } }); diff --git a/packages/server/src/setup/host-stack-rows.ts b/packages/server/src/setup/host-stack-rows.ts index 3217ca81ef..e433cb38ac 100644 --- a/packages/server/src/setup/host-stack-rows.ts +++ b/packages/server/src/setup/host-stack-rows.ts @@ -55,6 +55,8 @@ const PANEL_UNKNOWN_REASON = "The update check did not return a version for this panel."; const DOCKER_REASON = "Docker Engine upgrades restart the daemon, which stops this panel. Run them only in the maintenance window."; +const POSTGRES_REASON = + "Postgres changes only in the maintenance window, and keeps its major version."; const HOST_STACK_REFUSALS: Record< Exclude, @@ -149,7 +151,10 @@ const dockerRow = (readout: string | null): HostStackRow => { const target = PINNED_VERSIONS.docker; const installed = versionOf(readout); if (installed === null) { - return unknownRow("docker", target, "manual"); + return { + ...unknownRow("docker", target, "manual"), + command: DOCKER_UPGRADE_COMMAND, + }; } const outdated = compareVersions(installed, target) < 0; return { @@ -159,8 +164,8 @@ const dockerRow = (readout: string | null): HostStackRow => { status: outdated ? "outdated" : "current", outdated, action: "manual", - reason: outdated ? DOCKER_REASON : null, - command: outdated ? DOCKER_UPGRADE_COMMAND : null, + reason: DOCKER_REASON, + command: DOCKER_UPGRADE_COMMAND, }; }; @@ -200,10 +205,8 @@ const postgresRow = (image: string | null): HostStackRow => { action: "manual", reason: outdated ? `Major version change (${installedMajor} to ${targetMajor}) needs a migration. The panel never changes the Postgres major version.` - : null, - command: outdated - ? `POSTGRES_IMAGE=${withoutDigest(image)} ${INSTALLER_UPDATE}` - : null, + : POSTGRES_REASON, + command: `POSTGRES_IMAGE=${withoutDigest(image)} ${INSTALLER_UPDATE}`, }; }; From 6270ca6df028d58ece001884421b56ff4c8758b9 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:09:46 -0400 Subject: [PATCH 07/20] fix(servers): show every Traefik and Redis row with a checkbox The rows the panel can update always show a checkbox. It is enabled only when the component is outdated, so an up-to-date component cannot be selected by accident. Manual rows show their reason and command, and unknown rows show the reason they could not be read. Co-Authored-By: Claude Opus 5.5 --- .../settings/servers/panel-host-stack.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/servers/panel-host-stack.tsx b/apps/dokploy/components/dashboard/settings/servers/panel-host-stack.tsx index 004de4c85f..32dad057a3 100644 --- a/apps/dokploy/components/dashboard/settings/servers/panel-host-stack.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/panel-host-stack.tsx @@ -113,28 +113,31 @@ const HostStackRowItem = ({ Web Server Update )} - {row.action === "ui" && row.outdated && ( + {row.action === "ui" && (
onCheckedChange(value === true)} /> - +
)} - {row.action === "manual" && - (row.outdated || row.status === "unknown") && ( + {row.reason && + (row.action === "manual" || row.status === "unknown") && (

{row.reason}

)} - {row.action === "manual" && row.outdated && row.command && ( + {row.action === "manual" && row.command && ( {row.command} )} - {row.action !== "manual" && row.status === "unknown" && row.reason && ( -

{row.reason}

- )}
); From 56a09c6d6111701dabfa441d4f7eeb5ecb0db771 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:10:32 -0400 Subject: [PATCH 08/20] fix(settings): refuse a panel update while host components are updating The host components update replaces Traefik and restarts Redis from a script run by this panel. A panel update restarts the panel container, which would stop that script in the middle of the change. The update mutation now refuses while the host components update runs, the same way the host update refuses while a panel update runs. Co-Authored-By: Claude Opus 5.5 --- apps/dokploy/server/api/routers/settings.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index 99a60460e6..339724df8b 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -729,6 +729,14 @@ export const settingsRouter = createTRPCRouter({ return true; } + if (isHostStackUpdateRunning()) { + throw new TRPCError({ + code: "CONFLICT", + message: + "The panel host components are being updated. Try again when they finish.", + }); + } + const keepImages = input?.keepImages; const data = await getUpdateData(packageInfo.version); if (data.updateAvailable) { From 1227e8b671f0ddab0c6ccbbc29e51c7e28369ea1 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:19:34 -0400 Subject: [PATCH 09/20] fix(traefik): mount the Docker socket read-only in the generated container The installer mounts the socket read-only, but the generated container for remote servers and the panel host dropped the :ro flag. Keep the same mount in both places, so an update does not change how Traefik sees the daemon. Co-Authored-By: Claude Opus 5.5 --- packages/server/src/setup/traefik-setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/setup/traefik-setup.ts b/packages/server/src/setup/traefik-setup.ts index 4bfe45c08a..c9786c2c92 100644 --- a/packages/server/src/setup/traefik-setup.ts +++ b/packages/server/src/setup/traefik-setup.ts @@ -31,7 +31,7 @@ const traefikContainerOptions = ( --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 \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ -p ${TRAEFIK_SSL_PORT}:${TRAEFIK_SSL_PORT} \ -p ${TRAEFIK_PORT}:${TRAEFIK_PORT} \ -p ${TRAEFIK_HTTP3_PORT}:${TRAEFIK_HTTP3_PORT}/udp \ From eb8c5d47aa045a27c9de8150865d7bb8b91c8ca1 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:27:55 -0400 Subject: [PATCH 10/20] fix(traefik): publish port 80 only when the running container does The panel host's Traefik publishes 443 only, but the generated container and the installer always published 80 as well. A replacement would have opened port 80 without anyone asking for it. Read the current publication before the old container stops and reuse it for the new one and for the restore; a fresh install still publishes 80. Co-Authored-By: Claude Opus 5.5 --- .../__test__/server/server-components.test.ts | 5 ++++- install-agenthits.sh | 11 ++++++++++- packages/server/src/setup/server-components.ts | 12 ++++++++++-- packages/server/src/setup/traefik-setup.ts | 10 +++++++--- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/apps/dokploy/__test__/server/server-components.test.ts b/apps/dokploy/__test__/server/server-components.test.ts index 2f209f67b3..1e0d9822e8 100644 --- a/apps/dokploy/__test__/server/server-components.test.ts +++ b/apps/dokploy/__test__/server/server-components.test.ts @@ -125,7 +125,10 @@ 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"').trim()); + expect(script).toContain( + buildTraefikCreateWithImage('"$1"', "$traefik_http_publish").trim(), + ); + expect(script).toContain('traefik_http_publish="-p 80:80"'); expect(script).not.toContain("docker rename"); }); it("stops and removes the old Traefik, creates the new one, attaches its networks, and starts it last", () => { diff --git a/install-agenthits.sh b/install-agenthits.sh index 456486a8c9..3b9d0416c9 100755 --- a/install-agenthits.sh +++ b/install-agenthits.sh @@ -61,6 +61,7 @@ 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 @@ -1334,13 +1335,17 @@ 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 /etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic \ -v /var/run/docker.sock:/var/run/docker.sock:ro \ - -p 80:80/tcp \ + "${http_publish[@]}" \ -p 443:443/tcp \ -p 443:443/udp \ "$image" >/dev/null || return 1 @@ -1407,6 +1412,10 @@ 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 docker rm -f dokploy-traefik-previous >/dev/null 2>&1 || true diff --git a/packages/server/src/setup/server-components.ts b/packages/server/src/setup/server-components.ts index da31bd696d..c383f57b13 100644 --- a/packages/server/src/setup/server-components.ts +++ b/packages/server/src/setup/server-components.ts @@ -6,7 +6,11 @@ import { } from "../utils/servers/destination"; import { PINNED_VERSIONS } from "./component-versions"; import { rcloneInstallCommand } from "./server-setup"; -import { buildTraefikCreateWithImage, TRAEFIK_VERSION } from "./traefik-setup"; +import { + buildTraefikCreateWithImage, + TRAEFIK_PORT, + TRAEFIK_VERSION, +} from "./traefik-setup"; export const COMPONENT_NAMES = [ "docker", @@ -458,8 +462,12 @@ if $SUDO_CMD docker inspect dokploy-traefik >/dev/null 2>&1; then traefik_old_image="$($SUDO_CMD docker inspect -f '{{.Config.Image}}' dokploy-traefik 2>/dev/null || true)" traefik_extra_networks="$($SUDO_CMD docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' dokploy-traefik 2>/dev/null || true)" 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"')} +${buildTraefikCreateWithImage('"$1"', "$traefik_http_publish")} } traefik_connect() { $SUDO_CMD docker network connect dokploy-network dokploy-traefik || return 1 diff --git a/packages/server/src/setup/traefik-setup.ts b/packages/server/src/setup/traefik-setup.ts index c9786c2c92..e2e6aa4af8 100644 --- a/packages/server/src/setup/traefik-setup.ts +++ b/packages/server/src/setup/traefik-setup.ts @@ -27,13 +27,14 @@ 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} \ - -p ${TRAEFIK_PORT}:${TRAEFIK_PORT} \ + ${httpPublish} \ -p ${TRAEFIK_HTTP3_PORT}:${TRAEFIK_HTTP3_PORT}/udp \ ${image}`; @@ -44,8 +45,11 @@ 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) => ` - $SUDO_CMD docker create ${traefikContainerOptions(image, "no")} +export const buildTraefikCreateWithImage = ( + image: string, + httpPublish?: string, +) => ` + $SUDO_CMD docker create ${traefikContainerOptions(image, "no", httpPublish)} `; export const buildTraefikCreateCommand = (version: string) => From a414e1c7952b130968e6c4092783d333a62e093d Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:27:55 -0400 Subject: [PATCH 11/20] test(settings): mock the host stack exports used by the settings router The settings router reads the host stack constant and update guard, so the module mocks in the settings boundary tests must provide them. Co-Authored-By: Claude Opus 5.5 --- apps/dokploy/__test__/settings/docker-server-boundary.test.ts | 4 ++++ .../__test__/settings/request-log-authorization.test.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/apps/dokploy/__test__/settings/docker-server-boundary.test.ts b/apps/dokploy/__test__/settings/docker-server-boundary.test.ts index 32ae0e340e..de81f61150 100644 --- a/apps/dokploy/__test__/settings/docker-server-boundary.test.ts +++ b/apps/dokploy/__test__/settings/docker-server-boundary.test.ts @@ -79,6 +79,10 @@ vi.mock("@dokploy/server", async () => ({ getDokployImageKeepCount: vi.fn(), getDokployImages: vi.fn(), IS_CLOUD: false, + HOST_STACK_COMPONENTS: ["panel", "docker", "traefik", "postgres", "redis"], + isHostStackUpdateRunning: vi.fn(() => false), + readHostStackRows: vi.fn(), + updateHostStackComponents: vi.fn(), checkGPUStatus: mocks.checkGPUStatus, checkPortInUse: mocks.checkPortInUse, checkPostgresHealth: mocks.checkPostgresHealth, diff --git a/apps/dokploy/__test__/settings/request-log-authorization.test.ts b/apps/dokploy/__test__/settings/request-log-authorization.test.ts index 49b231f54d..6def3f05dc 100644 --- a/apps/dokploy/__test__/settings/request-log-authorization.test.ts +++ b/apps/dokploy/__test__/settings/request-log-authorization.test.ts @@ -99,6 +99,7 @@ vi.mock("@dokploy/server", async () => ({ getDokployImageKeepCount: vi.fn(), getDokployImages: vi.fn(), IS_CLOUD: false, + HOST_STACK_COMPONENTS: ["panel", "docker", "traefik", "postgres", "redis"], checkGPUStatus: mocks.checkGPUStatus, checkPortInUse: mocks.checkPortInUse, checkPostgresHealth: mocks.checkPostgresHealth, From 90ea2c388f93750a573d133701a85a2cbe30b315 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:30:58 -0400 Subject: [PATCH 12/20] fix(traefik): leave the default bridge network off when the running container did not have it A replacement container is created with the default bridge network. The live Traefik had no bridge attachment, so the update added one it did not have before. Detach bridge after the new container runs, but only when the container being replaced was not on it. Co-Authored-By: Claude Opus 5.5 --- apps/dokploy/__test__/server/server-components.test.ts | 3 +++ packages/server/src/setup/server-components.ts | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/apps/dokploy/__test__/server/server-components.test.ts b/apps/dokploy/__test__/server/server-components.test.ts index 1e0d9822e8..83dd9a6315 100644 --- a/apps/dokploy/__test__/server/server-components.test.ts +++ b/apps/dokploy/__test__/server/server-components.test.ts @@ -129,6 +129,9 @@ describe("buildComponentUpdateScript", () => { buildTraefikCreateWithImage('"$1"', "$traefik_http_publish").trim(), ); expect(script).toContain('traefik_http_publish="-p 80:80"'); + expect(script).toContain( + "docker network disconnect bridge dokploy-traefik", + ); expect(script).not.toContain("docker rename"); }); it("stops and removes the old Traefik, creates the new one, attaches its networks, and starts it last", () => { diff --git a/packages/server/src/setup/server-components.ts b/packages/server/src/setup/server-components.ts index c383f57b13..f673da5710 100644 --- a/packages/server/src/setup/server-components.ts +++ b/packages/server/src/setup/server-components.ts @@ -462,6 +462,10 @@ if $SUDO_CMD docker inspect dokploy-traefik >/dev/null 2>&1; then traefik_old_image="$($SUDO_CMD docker inspect -f '{{.Config.Image}}' dokploy-traefik 2>/dev/null || true)" traefik_extra_networks="$($SUDO_CMD docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' dokploy-traefik 2>/dev/null || true)" fi +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="" @@ -533,6 +537,9 @@ fi if ! traefik_wait_settled; then traefik_restore "checking the new Traefik container (running, with no restarts)" fi +if [ "$traefik_keep_bridge" = 0 ]; then + $SUDO_CMD docker network disconnect bridge dokploy-traefik >/dev/null 2>&1 || true +fi if ! $SUDO_CMD docker update --restart always dokploy-traefik >/dev/null; then traefik_restore "setting the restart policy of the new Traefik container" fi From 49a4cba028ffa32adc106592ff4890af0f01c1d7 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:36:16 -0400 Subject: [PATCH 13/20] fix(installer): expand the Traefik publish array safely on bash 3.2 An empty array expanded under set -u fails on bash 3.2, which the macOS CI runner uses. Use the guarded form, so port 80 is passed only when it is published. Co-Authored-By: Claude Opus 5.5 --- install-agenthits.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install-agenthits.sh b/install-agenthits.sh index 3b9d0416c9..cff54c1c59 100755 --- a/install-agenthits.sh +++ b/install-agenthits.sh @@ -1345,7 +1345,7 @@ create_dokploy_traefik() { -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 \ - "${http_publish[@]}" \ + ${http_publish[@]+"${http_publish[@]}"} \ -p 443:443/tcp \ -p 443:443/udp \ "$image" >/dev/null || return 1 From a0916a6275034280d4ba100190d89646f80b07f8 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:09:18 -0400 Subject: [PATCH 14/20] fix(installer): keep an installed Postgres whose major differs instead of refusing The operator update used to stop before any change when the installed Postgres major version differed from the pinned one. It now prints "Postgres stays. A major upgrade is a separate migration; this update does not change it." and continues: - no Postgres backup, no Postgres pre-pull and no Postgres swap in that run - Redis, Traefik, the panel and the Docker Engine step run as before - when the majors match, the backup and the swap behave as before postgres_ready now checks the image the service actually runs. Checking the pinned image would never find a kept older Postgres, so the Docker Engine health check would roll the upgrade back. Tests: the refusal test now expects the skip line, no backup, no swap and the other steps in order. A new Docker Engine upgrade test covers a kept Postgres. The fake docker ps honors the ancestor filter for dokploy-postgres, so the readiness check against the pinned image fails that test. Docs: docs/server-maintenance.md is a new operator guide. The install guide describes the new Postgres behaviour. Co-Authored-By: Claude Opus 5.5 --- .../settings/install-agenthits-update.test.ts | 71 ++++++++++++++-- docs/agenthits-install.md | 16 ++-- docs/server-maintenance.md | 83 +++++++++++++++++++ install-agenthits.sh | 20 +++-- 4 files changed, 169 insertions(+), 21 deletions(-) create mode 100644 docs/server-maintenance.md diff --git a/apps/dokploy/__test__/settings/install-agenthits-update.test.ts b/apps/dokploy/__test__/settings/install-agenthits-update.test.ts index 321c30bc35..b9195d8dc7 100644 --- a/apps/dokploy/__test__/settings/install-agenthits-update.test.ts +++ b/apps/dokploy/__test__/settings/install-agenthits-update.test.ts @@ -48,7 +48,14 @@ case "$1" in exit 0 ;; ps) - echo fake-task-container + case "$*" in + *"name=dokploy-postgres"*"ancestor="*) + case "$*" in + *"ancestor=$FAKE_POSTGRES_IMAGE") echo fake-task-container ;; + esac + ;; + *) echo fake-task-container ;; + esac exit 0 ;; exec) @@ -629,19 +636,42 @@ describe("install-agenthits.sh update", () => { SPAWN_TEST_TIMEOUT_MS, ); it( - "refuses a Postgres major version change before anything runs", + "keeps an installed Postgres whose major differs from the pin and runs the other steps", () => { - const { result, calls } = runInstaller({ + const { result, calls, backups } = runInstaller({ postgresImage: "postgres:17.5", + redisImage: "redis:8.10.1", + traefikImage: "traefik:v3.6.25", }); - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("changes the major version"); - expect(result.stderr).toContain("Nothing was changed"); - expect(calls.some((call) => call.startsWith("pull "))).toBe(false); - expect(calls.some((call) => call.startsWith("service update"))).toBe( - false, + expect(result.status).toBe(0); + expect(result.stdout).toContain( + "Postgres 17 stays. A major upgrade is a separate migration; this update does not change it.", + ); + expect(result.stderr).not.toContain("changes the major version"); + expect(backups).toEqual([]); + expect(calls.some((call) => call.includes("pg_dumpall"))).toBe(false); + expect(calls).not.toContain("pull postgres:18.6"); + expect( + calls.some( + (call) => + call.startsWith("service update") && + call.includes("dokploy-postgres"), + ), + ).toBe(false); + + const redis = firstIndex( + calls, + "service update --detach --update-order stop-first --update-failure-action rollback --image redis:8.10.2 dokploy-redis", ); + const traefikStop = firstIndex(calls, "stop dokploy-traefik"); + const traefikStart = firstIndex(calls, "start dokploy-traefik"); + const panel = firstIndex(calls, "buildx imagetools inspect"); + expect(redis).toBeGreaterThanOrEqual(0); + expect(redis).toBeLessThan(traefikStop); + expect(traefikStop).toBeLessThan(traefikStart); + expect(traefikStart).toBeLessThan(panel); + expect(result.stdout).toContain("Panel host update finished."); }, SPAWN_TEST_TIMEOUT_MS, ); @@ -709,6 +739,29 @@ describe("install-agenthits.sh Docker Engine upgrade", () => { SPAWN_TEST_TIMEOUT_MS, ); + it( + "finishes the Docker Engine upgrade while Postgres keeps its major version", + () => { + const { result, calls } = runInstaller({ + postgresImage: "postgres:17.5", + upgradeEngine: true, + engineVersion: "28.3.0", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Postgres 17 stays."); + expect(result.stdout).toContain("Docker Engine is 29.8.2"); + expect(result.stdout).toContain("Panel host update finished."); + expect(result.stderr).not.toContain("rolled back"); + expect( + calls.includes( + `apt-get install -y -qq --no-download docker-ce=${DOCKER_NEW} docker-ce-cli=${DOCKER_NEW} containerd.io docker-buildx-plugin docker-compose-plugin`, + ), + ).toBe(true); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + it( "stops before any change when a current Docker package cannot be saved", () => { diff --git a/docs/agenthits-install.md b/docs/agenthits-install.md index 986927067c..58516ee23b 100644 --- a/docs/agenthits-install.md +++ b/docs/agenthits-install.md @@ -229,8 +229,9 @@ Windows (PowerShell от администратора): повторного `docker pull` и без перезапуска `dokploy`. `update` обновляет весь стек панели, а не только её образ. Сначала скачивается -всё, что понадобится: образы Postgres, Redis, Traefik и панели (закреплённые -теги) и `update.sh`. Если что-то не скачалось, команда завершается строкой +всё, что понадобится: образы Redis, Traefik и панели (закреплённые теги), образ +Postgres, если его major-версия совпадает с закреплённой, и `update.sh`. Если +что-то не скачалось, команда завершается строкой `Pre-download failed: ... Nothing was changed.`, и ни один сервис не трогается. Затем шаги идут в таком порядке: @@ -247,7 +248,8 @@ Windows (PowerShell от администратора): после этого запускается. В течение `DOKPLOY_TRAEFIK_SETTLE` секунд (10 по умолчанию) installer раз в секунду проверяет, что он запущен и без перезапусков. Как только это перестает выполняться, обновление откатывается, не дожидаясь конца окна. В конце проверка повторяется, и только после нее ему выставляется `--restart always`. Если на любом шаге после остановки старого контейнера что-то не сходится, старый образ создается заново с теми же сетями и запускается, а команда завершается ошибкой с названием шага. -4. Postgres: `docker service update` с остановкой старой задачи до запуска новой, +4. Postgres: только если его major-версия совпадает с закреплённой. Выполняется + `docker service update` с остановкой старой задачи до запуска новой, потому что две задачи не могут работать с одним volume. Ждёт `pg_isready`. 5. Панель: `update.sh`. Ждёт `healthy`-статуса контейнера; при откате swarm или нездоровом статусе команда завершается с ошибкой. @@ -276,9 +278,11 @@ Windows (PowerShell от администратора): Серверы, которые панель обновляет по SSH (раздел Components), ждут 30 секунд, пока Docker отвечает закрепленной версией. Если за это время Docker не проходит проверку, Docker откатывается сразу после истечения окна, а `dokploy-traefik` запускается снова с `--restart always`. -Смена major-версии Postgres (например, 17 → 18) этим обновлением не выполняется: -команда останавливается до любых изменений. Перенос данных между major-версиями -делается отдельно (dump и restore или `pg_upgrade`). +Смена major-версии Postgres (например, 17 → 18) этим обновлением не выполняется. +Если установленная major-версия отличается от закреплённой, installer печатает +`Postgres 17 stays. A major upgrade is a separate migration; this update does not change it.`, +не делает резервную копию, не трогает Postgres и выполняет остальные шаги. +Перенос данных между major-версиями делается отдельно (dump и restore или `pg_upgrade`). Простой на каждом шаге равен времени остановки старой задачи (или контейнера) и запуска новой: образы уже локальные, поэтому скачивание в простое не участвует. diff --git a/docs/server-maintenance.md b/docs/server-maintenance.md new file mode 100644 index 0000000000..d51a44fb64 --- /dev/null +++ b/docs/server-maintenance.md @@ -0,0 +1,83 @@ +# Обслуживание сервера панели + +Этот текст для того, кто отвечает за сервер панели, но не пишет код. Здесь написано, что обновляется кнопкой, что обновляется только в плановое окно, какие порты открыты и как безопасно запустить обновление. + +## 1. Что обновляет кнопка + +В карточке **Panel host (this server)** есть два способа обновления: + +- **Components** обновляет **Traefik** (веб-сервер, через который идёт весь HTTPS-трафик) и **Redis** (кэш панели). Во время замены Traefik на несколько секунд недоступны все сайты и админка. Во время замены Redis он на несколько секунд недоступен. +- **Web Server Update** обновляет саму **панель**. + +## 2. Что обновляется только в плановое окно + +Docker и Postgres кнопкой не обновляются. Карточка показывает для них команду, которую запускают в заранее выбранное время. + +**Docker Engine.** При обновлении перезапускается демон Docker. Вместе с ним останавливаются все контейнеры сервера, включая панель. Поэтому это делают один раз, в объявленное окно, и предупреждают пользователей о простое. Команда: `DOCKER_ENGINE_UPGRADE=1 bash install-agenthits.sh update`. + +**Postgres (база панели).** Смена major-версии (например, 17 → 18) требует переноса данных. Это отдельная работа, а не обновление. Поэтому сервер сохраняет текущую major-версию: установщик её не меняет. Сейчас с Postgres ничего делать не нужно. Обновление внутри одной major-версии (например, 18.5 → 18.6) выполняет та же команда обновления. Перед заменой она сохраняет резервную копию базы. + +## 3. Порты + +По умолчанию установщик публикует наружу такие порты: + +- **443** (TCP и UDP) — сайты и панель по HTTPS. UDP нужен для HTTP/3. +- **80** (TCP) — нужен для сертификатов. Let's Encrypt проверяет домен через порт 80 при выпуске и при каждом продлении. Если закрыть порт 80, сертификаты перестанут продлеваться. +- **3000** (TCP) — прямой вход в панель. Адрес с этим портом установщик печатает в конце установки. +- **SSH** — вход на сервер. + +Если включён UFW (команда `bash install-agenthits.sh harden --ufw`), входящие подключения разрешены только для SSH, 80, 443 и 3000. Правила UFW не закрывают порты, которые публикует Docker, поэтому закрыть их таким правилом нельзя. + +## 4. Как безопасно запустить обновление + +1. Выберите тихое время. Пока заменяется Traefik, все сайты на сервере недоступны. Пока обновляется панель, недоступна её админка. При обновлении Docker останавливается весь сервер. +2. Проверьте свободное место: `df -h /var`. Резервная копия Postgres и копии пакетов Docker лежат в `/var/backups/dokploy`. +3. Запустите одну команду в папке, где лежит файл установщика `install-agenthits.sh`: + + ```bash + bash install-agenthits.sh update + ``` + + Если вы не root, скрипт сам перезапустится через sudo и спросит пароль. На Mac команда та же, но без sudo. + + Чтобы вывод сохранился в файл, запустите так: + + ```bash + bash install-agenthits.sh update 2>&1 | tee update-$(date +%F).log + ``` + +4. Смотрите вывод. Что значат строки: + + | Строка | Что значит | + |---|---| + | `Pre-download failed: <образ>. Nothing was changed.` | Образ не скачался. Сервер не тронут, команду можно повторить позже. | + | `Error: ... Nothing was changed.` | Остановка до любых изменений. Текст ошибки говорит, что именно не так. | + | `Postgres stays. A major upgrade is a separate migration; this update does not change it.` | Major-версия Postgres отличается от закреплённой. Postgres остаётся как есть, копия не делается. Это штатно. | + | `Postgres backup written to <файл>` | Резервная копия базы сохранена перед заменой Postgres. | + | `Traefik already runs <образ>` | Traefik уже нужной версии, менять нечего. | + | `Traefik runs <образ>` | Traefik заменён на новую версию. | + | `<сервис> runs <образ> (previous image kept: <образ>)` | Сервис заменён. Старый образ записан в скобках, он нужен для ручного отката. | + | Строка про панель с `already up to date` | Панель уже на нужной версии. | + | Строка про панель с `updated to` | Панель обновлена сейчас. | + | `Docker Engine is <версия>` | Docker обновлён и работает. Строка появляется только при `DOCKER_ENGINE_UPGRADE=1`. | + | `Panel host update finished.` | Обновление завершено без ошибок. | + +5. Проверьте результат: + + ```bash + docker service ls + ``` + + У `dokploy`, `dokploy-postgres` и `dokploy-redis` в колонке REPLICAS должно быть `1/1`. Затем откройте панель в браузере. + +6. Если обновление остановилось с ошибкой, не запускайте его снова вслепую. Сначала посмотрите логи (раздел 5). + +## 5. Где логи + +- Установщик на диск логи не пишет. Вывод виден в терминале, а если вы использовали `tee`, он сохранён в файл. +- Панель: `docker service logs --tail 120 dokploy` +- Postgres: `docker service logs --tail 120 dokploy-postgres` +- Redis: `docker service logs --tail 120 dokploy-redis` +- Traefik: `docker logs --tail 120 dokploy-traefik` +- Резервные копии Postgres: `/var/backups/dokploy/`, файлы вида `postgres-<дата>.sql.gz`. Доступны только root, сами не удаляются. +- Копии пакетов Docker, если обновляли Docker: `/var/backups/dokploy/docker-rollback-<дата>/`. diff --git a/install-agenthits.sh b/install-agenthits.sh index cff54c1c59..6ffe26fbec 100755 --- a/install-agenthits.sh +++ b/install-agenthits.sh @@ -64,6 +64,7 @@ TRAEFIK_EXTRA_NETWORKS="" TRAEFIK_HTTP_PUBLISH=1 TRAEFIK_OLD_IMAGE="" TRAEFIK_OLD_STOPPED=0 +POSTGRES_KEPT=0 command_exists() { command -v "$@" >/dev/null 2>&1 @@ -1169,7 +1170,11 @@ resolve_panel_update_script() { predownload_update_artifacts() { resolve_panel_update_script local image="" - for image in "$POSTGRES_IMAGE" "$REDIS_IMAGE" "$TRAEFIK_IMAGE" "$DOKPLOY_IMAGE"; do + local images=("$REDIS_IMAGE" "$TRAEFIK_IMAGE" "$DOKPLOY_IMAGE") + if [ "$POSTGRES_KEPT" = "0" ]; then + images=("$POSTGRES_IMAGE" "${images[@]}") + fi + for image in "${images[@]}"; do if ! download_with_retry docker pull "$image"; then echo "Pre-download failed: $image. Nothing was changed." >&2 exit 1 @@ -1208,9 +1213,10 @@ redis_ready() { [ -n "$container" ] && [ "$(docker exec "$container" redis-cli ping 2>/dev/null)" = "PONG" ] } +# A kept Postgres runs its installed major, so readiness follows the service's image, not the pinned one. postgres_ready() { local container="" - container="$(task_container dokploy-postgres "$POSTGRES_IMAGE")" + container="$(task_container dokploy-postgres "$(service_image dokploy-postgres)")" [ -n "$container" ] && docker exec "$container" pg_isready -U dokploy -d dokploy >/dev/null 2>&1 } @@ -1536,16 +1542,18 @@ update_agenthits_dokploy() { require_update_services local current_postgres="" + local current_postgres_major="" current_postgres="$(image_without_digest "$(service_image dokploy-postgres)")" - if [ "$(get_postgres_major_version "$current_postgres" || true)" != "$(get_postgres_major_version || true)" ]; then - echo "Error: Postgres $current_postgres -> $POSTGRES_IMAGE changes the major version, which this update does not migrate. Nothing was changed." >&2 - exit 1 + current_postgres_major="$(get_postgres_major_version "$current_postgres" || true)" + if [ "$current_postgres_major" != "$(get_postgres_major_version || true)" ]; then + POSTGRES_KEPT=1 + echo "Postgres ${current_postgres_major:-$current_postgres} stays. A major upgrade is a separate migration; this update does not change it." fi predownload_update_artifacts local postgres_swap=0 - if [ "$(image_without_digest "$(service_image dokploy-postgres)")" != "$(image_without_digest "$POSTGRES_IMAGE")" ]; then + if [ "$POSTGRES_KEPT" = "0" ] && [ "$(image_without_digest "$(service_image dokploy-postgres)")" != "$(image_without_digest "$POSTGRES_IMAGE")" ]; then postgres_swap=1 backup_postgres fi From a3863d0e1b8b18739d3e8831833027a4e2a0bbeb Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:12:32 -0400 Subject: [PATCH 15/20] docs: state that port 80 is never opened and certificates renew over 443 The operator guide now says only port 443 (TCP and UDP) serves domain traffic, port 80 is never opened, and certificates renew only over 443 through the TLS challenge. The UFW note no longer lists port 80. The note about the panel's own port 3000 is unchanged. The installer code is not changed here; that change is on a separate branch. Co-Authored-By: Claude Opus 5.5 --- docs/server-maintenance.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/server-maintenance.md b/docs/server-maintenance.md index d51a44fb64..7da63ad48c 100644 --- a/docs/server-maintenance.md +++ b/docs/server-maintenance.md @@ -19,14 +19,12 @@ Docker и Postgres кнопкой не обновляются. Карточка ## 3. Порты -По умолчанию установщик публикует наружу такие порты: +Для входа по домену открыт только порт **443** (TCP и UDP): через него работают сайты и панель по HTTPS. UDP нужен для HTTP/3. Порт **80** не открывается никогда. Сертификаты выпускаются и продлеваются только через порт 443 (TLS-challenge). -- **443** (TCP и UDP) — сайты и панель по HTTPS. UDP нужен для HTTP/3. -- **80** (TCP) — нужен для сертификатов. Let's Encrypt проверяет домен через порт 80 при выпуске и при каждом продлении. Если закрыть порт 80, сертификаты перестанут продлеваться. - **3000** (TCP) — прямой вход в панель. Адрес с этим портом установщик печатает в конце установки. - **SSH** — вход на сервер. -Если включён UFW (команда `bash install-agenthits.sh harden --ufw`), входящие подключения разрешены только для SSH, 80, 443 и 3000. Правила UFW не закрывают порты, которые публикует Docker, поэтому закрыть их таким правилом нельзя. +Если включён UFW (команда `bash install-agenthits.sh harden --ufw`), входящие подключения разрешены только для SSH, 443 и 3000. Правила UFW не закрывают порты, которые публикует Docker, поэтому закрыть их таким правилом нельзя. ## 4. Как безопасно запустить обновление From be4ad99a0c538e16fd84db51da01871c89114348 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:16:44 -0400 Subject: [PATCH 16/20] fix(traefik): never publish port 80, renew certificates over 443 only Traefik no longer publishes host port 80 from the panel or the installer. The installer no longer opens it in UFW or the WSL firewall rule and no longer requires it to be free. The ACME resolver uses tlsChallenge (TLS-ALPN-01 on port 443) instead of httpChallenge, so certificate renewal needs only port 443. - traefik-setup: remove the port 80 publication from the run, create, standalone and swarm builders. Stored port lists that still contain host port 80 are filtered before the container is built, so a later recreate cannot restore it. The web entrypoint stays for plain-HTTP routers. - server-components and server-setup: remove the traefik_http_publish branch, the UFW rule for port 80 and the port 80 warning. - settings router: updateTraefikPorts rejects host port 80. - install-agenthits.sh: remove TRAEFIK_HTTP_PUBLISH, the -p 80:80/tcp flag, the UFW rule and the port 80 preflight. The static config uses tlsChallenge. install-agenthits.ps1 drops port 80 from the WSL firewall rule and the busy-port check. - Existing traefik.yml files are not rewritten. The operator guide covers migrating a server. - Docs and CLAUDE.md state the rule and explain renewal errors. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 +- README.md | 4 +- .../__test__/server/server-components.test.ts | 23 ++- .../__test__/server/traefik-setup.test.ts | 147 ++++++++++++++++++ .../settings/docker-server-boundary.test.ts | 14 ++ .../settings/install-agenthits-update.test.ts | 36 +++++ apps/dokploy/server/api/routers/settings.ts | 7 + docs/agenthits-install.md | 6 +- docs/certificates-and-ports.md | 47 ++++++ docs/server-components-update.md | 2 +- docs/server-hardening.md | 2 +- install-agenthits.ps1 | 4 +- install-agenthits.sh | 17 +- .../server/src/setup/server-components.ts | 12 +- packages/server/src/setup/server-setup.ts | 7 - packages/server/src/setup/traefik-setup.ts | 39 ++--- 16 files changed, 299 insertions(+), 74 deletions(-) create mode 100644 apps/dokploy/__test__/server/traefik-setup.test.ts create mode 100644 docs/certificates-and-ports.md diff --git a/CLAUDE.md b/CLAUDE.md index 654dd27dba..f6c247cb46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,4 +3,8 @@ - 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 ни в код, ни в установщик. 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..27c1d9c837 100644 --- a/apps/dokploy/__test__/server/server-components.test.ts +++ b/apps/dokploy/__test__/server/server-components.test.ts @@ -36,6 +36,9 @@ import { } 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/; + describe("parseComponentVersions", () => { it("extracts the semantic version from each tool's output", () => { const output = [ @@ -125,15 +128,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 = 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 = [ @@ -1470,14 +1477,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/traefik-setup.test.ts b/apps/dokploy/__test__/server/traefik-setup.test.ts new file mode 100644 index 0000000000..5dbc850cb5 --- /dev/null +++ b/apps/dokploy/__test__/server/traefik-setup.test.ts @@ -0,0 +1,147 @@ +import { + getDefaultServerTraefikConfig, + getDefaultTraefikConfig, + 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("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 de81f61150..e51a031a94 100644 --- a/apps/dokploy/__test__/settings/docker-server-boundary.test.ts +++ b/apps/dokploy/__test__/settings/docker-server-boundary.test.ts @@ -405,6 +405,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..325e554419 100644 --- a/apps/dokploy/__test__/settings/install-agenthits-update.test.ts +++ b/apps/dokploy/__test__/settings/install-agenthits-update.test.ts @@ -1177,6 +1177,42 @@ 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("keeps port 80 out of the static Traefik config, the firewall and the preflight checks", () => { + const installer = readFileSync(installerScript, "utf8"); + + expect(installer).toContain("tlsChallenge: {}"); + expect(installer).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"); diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index 339724df8b..16fd25c0d7 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -1263,6 +1263,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..eb63f1f87d --- /dev/null +++ b/docs/certificates-and-ports.md @@ -0,0 +1,47 @@ +# Порты и сертификаты + +Документ для оператора сервера: почему сертификаты обновляются только через порт 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. Если причина устранена, следующая попытка пройдет по расписанию. Ручной перезапуск не нужен. + +## Серверы, установленные до этого изменения + +Панель и установщик не перезаписывают существующий файл `/etc/dokploy/traefik/traefik.yml`. На старом сервере в нем может остаться `httpChallenge`, а контейнер Traefik может публиковать порт 80. Чтобы привести сервер к новой схеме: + +1. В `/etc/dokploy/traefik/traefik.yml`, в блоке `certificatesResolvers.letsencrypt.acme`, замените `httpChallenge` на `tlsChallenge: {}`. +2. Пересоздайте контейнер Traefik: обновите компонент Traefik в панели или сохраните настройки портов Traefik. Новый контейнер не публикует порт 80. +3. Проверьте, что опубликованы только 443/tcp и 443/udp: `docker port dokploy-traefik`. 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..d34cfea2ef 100755 --- a/install-agenthits.sh +++ b/install-agenthits.sh @@ -61,7 +61,6 @@ 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 @@ -173,8 +172,7 @@ certificatesResolvers: acme: email: test@localhost.com storage: /etc/dokploy/traefik/dynamic/acme.json - httpChallenge: - entryPoint: web + tlsChallenge: {} EOF fi @@ -804,12 +802,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 +885,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 @@ -1341,17 +1337,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 /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 @@ -1418,10 +1409,6 @@ 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 docker rm -f dokploy-traefik-previous >/dev/null 2>&1 || true diff --git a/packages/server/src/setup/server-components.ts b/packages/server/src/setup/server-components.ts index f673da5710..f921561712 100644 --- a/packages/server/src/setup/server-components.ts +++ b/packages/server/src/setup/server-components.ts @@ -6,11 +6,7 @@ import { } from "../utils/servers/destination"; import { PINNED_VERSIONS } from "./component-versions"; import { rcloneInstallCommand } from "./server-setup"; -import { - buildTraefikCreateWithImage, - TRAEFIK_PORT, - TRAEFIK_VERSION, -} from "./traefik-setup"; +import { buildTraefikCreateWithImage, TRAEFIK_VERSION } from "./traefik-setup"; export const COMPONENT_NAMES = [ "docker", @@ -466,12 +462,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 diff --git a/packages/server/src/setup/server-setup.ts b/packages/server/src/setup/server-setup.ts index 32b8c8647a..571c33ce0f 100644 --- a/packages/server/src/setup/server-setup.ts +++ b/packages/server/src/setup/server-setup.ts @@ -14,7 +14,6 @@ import { getDefaultMiddlewares, getDefaultServerTraefikConfig, TRAEFIK_HTTP3_PORT, - TRAEFIK_PORT, TRAEFIK_SSL_PORT, TRAEFIK_VERSION, } from "@dokploy/server/setup/traefik-setup"; @@ -188,7 +187,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 +576,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 diff --git a/packages/server/src/setup/traefik-setup.ts b/packages/server/src/setup/traefik-setup.ts index e2e6aa4af8..45a484e937 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,11 +43,8 @@ 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) => @@ -65,6 +60,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 +73,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 +204,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 +330,7 @@ export const getDefaultTraefikConfig = () => { acme: { email: "test@localhost.com", storage: "/etc/dokploy/traefik/dynamic/acme.json", - httpChallenge: { - entryPoint: "web", - }, + tlsChallenge: {}, }, }, }, @@ -394,9 +383,7 @@ export const getDefaultServerTraefikConfig = () => { acme: { email: "test@localhost.com", storage: "/etc/dokploy/traefik/dynamic/acme.json", - httpChallenge: { - entryPoint: "web", - }, + tlsChallenge: {}, }, }, }, From e403c0a412302161a675f60729007d1e86236260 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:29:32 -0400 Subject: [PATCH 17/20] fix(traefik): convert an httpChallenge config before Traefik is replaced Renewal used the HTTP-01 challenge on port 80. Servers installed before the port 80 change may still have an httpChallenge block in traefik.yml. Removing host port 80 without converting that block would stop renewals, so every path that creates or replaces the Traefik container converts it to tlsChallenge first: - fresh installer install, and the installer Traefik update - the Traefik component update in the panel - saving Traefik ports in the panel - server setup when the Traefik container is missing Only the httpChallenge block is rewritten; other settings stay as they are. A dated copy of the config is kept next to it, and the result is verified before it is written. The awk exit status is checked, and a failed write is restored from the copy. A config path that is a directory, cannot be read, or cannot be checked through sudo stops the step without any change. The installer keeps its config path in TRAEFIK_CONFIG_FILE, so the file it converts is the file the container mounts. Tests cover the installer function and call order under bash, the panel step under bash and /bin/sh with temp files, the failing awk, sudo and test cases, and the port-save and server-setup paths. Co-Authored-By: Claude Opus 5.5 --- .../__test__/server/server-components.test.ts | 104 +++- .../__test__/server/server-setup.test.ts | 24 +- .../__test__/server/traefik-setup.test.ts | 582 ++++++++++++++++++ .../settings/install-agenthits-update.test.ts | 479 +++++++++++++- .../settings/write-traefik-setup.test.ts | 120 ++++ docs/certificates-and-ports.md | 19 +- install-agenthits.sh | 75 ++- packages/server/src/services/settings.ts | 16 + .../server/src/setup/server-components.ts | 13 +- packages/server/src/setup/server-setup.ts | 7 + packages/server/src/setup/traefik-setup.ts | 92 +++ 11 files changed, 1512 insertions(+), 19 deletions(-) create mode 100644 apps/dokploy/__test__/settings/write-traefik-setup.test.ts diff --git a/apps/dokploy/__test__/server/server-components.test.ts b/apps/dokploy/__test__/server/server-components.test.ts index 27c1d9c837..4300b87116 100644 --- a/apps/dokploy/__test__/server/server-components.test.ts +++ b/apps/dokploy/__test__/server/server-components.test.ts @@ -32,6 +32,7 @@ import { buildTraefikCreateCommand, buildTraefikCreateWithImage, buildTraefikRunCommand, + buildTraefikTlsMigrationStep, TRAEFIK_VERSION, } from "@dokploy/server/setup/traefik-setup"; import { describe, expect, it } from "vitest"; @@ -184,6 +185,38 @@ 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 migration = `if ! {\n${buildTraefikTlsMigrationStep()}\n}; then\n\ttraefik_restore "converting the Traefik config to tlsChallenge"\nfi\n`; + + expect(script).toContain(migration); + expect(script.indexOf(migration)).toBeLessThan( + script.indexOf("if ! $SUDO_CMD docker stop dokploy-traefik >/dev/null;"), + ); + }); + it("restores the Traefik config from its backup inside the restore function, before any container is recreated", () => { + const script = buildComponentUpdateScript(["traefik"]); + const restore = script.indexOf( + 'if [ -n "$traefik_acme_backup" ]; then $SUDO_CMD cat "$traefik_acme_backup" | $SUDO_CMD tee /etc/dokploy/traefik/traefik.yml >/dev/null && echo "Restored the previous Traefik config from $traefik_acme_backup." >&2; fi', + ); + const trapSet = script.indexOf( + "trap 'traefik_restore \"being interrupted\"' HUP INT TERM", + ); + + expect(restore).toBeGreaterThan(script.indexOf("traefik_restore() {")); + expect(restore).toBeLessThan(trapSet); + expect(restore).toBeLessThan( + script.indexOf('echo "Restoring the previous Traefik container from'), + ); + expect(script.indexOf('traefik_acme_backup=""')).toBeLessThan(trapSet); + }); + it("keeps httpChallenge out of the Traefik step apart from the conversion", () => { + const script = 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"]); @@ -552,6 +585,7 @@ const SYSTEM_TOOLS = [ "cut", "wc", "sort", + "test", ]; const linkSystemTools = (binDir: string) => { @@ -560,8 +594,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; @@ -580,6 +619,7 @@ type ScriptOptions = { sourceFiles?: Record; withoutApt?: boolean; preexistingKeyring?: string; + traefikConfig?: string; }; const listBackups = (backups: string) => { @@ -619,7 +659,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); @@ -643,8 +693,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, @@ -673,6 +727,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 }); @@ -682,6 +744,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( @@ -814,6 +882,36 @@ 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("creates the new Traefik with --restart no and raises the policy only after the start", () => { const run = runComponentScript(["traefik"]); 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 index 5dbc850cb5..fd53d11982 100644 --- a/apps/dokploy/__test__/server/traefik-setup.test.ts +++ b/apps/dokploy/__test__/server/traefik-setup.test.ts @@ -1,6 +1,21 @@ +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, @@ -123,6 +138,573 @@ describe("initializeTraefikService", () => { }); }); +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("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(); diff --git a/apps/dokploy/__test__/settings/install-agenthits-update.test.ts b/apps/dokploy/__test__/settings/install-agenthits-update.test.ts index 325e554419..e1bb42c360 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 { @@ -1202,11 +1255,35 @@ describe("install-agenthits.sh publishes no port 80", () => { 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(installer).toContain("tlsChallenge: {}"); - expect(installer).not.toContain("httpChallenge"); + 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/); @@ -1264,3 +1341,399 @@ describe("install-agenthits.sh Traefik settle check", () => { SPAWN_TEST_TIMEOUT_MS, ); }); + +const runMigrateTraefikAcme = ( + config: string | null, + { passPath = true }: { passPath?: 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"); + writeFileSync( + scriptPath, + `set -eu\n${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 }); + } +}; + +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("restores the saved config 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 configRestore = restore.indexOf( + 'cat "$TRAEFIK_ACME_BACKUP" > "$TRAEFIK_CONFIG_FILE"', + ); + const containerRestore = restore.indexOf( + 'if [ "$TRAEFIK_OLD_STOPPED" = "1" ]; then', + ); + + expect(failed).toBeGreaterThanOrEqual(0); + expect(configRestore).toBeGreaterThan(failed); + expect(containerRestore).toBeGreaterThan(configRestore); + }); + + 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 restores 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'); + expect(restore).toContain('> "$TRAEFIK_CONFIG_FILE"'); + 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( + "restores the previous Traefik config when the new Traefik does not start", + () => { + const { result, configText } = runInstaller({ + traefikImage: "traefik:v3.6.25", + traefikRunning: "false", + traefikConfig: HTTP_TRAEFIK_CONFIG, + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + "Restored the previous Traefik config from", + ); + expect(configText).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 \\", + ); + for (const text of [defaults, container]) { + expect(text).not.toContain("/etc/dokploy/traefik/traefik.yml"); + } + }); +}); 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..23f6b0f74b --- /dev/null +++ b/apps/dokploy/__test__/settings/write-traefik-setup.test.ts @@ -0,0 +1,120 @@ +import { writeTraefikSetup } from "@dokploy/server/services/settings"; +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("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/docs/certificates-and-ports.md b/docs/certificates-and-ports.md index eb63f1f87d..99a220bee5 100644 --- a/docs/certificates-and-ports.md +++ b/docs/certificates-and-ports.md @@ -38,10 +38,19 @@ Error renewing ACME certificate: <причина> 3. Проверьте доступность порта 443 извне: файрвол хостинга, UFW и правила облачной сети. На самом сервере порт 443 должен занимать Traefik. Команда `docker port dokploy-traefik` покажет публикацию 443/tcp и 443/udp. 4. Если причина устранена, следующая попытка пройдет по расписанию. Ручной перезапуск не нужен. -## Серверы, установленные до этого изменения +## Старые серверы и конфиг Traefik -Панель и установщик не перезаписывают существующий файл `/etc/dokploy/traefik/traefik.yml`. На старом сервере в нем может остаться `httpChallenge`, а контейнер Traefik может публиковать порт 80. Чтобы привести сервер к новой схеме: +На старом сервере в `/etc/dokploy/traefik/traefik.yml` может остаться метод `httpChallenge`, который требует порта 80. Файл целиком не переписывается, поэтому перевод на `tlsChallenge` выполняется перед каждым созданием или пересозданием контейнера Traefik: -1. В `/etc/dokploy/traefik/traefik.yml`, в блоке `certificatesResolvers.letsencrypt.acme`, замените `httpChallenge` на `tlsChallenge: {}`. -2. Пересоздайте контейнер Traefik: обновите компонент Traefik в панели или сохраните настройки портов Traefik. Новый контейнер не публикует порт 80. -3. Проверьте, что опубликованы только 443/tcp и 443/udp: `docker port dokploy-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/install-agenthits.sh b/install-agenthits.sh index d34cfea2ef..81a53dae12 100755 --- a/install-agenthits.sh +++ b/install-agenthits.sh @@ -55,6 +55,7 @@ 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="" @@ -136,12 +137,12 @@ 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 + rm -rf "$TRAEFIK_CONFIG_FILE" 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: @@ -923,6 +924,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)" @@ -1340,7 +1342,7 @@ create_dokploy_traefik() { 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 \ -p 443:443/tcp \ @@ -1383,6 +1385,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 cat "$TRAEFIK_ACME_BACKUP" > "$TRAEFIK_CONFIG_FILE" && echo "Restored the previous Traefik config from $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 @@ -1395,10 +1398,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 { + 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 + + if ! cat "$config.tmp" >"$config"; then + rm -f "$config.tmp" + TRAEFIK_ACME_BACKUP="$backup" + echo "Error: could not write $config. The previous config is saved at $backup." >&2 + 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 @@ -1411,6 +1473,7 @@ swap_dokploy_traefik() { TRAEFIK_EXTRA_NETWORKS="$(traefik_extra_networks)" 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 dd5d813d49..5b982f7b9d 100644 --- a/packages/server/src/services/settings.ts +++ b/packages/server/src/services/settings.ts @@ -11,6 +11,7 @@ import semver from "semver"; import { db } from "../db"; import { compose } from "../db/schema"; import { + buildTraefikTlsMigrationStep, initializeStandaloneTraefik, initializeTraefikService, type TraefikOptions, @@ -1125,6 +1126,19 @@ 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()); + if (serverId) { + await execAsyncRemote(serverId, script); + } else { + await execAsync(script); + } +}; + export const writeTraefikSetup = async (input: TraefikOptions) => { const resourceType = await getDockerResourceType( "dokploy-traefik", @@ -1132,6 +1146,7 @@ export const writeTraefikSetup = async (input: TraefikOptions) => { ); if (resourceType === "service") { + await convertTraefikConfigToTls(input.serverId); await initializeTraefikService({ env: input.env, additionalPorts: input.additionalPorts, @@ -1139,6 +1154,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 f921561712..dd023ced5b 100644 --- a/packages/server/src/setup/server-components.ts +++ b/packages/server/src/setup/server-components.ts @@ -6,7 +6,11 @@ import { } from "../utils/servers/destination"; import { PINNED_VERSIONS } from "./component-versions"; import { rcloneInstallCommand } from "./server-setup"; -import { buildTraefikCreateWithImage, TRAEFIK_VERSION } from "./traefik-setup"; +import { + buildTraefikCreateWithImage, + buildTraefikTlsMigrationStep, + TRAEFIK_VERSION, +} from "./traefik-setup"; export const COMPONENT_NAMES = [ "docker", @@ -453,6 +457,7 @@ traefik_existed=0 traefik_old_stopped=0 traefik_old_image="" traefik_extra_networks="" +traefik_acme_backup="" 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)" @@ -495,6 +500,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" ]; then $SUDO_CMD cat "$traefik_acme_backup" | $SUDO_CMD tee /etc/dokploy/traefik/traefik.yml >/dev/null && echo "Restored the previous Traefik config from $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 @@ -507,6 +513,11 @@ traefik_restore() { exit 1 } 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 571c33ce0f..91ba570fce 100644 --- a/packages/server/src/setup/server-setup.ts +++ b/packages/server/src/setup/server-setup.ts @@ -11,6 +11,7 @@ import { } from "@dokploy/server/services/server"; import { buildTraefikRunCommand, + buildTraefikTlsMigrationStep, getDefaultMiddlewares, getDefaultServerTraefikConfig, TRAEFIK_HTTP3_PORT, @@ -817,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 45a484e937..5c33aa48b1 100644 --- a/packages/server/src/setup/traefik-setup.ts +++ b/packages/server/src/setup/traefik-setup.ts @@ -50,6 +50,98 @@ export const buildTraefikCreateWithImage = (image: string) => ` export const buildTraefikCreateCommand = (version: string) => buildTraefikCreateWithImage(`traefik:v${version}`); +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 { + if (match($0, /[^[:space:]]/) && RSTART - 1 > indent) next + skipping = 0 +} +{ print } +`; + +// 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 = "/etc/dokploy/traefik/traefik.yml", +) => `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 + 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 ! $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 + 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; From e5874e2e7a15b4e117f3a96c561126d283061850 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:06:20 -0400 Subject: [PATCH 18/20] fix(traefik): harden the httpChallenge conversion after review - A comment line inside the httpChallenge block no longer ends the removal, and the output is rejected if a child line is left indented under tlsChallenge. - A failed update keeps the converted config instead of restoring the old httpChallenge one: the old container works with it because 443 is published, and restoring httpChallenge would leave renewal needing a closed port 80. - The fresh-install path restores the original config when the write fails. - rm -rf on the config path is limited to a directory named traefik.yml. - Saving Traefik ports no longer fails for a user without sudo when the config is readable and has no httpChallenge; otherwise it still stops without changes. - The error from a failed conversion names the config and its backup. Tests cover each case, including a comment inside the block and a failing awk. Co-Authored-By: Claude Sonnet 5.5 --- .../__test__/server/server-components.test.ts | 34 +++- .../__test__/server/traefik-setup.test.ts | 133 ++++++++++++++ .../settings/install-agenthits-update.test.ts | 169 ++++++++++++++++-- .../settings/write-traefik-setup.test.ts | 28 +++ install-agenthits.sh | 56 +++--- packages/server/src/services/settings.ts | 20 ++- .../server/src/setup/server-components.ts | 3 +- packages/server/src/setup/traefik-setup.ts | 29 ++- 8 files changed, 419 insertions(+), 53 deletions(-) diff --git a/apps/dokploy/__test__/server/server-components.test.ts b/apps/dokploy/__test__/server/server-components.test.ts index 4300b87116..8b13e0cfaf 100644 --- a/apps/dokploy/__test__/server/server-components.test.ts +++ b/apps/dokploy/__test__/server/server-components.test.ts @@ -194,21 +194,26 @@ describe("buildComponentUpdateScript", () => { script.indexOf("if ! $SUDO_CMD docker stop dokploy-traefik >/dev/null;"), ); }); - it("restores the Traefik config from its backup inside the restore function, before any container is recreated", () => { + it("keeps the converted Traefik config on rollback, names the backup, and never restores httpChallenge", () => { const script = buildComponentUpdateScript(["traefik"]); - const restore = script.indexOf( - 'if [ -n "$traefik_acme_backup" ]; then $SUDO_CMD cat "$traefik_acme_backup" | $SUDO_CMD tee /etc/dokploy/traefik/traefik.yml >/dev/null && echo "Restored the previous Traefik config from $traefik_acme_backup." >&2; fi', + 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", ); - expect(restore).toBeGreaterThan(script.indexOf("traefik_restore() {")); - expect(restore).toBeLessThan(trapSet); - expect(restore).toBeLessThan( + 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.indexOf('traefik_acme_backup=""')).toBeLessThan(trapSet); + 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 = buildComponentUpdateScript(["traefik"]); @@ -912,6 +917,21 @@ describe("buildComponentUpdateScript run order", () => { }, 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("creates the new Traefik with --restart no and raises the policy only after the start", () => { const run = runComponentScript(["traefik"]); diff --git a/apps/dokploy/__test__/server/traefik-setup.test.ts b/apps/dokploy/__test__/server/traefik-setup.test.ts index fd53d11982..2b8e7ba77c 100644 --- a/apps/dokploy/__test__/server/traefik-setup.test.ts +++ b/apps/dokploy/__test__/server/traefik-setup.test.ts @@ -529,6 +529,139 @@ describe("buildTraefikTlsMigrationStep", () => { 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")); diff --git a/apps/dokploy/__test__/settings/install-agenthits-update.test.ts b/apps/dokploy/__test__/settings/install-agenthits-update.test.ts index e1bb42c360..2bd728baa0 100644 --- a/apps/dokploy/__test__/settings/install-agenthits-update.test.ts +++ b/apps/dokploy/__test__/settings/install-agenthits-update.test.ts @@ -1344,7 +1344,10 @@ describe("install-agenthits.sh Traefik settle check", () => { const runMigrateTraefikAcme = ( config: string | null, - { passPath = true }: { passPath?: boolean } = {}, + { + passPath = true, + failWrite = false, + }: { passPath?: boolean; failWrite?: boolean } = {}, ) => { const installer = readFileSync(installerScript, "utf8"); const dir = mkdtempSync(path.join(tmpdir(), "traefik-acme-")); @@ -1354,9 +1357,12 @@ const runMigrateTraefikAcme = ( 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${shellFunctionText(installer, "migrate_traefik_acme_to_tls")}\nmigrate_traefik_acme_to_tls${passPath ? ' "$1"' : ""}\n`, + `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", @@ -1382,6 +1388,27 @@ const runMigrateTraefikAcme = ( } }; +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"); @@ -1423,22 +1450,23 @@ describe("install-agenthits.sh Traefik ACME migration", () => { expect(migrate).toContain('cat "$config.tmp" >"$config"'); }); - it("restores the saved config before any container is recreated", () => { + 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 configRestore = restore.indexOf( - 'cat "$TRAEFIK_ACME_BACKUP" > "$TRAEFIK_CONFIG_FILE"', + 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(configRestore).toBeGreaterThan(failed); - expect(containerRestore).toBeGreaterThan(configRestore); + 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", () => { @@ -1646,7 +1674,7 @@ describe("install-agenthits.sh Traefik ACME migration", () => { expect(run.backupBytes).toEqual(Buffer.from(original)); }); - it("reads and restores the Traefik config only through TRAEFIK_CONFIG_FILE", () => { + 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"); @@ -1657,7 +1685,6 @@ describe("install-agenthits.sh Traefik ACME migration", () => { ); expect(migrate).toContain('local config="${1:-$TRAEFIK_CONFIG_FILE}"'); expect(swap).toContain('"$TRAEFIK_CONFIG_FILE" 2>/dev/null'); - expect(restore).toContain('> "$TRAEFIK_CONFIG_FILE"'); for (const text of [migrate, swap, restore]) { expect(text).not.toContain("/etc/dokploy/traefik/traefik.yml"); } @@ -1696,19 +1723,20 @@ describe("install-agenthits.sh Traefik ACME migration", () => { ); it( - "restores the previous Traefik config when the new Traefik does not start", + "keeps the converted Traefik config when the new Traefik does not start", () => { - const { result, configText } = runInstaller({ + 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).toContain( - "Restored the previous Traefik config from", + expect(result.stderr).toMatch( + /Kept the converted Traefik config \(tlsChallenge\)\. Previous config saved to \S+traefik\.yml\.bak-\d{14}\./, ); - expect(configText).toBe(HTTP_TRAEFIK_CONFIG); + expect(configText).toBe(TLS_TRAEFIK_CONFIG); + expect(configBackupText).toBe(HTTP_TRAEFIK_CONFIG); }, SPAWN_TEST_TIMEOUT_MS, ); @@ -1732,8 +1760,117 @@ describe("install-agenthits.sh Traefik ACME migration", () => { expect(container).toContain( "-v /etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic \\", ); - for (const text of [defaults, container]) { - expect(text).not.toContain("/etc/dokploy/traefik/traefik.yml"); + 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 index 23f6b0f74b..31f1b80ebb 100644 --- a/apps/dokploy/__test__/settings/write-traefik-setup.test.ts +++ b/apps/dokploy/__test__/settings/write-traefik-setup.test.ts @@ -1,4 +1,6 @@ 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(() => ({ @@ -104,6 +106,32 @@ describe("writeTraefikSetup", () => { 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), diff --git a/install-agenthits.sh b/install-agenthits.sh index 81a53dae12..048b7531ee 100755 --- a/install-agenthits.sh +++ b/install-agenthits.sh @@ -138,7 +138,15 @@ create_default_traefik_files() { mkdir -p /etc/dokploy/traefik/dynamic if [ -d "$TRAEFIK_CONFIG_FILE" ]; then - rm -rf "$TRAEFIK_CONFIG_FILE" + 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 "$TRAEFIK_CONFIG_FILE" ]; then @@ -1385,7 +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 cat "$TRAEFIK_ACME_BACKUP" > "$TRAEFIK_CONFIG_FILE" && echo "Restored the previous Traefik config from $TRAEFIK_ACME_BACKUP." >&2; fi + 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 @@ -1415,26 +1423,11 @@ migrate_traefik_acme_to_tls() { 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 { - if (match($0, /[^[:space:]]/) && RSTART - 1 > indent) next - skipping = 0 - } + /^[[: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 @@ -1443,10 +1436,25 @@ migrate_traefik_acme_to_tls() { 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" - TRAEFIK_ACME_BACKUP="$backup" - echo "Error: could not write $config. The previous config is saved at $backup." >&2 + 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" diff --git a/packages/server/src/services/settings.ts b/packages/server/src/services/settings.ts index 5b982f7b9d..46d4797a1e 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, @@ -14,6 +15,7 @@ import { buildTraefikTlsMigrationStep, initializeStandaloneTraefik, initializeTraefikService, + TRAEFIK_MAIN_CONFIG_PATH, type TraefikOptions, } from "../setup/traefik-setup"; import { UPDATE_IMAGE_PULLED_MARKER } from "./web-server-update"; @@ -1132,10 +1134,20 @@ const convertTraefikConfigToTls = async (serverId?: string) => { "../setup/server-components" ); const script = wrapComponentUpdateSteps(buildTraefikTlsMigrationStep()); - if (serverId) { - await execAsyncRemote(serverId, script); - } else { - await execAsync(script); + 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}`, + ); } }; diff --git a/packages/server/src/setup/server-components.ts b/packages/server/src/setup/server-components.ts index dd023ced5b..d95708f07d 100644 --- a/packages/server/src/setup/server-components.ts +++ b/packages/server/src/setup/server-components.ts @@ -458,6 +458,7 @@ 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)" @@ -500,7 +501,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" ]; then $SUDO_CMD cat "$traefik_acme_backup" | $SUDO_CMD tee /etc/dokploy/traefik/traefik.yml >/dev/null && echo "Restored the previous Traefik config from $traefik_acme_backup." >&2; fi + 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 diff --git a/packages/server/src/setup/traefik-setup.ts b/packages/server/src/setup/traefik-setup.ts index 5c33aa48b1..963a3cdde8 100644 --- a/packages/server/src/setup/traefik-setup.ts +++ b/packages/server/src/setup/traefik-setup.ts @@ -50,6 +50,8 @@ export const buildTraefikCreateWithImage = (image: string) => ` 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 @@ -66,6 +68,9 @@ skipping && /^[[:space:]]*$/ { print next } +skipping && /^[[:space:]]*#/ { + next +} skipping { if (match($0, /[^[:space:]]/) && RSTART - 1 > indent) next skipping = 0 @@ -73,17 +78,33 @@ skipping { { 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 = "/etc/dokploy/traefik/traefik.yml", + 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 @@ -128,6 +149,11 @@ traefik_convert_acme_to_tls() { 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 @@ -137,6 +163,7 @@ traefik_convert_acme_to_tls() { fi return 1 fi + traefik_tls_converted=1 rm -f "$converted" echo "Certificate renewal now uses port 443 (tlsChallenge). Previous config saved to $backup" } From 63c11358119acd34c415804019a16d78b209e1be Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:25:52 -0400 Subject: [PATCH 19/20] fix(servers): make the host stack update safe to repeat and to race - The Traefik step does nothing when the running container already uses the target image, so a repeated run (for example after the panel reconnects) causes no second outage. - The update lock is taken synchronously before the audit call and released on every exit path, and a panel update cannot start while a host update runs. - The Postgres row compares the full tag, so 18 against 18.6 shows as needing an update. The manual command is shown only for a different major. - The dialog looks for the done and failed markers in the accumulated log, so a marker split across two chunks is not missed. Tests cover the repeated run, the lock race and release, the row comparison, and the split marker. Co-Authored-By: Claude Sonnet 5.5 --- .../__test__/server/host-stack-lock.test.ts | 31 +++++++ .../__test__/server/host-stack-rows.test.ts | 44 +++++++--- .../__test__/server/host-stack-script.test.ts | 62 +++++++++++++- .../settings/docker-server-boundary.test.ts | 81 ++++++++++++++++++- .../__test__/settings/host-stack-log.test.ts | 60 ++++++++++++++ .../settings/servers/host-stack-log.ts | 20 +++++ .../settings/servers/panel-host-stack.tsx | 40 ++++----- apps/dokploy/server/api/routers/settings.ts | 30 ++++--- packages/server/src/index.ts | 1 + packages/server/src/setup/host-stack-lock.ts | 19 +++++ packages/server/src/setup/host-stack-rows.ts | 29 ++++--- packages/server/src/setup/host-stack.ts | 11 +-- .../server/src/setup/server-components.ts | 60 +++++++------- 13 files changed, 396 insertions(+), 92 deletions(-) create mode 100644 apps/dokploy/__test__/server/host-stack-lock.test.ts create mode 100644 apps/dokploy/__test__/settings/host-stack-log.test.ts create mode 100644 apps/dokploy/components/dashboard/settings/servers/host-stack-log.ts create mode 100644 packages/server/src/setup/host-stack-lock.ts diff --git a/apps/dokploy/__test__/server/host-stack-lock.test.ts b/apps/dokploy/__test__/server/host-stack-lock.test.ts new file mode 100644 index 0000000000..e8fca1a813 --- /dev/null +++ b/apps/dokploy/__test__/server/host-stack-lock.test.ts @@ -0,0 +1,31 @@ +import { + acquireHostStackUpdateLock, + isHostStackUpdateRunning, +} from "@dokploy/server/setup/host-stack-lock"; +import { describe, expect, it } from "vitest"; + +describe("acquireHostStackUpdateLock", () => { + it("admits one holder at a time and frees the lock on release", () => { + const release = acquireHostStackUpdateLock(); + + expect(release).not.toBeNull(); + expect(isHostStackUpdateRunning()).toBe(true); + expect(acquireHostStackUpdateLock()).toBeNull(); + + release?.(); + expect(isHostStackUpdateRunning()).toBe(false); + }); + + it("ignores a repeated release, so it cannot free a lock taken after it", () => { + const first = acquireHostStackUpdateLock(); + first?.(); + const second = acquireHostStackUpdateLock(); + first?.(); + + expect(isHostStackUpdateRunning()).toBe(true); + expect(acquireHostStackUpdateLock()).toBeNull(); + + second?.(); + expect(isHostStackUpdateRunning()).toBe(false); + }); +}); diff --git a/apps/dokploy/__test__/server/host-stack-rows.test.ts b/apps/dokploy/__test__/server/host-stack-rows.test.ts index 0d7bcc26cc..a3c303e44c 100644 --- a/apps/dokploy/__test__/server/host-stack-rows.test.ts +++ b/apps/dokploy/__test__/server/host-stack-rows.test.ts @@ -15,7 +15,7 @@ const pinnedPostgresMajor = PINNED_VERSIONS.postgres.split(".")[0]; const upToDate: HostStackReadings = { docker: PINNED_VERSIONS.docker, traefikImage: `traefik:v${TRAEFIK_VERSION}`, - postgresImage: `postgres:${pinnedPostgresMajor}`, + postgresImage: `postgres:${PINNED_VERSIONS.postgres}`, redisImage: `redis:${PINNED_VERSIONS.redis}`, panel: { installed: "agenthits-dev", @@ -53,7 +53,7 @@ describe("buildHostStackRows", () => { } }); - it("keeps the reason and command of the maintenance-window steps even when they are current", () => { + it("keeps the reason of the maintenance-window steps even when they are current", () => { const rows = rowsFor({}); expect(rowNamed(rows, "docker")).toMatchObject({ @@ -63,8 +63,9 @@ describe("buildHostStackRows", () => { expect(rowNamed(rows, "docker").reason).toContain("maintenance window"); expect(rowNamed(rows, "postgres")).toMatchObject({ action: "manual", - command: `POSTGRES_IMAGE=postgres:${pinnedPostgresMajor} bash install-agenthits.sh update`, + command: null, }); + expect(rowNamed(rows, "postgres").reason).toContain("maintenance window"); }); it("gives Traefik and Redis no reason or command, since the panel runs them", () => { @@ -147,19 +148,42 @@ describe("buildHostStackRows", () => { ); }); - it("does not flag Postgres whose major version matches the pin, whatever the minor and suffix", () => { - const postgres = rowNamed( - rowsFor({ postgresImage: `postgres:${pinnedPostgresMajor}.5-alpine` }), + it("compares the full Postgres version within the pinned major", () => { + const current = rowNamed( + rowsFor({ postgresImage: `postgres:${PINNED_VERSIONS.postgres}-alpine` }), "postgres", ); - expect(postgres).toMatchObject({ - installed: pinnedPostgresMajor, + expect(current).toMatchObject({ + installed: PINNED_VERSIONS.postgres, status: "current", outdated: false, - command: `POSTGRES_IMAGE=postgres:${pinnedPostgresMajor}.5-alpine bash install-agenthits.sh update`, + command: null, + }); + + const minorBehind = rowNamed( + rowsFor({ postgresImage: `postgres:${pinnedPostgresMajor}` }), + "postgres", + ); + + expect(minorBehind).toMatchObject({ + installed: pinnedPostgresMajor, + target: PINNED_VERSIONS.postgres, + status: "outdated", + outdated: true, + action: "manual", + command: null, }); - expect(postgres.reason).toContain("maintenance window"); + expect(minorBehind.reason).toContain("maintenance window"); + }); + + it("compares Postgres minor versions numerically", () => { + expect( + rowNamed( + rowsFor({ postgresImage: `postgres:${pinnedPostgresMajor}.10` }), + "postgres", + ).outdated, + ).toBe(false); }); it("keeps the image digest out of the Postgres command", () => { diff --git a/apps/dokploy/__test__/server/host-stack-script.test.ts b/apps/dokploy/__test__/server/host-stack-script.test.ts index b60edf9518..ebfc91447f 100644 --- a/apps/dokploy/__test__/server/host-stack-script.test.ts +++ b/apps/dokploy/__test__/server/host-stack-script.test.ts @@ -15,6 +15,7 @@ import { buildComponentUpdateSteps, COMPONENTS_UPDATE_DONE, } from "@dokploy/server/setup/server-components"; +import { TRAEFIK_VERSION } from "@dokploy/server/setup/traefik-setup"; import { describe, expect, it } from "vitest"; const SPAWN_TEST_TIMEOUT_MS = 30_000; @@ -22,6 +23,12 @@ const SPAWN_TEST_TIMEOUT_MS = 30_000; const FAKE_DOCKER = `#!/bin/sh printf 'docker %s\\n' "$*" >> "$CALL_LOG" case "$1" in + inspect) + case "$*" in + *Config.Image*) cat "$STATE_DIR/traefik_image" ;; + *RestartCount*) echo "true 0" ;; + esac + ;; pull) [ "$FAKE_PULL_FAILS" = 1 ] && exit 1 ;; ps) echo cid-redis ;; exec) [ "$FAKE_PING_FAILS" = 1 ] || echo PONG ;; @@ -63,20 +70,25 @@ const FAKE_SLEEP = `#!/bin/sh exit 0 `; -type RedisFixture = { +type HostFixture = { image?: string; + traefikImage?: string; env?: Record; }; const runHostScript = ( components: HostStackUiComponent[], - fixture: RedisFixture = {}, + fixture: HostFixture = {}, ) => { const dir = mkdtempSync(path.join(tmpdir(), "host-stack-")); try { const callLog = path.join(dir, "calls.log"); writeFileSync(callLog, ""); writeFileSync(path.join(dir, "image"), `${fixture.image ?? "redis:7"}\n`); + writeFileSync( + path.join(dir, "traefik_image"), + `${fixture.traefikImage ?? "traefik:v1.0.0"}\n`, + ); writeFileSync(path.join(dir, "index"), "1\n"); writeFileSync(path.join(dir, "state"), "\n"); for (const [name, body] of Object.entries({ @@ -168,6 +180,52 @@ describe("buildHostStackUpdateScript", () => { }); }); +describe("Traefik update run", () => { + it( + "leaves Traefik alone when its container already runs the target image, digest aside", + () => { + const run = runHostScript(["traefik"], { + traefikImage: `traefik:v${TRAEFIK_VERSION}@sha256:0123abcd`, + }); + + expect(run.status).toBe(0); + expect( + run.calls.filter((call) => + /^docker (stop|create|start) |^docker rm dokploy-traefik$/.test(call), + ), + ).toEqual([]); + expect(run.stdout).toContain( + `Traefik already runs traefik:v${TRAEFIK_VERSION}`, + ); + expect(run.stdout).toContain(COMPONENTS_UPDATE_DONE); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it( + "replaces the Traefik container when it runs another image", + () => { + const run = runHostScript(["traefik"], { + traefikImage: "traefik:v1.0.0", + }); + + expect(run.status).toBe(0); + expect(run.calls).toContain("docker stop dokploy-traefik"); + expect( + run.calls.some( + (call) => + call.startsWith("docker create ") && + call.includes(`traefik:v${TRAEFIK_VERSION}`), + ), + ).toBe(true); + expect(run.stdout).toContain( + `Traefik version ${TRAEFIK_VERSION} installed`, + ); + }, + SPAWN_TEST_TIMEOUT_MS, + ); +}); + describe("Redis update run", () => { it( "updates Redis to the pinned image, waits for PONG, and reports done", diff --git a/apps/dokploy/__test__/settings/docker-server-boundary.test.ts b/apps/dokploy/__test__/settings/docker-server-boundary.test.ts index de81f61150..897017d1a8 100644 --- a/apps/dokploy/__test__/settings/docker-server-boundary.test.ts +++ b/apps/dokploy/__test__/settings/docker-server-boundary.test.ts @@ -80,9 +80,12 @@ vi.mock("@dokploy/server", async () => ({ getDokployImages: vi.fn(), IS_CLOUD: false, HOST_STACK_COMPONENTS: ["panel", "docker", "traefik", "postgres", "redis"], - isHostStackUpdateRunning: vi.fn(() => false), + ...(await import("@dokploy/server/setup/host-stack-lock")), + hostStackRefusals: () => [], + isHostStackUiComponent: (component: string) => + component === "traefik" || component === "redis", readHostStackRows: vi.fn(), - updateHostStackComponents: vi.fn(), + updateHostStackComponents: vi.fn(() => Promise.resolve()), checkGPUStatus: mocks.checkGPUStatus, checkPortInUse: mocks.checkPortInUse, checkPostgresHealth: mocks.checkPostgresHealth, @@ -804,3 +807,77 @@ describe("settings Traefik file access", () => { }); }); }); + +describe("panel host update lock", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.isServerUpdateRunning.mockReturnValue(false); + mocks.getUpdateData.mockResolvedValue({ + updateAvailable: true, + updateSource: "official", + latestVersion: "v2.0.0", + latestOfficialVersion: "v2.0.0", + }); + }); + + const startHostUpdate = () => + createCaller().updateHostStack({ components: ["redis"] }); + + const runToCompletion = ( + stream: Awaited>, + ) => + new Promise((resolve, reject) => { + stream.subscribe({ + next: () => {}, + error: reject, + complete: () => resolve(), + }); + }); + + const holdAudit = () => { + let recorded = () => {}; + mocks.audit.mockReturnValueOnce( + new Promise((resolve) => { + recorded = resolve; + }), + ); + return () => recorded(); + }; + + it("admits one host-stack update while another is still being recorded", async () => { + const recorded = holdAudit(); + const first = startHostUpdate(); + const second = startHostUpdate(); + + await expect(second).rejects.toMatchObject({ code: "CONFLICT" }); + recorded(); + await runToCompletion(await first); + await runToCompletion(await startHostUpdate()); + }); + + it("frees the lock when recording the update fails", async () => { + mocks.audit.mockRejectedValueOnce(new Error("audit store is down")); + + await expect(startHostUpdate()).rejects.toThrow("audit store is down"); + await runToCompletion(await startHostUpdate()); + }); + + it("keeps a panel update out while a host-stack update holds the lock", async () => { + const recorded = holdAudit(); + const hostUpdate = startHostUpdate(); + await vi.waitFor(() => expect(mocks.audit).toHaveBeenCalledTimes(1)); + + await expect(createCaller().updateServer()).rejects.toMatchObject({ + code: "CONFLICT", + }); + expect(mocks.startServerUpdate).not.toHaveBeenCalled(); + + recorded(); + await runToCompletion(await hostUpdate); + }); + + it("starts a panel update when no host-stack update is running", async () => { + await expect(createCaller().updateServer()).resolves.toBe(true); + expect(mocks.startServerUpdate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/dokploy/__test__/settings/host-stack-log.test.ts b/apps/dokploy/__test__/settings/host-stack-log.test.ts new file mode 100644 index 0000000000..be96a652ef --- /dev/null +++ b/apps/dokploy/__test__/settings/host-stack-log.test.ts @@ -0,0 +1,60 @@ +import { + COMPONENTS_UPDATE_DONE, + COMPONENTS_UPDATE_FAILED, +} from "@dokploy/server/setup/server-components"; +import { describe, expect, it } from "vitest"; +import { + UPDATE_DONE, + UPDATE_FAILED, + updateOutcome, +} from "@/components/dashboard/settings/servers/host-stack-log"; + +const outcomesFor = (chunks: string[]) => { + let log = ""; + const outcomes: string[] = []; + for (const chunk of chunks) { + const outcome = updateOutcome(log, `${log}${chunk}`); + log += chunk; + if (outcome !== null) { + outcomes.push(outcome); + } + } + return outcomes; +}; + +describe("updateOutcome", () => { + it("uses the same markers as the server", () => { + expect(UPDATE_DONE).toBe(COMPONENTS_UPDATE_DONE); + expect(UPDATE_FAILED).toBe(COMPONENTS_UPDATE_FAILED); + }); + + it("reports a success marker split across two chunks", () => { + expect( + outcomesFor([ + "Redis version 8.10.2 installed ✅\nComponents update fin", + "ished ✅\n", + ]), + ).toEqual(["done"]); + }); + + it("reports a failure marker split across two chunks", () => { + expect(outcomesFor(["Components update fa", "iled ❌\n"])).toEqual([ + "failed", + ]); + }); + + it("reports a marker once, even when more output follows it", () => { + expect( + outcomesFor([`${COMPONENTS_UPDATE_DONE}\n`, "trailing output\n"]), + ).toEqual(["done"]); + }); + + it("reports nothing while no marker has arrived", () => { + expect( + outcomesFor([ + "Pulling redis:8.10.2\n", + "Traefik version 3.6.1 installed ✅\n", + ]), + ).toEqual([]); + }); +}); diff --git a/apps/dokploy/components/dashboard/settings/servers/host-stack-log.ts b/apps/dokploy/components/dashboard/settings/servers/host-stack-log.ts new file mode 100644 index 0000000000..a8c00413ac --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/servers/host-stack-log.ts @@ -0,0 +1,20 @@ +export const UPDATE_DONE = "Components update finished ✅"; +export const UPDATE_FAILED = "Components update failed ❌"; + +// A chunk can end in the middle of a marker, so the outcome is read from the +// whole log. Only the chunk that completes a marker reports it, so a marker +// that is already in the log is not reported again. +export const updateOutcome = ( + previousLog: string, + log: string, +): "done" | "failed" | null => { + const completes = (marker: string) => + !previousLog.includes(marker) && log.includes(marker); + if (completes(UPDATE_DONE)) { + return "done"; + } + if (completes(UPDATE_FAILED)) { + return "failed"; + } + return null; +}; diff --git a/apps/dokploy/components/dashboard/settings/servers/panel-host-stack.tsx b/apps/dokploy/components/dashboard/settings/servers/panel-host-stack.tsx index 32dad057a3..89c72ec16f 100644 --- a/apps/dokploy/components/dashboard/settings/servers/panel-host-stack.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/panel-host-stack.tsx @@ -9,7 +9,7 @@ import { RefreshCw, Server, } from "lucide-react"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { toast } from "sonner"; import { AlertDialog, @@ -43,9 +43,8 @@ import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import { UpdateServer } from "../web-server/update-server"; +import { updateOutcome } from "./host-stack-log"; -const UPDATE_DONE = "Components update finished ✅"; -const UPDATE_FAILED = "Components update failed ❌"; const TRAEFIK_CONNECTION_NOTE = "If the connection to the panel dropped while Traefik was recreated, the update can still finish on the host. Reload this page to see the result."; @@ -159,6 +158,7 @@ const PanelHostCard = ({ const [isUpdating, setIsUpdating] = useState(false); const [runComponents, setRunComponents] = useState([]); const [logs, setLogs] = useState(""); + const logBuffer = useRef(""); const outdatedCount = rows.filter((row) => row.outdated).length; const unknownCount = rows.filter((row) => row.status === "unknown").length; @@ -178,24 +178,27 @@ const PanelHostCard = ({ { enabled: isUpdating, onData(log) { - setLogs((prev) => `${prev}${log}`); - if (log.includes(UPDATE_DONE) || log.includes(UPDATE_FAILED)) { - setIsUpdating(false); - if (log.includes(UPDATE_DONE)) { - toast.success("Panel host components updated"); - } else { - toast.error("Panel host update failed, see the log"); - } - void refetch(); + const previous = logBuffer.current; + logBuffer.current = `${previous}${log}`; + setLogs(logBuffer.current); + const outcome = updateOutcome(previous, logBuffer.current); + if (outcome === null) { + return; } + setIsUpdating(false); + if (outcome === "done") { + toast.success("Panel host components updated"); + } else { + toast.error("Panel host update failed, see the log"); + } + void refetch(); }, onError(err) { - setLogs((prev) => { - const note = runComponents.includes("traefik") - ? `\n${TRAEFIK_CONNECTION_NOTE}` - : ""; - return `${prev}${err.message}${note}\n`; - }); + const note = runComponents.includes("traefik") + ? `\n${TRAEFIK_CONNECTION_NOTE}` + : ""; + logBuffer.current = `${logBuffer.current}${err.message}${note}\n`; + setLogs(logBuffer.current); setIsUpdating(false); toast.error("Panel host update did not finish"); void refetch(); @@ -204,6 +207,7 @@ const PanelHostCard = ({ ); const startUpdate = () => { + logBuffer.current = ""; setLogs(""); setRunComponents(selected); setIsConfirmOpen(false); diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index 339724df8b..e67b39c333 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -1,4 +1,5 @@ import { + acquireHostStackUpdateLock, CLEANUP_CRON_JOB, COMPONENTS_UPDATE_FAILED, checkGPUStatus, @@ -237,7 +238,10 @@ export const settingsRouter = createTRPCRouter({ message: refusals.join(" "), }); } - if (isServerUpdateRunning() || isHostStackUpdateRunning()) { + const releaseHostStackLock = isServerUpdateRunning() + ? null + : acquireHostStackUpdateLock(); + if (!releaseHostStackLock) { throw new TRPCError({ code: "CONFLICT", message: @@ -245,12 +249,17 @@ export const settingsRouter = createTRPCRouter({ }); } const components = input.components.filter(isHostStackUiComponent); - await audit(ctx, { - action: "update", - resourceType: "settings", - resourceName: "panel-host", - metadata: { components }, - }); + try { + await audit(ctx, { + action: "update", + resourceType: "settings", + resourceName: "panel-host", + metadata: { components }, + }); + } catch (error) { + releaseHostStackLock(); + throw error; + } return observable((emit) => { updateHostStackComponents(components, (log) => { emit.next(log); @@ -261,6 +270,7 @@ export const settingsRouter = createTRPCRouter({ ); }) .finally(() => { + releaseHostStackLock(); emit.complete(); }); }); @@ -729,6 +739,9 @@ export const settingsRouter = createTRPCRouter({ return true; } + const keepImages = input?.keepImages; + const data = await getUpdateData(packageInfo.version); + // After the await: a host-stack update can start while the update check runs. if (isHostStackUpdateRunning()) { throw new TRPCError({ code: "CONFLICT", @@ -736,9 +749,6 @@ export const settingsRouter = createTRPCRouter({ "The panel host components are being updated. Try again when they finish.", }); } - - const keepImages = input?.keepImages; - const data = await getUpdateData(packageInfo.version); if (data.updateAvailable) { startServerUpdate( data.updateSource === "agenthits" diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index c85811c3ab..c1c52b4d25 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -65,6 +65,7 @@ export * from "./services/web-server-update"; export * from "./setup/config-paths"; export * from "./setup/forward-auth-setup"; export * from "./setup/host-stack"; +export * from "./setup/host-stack-lock"; export * from "./setup/host-stack-rows"; export * from "./setup/monitoring-setup"; export * from "./setup/postgres-setup"; diff --git a/packages/server/src/setup/host-stack-lock.ts b/packages/server/src/setup/host-stack-lock.ts new file mode 100644 index 0000000000..4d2e8da51f --- /dev/null +++ b/packages/server/src/setup/host-stack-lock.ts @@ -0,0 +1,19 @@ +let hostStackUpdating = false; + +export const isHostStackUpdateRunning = () => hostStackUpdating; + +// The check and the set run in one synchronous step, so no other request can +// pass the check while the caller is still awaiting something. +export const acquireHostStackUpdateLock = (): (() => void) | null => { + if (hostStackUpdating) { + return null; + } + hostStackUpdating = true; + let held = true; + return () => { + if (held) { + held = false; + hostStackUpdating = false; + } + }; +}; diff --git a/packages/server/src/setup/host-stack-rows.ts b/packages/server/src/setup/host-stack-rows.ts index e433cb38ac..14ecc22eb2 100644 --- a/packages/server/src/setup/host-stack-rows.ts +++ b/packages/server/src/setup/host-stack-rows.ts @@ -85,10 +85,8 @@ const versionOf = (value: string | null) => const imageVersion = (image: string | null) => versionOf(image ? imageTag(image) : null); -const postgresMajor = (image: string | null) => { - const major = (image ? imageTag(image) : null)?.match(/^\d+/)?.[0]; - return major === undefined ? null : Number(major); -}; +const postgresVersion = (image: string | null) => + (image ? imageTag(image) : null)?.match(/^\d+(?:\.\d+)*/)?.[0] ?? null; const compareVersions = (left: string, right: string) => { const leftParts = left.split(".").map(Number); @@ -191,22 +189,29 @@ const traefikRow = (image: string | null): HostStackRow => { const postgresRow = (image: string | null): HostStackRow => { const target = PINNED_VERSIONS.postgres; const targetMajor = Number(target.split(".")[0]); - const installedMajor = postgresMajor(image); - if (image === null || installedMajor === null) { + const installed = postgresVersion(image); + if (image === null || installed === null) { return unknownRow("postgres", target, "manual"); } - const outdated = installedMajor < targetMajor; + const installedMajor = Number(installed.split(".")[0]); + const majorChanged = installedMajor !== targetMajor; + const outdated = majorChanged + ? installedMajor < targetMajor + : compareVersions(installed, target) < 0; return { name: "postgres", - installed: String(installedMajor), + installed, target, status: outdated ? "outdated" : "current", outdated, action: "manual", - reason: outdated - ? `Major version change (${installedMajor} to ${targetMajor}) needs a migration. The panel never changes the Postgres major version.` - : POSTGRES_REASON, - command: `POSTGRES_IMAGE=${withoutDigest(image)} ${INSTALLER_UPDATE}`, + reason: + majorChanged && outdated + ? `Major version change (${installedMajor} to ${targetMajor}) needs a migration. The panel never changes the Postgres major version.` + : POSTGRES_REASON, + command: majorChanged + ? `POSTGRES_IMAGE=${withoutDigest(image)} ${INSTALLER_UPDATE}` + : null, }; }; diff --git a/packages/server/src/setup/host-stack.ts b/packages/server/src/setup/host-stack.ts index 5caa6454f2..0b40b4f46b 100644 --- a/packages/server/src/setup/host-stack.ts +++ b/packages/server/src/setup/host-stack.ts @@ -163,18 +163,11 @@ export const buildHostStackUpdateScript = ( components: HostStackUiComponent[], ) => wrapComponentUpdateSteps(hostStackSteps(components)); -let hostStackUpdating = false; - -export const isHostStackUpdateRunning = () => hostStackUpdating; - +// Callers hold the lock from acquireHostStackUpdateLock for the whole run. export const updateHostStackComponents = async ( components: HostStackUiComponent[], onData?: (data: string) => void, ) => { - if (hostStackUpdating) { - throw new Error("Another panel host component update is already running."); - } - hostStackUpdating = true; try { await spawnAsync( "bash", @@ -189,7 +182,5 @@ export const updateHostStackComponents = async ( ? `Panel host update failed with exit code ${code}` : `Panel host update could not start: ${error instanceof Error ? error.message : String(error)}`, ); - } finally { - hostStackUpdating = false; } }; diff --git a/packages/server/src/setup/server-components.ts b/packages/server/src/setup/server-components.ts index f673da5710..9eb7cd9f04 100644 --- a/packages/server/src/setup/server-components.ts +++ b/packages/server/src/setup/server-components.ts @@ -514,37 +514,41 @@ traefik_restore() { fi exit 1 } -trap 'traefik_restore "being interrupted"' HUP INT TERM -$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 - traefik_restore "stopping the running Traefik container" +if [ "$traefik_existed" = 1 ] && [ "\${traefik_old_image%%@*}" = "traefik:v${TRAEFIK_VERSION}" ] && traefik_check_running; then + echo "Traefik already runs traefik:v${TRAEFIK_VERSION}" +else + trap 'traefik_restore "being interrupted"' HUP INT TERM + $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 + traefik_restore "stopping the running Traefik container" + fi + traefik_old_stopped=1 + if ! $SUDO_CMD docker rm dokploy-traefik >/dev/null; then + traefik_restore "removing the stopped Traefik container" + fi fi - traefik_old_stopped=1 - if ! $SUDO_CMD docker rm dokploy-traefik >/dev/null; then - traefik_restore "removing the stopped Traefik container" + if ! traefik_create "traefik:v${TRAEFIK_VERSION}"; then + traefik_restore "creating the new Traefik container" fi + if ! traefik_connect "$traefik_extra_networks"; then + traefik_restore "connecting the new Traefik container to its networks" + fi + if ! $SUDO_CMD docker start dokploy-traefik >/dev/null; then + traefik_restore "starting the new Traefik container" + fi + if ! traefik_wait_settled; then + traefik_restore "checking the new Traefik container (running, with no restarts)" + fi + if [ "$traefik_keep_bridge" = 0 ]; then + $SUDO_CMD docker network disconnect bridge dokploy-traefik >/dev/null 2>&1 || true + fi + if ! $SUDO_CMD docker update --restart always dokploy-traefik >/dev/null; then + traefik_restore "setting the restart policy of the new Traefik container" + fi + trap - HUP INT TERM + echo "Traefik version ${TRAEFIK_VERSION} installed ✅" fi -if ! traefik_create "traefik:v${TRAEFIK_VERSION}"; then - traefik_restore "creating the new Traefik container" -fi -if ! traefik_connect "$traefik_extra_networks"; then - traefik_restore "connecting the new Traefik container to its networks" -fi -if ! $SUDO_CMD docker start dokploy-traefik >/dev/null; then - traefik_restore "starting the new Traefik container" -fi -if ! traefik_wait_settled; then - traefik_restore "checking the new Traefik container (running, with no restarts)" -fi -if [ "$traefik_keep_bridge" = 0 ]; then - $SUDO_CMD docker network disconnect bridge dokploy-traefik >/dev/null 2>&1 || true -fi -if ! $SUDO_CMD docker update --restart always dokploy-traefik >/dev/null; then - traefik_restore "setting the restart policy of the new Traefik container" -fi -trap - HUP INT TERM -echo "Traefik version ${TRAEFIK_VERSION} installed ✅" `; case "nixpacks": return ` From ca85de72afe1f4bda19a9bfa6de52ba453e6000f Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Fri, 9 Oct 2026 03:08:23 -0400 Subject: [PATCH 20/20] docs(claude): open every pull request against AgentHits-Dev Checks run only for PRs whose base is AgentHits-Dev, so a PR stacked on another branch is never checked. The rule keeps every PR on that base. Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index f6c247cb46..5e9e409f35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,3 +8,7 @@ ## 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.