From 68a05551d04142b541090aea81685e8190b1d0c6 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 1 Sep 2026 01:53:45 -0600 Subject: [PATCH] fix: write remote traefik config via SFTP instead of exec+base64 Embedding the full YAML (base64-encoded) into a single SSH exec command silently fails once the payload passes the SSH transport's packet size ceiling (~90-100KB), truncating the dynamic config with no error surfaced anywhere. Switch to SFTP, which has no such limit. --- .../server/src/utils/process/execAsync.ts | 81 ++++++++++++++++++- .../server/src/utils/traefik/application.ts | 21 +---- 2 files changed, 81 insertions(+), 21 deletions(-) diff --git a/packages/server/src/utils/process/execAsync.ts b/packages/server/src/utils/process/execAsync.ts index d44e3ccaf..81b12e2f9 100644 --- a/packages/server/src/utils/process/execAsync.ts +++ b/packages/server/src/utils/process/execAsync.ts @@ -4,6 +4,20 @@ import { findServerById } from "@dokploy/server/services/server"; import { Client } from "ssh2"; import { ExecError } from "./ExecError"; +export class WriteFileRemoteError extends Error { + constructor( + message: string, + public readonly context: { + remotePath: string; + serverId: string; + originalError: Error; + }, + ) { + super(message); + this.name = "WriteFileRemoteError"; + } +} + // Re-export ExecError for easier imports export { ExecError } from "./ExecError"; @@ -21,11 +35,11 @@ export const execAsync = async ( }; } catch (error) { if (error instanceof Error) { - // @ts-ignore - exec error has these properties + // @ts-expect-error - exec error has these properties const exitCode = error.code; - // @ts-ignore + // @ts-expect-error const stdout = error.stdout?.toString() || ""; - // @ts-ignore + // @ts-expect-error const stderr = error.stderr?.toString() || ""; throw new ExecError(`Command execution failed: ${error.message}`, { @@ -61,7 +75,7 @@ export const execAsyncStream = ( command, stdout: stdoutComplete, stderr: stderrComplete, - // @ts-ignore + // @ts-expect-error exitCode: error.code, originalError: error, }), @@ -249,6 +263,65 @@ export const execAsyncRemote = async ( }); }; +export const writeFileRemote = async ( + serverId: string, + remotePath: string, + content: string, +): Promise => { + const server = await findServerById(serverId); + if (!server.sshKeyId) throw new Error("No SSH key available for this server"); + + return new Promise((resolve, reject) => { + const conn = new Client(); + conn + .once("ready", () => { + conn.sftp((err, sftp) => { + if (err) { + conn.end(); + reject( + new WriteFileRemoteError(`SFTP session failed: ${err.message}`, { + remotePath, + serverId, + originalError: err, + }), + ); + return; + } + sftp.writeFile(remotePath, content, (writeErr) => { + conn.end(); + if (writeErr) { + reject( + new WriteFileRemoteError( + `Failed to write remote file ${remotePath}: ${writeErr.message}`, + { remotePath, serverId, originalError: writeErr }, + ), + ); + return; + } + resolve(); + }); + }); + }) + .on("error", (err) => { + conn.end(); + reject( + new WriteFileRemoteError(`SSH connection error: ${err.message}`, { + remotePath, + serverId, + originalError: err, + }), + ); + }) + .connect({ + host: server.ipAddress, + port: server.port, + username: server.username, + privateKey: server.sshKey?.privateKey, + timeout: 99999, + }); + }); +}; + export const sleep = (ms: number) => { return new Promise((resolve) => setTimeout(resolve, ms)); }; diff --git a/packages/server/src/utils/traefik/application.ts b/packages/server/src/utils/traefik/application.ts index fb77e5ece..6e61f3e7e 100644 --- a/packages/server/src/utils/traefik/application.ts +++ b/packages/server/src/utils/traefik/application.ts @@ -5,8 +5,7 @@ import { paths } from "@dokploy/server/constants"; import type { Domain } from "@dokploy/server/services/domain"; import { quote } from "shell-quote"; import { parse, stringify } from "yaml"; -import { encodeBase64 } from "../docker/utils"; -import { execAsync, execAsyncRemote } from "../process/execAsync"; +import { execAsync, execAsyncRemote, writeFileRemote } from "../process/execAsync"; import type { FileConfig, HttpLoadBalancerService } from "./file-types"; export const createTraefikConfig = (appName: string) => { @@ -228,11 +227,7 @@ export const writeConfigRemote = async ( try { const { DYNAMIC_TRAEFIK_PATH } = paths(true); const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`); - const encoded = encodeBase64(traefikConfig); - await execAsyncRemote( - serverId, - `echo "${encoded}" | base64 -d > ${quote([configPath])}`, - ); + await writeFileRemote(serverId, configPath, traefikConfig); } catch (e) { console.error("Error saving the YAML config file:", e); } @@ -246,11 +241,7 @@ export const writeTraefikConfigInPath = async ( try { const configPath = path.join(pathFile); if (serverId) { - const encoded = encodeBase64(traefikConfig); - await execAsyncRemote( - serverId, - `echo "${encoded}" | base64 -d > ${quote([configPath])}`, - ); + await writeFileRemote(serverId, configPath, traefikConfig); } else { fs.writeFileSync(configPath, traefikConfig, "utf8"); } @@ -282,11 +273,7 @@ export const writeTraefikConfigRemote = async ( const { DYNAMIC_TRAEFIK_PATH } = paths(true); const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`); const yamlStr = stringify(traefikConfig); - const encoded = encodeBase64(yamlStr); - await execAsyncRemote( - serverId, - `echo "${encoded}" | base64 -d > ${quote([configPath])}`, - ); + await writeFileRemote(serverId, configPath, yamlStr); } catch (e) { console.error("Error saving the YAML config file:", e); }