-
+ {isLocalServer && (
+
+ )}
+
+
+
diff --git a/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx b/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx
index d38f5d9e..e45b73d2 100644
--- a/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx
@@ -5,6 +5,7 @@ import { FitAddon } from "xterm-addon-fit";
import "@xterm/xterm/css/xterm.css";
import { AttachAddon } from "@xterm/addon-attach";
import { useTheme } from "next-themes";
+import { getLocalServerData } from "./local-server-config";
interface Props {
id: string;
@@ -12,9 +13,16 @@ interface Props {
}
export const Terminal: React.FC
= ({ id, serverId }) => {
- const termRef = useRef(null);
+ const termRef = useRef(null);
+ const initialized = useRef(false);
const { resolvedTheme } = useTheme();
useEffect(() => {
+ if (initialized.current) {
+ // Required in strict mode to avoid issues due to double wss connection
+ return;
+ }
+
+ initialized.current = true;
const container = document.getElementById(id);
if (container) {
container.innerHTML = "";
@@ -33,7 +41,16 @@ export const Terminal: React.FC = ({ id, serverId }) => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
- const wsUrl = `${protocol}//${window.location.host}/terminal?serverId=${serverId}`;
+ const urlParams = new URLSearchParams();
+ urlParams.set("serverId", serverId);
+
+ if (serverId === "local") {
+ const { port, username } = getLocalServerData();
+ urlParams.set("port", port.toString());
+ urlParams.set("username", username);
+ }
+
+ const wsUrl = `${protocol}//${window.location.host}/terminal?${urlParams}`;
const ws = new WebSocket(wsUrl);
const addonAttach = new AttachAddon(ws);
diff --git a/apps/dokploy/pages/dashboard/settings/servers.tsx b/apps/dokploy/pages/dashboard/settings/servers.tsx
index 7aea531b..d25a3513 100644
--- a/apps/dokploy/pages/dashboard/settings/servers.tsx
+++ b/apps/dokploy/pages/dashboard/settings/servers.tsx
@@ -2,6 +2,7 @@ import { ShowServers } from "@/components/dashboard/settings/servers/show-server
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { SettingsLayout } from "@/components/layouts/settings-layout";
import { appRouter } from "@/server/api/root";
+import { getLocale, serverSideTranslations } from "@/utils/i18n";
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
@@ -29,6 +30,7 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
+ const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(req, res);
if (!user) {
return {
@@ -64,6 +66,7 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
+ ...(await serverSideTranslations(locale, ["settings"])),
},
};
}
diff --git a/apps/dokploy/public/locales/en/settings.json b/apps/dokploy/public/locales/en/settings.json
index 6bfa4e3b..ebc5ea62 100644
--- a/apps/dokploy/public/locales/en/settings.json
+++ b/apps/dokploy/public/locales/en/settings.json
@@ -1,5 +1,6 @@
{
"settings.common.save": "Save",
+ "settings.common.enterTerminal": "Enter the terminal",
"settings.server.domain.title": "Server Domain",
"settings.server.domain.description": "Add a domain to your server application.",
"settings.server.domain.form.domain": "Domain",
@@ -48,5 +49,10 @@
"settings.appearance.themes.dark": "Dark",
"settings.appearance.themes.system": "System",
"settings.appearance.language": "Language",
- "settings.appearance.languageDescription": "Select a language for your dashboard"
+ "settings.appearance.languageDescription": "Select a language for your dashboard",
+
+ "settings.terminal.connectionSettings": "Connection settings",
+ "settings.terminal.ipAddress": "IP Address",
+ "settings.terminal.port": "Port",
+ "settings.terminal.username": "Username"
}
diff --git a/apps/dokploy/public/locales/pl/settings.json b/apps/dokploy/public/locales/pl/settings.json
index 48531e69..93414825 100644
--- a/apps/dokploy/public/locales/pl/settings.json
+++ b/apps/dokploy/public/locales/pl/settings.json
@@ -1,5 +1,6 @@
{
"settings.common.save": "Zapisz",
+ "settings.common.enterTerminal": "Otwórz terminal",
"settings.server.domain.title": "Domena",
"settings.server.domain.description": "Dodaj domenę do aplikacji",
"settings.server.domain.form.domain": "Domena",
@@ -40,5 +41,10 @@
"settings.appearance.themes.dark": "Ciemny",
"settings.appearance.themes.system": "System",
"settings.appearance.language": "Język",
- "settings.appearance.languageDescription": "Wybierz język swojego pulpitu"
+ "settings.appearance.languageDescription": "Wybierz język swojego pulpitu",
+
+ "settings.terminal.connectionSettings": "Ustawienia połączenia",
+ "settings.terminal.ipAddress": "Adres IP",
+ "settings.terminal.port": "Port",
+ "settings.terminal.username": "Nazwa użytkownika"
}
diff --git a/apps/dokploy/server/wss/terminal.ts b/apps/dokploy/server/wss/terminal.ts
index a3c231aa..7ca7d13d 100644
--- a/apps/dokploy/server/wss/terminal.ts
+++ b/apps/dokploy/server/wss/terminal.ts
@@ -1,8 +1,9 @@
import type http from "node:http";
import { findServerById, validateWebSocketRequest } from "@dokploy/server";
import { publicIpv4, publicIpv6 } from "public-ip";
-import { Client } from "ssh2";
+import { Client, type ConnectConfig } from "ssh2";
import { WebSocketServer } from "ws";
+import { setupLocalServerSSHKey } from "./utils";
export const getPublicIpWithFallback = async () => {
// @ts-ignore
@@ -55,21 +56,67 @@ export const setupTerminalWebSocketServer = (
return;
}
- const server = await findServerById(serverId);
+ let connectionDetails: ConnectConfig = {};
- if (!server) {
- ws.close();
- return;
+ const isLocalServer = serverId === "local";
+
+ if (isLocalServer) {
+ const port = Number(url.searchParams.get("port"));
+ const username = url.searchParams.get("username");
+
+ if (!port || !username) {
+ ws.close();
+ return;
+ }
+
+ ws.send("Setting up private SSH key...\n");
+ const privateKey = await setupLocalServerSSHKey();
+
+ if (!privateKey) {
+ ws.close();
+ return;
+ }
+
+ connectionDetails = {
+ host: "localhost",
+ port,
+ username,
+ privateKey,
+ };
+ } else {
+ ws.send("Getting server data...\n");
+ const server = await findServerById(serverId);
+
+ if (!server) {
+ ws.close();
+ return;
+ }
+
+ const { ipAddress: host, port, username, sshKey, sshKeyId } = server;
+
+ if (!sshKeyId) {
+ throw new Error("No SSH key available for this server");
+ }
+
+ connectionDetails = {
+ host,
+ port,
+ username,
+ privateKey: sshKey?.privateKey,
+ };
}
- if (!server.sshKeyId)
- throw new Error("No SSH key available for this server");
-
const conn = new Client();
let stdout = "";
let stderr = "";
+
+ ws.send("Connecting...\n");
+
conn
.once("ready", () => {
+ // Clear terminal content once connected
+ ws.send("\x1bc");
+
conn.shell({}, (err, stream) => {
if (err) throw err;
@@ -112,18 +159,13 @@ export const setupTerminalWebSocketServer = (
.on("error", (err) => {
if (err.level === "client-authentication") {
ws.send(
- `Authentication failed: Invalid SSH private key. ❌ Error: ${err.message} ${err.level}`,
+ `Authentication failed: Unauthorized ${isLocalServer ? "" : "private SSH key or "}username.\n❌ Error: ${err.message} ${err.level}`,
);
} else {
ws.send(`SSH connection error: ${err.message}`);
}
conn.end();
})
- .connect({
- host: server.ipAddress,
- port: server.port,
- username: server.username,
- privateKey: server.sshKey?.privateKey,
- });
+ .connect(connectionDetails);
});
};
diff --git a/apps/dokploy/server/wss/utils.ts b/apps/dokploy/server/wss/utils.ts
index b5567127..4971bac2 100644
--- a/apps/dokploy/server/wss/utils.ts
+++ b/apps/dokploy/server/wss/utils.ts
@@ -1,4 +1,17 @@
+import { execAsync } from "@dokploy/server/utils/process/execAsync";
import os from "node:os";
+import path from "node:path";
+import fs from "node:fs";
+
+const HOME_PATH = process.env.HOME || process.env.USERPROFILE || "/";
+
+const LOCAL_SSH_KEY_PATH = path.join(
+ HOME_PATH,
+ ".ssh",
+ "auto_generated-dokploy-local",
+);
+
+const AUTHORIZED_KEYS_PATH = path.join(HOME_PATH, ".ssh", "authorized_keys");
export const getShell = () => {
switch (os.platform()) {
@@ -10,3 +23,40 @@ export const getShell = () => {
return "bash";
}
};
+
+/** Returns private SSH key for dokploy local server terminal. Uses already created SSH key or generates a new SSH key, also automatically appends the public key to `authorized_keys`, creating the file if needed. */
+export const setupLocalServerSSHKey = async () => {
+ try {
+ if (!fs.existsSync(LOCAL_SSH_KEY_PATH)) {
+ // Generate new SSH key if it hasn't been created yet
+ await execAsync(
+ `ssh-keygen -t rsa -b 4096 -f ${LOCAL_SSH_KEY_PATH} -N ""`,
+ );
+ }
+
+ const privateKey = fs.readFileSync(LOCAL_SSH_KEY_PATH, "utf8");
+ const publicKey = fs.readFileSync(`${LOCAL_SSH_KEY_PATH}.pub`, "utf8");
+ const authKeyContent = `${publicKey}\n`;
+
+ if (!fs.existsSync(AUTHORIZED_KEYS_PATH)) {
+ // Create authorized_keys if it doesn't exist yet
+ fs.writeFileSync(AUTHORIZED_KEYS_PATH, authKeyContent, { mode: 0o600 });
+ return privateKey;
+ }
+
+ const existingAuthKeys = fs.readFileSync(AUTHORIZED_KEYS_PATH, "utf8");
+ if (existingAuthKeys.includes(publicKey)) {
+ return privateKey;
+ }
+
+ // Append the public key to authorized_keys
+ fs.appendFileSync(AUTHORIZED_KEYS_PATH, authKeyContent, {
+ mode: 0o600,
+ });
+
+ return privateKey;
+ } catch (error) {
+ console.error("Error getting private SSH key for local terminal:", error);
+ return "";
+ }
+};