Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
739e6e1
refactor(servers): split the component update script into steps
agentHits Oct 8, 2026
49223ad
feat(servers): compare panel host components with their pinned targets
agentHits Oct 8, 2026
663c94e
feat(servers): update Traefik and Redis on the panel host locally
agentHits Oct 8, 2026
12a557d
feat(settings): expose the panel host components to the Remote Server…
agentHits Oct 8, 2026
f54485f
feat(servers): show the panel host components on the Remote Servers page
agentHits Oct 8, 2026
5d5a0a5
fix(servers): keep the maintenance reason and command on Docker and P…
agentHits Oct 8, 2026
6270ca6
fix(servers): show every Traefik and Redis row with a checkbox
agentHits Oct 8, 2026
56a09c6
fix(settings): refuse a panel update while host components are updating
agentHits Oct 8, 2026
1227e8b
fix(traefik): mount the Docker socket read-only in the generated cont…
agentHits Oct 8, 2026
eb8c5d4
fix(traefik): publish port 80 only when the running container does
agentHits Oct 8, 2026
a414e1c
test(settings): mock the host stack exports used by the settings router
agentHits Oct 8, 2026
90ea2c3
fix(traefik): leave the default bridge network off when the running c…
agentHits Oct 8, 2026
49a4cba
fix(installer): expand the Traefik publish array safely on bash 3.2
agentHits Oct 8, 2026
a0916a6
fix(installer): keep an installed Postgres whose major differs instea…
agentHits Oct 8, 2026
a3863d0
docs: state that port 80 is never opened and certificates renew over 443
agentHits Oct 8, 2026
be4ad99
fix(traefik): never publish port 80, renew certificates over 443 only
agentHits Oct 8, 2026
e403c0a
fix(traefik): convert an httpChallenge config before Traefik is replaced
agentHits Oct 9, 2026
e5874e2
fix(traefik): harden the httpChallenge conversion after review
agentHits Oct 9, 2026
63c1135
fix(servers): make the host stack update safe to repeat and to race
agentHits Oct 9, 2026
2192629
Merge remote-tracking branch 'origin/feat/panel-host-stack' into feat…
agentHits Oct 9, 2026
ca85de7
docs(claude): open every pull request against AgentHits-Dev
agentHits Oct 9, 2026
ee42c4c
Merge remote-tracking branch 'origin/AgentHits-Dev' into feat/traefik…
agentHits Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,12 @@
- Comment only the "why" when something isn't obvious: workarounds,
counterintuitive decisions, constraints from an external API.
- No section-divider comments like `// --- Helpers ---`.
- Don't leave comments describing the change you just made.
- Don't leave comments describing the change you just made.

## Ports and certificates

Порт 80 на серверах не открываем никогда. Сертификаты обновляются только через порт 443 (tlsChallenge). Не добавлять httpChallenge и публикацию порта 80 ни в код, ни в установщик.

## Pull requests

Every pull request targets `AgentHits-Dev`, including a PR that builds on another open PR. Checks run only for that base, so a PR stacked on another branch gets none. When one PR depends on another, say so in the description; do not change the base.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
228 changes: 219 additions & 9 deletions apps/dokploy/__test__/server/server-components.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,23 @@ import {
buildTraefikCreateCommand,
buildTraefikCreateWithImage,
buildTraefikRunCommand,
buildTraefikTlsMigrationStep,
TRAEFIK_VERSION,
} from "@dokploy/server/setup/traefik-setup";
import { describe, expect, it } from "vitest";

const PORT_80_PUBLICATION =
/(?:-p|--publish)[=\s]+\S*\b80\b|published=80\b|\b80\/tcp\b/;

