diff --git a/apps/dokploy/__test__/settings/docker-server-boundary.test.ts b/apps/dokploy/__test__/settings/docker-server-boundary.test.ts index 837b6f0bb16..54c422d4d94 100644 --- a/apps/dokploy/__test__/settings/docker-server-boundary.test.ts +++ b/apps/dokploy/__test__/settings/docker-server-boundary.test.ts @@ -539,6 +539,60 @@ describe("settings Docker server boundary", () => { ); }); + it("logs who started a panel update", async () => { + mocks.getUpdateData.mockResolvedValue({ + latestVersion: "off_v0.29.8/Fork_159+next", + updateAvailable: true, + updateSource: "agenthits", + latestOfficialVersion: "v0.30.0", + }); + mocks.startServerUpdate.mockReturnValue(true); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + await expect(createCaller("owner").updateServer()).resolves.toBe(true); + + expect(log).toHaveBeenCalledWith( + "[panel] Update to off_v0.29.8/Fork_159+next requested by an unknown user (owner) through a browser session from an unknown address, user agent: none", + ); + log.mockRestore(); + }); + + it("does not log an update that did not start", async () => { + mocks.getUpdateData.mockResolvedValue({ + latestVersion: "off_v0.29.8/Fork_159+next", + updateAvailable: true, + updateSource: "agenthits", + latestOfficialVersion: "v0.30.0", + }); + mocks.startServerUpdate.mockReturnValue(false); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + await expect(createCaller().updateServer()).resolves.toBe(true); + + expect(log).not.toHaveBeenCalledWith( + expect.stringContaining("[panel] Update"), + ); + log.mockRestore(); + }); + + it("logs who reloaded the panel before it restarts", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + mocks.reloadDockerResource.mockImplementation(async () => { + expect(log).toHaveBeenCalledWith( + expect.stringMatching(/^\[panel\] Reload requested by .* \(owner\) /), + ); + }); + + await expect(createCaller("owner").reloadServer()).resolves.toBe(true); + + expect(mocks.reloadDockerResource).toHaveBeenCalledWith( + "dokploy", + undefined, + "v0.30.6", + ); + log.mockRestore(); + }); + it("passes the image keep count to the AgentHits update command", async () => { mocks.getUpdateData.mockResolvedValue({ latestVersion: "off_v0.29.8/Fork_159+next", diff --git a/apps/dokploy/__test__/settings/dokploy-image-cleanup.test.ts b/apps/dokploy/__test__/settings/dokploy-image-cleanup.test.ts index c68d12fe680..13e2aacdbe9 100644 --- a/apps/dokploy/__test__/settings/dokploy-image-cleanup.test.ts +++ b/apps/dokploy/__test__/settings/dokploy-image-cleanup.test.ts @@ -23,12 +23,14 @@ vi.mock("@dokploy/server/utils/process/execAsync", () => ({ const { cleanupOldDokployImages, + getAgentHitsReloadCommand, getAgentHitsUpdateCommand, getDokployImageCleanupCommand, getDokployImageKeepCount, getDokployImageRepositories, getDokployImages, getOfficialUpdateCommand, + reloadDockerResource, } = await import("@dokploy/server/services/settings"); const { UPDATE_IMAGE_PULLED_MARKER } = await import( "@dokploy/server/services/web-server-update" @@ -372,3 +374,96 @@ describe("Dokploy update commands", () => { ); }); }); + +describe("Dokploy reload command", () => { + const DIGEST = `ghcr.io/agenthits/dokploy@sha256:${"b".repeat(64)}`; + + const runReload = (env: Record) => { + const dir = mkdtempSync(join(tmpdir(), "dokploy-reload-")); + const log = join(dir, "log"); + try { + writeFileSync( + join(dir, "docker"), + `#!/bin/sh +echo "$*" >> "${log}" +case "$*" in + "ps -q"*) [ -n "$NO_TASK" ] || echo task1 ;; + "inspect --format {{.Image}} task1") echo sha256:cur ;; + "image inspect --format {{index .RepoDigests 0}} sha256:cur") + if [ -n "$NO_DIGEST" ]; then echo "index out of range: 0" >&2; exit 1; fi + echo "${DIGEST}" ;; +esac +`, + ); + chmodSync(join(dir, "docker"), 0o755); + writeFileSync(log, ""); + let status = 0; + try { + execFileSync("sh", ["-c", getAgentHitsReloadCommand()], { + env: { + ...process.env, + ...env, + PATH: `${dir}:${process.env.PATH}`, + }, + stdio: "pipe", + }); + } catch (error) { + status = (error as { status: number }).status; + } + return { status, calls: readFileSync(log, "utf8").trim().split("\n") }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + + it( + "restarts the running build by digest without pulling", + () => { + const { status, calls } = runReload({}); + expect(status).toBe(0); + expect(calls.some((call) => call.startsWith("pull"))).toBe(false); + expect(calls.at(-1)).toBe( + `service update --force --no-resolve-image --image ${DIGEST} --update-order stop-first --update-failure-action rollback dokploy`, + ); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it( + "falls back to the image id for a build without a registry digest", + () => { + const { status, calls } = runReload({ NO_DIGEST: "1" }); + expect(status).toBe(0); + expect(calls.at(-1)).toContain("--image sha256:cur "); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it( + "does not restart the panel when its running image is unknown", + () => { + const { status, calls } = runReload({ NO_TASK: "1" }); + expect(status).toBe(1); + expect(calls.some((call) => call.startsWith("service update"))).toBe( + false, + ); + }, + SPAWN_TEST_TIMEOUT_MS, + ); + + it("reloads an AgentHits panel without the update command", async () => { + vi.stubEnv("DOKPLOY_UPDATE_SOURCE", "agenthits"); + execAsync.mockReset(); + execAsync + .mockResolvedValueOnce({ stdout: "service\n", stderr: "" }) + .mockResolvedValueOnce({ stdout: "", stderr: "" }); + try { + await reloadDockerResource("dokploy", undefined, "v0.30.6"); + + expect(execAsync).toHaveBeenLastCalledWith(getAgentHitsReloadCommand()); + expect(execAsync.mock.lastCall?.[0]).not.toContain("docker pull"); + } finally { + vi.unstubAllEnvs(); + } + }); +}); diff --git a/apps/dokploy/__test__/settings/panel-restart-log.test.ts b/apps/dokploy/__test__/settings/panel-restart-log.test.ts new file mode 100644 index 00000000000..c5735e35c52 --- /dev/null +++ b/apps/dokploy/__test__/settings/panel-restart-log.test.ts @@ -0,0 +1,48 @@ +import type { IncomingMessage } from "node:http"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@dokploy/server/db", () => ({ db: {} })); + +const { logPanelRestart } = await import( + "../../server/api/utils/panel-restart-log" +); + +const request = (headers: Record) => + ({ headers }) as unknown as IncomingMessage; + +describe("panel restart log", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("names the user, the address and the browser", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + logPanelRestart("Reload", { + req: request({ + "x-forwarded-for": "198.51.100.7, 10.0.0.2", + "user-agent": "Mozilla/5.0 Test", + }), + user: { email: "owner@example.com", role: "owner" }, + session: { authMethod: "session" }, + }); + + expect(log).toHaveBeenCalledWith( + "[panel] Reload requested by owner@example.com (owner) through a browser session from 198.51.100.7, user agent: Mozilla/5.0 Test", + ); + }); + + it("says when an API key started it", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + logPanelRestart("Update to agenthits-v1.1.0", { + req: request({ "x-real-ip": "203.0.113.5" }), + user: { email: "ci@example.com", role: "admin" }, + session: { authMethod: "api-key" }, + }); + + expect(log).toHaveBeenCalledWith( + "[panel] Update to agenthits-v1.1.0 requested by ci@example.com (admin) through an API key from 203.0.113.5, user agent: none", + ); + }); +}); diff --git a/apps/dokploy/__test__/wss/docker-container-terminal-hangup.test.ts b/apps/dokploy/__test__/wss/docker-container-terminal-hangup.test.ts new file mode 100644 index 00000000000..70e7f0f5fbe --- /dev/null +++ b/apps/dokploy/__test__/wss/docker-container-terminal-hangup.test.ts @@ -0,0 +1,279 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { quoteShellArgs } from "@dokploy/server/utils/shell"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WebSocket } from "ws"; + +type FakeStream = import("node:events").EventEmitter & { + stderr: import("node:events").EventEmitter & { resume: () => void }; + end: ReturnType; + resume: ReturnType; + setWindow: ReturnType; + write: ReturnType; +}; +type FakeConnection = { + commands: string[]; + streams: FakeStream[]; + end: ReturnType; + failNextExec: boolean; +}; + +const mocks = vi.hoisted(() => ({ + canAccessDockerOverWss: vi.fn(), + canAccessDockerTerminalWebSocket: vi.fn(), + connections: [] as FakeConnection[], + execFile: vi.fn(), + findServerById: vi.fn(), + spawn: vi.fn(), + validateRequest: vi.fn(), +})); + +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: false, + execAsync: vi.fn(), + findServerById: mocks.findServerById, + resolveTrustedOriginsForAuthRequest: vi.fn(async () => []), + validateRequest: mocks.validateRequest, +})); + +vi.mock("@dokploy/server/utils/servers/destination", () => ({ + resolveServerDestinationHost: vi.fn(async () => "203.0.113.10"), +})); + +vi.mock("@dokploy/server/db", async () => { + const schema = await import("@dokploy/server/db/schema"); + const { createFakeDrizzleDb } = await import("../helpers/fake-drizzle-db"); + const fake = createFakeDrizzleDb({ + superPassword: schema.superPassword, + superSession: schema.superSession, + account: schema.account, + }); + return { db: fake.db, fake }; +}); + +vi.mock("../../server/wss/authorize", () => ({ + canAccessDockerOverWss: mocks.canAccessDockerOverWss, +})); + +vi.mock("../../server/wss/docker-permission", () => ({ + canAccessDockerTerminalWebSocket: mocks.canAccessDockerTerminalWebSocket, +})); + +vi.mock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + execFile: mocks.execFile, +})); + +vi.mock("node-pty", () => ({ spawn: mocks.spawn })); + +vi.mock("ssh2", async () => { + const { EventEmitter } = await import("node:events"); + const makeStream = () => + Object.assign(new EventEmitter(), { + stderr: Object.assign(new EventEmitter(), { resume: vi.fn() }), + end: vi.fn(), + resume: vi.fn(), + setWindow: vi.fn(), + write: vi.fn(), + }); + class Client extends EventEmitter { + commands: string[] = []; + streams: FakeStream[] = []; + end = vi.fn(); + failNextExec = false; + constructor() { + super(); + mocks.connections.push(this); + } + connect() { + setImmediate(() => this.emit("ready")); + return this; + } + exec( + command: string, + optionsOrCallback: unknown, + maybeCallback?: (error: Error | undefined, stream?: FakeStream) => void, + ) { + const callback = ( + typeof optionsOrCallback === "function" + ? optionsOrCallback + : maybeCallback + ) as (error: Error | undefined, stream?: FakeStream) => void; + this.commands.push(command); + if (this.failNextExec) { + setImmediate(() => callback(new Error("channel refused"))); + return this; + } + const stream = makeStream(); + this.streams.push(stream); + setImmediate(() => callback(undefined, stream)); + return this; + } + } + return { Client }; +}); + +const { setupDockerContainerTerminalWebSocketServer } = await import( + "../../server/wss/docker-container-terminal" +); +const { getTerminalHangupArgs } = await import("../../server/wss/utils"); + +const listen = async (server: http.Server) => + new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + resolve((server.address() as AddressInfo).port); + }); + }); + +const openTerminal = async (port: number, query: string) => { + const ws = new WebSocket( + `ws://127.0.0.1:${port}/docker-container-terminal?${query}`, + ); + await new Promise((resolve, reject) => { + ws.once("open", resolve); + ws.once("error", reject); + }); + return ws; +}; + +const markerIn = (args: readonly string[]) => { + const marker = args[args.indexOf("-e") + 1]; + expect(marker).toMatch(/^DOKPLOY_TERMINAL_SESSION=[a-f0-9]{32}$/); + return marker as string; +}; + +describe("container terminal hangs up its shell when it closes", () => { + let server: http.Server; + let port: number; + let pty: { kill: ReturnType }; + + beforeEach(async () => { + vi.clearAllMocks(); + mocks.connections.length = 0; + mocks.canAccessDockerOverWss.mockResolvedValue(true); + mocks.canAccessDockerTerminalWebSocket.mockResolvedValue(true); + mocks.validateRequest.mockResolvedValue({ + user: { id: "user-1" }, + session: { + id: "session-1", + activeOrganizationId: "org-1", + authMethod: "session", + }, + }); + mocks.findServerById.mockResolvedValue({ + organizationId: "org-1", + port: 22, + serverId: "server-1", + sshKey: { privateKey: "test-key" }, + sshKeyId: "key-1", + username: "root", + }); + pty = { kill: vi.fn() }; + mocks.spawn.mockReturnValue({ + ...pty, + onData: vi.fn(), + onExit: vi.fn(), + resize: vi.fn(), + write: vi.fn(), + }); + + server = http.createServer(); + setupDockerContainerTerminalWebSocketServer(server); + port = await listen(server); + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + it("hangs up the local session it started, and only that one", async () => { + const ws = await openTerminal(port, "containerId=abc123def456"); + await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalled()); + const args = mocks.spawn.mock.calls[0]?.[1] as string[]; + expect(args).toEqual([ + "exec", + "-it", + "-e", + markerIn(args), + "-w", + "/", + "abc123def456", + "sh", + ]); + expect(mocks.execFile).not.toHaveBeenCalled(); + + ws.close(); + + await vi.waitFor(() => expect(mocks.execFile).toHaveBeenCalled()); + expect(pty.kill).toHaveBeenCalled(); + expect(mocks.execFile).toHaveBeenCalledWith( + "docker", + getTerminalHangupArgs("abc123def456", "sh", markerIn(args)), + expect.any(Function), + ); + }); + + it("gives two local sessions different markers", async () => { + for (let i = 0; i < 2; i++) { + const ws = await openTerminal(port, "containerId=abc123def456"); + await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalledTimes(i + 1)); + ws.close(); + } + const [first, second] = mocks.spawn.mock.calls.map((call) => + markerIn(call[1] as string[]), + ); + expect(first).not.toBe(second); + }); + + it("hangs up a remote session before it ends the SSH connection", async () => { + const ws = await openTerminal( + port, + "containerId=abc123def456&serverId=server-1&activeWay=bash", + ); + await vi.waitFor(() => + expect(mocks.connections[0]?.commands).toHaveLength(1), + ); + const connection = mocks.connections[0] as FakeConnection; + const command = connection.commands[0] as string; + const marker = markerIn(command.split(" ")); + expect(command).toBe(`docker exec -it -e ${marker} -w / abc123def456 bash`); + + ws.close(); + + await vi.waitFor(() => expect(connection.commands).toHaveLength(2)); + expect(connection.commands[1]).toBe( + quoteShellArgs([ + "docker", + ...getTerminalHangupArgs("abc123def456", "bash", marker), + ]), + ); + const [shell, hangup] = connection.streams as [FakeStream, FakeStream]; + await vi.waitFor(() => expect(hangup.resume).toHaveBeenCalled()); + + shell.emit("close", 129, "SIGHUP"); + expect(connection.end).not.toHaveBeenCalled(); + expect(shell.end).not.toHaveBeenCalled(); + + hangup.emit("close", 0); + expect(shell.end).toHaveBeenCalled(); + expect(connection.end).toHaveBeenCalled(); + }); + + it("still ends the SSH connection when the hangup cannot start", async () => { + const ws = await openTerminal( + port, + "containerId=abc123def456&serverId=server-1&activeWay=bash", + ); + await vi.waitFor(() => + expect(mocks.connections[0]?.commands).toHaveLength(1), + ); + const connection = mocks.connections[0] as FakeConnection; + connection.failNextExec = true; + + ws.close(); + + await vi.waitFor(() => expect(connection.end).toHaveBeenCalled()); + expect(connection.commands).toHaveLength(2); + expect(connection.streams[0]?.end).toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/wss/utils.test.ts b/apps/dokploy/__test__/wss/utils.test.ts index 209bd5f8661..2ab6a325896 100644 --- a/apps/dokploy/__test__/wss/utils.test.ts +++ b/apps/dokploy/__test__/wss/utils.test.ts @@ -1,9 +1,13 @@ +import { execFileSync } from "node:child_process"; +import { quoteShellArgs } from "@dokploy/server/utils/shell"; import { describe, expect, it } from "vitest"; import { + getTerminalHangupArgs, isValidContainerId, isValidSearch, isValidSince, isValidTail, + newTerminalSessionMarker, } from "../../server/wss/utils"; describe("isValidTail (docker-container-logs)", () => { @@ -130,3 +134,34 @@ describe("isValidContainerId (docker-container-logs)", () => { expect(isValidContainerId("x; env | grep DATABASE")).toBe(false); }); }); + +describe("terminal session hangup", () => { + const marker = newTerminalSessionMarker(); + const args = getTerminalHangupArgs("abc123def456", "bash", marker); + + it("gives every session its own marker", () => { + expect(marker).toMatch(/^DOKPLOY_TERMINAL_SESSION=[a-f0-9]{32}$/); + expect(newTerminalSessionMarker()).not.toBe(marker); + }); + + it("runs the hangup with the session's shell in its container", () => { + expect(args.slice(0, 4)).toEqual(["exec", "abc123def456", "bash", "-c"]); + expect(args[4]).toContain(`marker="${marker}"`); + expect(args[4]).toContain('kill -HUP "${dir#/proc/}"'); + expect(() => + execFileSync("sh", ["-n", "-c", args[4] as string]), + ).not.toThrow(); + }); + + it("survives the quoting of the SSH path unchanged", () => { + expect(args[4]).not.toMatch(/['\\]/); + const parsed = execFileSync( + "sh", + ["-c", `printf '%s\\0' ${quoteShellArgs(["docker", ...args])}`], + { encoding: "utf8" }, + ) + .split("\0") + .slice(0, -1); + expect(parsed).toEqual(["docker", ...args]); + }); +}); diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index 3be6c914e86..ddd1ac238a5 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -100,6 +100,7 @@ import { parse, stringify } from "yaml"; import { z } from "zod"; import { audit } from "@/server/api/utils/audit"; import { assertLocalHostAccess } from "@/server/api/utils/local-host-access"; +import { logPanelRestart } from "@/server/api/utils/panel-restart-log"; import { assertProtectedResourceAccess, hasProtectedResourceAccess, @@ -185,6 +186,7 @@ export const settingsRouter = createTRPCRouter({ if (IS_CLOUD) { return true; } + logPanelRestart("Reload", ctx); await reloadDockerResource("dokploy", undefined, packageInfo.version); await audit(ctx, { action: "reload", @@ -887,7 +889,7 @@ export const settingsRouter = createTRPCRouter({ }); } if (data.updateAvailable) { - startServerUpdate( + const started = startServerUpdate( data.updateSource === "agenthits" ? getAgentHitsUpdateCommand( packageInfo.version, @@ -900,6 +902,12 @@ export const settingsRouter = createTRPCRouter({ ? getAgentHitsUpdateImage() : getOfficialUpdateImage(data.latestVersion ?? ""), ); + if (started) { + logPanelRestart( + `Update to ${data.latestVersion ?? "the latest version"}`, + ctx, + ); + } await audit(ctx, { action: "update", resourceType: "settings", diff --git a/apps/dokploy/server/api/utils/panel-restart-log.ts b/apps/dokploy/server/api/utils/panel-restart-log.ts new file mode 100644 index 00000000000..ee92993903b --- /dev/null +++ b/apps/dokploy/server/api/utils/panel-restart-log.ts @@ -0,0 +1,22 @@ +import type { IncomingMessage } from "node:http"; +import { getRequestMeta } from "@/server/api/utils/request-meta"; +import { isApiKeySession } from "@/server/api/utils/super-session"; + +type PanelRestartRequester = { + req?: IncomingMessage; + user: { email?: string | null; role?: string | null }; + session: { authMethod?: string | null } | null | undefined; +}; + +// Audit entries are stored only with an enterprise licence, so the panel's own +// log is the one record of who restarted or updated the panel. +export const logPanelRestart = ( + action: string, + { req, user, session }: PanelRestartRequester, +) => { + const { ipAddress, userAgent } = getRequestMeta(req); + const via = isApiKeySession(session) ? "an API key" : "a browser session"; + console.log( + `[panel] ${action} requested by ${user.email || "an unknown user"} (${user.role || "no role"}) through ${via} from ${ipAddress ?? "an unknown address"}, user agent: ${userAgent ?? "none"}`, + ); +}; diff --git a/apps/dokploy/server/wss/docker-container-terminal.ts b/apps/dokploy/server/wss/docker-container-terminal.ts index f885d348c2d..8e3a1f6920b 100644 --- a/apps/dokploy/server/wss/docker-container-terminal.ts +++ b/apps/dokploy/server/wss/docker-container-terminal.ts @@ -1,6 +1,8 @@ +import { execFile } from "node:child_process"; import type http from "node:http"; import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server"; import { resolveServerDestinationHost } from "@dokploy/server/utils/servers/destination"; +import { quoteShellArgs } from "@dokploy/server/utils/shell"; import { spawn } from "node-pty"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; @@ -11,8 +13,10 @@ import { canAccessDockerTerminalWebSocket } from "./docker-permission"; import { verifyWebSocketOrigin } from "./origin"; import { rejectTerminalWithoutSuperSession } from "./super-session"; import { + getTerminalHangupArgs, isValidContainerId, isValidShell, + newTerminalSessionMarker, parseResizeMessage, parseTerminalSize, } from "./utils"; @@ -76,6 +80,8 @@ export const setupDockerContainerTerminalWebSocketServer = ( // Default to 'sh' if no shell specified const shell = activeWay || "sh"; + const sessionMarker = newTerminalSessionMarker(); + const hangupArgs = getTerminalHangupArgs(containerId, shell, sessionMarker); if ( !(await canAccessDockerTerminalWebSocket({ @@ -130,6 +136,8 @@ export const setupDockerContainerTerminalWebSocketServer = ( "docker", "exec", "-it", + "-e", + sessionMarker, "-w", "/", containerId, @@ -143,10 +151,13 @@ export const setupDockerContainerTerminalWebSocketServer = ( return; } + let hangingUp = false; stream .on("close", (code: number, _signal: string) => { ws.send(`\nContainer closed with code: ${code}\n`); - conn.end(); + if (!hangingUp) { + conn.end(); + } }) .on("data", (data: string) => { _stdout += data.toString(); @@ -181,9 +192,29 @@ export const setupDockerContainerTerminalWebSocketServer = ( }); ws.on("close", () => { - stream.end(); - // Ensure SSH connection is closed when WebSocket closes - conn.end(); + hangingUp = true; + const endConnection = () => { + stream.end(); + conn.end(); + }; + // Ending the connection first would kill the hangup with it. + const fallback = setTimeout(endConnection, 10_000); + conn.exec( + quoteShellArgs(["docker", ...hangupArgs]), + (hangupError, hangup) => { + if (hangupError) { + clearTimeout(fallback); + endConnection(); + return; + } + hangup.on("close", () => { + clearTimeout(fallback); + endConnection(); + }); + hangup.resume(); + hangup.stderr.resume(); + }, + ); }); }); }) @@ -209,7 +240,7 @@ export const setupDockerContainerTerminalWebSocketServer = ( } const ptyProcess = spawn( "docker", - ["exec", "-it", "-w", "/", containerId, shell], + ["exec", "-it", "-e", sessionMarker, "-w", "/", containerId, shell], { cols, rows }, ); @@ -222,6 +253,7 @@ export const setupDockerContainerTerminalWebSocketServer = ( }); ws.on("close", () => { ptyProcess.kill(); + execFile("docker", hangupArgs, () => {}); }); ws.on("message", (message) => { try { diff --git a/apps/dokploy/server/wss/utils.ts b/apps/dokploy/server/wss/utils.ts index fb47f08fe8c..8cdefc2b6c4 100644 --- a/apps/dokploy/server/wss/utils.ts +++ b/apps/dokploy/server/wss/utils.ts @@ -1,3 +1,4 @@ +import { randomBytes } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -105,6 +106,34 @@ export const parseResizeMessage = (data: string) => { return null; }; +// Killing a `docker exec` client leaves its shell running in the container: the +// daemon keeps the terminal open, so the shell never gets a hangup. Every +// process of a terminal session inherits this marker, so closing the session +// can hang up its shell the way closing an SSH session does. +export const newTerminalSessionMarker = () => + `DOKPLOY_TERMINAL_SESSION=${randomBytes(16).toString("hex")}`; + +// Only the session's first process gets the hangup, so a shell passes it on to +// its jobs and a process started with nohup keeps running, as over SSH. The +// script avoids single quotes and backslashes because the SSH path quotes it. +export const getTerminalHangupArgs = ( + containerId: string, + shell: string, + marker: string, +) => [ + "exec", + containerId, + shell, + "-c", + `marker="${marker}" +for dir in /proc/[0-9]*; do + grep -qF "$marker" "$dir/environ" 2>/dev/null || continue + parent=$(sed -n "s/^PPid:[[:space:]]*//p" "$dir/status" 2>/dev/null) + grep -qF "$marker" "/proc/$parent/environ" 2>/dev/null || kill -HUP "\${dir#/proc/}" 2>/dev/null +done +exit 0`, +]; + export const getShell = () => { if (IS_CLOUD) { return "NO_AVAILABLE"; diff --git a/packages/server/src/services/settings.ts b/packages/server/src/services/settings.ts index 61fc1ab618b..f49a486a4b4 100644 --- a/packages/server/src/services/settings.ts +++ b/packages/server/src/services/settings.ts @@ -665,6 +665,29 @@ docker service update --force \\ `; }; +// The service names a moving tag such as `agenthits-dev`, and swarm pulls a +// tag again whenever it starts a task, so a plain forced restart can come up on +// a newer build. Reload pins the image the panel runs now: by digest, which +// swarm does not pull when the image is present, or by image id for a build +// that never came from a registry. +export const getAgentHitsReloadCommand = () => ` +running_tasks=$(${DOKPLOY_RUNNING_TASKS}) +running_image="" +if [ -n "$running_tasks" ]; then + running_image=$(docker inspect --format '{{.Image}}' $running_tasks 2>/dev/null | head -n 1) +fi +if [ -z "$running_image" ]; then + echo "The image of the running panel could not be found, so the panel was not restarted." >&2 + exit 1 +fi +reload_image=$(docker image inspect --format '{{index .RepoDigests 0}}' "$running_image" 2>/dev/null || true) +docker service update --force --no-resolve-image \\ + --image "\${reload_image:-$running_image}" \\ + --update-order stop-first \\ + --update-failure-action rollback \\ + dokploy +`; + export const getOfficialUpdateImage = (version: string) => `dokploy/dokploy:${version}`; @@ -980,7 +1003,7 @@ export const reloadDockerResource = async ( } command = isAgentHitsUpdateChannel(version || "") - ? getAgentHitsUpdateCommand(version || "") + ? getAgentHitsReloadCommand() : `docker service update --force --image dokploy/dokploy:${imageTag} ${resourceName}`; } else { command = `docker service update --force ${resourceName}`;