diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx index ee749244..0fa4638b 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx @@ -16,6 +16,7 @@ import { useTranslation } from "next-i18next"; import { toast } from "sonner"; import { ShowModalLogs } from "../../web-server/show-modal-logs"; import { GPUSupportModal } from "../gpu-support-modal"; +import { TerminalModal } from "../../web-server/terminal-modal"; export const ShowDokployActions = () => { const { t } = useTranslation("settings"); @@ -49,6 +50,9 @@ export const ShowDokployActions = () => { > {t("settings.server.webServer.reload")} + + {t("settings.common.enterTerminal")} + ; export const AddServer = () => { + const { t } = useTranslation("settings"); + const utils = api.useUtils(); const [isOpen, setIsOpen] = useState(false); const { data: canCreateMoreServers, refetch } = @@ -212,7 +214,7 @@ export const AddServer = () => { name="ipAddress" render={({ field }) => ( - IP Address + {t("settings.terminal.ipAddress")} @@ -226,7 +228,7 @@ export const AddServer = () => { name="port" render={({ field }) => ( - Port + {t("settings.terminal.port")} { name="username" render={({ field }) => ( - Username + {t("settings.terminal.username")} diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx index ef63597c..181c6569 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx @@ -34,8 +34,10 @@ import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal"; import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal"; import { UpdateServer } from "./update-server"; import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription"; +import { useTranslation } from "next-i18next"; export const ShowServers = () => { + const { t } = useTranslation("settings"); const router = useRouter(); const query = router.query; const { data, refetch } = api.server.all.useQuery(); @@ -191,7 +193,9 @@ export const ShowServers = () => { <> {server.sshKeyId && ( - Enter the terminal + + {t("settings.common.enterTerminal")} + )} diff --git a/apps/dokploy/components/dashboard/settings/servers/update-server.tsx b/apps/dokploy/components/dashboard/settings/servers/update-server.tsx index 7de7a0e2..728feabd 100644 --- a/apps/dokploy/components/dashboard/settings/servers/update-server.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/update-server.tsx @@ -31,8 +31,7 @@ import { import { Textarea } from "@/components/ui/textarea"; import { api } from "@/utils/api"; import { zodResolver } from "@hookform/resolvers/zod"; -import { PlusIcon } from "lucide-react"; -import { useRouter } from "next/router"; +import { useTranslation } from "next-i18next"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -60,6 +59,8 @@ interface Props { } export const UpdateServer = ({ serverId }: Props) => { + const { t } = useTranslation("settings"); + const utils = api.useUtils(); const [isOpen, setIsOpen] = useState(false); const { data, isLoading } = api.server.one.useQuery( @@ -212,7 +213,7 @@ export const UpdateServer = ({ serverId }: Props) => { name="ipAddress" render={({ field }) => ( - IP Address + {t("settings.terminal.ipAddress")} @@ -226,7 +227,7 @@ export const UpdateServer = ({ serverId }: Props) => { name="port" render={({ field }) => ( - Port + {t("settings.terminal.port")} { name="username" render={({ field }) => ( - Username + {t("settings.terminal.username")} @@ -273,7 +274,7 @@ export const UpdateServer = ({ serverId }: Props) => { form="hook-form-update-server" type="submit" > - Update + {t("settings.common.save")} diff --git a/apps/dokploy/components/dashboard/settings/web-server/local-server-config.tsx b/apps/dokploy/components/dashboard/settings/web-server/local-server-config.tsx new file mode 100644 index 00000000..fde03ffb --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/web-server/local-server-config.tsx @@ -0,0 +1,154 @@ +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Input } from "@/components/ui/input"; +import { + FormControl, + FormLabel, + FormField, + FormMessage, + FormItem, + Form, +} from "@/components/ui/form"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Settings } from "lucide-react"; +import React from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { useTranslation } from "next-i18next"; + +const Schema = z.object({ + port: z.number().min(1, "Port must be higher than 0"), + username: z.string().min(1, "Username is required"), +}); + +type Schema = z.infer; + +const DEFAULT_LOCAL_SERVER_DATA: Schema = { + port: 22, + username: "root", +}; + +/** Returns local server data for use with local server terminal */ +export const getLocalServerData = () => { + try { + const localServerData = localStorage.getItem("localServerData"); + const parsedLocalServerData = localServerData + ? (JSON.parse(localServerData) as typeof DEFAULT_LOCAL_SERVER_DATA) + : DEFAULT_LOCAL_SERVER_DATA; + + return parsedLocalServerData; + } catch { + return DEFAULT_LOCAL_SERVER_DATA; + } +}; + +interface Props { + onSave: () => void; +} + +const LocalServerConfig = ({ onSave }: Props) => { + const { t } = useTranslation("settings"); + + const form = useForm({ + defaultValues: getLocalServerData(), + resolver: zodResolver(Schema), + }); + + const onSubmit = (data: Schema) => { + localStorage.setItem("localServerData", JSON.stringify(data)); + form.reset(data); + onSave(); + }; + + return ( + + + +
+
+ + + {t("settings.terminal.connectionSettings")} + +
+
+
+ + +
+ + ( + + {t("settings.terminal.port")} + + { + const value = e.target.value; + if (value === "") { + field.onChange(1); + } else { + const number = Number.parseInt(value, 10); + if (!Number.isNaN(number)) { + field.onChange(number); + } + } + }} + /> + + + + + )} + /> + + ( + + {t("settings.terminal.username")} + + + + + + + )} + /> + + + + +
+
+
+ ); +}; + +export default LocalServerConfig; diff --git a/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx b/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx index 5bdba8b8..7c64ebc0 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx @@ -10,24 +10,38 @@ import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { api } from "@/utils/api"; import dynamic from "next/dynamic"; import type React from "react"; +import { useState } from "react"; +import LocalServerConfig from "./local-server-config"; const Terminal = dynamic(() => import("./terminal").then((e) => e.Terminal), { ssr: false, }); +const getTerminalKey = () => { + return `terminal-${Date.now()}`; +}; + interface Props { children?: React.ReactNode; serverId: string; } export const TerminalModal = ({ children, serverId }: Props) => { + const [terminalKey, setTerminalKey] = useState(getTerminalKey()); + const isLocalServer = serverId === "local"; + const { data } = api.server.one.useQuery( { serverId, }, - { enabled: !!serverId }, + { enabled: !!serverId && !isLocalServer }, ); + const handleLocalServerConfigSave = () => { + // Rerender Terminal component to reconnect using new component key when saving local server config + setTerminalKey(getTerminalKey()); + }; + return ( @@ -43,12 +57,16 @@ export const TerminalModal = ({ children, serverId }: Props) => { onEscapeKeyDown={(event) => event.preventDefault()} > - Terminal ({data?.name}) + Terminal ({data?.name ?? serverId}) Easy way to access the server -
- + {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 ""; + } +};