const TRAEFIK_SKIP_CHECK_LINE =
/^if \[ "\$traefik_existed" = 1 \] && \[ "\$\{traefik_old_image%%@\*\}" = .*$/m;

// The read-only skip check names httpChallenge and 80/tcp; the assertions cover everything else.
const withoutSkipCheck = (script: string) => {
expect(script.match(TRAEFIK_SKIP_CHECK_LINE)).not.toBeNull();
return script.replace(TRAEFIK_SKIP_CHECK_LINE, "");
};

describe("parseComponentVersions", () => {
it("extracts the semantic version from each tool's output", () => {
const output = [
Expand Down Expand Up @@ -125,15 +138,19 @@ describe("buildComponentUpdateScript", () => {

expect(script).toContain(`docker pull traefik:v${TRAEFIK_VERSION}`);
expect(script).toContain(`traefik_create "traefik:v${TRAEFIK_VERSION}"`);
expect(script).toContain(
buildTraefikCreateWithImage('"$1"', "$traefik_http_publish").trim(),
);
expect(script).toContain('traefik_http_publish="-p 80:80"');
expect(script).toContain(buildTraefikCreateWithImage('"$1"').trim());
expect(script).toContain(
"docker network disconnect bridge dokploy-traefik",
);
expect(script).not.toContain("docker rename");
});
it("never publishes host port 80 when it replaces the Traefik container", () => {
const script = withoutSkipCheck(buildComponentUpdateScript(["traefik"]));

expect(script).not.toMatch(PORT_80_PUBLICATION);
expect(script).toMatch(/-p 443:443\s/);
expect(script).toContain("-p 443:443/udp");
});
it("stops and removes the old Traefik, creates the new one, attaches its networks, and starts it last", () => {
const script = buildComponentUpdateScript(["traefik"]);
const order = [
Expand Down Expand Up @@ -177,6 +194,48 @@ describe("buildComponentUpdateScript", () => {
expect(check).toBeGreaterThanOrEqual(0);
expect(raise).toBeGreaterThan(check);
});
it("converts the Traefik config to tlsChallenge before the old container is stopped", () => {
const script = buildComponentUpdateScript(["traefik"]);
const step = buildTraefikTlsMigrationStep();
const at = script.indexOf(step);

expect(at).toBeGreaterThanOrEqual(0);
expect(script.slice(0, at)).toMatch(/if ! \{\n$/);
expect(script.slice(at + step.length)).toMatch(
/^\n\s*\}; then\n\s*traefik_restore "converting the Traefik config to tlsChallenge"\n/,
);
expect(at).toBeLessThan(
script.indexOf("if ! $SUDO_CMD docker stop dokploy-traefik >/dev/null;"),
);
});
it("keeps the converted Traefik config on rollback, names the backup, and never restores httpChallenge", () => {
const script = buildComponentUpdateScript(["traefik"]);
const kept = script.indexOf(
'if [ "$traefik_tls_converted" = 1 ]; then echo "Kept the converted Traefik config (tlsChallenge). Port 443 is published, so the restored container works with it. Previous config saved to $traefik_acme_backup." >&2; fi',
);
const trapSet = script.indexOf(
"trap 'traefik_restore \"being interrupted\"' HUP INT TERM",
);

const definition = script.indexOf("traefik_restore() {");

expect(kept).toBeGreaterThan(definition);
expect(kept).toBeLessThan(trapSet);
expect(kept).toBeLessThan(
script.indexOf('echo "Restoring the previous Traefik container from'),
);
expect(script.slice(definition, trapSet)).not.toContain(
'cat "$traefik_acme_backup"',
);
expect(script.indexOf('traefik_tls_converted=""')).toBeLessThan(trapSet);
});
it("keeps httpChallenge out of the Traefik step apart from the conversion", () => {
const script = withoutSkipCheck(buildComponentUpdateScript(["traefik"]));

expect(script.replace(buildTraefikTlsMigrationStep(), "")).not.toMatch(
/httpchallenge/i,
);
});
it("passes the pinned versions into the installers through env", () => {
const script = buildComponentUpdateScript(["nixpacks", "railpack"]);

Expand Down Expand Up @@ -407,6 +466,7 @@ case "$1" in
case "$*" in
*Config.Image*) echo "$FAKE_OLD_IMAGE" ;;
*NetworkSettings*) echo "$FAKE_TRAEFIK_NETWORKS" ;;
*PortBindings*) echo "$FAKE_PORT_BINDINGS" ;;
*RestartCount*)
traefik_now="$(cat "$FAKE_STATE/traefik-image" 2>/dev/null || echo "$FAKE_OLD_IMAGE")"
if [ "$FAKE_NOT_RUNNING" = 1 ] && [ "$traefik_now" != "$FAKE_OLD_IMAGE" ]; then echo "false 0"; else echo "true 0"; fi
Expand Down Expand Up @@ -545,6 +605,7 @@ const SYSTEM_TOOLS = [
"cut",
"wc",
"sort",
"test",
];

const linkSystemTools = (binDir: string) => {
Expand All @@ -553,8 +614,13 @@ const linkSystemTools = (binDir: string) => {
const found = spawnSync("sh", ["-c", `command -v ${name}`], {
encoding: "utf8",
}).stdout.trim();
if (found) {
symlinkSync(found, path.join(binDir, name));
const executable = found.startsWith("/")
? found
: ["/usr/bin", "/bin"]
.map((dir) => path.join(dir, name))
.find((candidate) => existsSync(candidate));
if (executable) {
symlinkSync(executable, path.join(binDir, name));
}
}
return binDir;
Expand All @@ -573,6 +639,7 @@ type ScriptOptions = {
sourceFiles?: Record<string, string>;
withoutApt?: boolean;
preexistingKeyring?: string;
traefikConfig?: string;
};

const listBackups = (backups: string) => {
Expand Down Expand Up @@ -612,7 +679,17 @@ const runComponentScript = (
const sources = path.join(dir, "sources");
const keyring = path.join(dir, "keyrings", "docker.asc");
const backups = path.join(dir, "backups");
const traefikConfigPath = path.join(
dir,
"etc-root",
"traefik",
"traefik.yml",
);
mkdirSync(sources);
if (options.traefikConfig !== undefined) {
mkdirSync(path.dirname(traefikConfigPath), { recursive: true });
writeFileSync(traefikConfigPath, options.traefikConfig);
}
if (options.preexistingKeyring !== undefined) {
mkdirSync(path.dirname(keyring));
writeFileSync(keyring, options.preexistingKeyring);
Expand All @@ -636,8 +713,12 @@ const runComponentScript = (
const pathValue = options.withoutApt
? `${dir}:${linkSystemTools(path.join(dir, "bin"))}`
: `${dir}:${process.env.PATH}`;
// Config paths point into the temp dir, so a run never touches the host's real config.
const result = spawnSync(BASH, [], {
input: buildComponentUpdateScript(components),
input: buildComponentUpdateScript(components).replaceAll(
"/etc/dokploy",
path.join(dir, "etc-root"),
),
encoding: "utf8",
env: {
...process.env,
Expand Down Expand Up @@ -666,6 +747,14 @@ const runComponentScript = (
keyring: existsSync(keyring) ? readFileSync(keyring, "utf8") : null,
keyringDirExists: existsSync(path.dirname(keyring)),
sources: readSources(sources),
traefikConfigFile: existsSync(traefikConfigPath)
? readFileSync(traefikConfigPath, "utf8")
: null,
traefikBackups: existsSync(path.dirname(traefikConfigPath))
? readdirSync(path.dirname(traefikConfigPath)).filter((name) =>
name.includes(".bak-"),
)
: [],
};
} finally {
rmSync(dir, { force: true, recursive: true });
Expand All @@ -675,6 +764,12 @@ const runComponentScript = (
const SPAWN_TEST_TIMEOUT_MS = 30_000;
const DOCKER_OLD = "5:28.3.0-1~ubuntu.24.04~noble";
const DOCKER_NEW = "5:29.8.2-1~ubuntu.24.04~noble";
const HTTP_CHALLENGE_CONFIG =
"certificatesResolvers:\n letsencrypt:\n acme:\n email: admin@example.com\n httpChallenge:\n entryPoint: web\n";
const TLS_CHALLENGE_CONFIG =
"certificatesResolvers:\n letsencrypt:\n acme:\n email: admin@example.com\n tlsChallenge: {}\n";
const UNCONVERTIBLE_CONFIG =
"certificatesResolvers:\n letsencrypt:\n acme:\n httpChallenge: # HTTP-01\n entryPoint: web\n";

describe("buildComponentUpdateScript run order", () => {
it(
Expand Down Expand Up @@ -807,6 +902,117 @@ describe("buildComponentUpdateScript run order", () => {
"docker update --restart always dokploy-traefik",
);
});
it(
"converts a Traefik config that still uses httpChallenge before the old container is stopped",
() => {
const run = runComponentScript(["traefik"], {
traefikConfig: HTTP_CHALLENGE_CONFIG,
});

expect(run.status).toBe(0);
expect(run.traefikConfigFile).toBe(TLS_CHALLENGE_CONFIG);
expect(run.traefikBackups).toHaveLength(1);
expect(run.calls).toContain("docker stop dokploy-traefik");
},
SPAWN_TEST_TIMEOUT_MS,
);
it(
"leaves the old Traefik running when its config cannot be converted",
() => {
const run = runComponentScript(["traefik"], {
traefikConfig: UNCONVERTIBLE_CONFIG,
});

expect(run.status).not.toBe(0);
expect(run.stderr).toContain("could not convert");
expect(run.traefikConfigFile).toBe(UNCONVERTIBLE_CONFIG);
expect(run.calls.some((call) => call.startsWith("docker stop"))).toBe(
false,
);
},
SPAWN_TEST_TIMEOUT_MS,
);
it(
"keeps the converted Traefik config when a later step fails, without restoring httpChallenge",
() => {
const run = runComponentScript(["traefik"], {
traefikConfig: HTTP_CHALLENGE_CONFIG,
env: { FAKE_CREATE_FAILS: "1" },
});

expect(run.status).not.toBe(0);
expect(run.stderr).toContain("Kept the converted Traefik config");
expect(run.stderr).not.toContain("Restored the previous Traefik config");
expect(run.traefikConfigFile).toBe(TLS_CHALLENGE_CONFIG);
},
SPAWN_TEST_TIMEOUT_MS,
);
it(
"converts and replaces Traefik when its image is the target but the config still uses httpChallenge",
() => {
const run = runComponentScript(["traefik"], {
traefikConfig: HTTP_CHALLENGE_CONFIG,
env: {
FAKE_OLD_IMAGE: `traefik:v${TRAEFIK_VERSION}`,
FAKE_PORT_BINDINGS: "{}",
},
});

expect(run.status).toBe(0);
expect(run.stdout).not.toContain("Traefik already runs");
expect(run.traefikConfigFile).toBe(TLS_CHALLENGE_CONFIG);
expect(run.calls).toContain("docker stop dokploy-traefik");
expect(run.stdout).toContain(
`Traefik version ${TRAEFIK_VERSION} installed`,
);
},
SPAWN_TEST_TIMEOUT_MS,
);
it(
"leaves Traefik running when its image is the target and neither the config nor port 80 needs a change",
() => {
const run = runComponentScript(["traefik"], {
traefikConfig: TLS_CHALLENGE_CONFIG,
env: {
FAKE_OLD_IMAGE: `traefik:v${TRAEFIK_VERSION}`,
FAKE_PORT_BINDINGS: "{}",
},
});

expect(run.status).toBe(0);
expect(run.stdout).toContain(
`Traefik already runs traefik:v${TRAEFIK_VERSION}`,
);
expect(run.calls.some((call) => call.startsWith("docker stop"))).toBe(
false,
);
expect(run.calls.some((call) => call.startsWith("docker create"))).toBe(
false,
);
},
SPAWN_TEST_TIMEOUT_MS,
);
it(
"replaces Traefik when its image is the target but port 80 is still published",
() => {
const run = runComponentScript(["traefik"], {
traefikConfig: TLS_CHALLENGE_CONFIG,
env: {
FAKE_OLD_IMAGE: `traefik:v${TRAEFIK_VERSION}`,
FAKE_PORT_BINDINGS:
'{"80/tcp":[{"HostIp":"0.0.0.0","HostPort":"80"}]}',
},
});

expect(run.status).toBe(0);
expect(run.stdout).not.toContain("Traefik already runs");
expect(run.calls).toContain("docker stop dokploy-traefik");
expect(run.stdout).toContain(
`Traefik version ${TRAEFIK_VERSION} installed`,
);
},
SPAWN_TEST_TIMEOUT_MS,
);
it("creates the new Traefik with --restart no and raises the policy only after the start", () => {
const run = runComponentScript(["traefik"]);

Expand Down Expand Up @@ -1470,14 +1676,18 @@ describe("buildTraefikCreateCommand", () => {
expect(command).toContain("-p 443:443/udp");
expect(command).toContain("traefik:v3.7.5");
});

it("never publishes host port 80", () => {
expect(buildTraefikCreateCommand("3.7.5")).not.toMatch(PORT_80_PUBLICATION);
});
});

describe("buildTraefikRunCommand", () => {
it("publishes the HTTP, HTTPS and HTTP/3 ports with the requested image", () => {
it("publishes HTTPS and HTTP/3 with the requested image and never port 80", () => {
const command = buildTraefikRunCommand("3.7.5");

expect(command).toMatch(/-p 443:443\s/);
expect(command).toMatch(/-p 80:80\s/);
expect(command).not.toMatch(PORT_80_PUBLICATION);
expect(command).toContain("-p 443:443/udp");
expect(command).toContain("traefik:v3.7.5");
});
Expand Down
Loading