diff --git a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts index e25bd425e6..c21f22fd54 100644 --- a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts +++ b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts @@ -222,6 +222,34 @@ describe("GitHub app webhook auto-deploy", () => { expect(res.json).toHaveBeenCalledWith({ message: "Deployed 1 apps" }); }); + it("keeps remote application server id in queued push deployments", async () => { + mocks.applicationsFindMany.mockResolvedValue([ + { + applicationId: "application-id", + serverId: "server-application", + watchPaths: null, + }, + ]); + const res = createResponse(); + + await handler(createPushRequest("main"), res); + + expect(mocks.queueAdd).toHaveBeenCalledWith( + "deployments", + expect.objectContaining({ + applicationId: "application-id", + applicationType: "application", + server: true, + serverId: "server-application", + type: "deploy", + }), + expect.objectContaining({ + removeOnComplete: true, + removeOnFail: true, + }), + ); + }); + it("matches compose push events using repository owner login fallback", async () => { mocks.applicationsFindMany.mockResolvedValue([]); mocks.composeFindMany.mockImplementation(({ where }) => { @@ -239,7 +267,7 @@ describe("GitHub app webhook auto-deploy", () => { ? [ { composeId: "compose-id", - serverId: null, + serverId: "server-compose", watchPaths: null, }, ] @@ -255,6 +283,8 @@ describe("GitHub app webhook auto-deploy", () => { expect.objectContaining({ applicationType: "compose", composeId: "compose-id", + server: true, + serverId: "server-compose", type: "deploy", }), expect.objectContaining({ diff --git a/apps/dokploy/__test__/env/application-env-upsert.test.ts b/apps/dokploy/__test__/env/application-env-upsert.test.ts new file mode 100644 index 0000000000..7482a2a0f2 --- /dev/null +++ b/apps/dokploy/__test__/env/application-env-upsert.test.ts @@ -0,0 +1,152 @@ +import { upsertApplicationEnvironment } from "@dokploy/server/services/application"; +import { getApplicationEnvRevision } from "@dokploy/server/utils/env-upsert"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildApplicationEnvUpsertDeploymentJob } from "@/server/api/utils/application-env-upsert"; + +const dbMocks = vi.hoisted(() => { + const returning = vi.fn(); + const where = vi.fn(() => ({ returning })); + const set = vi.fn(() => ({ where })); + const update = vi.fn(() => ({ set })); + const findFirst = vi.fn(); + + return { + findFirst, + returning, + set, + update, + where, + }; +}); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + applications: { + findFirst: dbMocks.findFirst, + }, + }, + update: dbMocks.update, + }, +})); + +const mockApplication = (env: string | null = null) => ({ + applicationId: "app_1", + env, +}); + +describe("upsertApplicationEnvironment", () => { + beforeEach(() => { + vi.clearAllMocks(); + dbMocks.returning.mockResolvedValue([mockApplication()]); + }); + + it("returns dry run metadata without saving raw values", async () => { + dbMocks.findFirst.mockResolvedValue( + mockApplication("API_URL=https://old.example.com\nREDIS_PASSWORD=old"), + ); + + const result = await upsertApplicationEnvironment({ + applicationId: "app_1", + variables: { + API_URL: "https://api.example.com", + REDIS_PASSWORD: "new", + }, + dryRun: true, + }); + + expect(dbMocks.update).not.toHaveBeenCalled(); + expect(result).toEqual({ + applicationId: "app_1", + changed: true, + revision: getApplicationEnvRevision( + "app_1", + "API_URL=https://old.example.com\nREDIS_PASSWORD=old", + ), + dryRun: true, + variables: [ + { + name: "API_URL", + action: "updated", + secret: false, + }, + { + name: "REDIS_PASSWORD", + action: "updated", + secret: true, + }, + ], + }); + expect(JSON.stringify(result)).not.toContain("new"); + }); + + it("saves only the merged environment when the revision matches", async () => { + const currentEnv = "API_URL=https://old.example.com\nREDIS_PASSWORD=old"; + dbMocks.findFirst.mockResolvedValue(mockApplication(currentEnv)); + + const result = await upsertApplicationEnvironment({ + applicationId: "app_1", + variables: { + API_URL: "https://api.example.com", + REDIS_HOST: "redis-dev", + }, + expectedRevision: getApplicationEnvRevision("app_1", currentEnv), + }); + + expect(dbMocks.set).toHaveBeenCalledWith({ + env: "API_URL=https://api.example.com\nREDIS_PASSWORD=old\nREDIS_HOST=redis-dev", + }); + expect(result.changed).toBe(true); + expect(result.revision).toBe( + getApplicationEnvRevision( + "app_1", + "API_URL=https://api.example.com\nREDIS_PASSWORD=old\nREDIS_HOST=redis-dev", + ), + ); + }); + + it("rejects stale expected revisions without writing", async () => { + dbMocks.findFirst.mockResolvedValue(mockApplication("API_URL=https://old")); + + await expect( + upsertApplicationEnvironment({ + applicationId: "app_1", + variables: { + API_URL: "https://api.example.com", + }, + expectedRevision: "env:stale", + }), + ).rejects.toMatchObject({ + code: "CONFLICT", + message: "Application environment revision does not match", + }); + expect(dbMocks.update).not.toHaveBeenCalled(); + }); +}); + +describe("buildApplicationEnvUpsertDeploymentJob", () => { + it("keeps the remote server id for queue partitioning", () => { + expect( + buildApplicationEnvUpsertDeploymentJob({ + applicationId: "app_1", + serverId: "server_1", + }), + ).toMatchObject({ + applicationId: "app_1", + applicationType: "application", + type: "redeploy", + server: true, + serverId: "server_1", + }); + }); + + it("keeps local redeploy jobs in the local queue partition", () => { + const jobData = buildApplicationEnvUpsertDeploymentJob({ + applicationId: "app_1", + serverId: null, + }); + + expect(jobData.server).toBe(false); + expect(jobData.serverId).toBeUndefined(); + }); +}); diff --git a/apps/dokploy/__test__/env/upsert.test.ts b/apps/dokploy/__test__/env/upsert.test.ts new file mode 100644 index 0000000000..45b0664fde --- /dev/null +++ b/apps/dokploy/__test__/env/upsert.test.ts @@ -0,0 +1,86 @@ +import { + getApplicationEnvRevision, + isSecretEnvName, + upsertEnvVariables, +} from "@dokploy/server/utils/env-upsert"; +import { describe, expect, it } from "vitest"; + +describe("upsertEnvVariables", () => { + it("updates existing variables and appends new variables without removing existing secrets", () => { + const result = upsertEnvVariables( + `# Existing app env +API_URL=https://old.example.com +REDIS_PASSWORD=secret-value +`, + { + API_URL: "https://api.example.com", + REDIS_HOST: "redis-dev", + REDIS_PASSWORD: "secret-value", + }, + ); + + expect(result.changed).toBe(true); + expect(result.env).toBe(`# Existing app env +API_URL=https://api.example.com +REDIS_PASSWORD=secret-value +REDIS_HOST=redis-dev +`); + expect(result.variables).toEqual([ + { + name: "API_URL", + action: "updated", + secret: false, + }, + { + name: "REDIS_HOST", + action: "created", + secret: false, + }, + { + name: "REDIS_PASSWORD", + action: "unchanged", + secret: true, + }, + ]); + }); + + it("quotes values that would be unsafe as plain dotenv values", () => { + const result = upsertEnvVariables("", { + PLAIN_VALUE: "redis-dev", + SPACED_VALUE: "redis dev", + MULTILINE_VALUE: "first\nsecond", + HASH_VALUE: "value # not a comment", + }); + + expect(result.env).toBe(`PLAIN_VALUE=redis-dev +SPACED_VALUE="redis dev" +MULTILINE_VALUE="first\\nsecond" +HASH_VALUE="value # not a comment"`); + }); + + it("marks common credential names as secret metadata", () => { + expect(isSecretEnvName("REDIS_PASSWORD")).toBe(true); + expect(isSecretEnvName("API_TOKEN")).toBe(true); + expect(isSecretEnvName("PUBLIC_URL")).toBe(false); + }); + + it("creates a stable opaque revision that changes when the env changes", () => { + const firstRevision = getApplicationEnvRevision( + "app_1", + "REDIS_PASSWORD=secret-value", + ); + const sameRevision = getApplicationEnvRevision( + "app_1", + "REDIS_PASSWORD=secret-value", + ); + const nextRevision = getApplicationEnvRevision( + "app_1", + "REDIS_PASSWORD=new-secret-value", + ); + + expect(firstRevision).toBe(sameRevision); + expect(firstRevision).not.toBe(nextRevision); + expect(firstRevision).toMatch(/^env:/); + expect(firstRevision).not.toContain("secret-value"); + }); +}); diff --git a/apps/dokploy/__test__/git-provider/git-provider-access.test.ts b/apps/dokploy/__test__/git-provider/git-provider-access.test.ts index 4ddf36244a..bc2a18c3f6 100644 --- a/apps/dokploy/__test__/git-provider/git-provider-access.test.ts +++ b/apps/dokploy/__test__/git-provider/git-provider-access.test.ts @@ -1,8 +1,9 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; import { canEditDeployGitSource, getAccessibleGitProviderIds, + redactGitProviderSecrets, } from "@dokploy/server/services/git-provider"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mockDb = vi.hoisted(() => ({ query: { @@ -67,6 +68,46 @@ beforeEach(() => { mockHasValidLicense.mockResolvedValue(false); }); +describe("redactGitProviderSecrets", () => { + it("removes nested provider credential fields", () => { + const redacted = redactGitProviderSecrets({ + name: "compose", + github: { + githubClientId: "client-id", + githubClientSecret: "client-secret", + githubPrivateKey: "private-key", + githubWebhookSecret: "webhook-secret", + }, + gitlab: { + gitlabUrl: "https://gitlab.example.com", + secret: "secret", + accessToken: "access-token", + refreshToken: "refresh-token", + }, + gitea: { + giteaUrl: "https://gitea.example.com", + clientSecret: "client-secret", + accessToken: "access-token", + refreshToken: "refresh-token", + }, + bitbucket: { + bitbucketWorkspaceName: "workspace", + appPassword: "app-password", + apiToken: "api-token", + }, + }); + + expect(redacted.github).toEqual({ githubClientId: "client-id" }); + expect(redacted.gitlab).toEqual({ + gitlabUrl: "https://gitlab.example.com", + }); + expect(redacted.gitea).toEqual({ giteaUrl: "https://gitea.example.com" }); + expect(redacted.bitbucket).toEqual({ + bitbucketWorkspaceName: "workspace", + }); + }); +}); + describe("getAccessibleGitProviderIds", () => { describe("owner", () => { beforeEach(() => { diff --git a/apps/dokploy/__test__/security/env-redaction.test.ts b/apps/dokploy/__test__/security/env-redaction.test.ts new file mode 100644 index 0000000000..35807ed326 --- /dev/null +++ b/apps/dokploy/__test__/security/env-redaction.test.ts @@ -0,0 +1,98 @@ +import { + preserveSecretPlaceholderFields, + REDACTED_SECRET_VALUE, + redactDatabaseServiceSecrets, + redactDeployableServiceSecrets, + redactSecretFields, + redactSensitiveText, +} from "@dokploy/server/utils/security/redaction"; +import { describe, expect, it } from "vitest"; + +describe("env secret redaction helpers", () => { + it("preserves stored values when an update contains redacted placeholders", () => { + const next = preserveSecretPlaceholderFields( + { + env: REDACTED_SECRET_VALUE, + buildArgs: "[REDACTED]", + buildSecrets: "NPM_TOKEN=new", + name: "api", + }, + { + env: "TOKEN=old", + buildArgs: "ARG_TOKEN=old", + buildSecrets: "NPM_TOKEN=old", + name: "old-api", + }, + ["env", "buildArgs", "buildSecrets"], + ); + + expect(next).toEqual({ + env: "TOKEN=old", + buildArgs: "ARG_TOKEN=old", + buildSecrets: "NPM_TOKEN=new", + name: "api", + }); + }); + + it("redacts deployable service environment fields on normal reads", () => { + const redacted = redactDeployableServiceSecrets({ + env: "TOKEN=secret", + previewEnv: "TOKEN=preview-secret", + buildArgs: "NPM_TOKEN=secret", + buildSecrets: "NPM_TOKEN=secret", + password: "registry-password", + name: "api", + }); + + expect(redacted).toMatchObject({ + env: REDACTED_SECRET_VALUE, + previewEnv: REDACTED_SECRET_VALUE, + buildArgs: REDACTED_SECRET_VALUE, + buildSecrets: REDACTED_SECRET_VALUE, + password: REDACTED_SECRET_VALUE, + name: "api", + }); + }); + + it("redacts database env and password fields on normal reads", () => { + const redacted = redactDatabaseServiceSecrets({ + env: "PGSSLMODE=require", + databasePassword: "database-password", + databaseRootPassword: "root-password", + appName: "postgres", + }); + + expect(redacted).toMatchObject({ + env: REDACTED_SECRET_VALUE, + databasePassword: REDACTED_SECRET_VALUE, + databaseRootPassword: REDACTED_SECRET_VALUE, + appName: "postgres", + }); + }); + + it("can redact compose file content with the generic field helper", () => { + const redacted = redactSecretFields( + { + composeFile: "services:\n api:\n environment:\n TOKEN: secret", + env: "TOKEN=secret", + name: "stack", + }, + ["composeFile", "env"], + ); + + expect(redacted).toEqual({ + composeFile: REDACTED_SECRET_VALUE, + env: REDACTED_SECRET_VALUE, + name: "stack", + }); + }); + + it("redacts credentials embedded in custom git urls", () => { + expect( + redactSensitiveText("https://git-token@example.com/org/private.git"), + ).toBe(`https://${REDACTED_SECRET_VALUE}@example.com/org/private.git`); + expect(redactSensitiveText("https://example.com/org/public.git")).toBe( + "https://example.com/org/public.git", + ); + }); +}); diff --git a/apps/dokploy/__test__/security/service-environment.test.ts b/apps/dokploy/__test__/security/service-environment.test.ts new file mode 100644 index 0000000000..e34ff40fc5 --- /dev/null +++ b/apps/dokploy/__test__/security/service-environment.test.ts @@ -0,0 +1,68 @@ +import type { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + checkServicePermissionAndAccess: vi.fn(), +})); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkServicePermissionAndAccess: mocks.checkServicePermissionAndAccess, +})); + +const { assertServiceEnvironmentReadAccess } = await import( + "../../server/api/utils/service-environment" +); + +const createContext = (organizationId = "org-1") => + ({ + session: { + activeOrganizationId: organizationId, + }, + }) as never; + +const service = (organizationId = "org-1") => ({ + env: "TOKEN=secret", + environment: { + project: { + organizationId, + }, + }, +}); + +describe("service environment reveal access", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.checkServicePermissionAndAccess.mockResolvedValue(undefined); + }); + + it("requires envVars read permission and returns the service in the active organization", async () => { + const result = await assertServiceEnvironmentReadAccess( + createContext(), + "service-1", + async () => service(), + "service", + ); + + expect(result.env).toBe("TOKEN=secret"); + expect(mocks.checkServicePermissionAndAccess).toHaveBeenCalledWith( + expect.anything(), + "service-1", + { + envVars: ["read"], + }, + ); + }); + + it("rejects reveal when the service belongs to another organization", async () => { + await expect( + assertServiceEnvironmentReadAccess( + createContext("org-1"), + "service-1", + async () => service("org-2"), + "service", + ), + ).rejects.toMatchObject({ + code: "UNAUTHORIZED", + } satisfies Partial); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/environment/show-environment.tsx b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx index f5327818f4..8c4873cc2e 100644 --- a/apps/dokploy/components/dashboard/application/environment/show-environment.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx @@ -54,6 +54,9 @@ export const ShowEnvironment = ({ id, type }: Props) => { ? queryMap[type]() : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); const [isEnvVisible, setIsEnvVisible] = useState(true); + const [revealedEnvironment, setRevealedEnvironment] = useState( + null, + ); const mutationMap = { compose: () => api.compose.saveEnvironment.useMutation(), @@ -67,6 +70,19 @@ export const ShowEnvironment = ({ id, type }: Props) => { const { mutateAsync, isPending } = mutationMap[type] ? mutationMap[type]() : api.mongo.saveEnvironment.useMutation(); + const revealMutationMap = { + compose: () => api.compose.revealEnvironment.useMutation(), + libsql: () => api.libsql.revealEnvironment.useMutation(), + mariadb: () => api.mariadb.revealEnvironment.useMutation(), + mongo: () => api.mongo.revealEnvironment.useMutation(), + mysql: () => api.mysql.revealEnvironment.useMutation(), + postgres: () => api.postgres.revealEnvironment.useMutation(), + redis: () => api.redis.revealEnvironment.useMutation(), + }; + const { mutateAsync: revealEnvironment, isPending: isRevealingEnvironment } = + revealMutationMap[type] + ? revealMutationMap[type]() + : api.mongo.revealEnvironment.useMutation(); const form = useForm({ defaultValues: { @@ -77,10 +93,12 @@ export const ShowEnvironment = ({ id, type }: Props) => { // Watch form value const currentEnvironment = form.watch("environment"); - const hasChanges = currentEnvironment !== (data?.env || ""); + const baselineEnvironment = revealedEnvironment ?? data?.env ?? ""; + const hasChanges = currentEnvironment !== baselineEnvironment; useEffect(() => { if (data) { + setRevealedEnvironment(null); form.reset({ environment: data.env || "", }); @@ -100,6 +118,7 @@ export const ShowEnvironment = ({ id, type }: Props) => { }) .then(async () => { toast.success("Environments Added"); + setRevealedEnvironment(null); await refetch(); }) .catch(() => { @@ -109,10 +128,39 @@ export const ShowEnvironment = ({ id, type }: Props) => { const handleCancel = () => { form.reset({ - environment: data?.env || "", + environment: baselineEnvironment, }); }; + const handleReveal = async () => { + if (revealedEnvironment !== null) { + return; + } + if (hasChanges) { + toast.error("Save or cancel changes before revealing environment"); + throw new Error("Unsaved environment changes"); + } + + try { + const values = await revealEnvironment({ + composeId: id || "", + libsqlId: id || "", + mariadbId: id || "", + mongoId: id || "", + mysqlId: id || "", + postgresId: id || "", + redisId: id || "", + }); + setRevealedEnvironment(values.env); + form.reset({ + environment: values.env, + }); + } catch (error) { + toast.error("Error revealing environment"); + throw error; + } + }; + // Add keyboard shortcut for Ctrl+S/Cmd+S useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -145,9 +193,19 @@ export const ShowEnvironment = ({ id, type }: Props) => { { + if (!pressed) { + try { + await handleReveal(); + } catch { + return; + } + } + setIsEnvVisible(pressed); + }} + disabled={isRevealingEnvironment} > {isEnvVisible ? ( diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx index 909cf5287c..d791db77e6 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -1,5 +1,5 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -25,6 +25,10 @@ const addEnvironmentSchema = z.object({ }); type EnvironmentSchema = z.infer; +type RevealedEnvironment = Pick< + EnvironmentSchema, + "env" | "buildArgs" | "buildSecrets" +>; interface Props { applicationId: string; @@ -35,6 +39,10 @@ export const ShowEnvironment = ({ applicationId }: Props) => { const canWrite = permissions?.envVars.write ?? false; const { mutateAsync, isPending } = api.application.saveEnvironment.useMutation(); + const { mutateAsync: revealEnvironment, isPending: isRevealingEnvironment } = + api.application.revealEnvironment.useMutation(); + const [revealedEnvironment, setRevealedEnvironment] = + useState(null); const { data, refetch } = api.application.one.useQuery( { @@ -60,14 +68,21 @@ export const ShowEnvironment = ({ applicationId }: Props) => { const currentBuildArgs = form.watch("buildArgs"); const currentBuildSecrets = form.watch("buildSecrets"); const currentCreateEnvFile = form.watch("createEnvFile"); + const baselineEnvironment = { + env: revealedEnvironment?.env ?? data?.env ?? "", + buildArgs: revealedEnvironment?.buildArgs ?? data?.buildArgs ?? "", + buildSecrets: revealedEnvironment?.buildSecrets ?? data?.buildSecrets ?? "", + createEnvFile: data?.createEnvFile ?? true, + }; const hasChanges = - currentEnv !== (data?.env || "") || - currentBuildArgs !== (data?.buildArgs || "") || - currentBuildSecrets !== (data?.buildSecrets || "") || - currentCreateEnvFile !== (data?.createEnvFile ?? true); + currentEnv !== baselineEnvironment.env || + currentBuildArgs !== baselineEnvironment.buildArgs || + currentBuildSecrets !== baselineEnvironment.buildSecrets || + currentCreateEnvFile !== baselineEnvironment.createEnvFile; useEffect(() => { if (data) { + setRevealedEnvironment(null); form.reset({ env: data.env || "", buildArgs: data.buildArgs || "", @@ -87,6 +102,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { }) .then(async () => { toast.success("Environments Added"); + setRevealedEnvironment(null); await refetch(); }) .catch(() => { @@ -96,13 +112,37 @@ export const ShowEnvironment = ({ applicationId }: Props) => { const handleCancel = () => { form.reset({ - env: data?.env || "", - buildArgs: data?.buildArgs || "", - buildSecrets: data?.buildSecrets || "", - createEnvFile: data?.createEnvFile ?? true, + env: baselineEnvironment.env, + buildArgs: baselineEnvironment.buildArgs, + buildSecrets: baselineEnvironment.buildSecrets, + createEnvFile: baselineEnvironment.createEnvFile, }); }; + const handleReveal = async () => { + if (revealedEnvironment) { + return; + } + if (hasChanges) { + toast.error("Save or cancel changes before revealing environment"); + throw new Error("Unsaved environment changes"); + } + + try { + const values = await revealEnvironment({ applicationId }); + setRevealedEnvironment(values); + form.reset({ + env: values.env, + buildArgs: values.buildArgs, + buildSecrets: values.buildSecrets, + createEnvFile: data?.createEnvFile ?? true, + }); + } catch (error) { + toast.error("Error revealing environment"); + throw error; + } + }; + // Add keyboard shortcut for Ctrl+S/Cmd+S useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -139,6 +179,8 @@ export const ShowEnvironment = ({ applicationId }: Props) => { } placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")} + isRevealing={isRevealingEnvironment} + onReveal={handleReveal} /> {data?.buildType === "dockerfile" && ( { target="_blank" rel="noopener noreferrer" > - here + Docker build variables . } placeholder="NPM_TOKEN=xyz" + isRevealing={isRevealingEnvironment} + onReveal={handleReveal} /> )} {data?.buildType === "dockerfile" && ( @@ -176,12 +220,14 @@ export const ShowEnvironment = ({ applicationId }: Props) => { target="_blank" rel="noopener noreferrer" > - here + Docker build secrets . } placeholder="NPM_TOKEN=xyz" + isRevealing={isRevealingEnvironment} + onReveal={handleReveal} /> )} {data?.buildType === "dockerfile" && ( diff --git a/apps/dokploy/components/ui/command.tsx b/apps/dokploy/components/ui/command.tsx index 6e5b0921d6..881b73748b 100644 --- a/apps/dokploy/components/ui/command.tsx +++ b/apps/dokploy/components/ui/command.tsx @@ -1,9 +1,8 @@ "use client"; -import * as React from "react"; import { Command as CommandPrimitive } from "cmdk"; - -import { cn } from "@/lib/utils"; +import { CheckIcon, SearchIcon } from "lucide-react"; +import type * as React from "react"; import { Dialog, DialogContent, @@ -12,7 +11,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"; -import { SearchIcon, CheckIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; function Command({ className, @@ -56,9 +55,7 @@ function CommandDialog({ {title} {description} - - {children} - + {children} ); @@ -184,11 +181,11 @@ function CommandShortcut({ export { Command, CommandDialog, - CommandInput, - CommandList, CommandEmpty, CommandGroup, + CommandInput, CommandItem, - CommandShortcut, + CommandList, CommandSeparator, + CommandShortcut, }; diff --git a/apps/dokploy/components/ui/dialog.tsx b/apps/dokploy/components/ui/dialog.tsx index 7ddff98da4..21ad3921fb 100644 --- a/apps/dokploy/components/ui/dialog.tsx +++ b/apps/dokploy/components/ui/dialog.tsx @@ -1,10 +1,9 @@ -import * as React from "react"; +import { XIcon } from "lucide-react"; import { Dialog as DialogPrimitive } from "radix-ui"; - -import { cn } from "@/lib/utils"; +import type * as React from "react"; import { Button } from "@/components/ui/button"; import { wasNestedPopupJustClosed } from "@/components/ui/nested-popup-context"; -import { XIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; function Dialog({ ...props diff --git a/apps/dokploy/components/ui/input-group.tsx b/apps/dokploy/components/ui/input-group.tsx index 99ef7f6910..5bb2e9f07b 100644 --- a/apps/dokploy/components/ui/input-group.tsx +++ b/apps/dokploy/components/ui/input-group.tsx @@ -1,18 +1,16 @@ "use client"; -import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; - -import { cn } from "@/lib/utils"; +import type * as React from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; function InputGroup({ className, ...props }: React.ComponentProps<"div">) { return (
[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", className, @@ -50,7 +48,6 @@ function InputGroupAddon({ }: React.ComponentProps<"div"> & VariantProps) { return (
Promise | void; } export const Secrets = (props: Props) => { const [isVisible, setIsVisible] = useState(true); const form = useFormContext>(); + const handleVisibilityChange = async (pressed: boolean) => { + if (!pressed && props.onReveal) { + try { + await props.onReveal(); + } catch { + return; + } + } + setIsVisible(pressed); + }; + return ( <> @@ -36,9 +49,10 @@ export const Secrets = (props: Props) => {
{isVisible ? ( diff --git a/apps/dokploy/pages/api/[...trpc].ts b/apps/dokploy/pages/api/[...trpc].ts index 83ff9b0503..20b0904a2f 100644 --- a/apps/dokploy/pages/api/[...trpc].ts +++ b/apps/dokploy/pages/api/[...trpc].ts @@ -3,8 +3,24 @@ import { createOpenApiNextHandler } from "@dokploy/trpc-openapi"; import type { NextApiRequest, NextApiResponse } from "next"; import { appRouter } from "@/server/api/root"; import { createTRPCContext } from "@/server/api/trpc"; +import { handleApplicationEnvUpsert } from "./application.env.upsert"; const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const slashPath = Array.isArray(req.query.trpc) + ? req.query.trpc.join("/") + : req.query.trpc; + const dotPath = Array.isArray(req.query.trpc) + ? req.query.trpc.join(".") + : req.query.trpc; + + if ( + slashPath === "application/env/upsert" || + dotPath === "application.env.upsert" + ) { + await handleApplicationEnvUpsert(req, res); + return; + } + const { session, user } = await validateRequest(req); if (!user || !session) { diff --git a/apps/dokploy/pages/api/application.env.upsert.ts b/apps/dokploy/pages/api/application.env.upsert.ts new file mode 100644 index 0000000000..f14407913b --- /dev/null +++ b/apps/dokploy/pages/api/application.env.upsert.ts @@ -0,0 +1,148 @@ +import { + findApplicationById, + IS_CLOUD, + upsertApplicationEnvironment, + validateRequest, +} from "@dokploy/server"; +import { apiUpsertApplicationEnv } from "@dokploy/server/db/schema"; +import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission"; +import { TRPCError } from "@trpc/server"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { ZodError } from "zod"; +import { buildApplicationEnvUpsertDeploymentJob } from "@/server/api/utils/application-env-upsert"; +import { audit } from "@/server/api/utils/audit"; +import type { DeploymentJob } from "@/server/queues/queue-types"; +import { myQueue } from "@/server/queues/queueSetup"; +import { deploy } from "@/server/utils/deploy"; + +const getErrorStatus = (error: unknown) => { + if (error instanceof ZodError) { + return 400; + } + + if (!(error instanceof TRPCError)) { + return 500; + } + + switch (error.code) { + case "BAD_REQUEST": + return 400; + case "UNAUTHORIZED": + return 403; + case "NOT_FOUND": + return 404; + case "CONFLICT": + return 409; + default: + return 500; + } +}; + +const getErrorMessage = (error: unknown) => { + if (error instanceof ZodError) { + return "Invalid request body"; + } + + if (error instanceof Error) { + return error.message; + } + + return "Internal server error"; +}; + +export const handleApplicationEnvUpsert = async ( + req: NextApiRequest, + res: NextApiResponse, +) => { + if (req.method !== "POST") { + res.setHeader("Allow", "POST"); + res.status(405).json({ message: "Method Not Allowed" }); + return; + } + + try { + const { session, user } = await validateRequest(req); + + if (!user || !session) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const ctx = { + session: { + ...session, + activeOrganizationId: session.activeOrganizationId || "", + }, + user: { + ...user, + role: user.role as "owner" | "member" | "admin", + }, + }; + + const input = apiUpsertApplicationEnv.parse(req.body); + + await checkServicePermissionAndAccess( + ctx, + input.applicationId, + input.redeploy + ? { + envVars: ["write"], + deployment: ["create"], + } + : { + envVars: ["write"], + }, + ); + + const result = await upsertApplicationEnvironment(input); + let redeployed = false; + + if (!result.dryRun && result.changed) { + const application = await findApplicationById(input.applicationId); + + await audit(ctx, { + action: "update", + resourceType: "application", + resourceId: application.applicationId, + resourceName: application.appName, + }); + + if (input.redeploy) { + const jobData: DeploymentJob = + buildApplicationEnvUpsertDeploymentJob(application); + + if (IS_CLOUD && application.serverId) { + deploy(jobData).catch((error) => { + console.error("Background deployment failed:", error); + }); + } else { + await myQueue.add( + "deployments", + { ...jobData }, + { + removeOnComplete: true, + removeOnFail: true, + }, + ); + } + + await audit(ctx, { + action: "rebuild", + resourceType: "application", + resourceId: application.applicationId, + resourceName: application.appName, + }); + redeployed = true; + } + } + + res.status(200).json({ + ...result, + redeployed, + }); + } catch (error) { + res.status(getErrorStatus(error)).json({ message: getErrorMessage(error) }); + } +}; + +export default handleApplicationEnvUpsert; diff --git a/apps/dokploy/pages/api/application/env/upsert.ts b/apps/dokploy/pages/api/application/env/upsert.ts new file mode 100644 index 0000000000..42f68c2aa5 --- /dev/null +++ b/apps/dokploy/pages/api/application/env/upsert.ts @@ -0,0 +1,4 @@ +export { + default, + handleApplicationEnvUpsert, +} from "../../application.env.upsert"; diff --git a/apps/dokploy/pages/api/deploy/[refreshToken].ts b/apps/dokploy/pages/api/deploy/[refreshToken].ts index bb6eb06d37..349a6631c3 100644 --- a/apps/dokploy/pages/api/deploy/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/[refreshToken].ts @@ -253,10 +253,10 @@ export default async function handler( type: "deploy", applicationType: "application", server: !!application.serverId, + serverId: application.serverId ?? undefined, }; if (IS_CLOUD && application.serverId) { - jobData.serverId = application.serverId; deploy(jobData).catch((error) => { console.error("Background deployment failed:", error); }); diff --git a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts index 85a379eb3e..fd2cb2c6a7 100644 --- a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts @@ -178,10 +178,10 @@ export default async function handler( applicationType: "compose", descriptionLog: `Hash: ${deploymentHash}`, server: !!composeResult.serverId, + serverId: composeResult.serverId ?? undefined, }; if (IS_CLOUD && composeResult.serverId) { - jobData.serverId = composeResult.serverId; deploy(jobData).catch((error) => { console.error("Background deployment failed:", error); }); diff --git a/apps/dokploy/pages/api/deploy/github.ts b/apps/dokploy/pages/api/deploy/github.ts index f03bf786ec..4e42a8e9df 100644 --- a/apps/dokploy/pages/api/deploy/github.ts +++ b/apps/dokploy/pages/api/deploy/github.ts @@ -136,10 +136,10 @@ export default async function handler( type: "deploy", applicationType: "application", server: !!app.serverId, + serverId: app.serverId ?? undefined, }; if (IS_CLOUD && app.serverId) { - jobData.serverId = app.serverId; deploy(jobData).catch((error) => { console.error("Background deployment failed:", error); }); @@ -175,10 +175,10 @@ export default async function handler( applicationType: "compose", descriptionLog: `Hash: ${deploymentHash}`, server: !!composeApp.serverId, + serverId: composeApp.serverId ?? undefined, }; if (IS_CLOUD && composeApp.serverId) { - jobData.serverId = composeApp.serverId; deploy(jobData).catch((error) => { console.error("Background deployment failed:", error); }); @@ -247,6 +247,7 @@ export default async function handler( type: "deploy", applicationType: "application", server: !!app.serverId, + serverId: app.serverId ?? undefined, }; const shouldDeployPaths = shouldDeploy( @@ -259,7 +260,6 @@ export default async function handler( } if (IS_CLOUD && app.serverId) { - jobData.serverId = app.serverId; deploy(jobData).catch((error) => { console.error("Background deployment failed:", error); }); @@ -295,6 +295,7 @@ export default async function handler( applicationType: "compose", descriptionLog: `Hash: ${deploymentHash}`, server: !!composeApp.serverId, + serverId: composeApp.serverId ?? undefined, }; const shouldDeployPaths = shouldDeploy( @@ -306,7 +307,6 @@ export default async function handler( continue; } if (IS_CLOUD && composeApp.serverId) { - jobData.serverId = composeApp.serverId; deploy(jobData).catch((error) => { console.error("Background deployment failed:", error); }); @@ -461,7 +461,7 @@ export default async function handler( await createSecurityBlockedComment({ owner, repository, - prNumber: Number.parseInt(prNumber), + prNumber: Number.parseInt(prNumber, 10), prAuthor, permission: userPermission, githubId: githubResult.githubId, @@ -511,12 +511,12 @@ export default async function handler( type: "deploy", applicationType: "application-preview", server: !!app.serverId, + serverId: app.serverId ?? undefined, previewDeploymentId, }; if (previewDeploymentId) { if (IS_CLOUD && app.serverId) { - jobData.serverId = app.serverId; deploy(jobData).catch((error) => { console.error("Background deployment failed:", error); }); diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index e1bbedcb9e..923cb64b43 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -26,6 +26,7 @@ import { updateApplication, updateApplicationStatus, updateDeploymentStatus, + upsertApplicationEnvironment, writeConfig, writeConfigRemote, } from "@dokploy/server"; @@ -37,6 +38,10 @@ import { checkServicePermissionAndAccess, findMemberByUserId, } from "@dokploy/server/services/permission"; +import { + preserveSecretPlaceholderFields, + redactDeployableServiceSecrets, +} from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { nanoid } from "nanoid"; @@ -47,7 +52,9 @@ import { protectedProcedure, withPermission, } from "@/server/api/trpc"; +import { buildApplicationEnvUpsertDeploymentJob } from "@/server/api/utils/application-env-upsert"; import { audit } from "@/server/api/utils/audit"; +import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; import { apiCreateApplication, apiDeployApplication, @@ -64,6 +71,8 @@ import { apiSaveGitlabProvider, apiSaveGitProvider, apiUpdateApplication, + apiUpsertApplicationEnv, + apiUpsertApplicationEnvResponse, applications, environments, projects, @@ -183,12 +192,29 @@ export const applicationRouter = createTRPCRouter({ } return { - ...application, + ...redactDeployableServiceSecrets(application), hasGitProviderAccess, unauthorizedProvider, }; }), + revealEnvironment: protectedProcedure + .input(apiFindOneApplication) + .mutation(async ({ input, ctx }) => { + const application = await assertServiceEnvironmentReadAccess( + ctx, + input.applicationId, + () => findApplicationById(input.applicationId), + "application", + ); + + return { + env: application.env ?? "", + buildArgs: application.buildArgs ?? "", + buildSecrets: application.buildSecrets ?? "", + }; + }), + reload: protectedProcedure .input(apiReloadApplication) .mutation(async ({ input, ctx }) => { @@ -362,24 +388,104 @@ export const applicationRouter = createTRPCRouter({ resourceName: application.appName, }); }), + env: createTRPCRouter({ + upsert: protectedProcedure + .meta({ + openapi: { + path: "/application/env/upsert", + method: "POST", + }, + }) + .input(apiUpsertApplicationEnv) + .output(apiUpsertApplicationEnvResponse) + .mutation(async ({ input, ctx }) => { + await checkServicePermissionAndAccess( + ctx, + input.applicationId, + input.redeploy + ? { + envVars: ["write"], + deployment: ["create"], + } + : { + envVars: ["write"], + }, + ); + + const result = await upsertApplicationEnvironment(input); + let redeployed = false; + + if (!result.dryRun && result.changed) { + const application = await findApplicationById(input.applicationId); + + await audit(ctx, { + action: "update", + resourceType: "application", + resourceId: application.applicationId, + resourceName: application.appName, + }); + + if (input.redeploy) { + const jobData: DeploymentJob = + buildApplicationEnvUpsertDeploymentJob(application); + + if (IS_CLOUD && application.serverId) { + deploy(jobData).catch((error) => { + console.error("Background deployment failed:", error); + }); + } else { + await myQueue.add( + "deployments", + { ...jobData }, + { + removeOnComplete: true, + removeOnFail: true, + }, + ); + } + + await audit(ctx, { + action: "rebuild", + resourceType: "application", + resourceId: application.applicationId, + resourceName: application.appName, + }); + redeployed = true; + } + } + + return { + ...result, + redeployed, + }; + }), + }), saveEnvironment: protectedProcedure .input(apiSaveEnvironmentVariables) .mutation(async ({ input, ctx }) => { await checkServicePermissionAndAccess(ctx, input.applicationId, { envVars: ["write"], }); - await updateApplication(input.applicationId, { - env: input.env, - buildArgs: input.buildArgs, - buildSecrets: input.buildSecrets, - createEnvFile: input.createEnvFile, - }); - const application = await findApplicationById(input.applicationId); + const currentApplication = await findApplicationById(input.applicationId); + const application = await updateApplication( + input.applicationId, + preserveSecretPlaceholderFields( + { + env: input.env, + buildArgs: input.buildArgs, + buildSecrets: input.buildSecrets, + createEnvFile: input.createEnvFile, + }, + currentApplication, + ["env", "buildArgs", "buildSecrets"], + ), + ); await audit(ctx, { action: "update", resourceType: "application", - resourceId: application.applicationId, - resourceName: application.appName, + resourceId: + application?.applicationId ?? currentApplication.applicationId, + resourceName: application?.appName ?? currentApplication.appName, }); return true; }), @@ -522,20 +628,27 @@ export const applicationRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.applicationId, { service: ["create"], }); - await updateApplication(input.applicationId, { - dockerImage: input.dockerImage, - username: input.username, - password: input.password, - sourceType: "docker", - applicationStatus: "idle", - registryUrl: input.registryUrl, - }); - const application = await findApplicationById(input.applicationId); + const currentApplication = await findApplicationById(input.applicationId); + await updateApplication( + input.applicationId, + preserveSecretPlaceholderFields( + { + dockerImage: input.dockerImage, + username: input.username, + password: input.password, + sourceType: "docker" as const, + applicationStatus: "idle" as const, + registryUrl: input.registryUrl, + }, + currentApplication, + ["password"], + ), + ); await audit(ctx, { action: "update", resourceType: "application", - resourceId: application.applicationId, - resourceName: application.appName, + resourceId: currentApplication.applicationId, + resourceName: currentApplication.appName, }); return true; }), @@ -545,22 +658,29 @@ export const applicationRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.applicationId, { service: ["create"], }); - await updateApplication(input.applicationId, { - customGitBranch: input.customGitBranch, - customGitBuildPath: input.customGitBuildPath, - customGitUrl: input.customGitUrl, - customGitSSHKeyId: input.customGitSSHKeyId, - sourceType: "git", - applicationStatus: "idle", - watchPaths: input.watchPaths, - enableSubmodules: input.enableSubmodules, - }); - const application = await findApplicationById(input.applicationId); + const currentApplication = await findApplicationById(input.applicationId); + await updateApplication( + input.applicationId, + preserveSecretPlaceholderFields( + { + customGitBranch: input.customGitBranch, + customGitBuildPath: input.customGitBuildPath, + customGitUrl: input.customGitUrl, + customGitSSHKeyId: input.customGitSSHKeyId, + sourceType: "git" as const, + applicationStatus: "idle" as const, + watchPaths: input.watchPaths, + enableSubmodules: input.enableSubmodules, + }, + currentApplication, + ["customGitUrl"], + ), + ); await audit(ctx, { action: "update", resourceType: "application", - resourceId: application.applicationId, - resourceName: application.appName, + resourceId: currentApplication.applicationId, + resourceName: currentApplication.appName, }); return true; }), @@ -650,9 +770,20 @@ export const applicationRouter = createTRPCRouter({ } const { applicationId, ...rest } = input; - const updateApp = await updateApplication(applicationId, { - ...rest, - }); + const currentApplication = await findApplicationById(applicationId); + const updateApp = await updateApplication( + applicationId, + preserveSecretPlaceholderFields(rest, currentApplication, [ + "env", + "previewEnv", + "buildArgs", + "buildSecrets", + "previewBuildArgs", + "previewBuildSecrets", + "password", + "customGitUrl", + ]), + ); if (!updateApp) { throw new TRPCError({ diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 403dc25d9b..575bcf58ce 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -33,7 +33,10 @@ import { updateDeploymentStatus, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; -import { canEditDeployGitSource } from "@dokploy/server/services/git-provider"; +import { + canEditDeployGitSource, + redactGitProviderSecrets, +} from "@dokploy/server/services/git-provider"; import { addNewService, checkServiceAccess, @@ -46,6 +49,12 @@ import { fetchTemplatesList, } from "@dokploy/server/templates/github"; import { processTemplate } from "@dokploy/server/templates/processors"; +import { + preserveSecretPlaceholderFields, + redactDeployableServiceSecrets, + redactSecretFields, + redactSensitiveText, +} from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import _ from "lodash"; @@ -78,6 +87,47 @@ import { cancelDeployment, deploy } from "@/server/utils/deploy"; import { generatePassword } from "@/templates/utils"; import { createTRPCRouter, protectedProcedure } from "../trpc"; import { audit } from "../utils/audit"; +import { assertServiceEnvironmentReadAccess } from "../utils/service-environment"; + +type SecretRecord = Record; + +const redactCustomGitUrl = ( + record: T, +) => { + if (!record) { + return record; + } + + const redacted = { ...record }; + if ("customGitUrl" in redacted) { + redacted.customGitUrl = redactSensitiveText( + redacted.customGitUrl as string | null | undefined, + ); + } + + return redacted as T; +}; + +const redactComposeSecrets = ( + record: T, +) => + redactSecretFields( + redactCustomGitUrl( + redactDeployableServiceSecrets( + record + ? redactGitProviderSecrets( + record as T & { + bitbucket?: object | null; + gitea?: object | null; + github?: object | null; + gitlab?: object | null; + }, + ) + : record, + ), + ), + ["composeFile"], + ); export const composeRouter = createTRPCRouter({ create: protectedProcedure @@ -128,7 +178,7 @@ export const composeRouter = createTRPCRouter({ resourceId: newService.composeId, resourceName: newService.appName, }); - return newService; + return redactComposeSecrets(newService); } catch (error) { throw error; } @@ -182,26 +232,49 @@ export const composeRouter = createTRPCRouter({ } return { - ...compose, + ...redactComposeSecrets(compose), hasGitProviderAccess, unauthorizedProvider, }; }), + revealEnvironment: protectedProcedure + .input(apiFindCompose) + .mutation(async ({ input, ctx }) => { + const compose = await assertServiceEnvironmentReadAccess( + ctx, + input.composeId, + () => findComposeById(input.composeId), + "compose", + ); + + return { + env: compose.env ?? "", + }; + }), + update: protectedProcedure .input(apiUpdateCompose) .mutation(async ({ input, ctx }) => { await checkServicePermissionAndAccess(ctx, input.composeId, { service: ["create"], }); - const updated = await updateCompose(input.composeId, input); + const currentCompose = await findComposeById(input.composeId); + const updated = await updateCompose( + input.composeId, + preserveSecretPlaceholderFields(input, currentCompose, [ + "env", + "composeFile", + "customGitUrl", + ]), + ); await audit(ctx, { action: "update", resourceType: "compose", resourceId: input.composeId, resourceName: updated?.name, }); - return updated; + return redactComposeSecrets(updated); }), saveEnvironment: protectedProcedure .input(apiSaveEnvironmentVariablesCompose) @@ -209,9 +282,17 @@ export const composeRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.composeId, { envVars: ["write"], }); - const updated = await updateCompose(input.composeId, { - env: input.env, - }); + const currentCompose = await findComposeById(input.composeId); + const updated = await updateCompose( + input.composeId, + preserveSecretPlaceholderFields( + { + env: input.env, + }, + currentCompose, + ["env"], + ), + ); if (!updated) { throw new TRPCError({ @@ -271,7 +352,7 @@ export const composeRouter = createTRPCRouter({ resourceId: composeResult.composeId, resourceName: composeResult.appName, }); - return composeResult; + return redactComposeSecrets(composeResult); }), cleanQueues: protectedProcedure .input(apiFindCompose) @@ -675,7 +756,7 @@ export const composeRouter = createTRPCRouter({ resourceId: compose.composeId, resourceName: compose.name, }); - return compose; + return redactComposeSecrets(compose); }), templates: protectedProcedure @@ -794,7 +875,7 @@ export const composeRouter = createTRPCRouter({ resourceId: input.composeId, resourceName: updatedCompose.name, }); - return updatedCompose; + return redactComposeSecrets(updatedCompose); }), processTemplate: protectedProcedure diff --git a/apps/dokploy/server/api/routers/environment.ts b/apps/dokploy/server/api/routers/environment.ts index c11673ea7b..325f5f6454 100644 --- a/apps/dokploy/server/api/routers/environment.ts +++ b/apps/dokploy/server/api/routers/environment.ts @@ -17,6 +17,7 @@ import { checkPermission, findMemberByUserId, } from "@dokploy/server/services/permission"; +import { preserveSecretPlaceholderFields } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; @@ -254,7 +255,9 @@ export const environmentRouter = createTRPCRouter({ const environment = await updateEnvironmentById( environmentId, - updateData, + preserveSecretPlaceholderFields(updateData, currentEnvironment, [ + "env", + ]), ); if (environment) { await audit(ctx, { diff --git a/apps/dokploy/server/api/routers/libsql.ts b/apps/dokploy/server/api/routers/libsql.ts index 3fdfcf55b7..441af6e6ee 100644 --- a/apps/dokploy/server/api/routers/libsql.ts +++ b/apps/dokploy/server/api/routers/libsql.ts @@ -24,11 +24,16 @@ import { checkServiceAccess, checkServicePermissionAndAccess, } from "@dokploy/server/services/permission"; +import { + preserveSecretPlaceholderFields, + redactDatabaseServiceSecrets, +} from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; +import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; import { db } from "@/server/db"; import { apiChangeLibsqlStatus, @@ -119,7 +124,22 @@ export const libsqlRouter = createTRPCRouter({ message: "You are not authorized to access this Libsql", }); } - return libsql; + return redactDatabaseServiceSecrets(libsql); + }), + + revealEnvironment: protectedProcedure + .input(apiFindOneLibsql) + .mutation(async ({ input, ctx }) => { + const libsql = await assertServiceEnvironmentReadAccess( + ctx, + input.libsqlId, + () => findLibsqlById(input.libsqlId), + "Libsql", + ); + + return { + env: libsql.env ?? "", + }; }), start: protectedProcedure @@ -345,9 +365,17 @@ export const libsqlRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.libsqlId, { envVars: ["write"], }); - const service = await updateLibsqlById(input.libsqlId, { - env: input.env, - }); + const currentLibsql = await findLibsqlById(input.libsqlId); + const service = await updateLibsqlById( + input.libsqlId, + preserveSecretPlaceholderFields( + { + env: input.env, + }, + currentLibsql, + ["env"], + ), + ); if (!service) { throw new TRPCError({ @@ -402,9 +430,14 @@ export const libsqlRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, libsqlId, { service: ["create"], }); - const libsql = await updateLibsqlById(libsqlId, { - ...rest, - }); + const currentLibsql = await findLibsqlById(libsqlId); + const libsql = await updateLibsqlById( + libsqlId, + preserveSecretPlaceholderFields(rest, currentLibsql, [ + "env", + "databasePassword", + ]), + ); if (!libsql) { throw new TRPCError({ diff --git a/apps/dokploy/server/api/routers/mariadb.ts b/apps/dokploy/server/api/routers/mariadb.ts index 4366e73937..1d701b7e29 100644 --- a/apps/dokploy/server/api/routers/mariadb.ts +++ b/apps/dokploy/server/api/routers/mariadb.ts @@ -30,12 +30,17 @@ import { checkServicePermissionAndAccess, findMemberByUserId, } from "@dokploy/server/services/permission"; +import { + preserveSecretPlaceholderFields, + redactDatabaseServiceSecrets, +} from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; +import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; import { apiChangeMariaDBStatus, apiCreateMariaDB, @@ -132,7 +137,22 @@ export const mariadbRouter = createTRPCRouter({ message: "You are not authorized to access this Mariadb", }); } - return mariadb; + return redactDatabaseServiceSecrets(mariadb); + }), + + revealEnvironment: protectedProcedure + .input(apiFindOneMariaDB) + .mutation(async ({ input, ctx }) => { + const mariadb = await assertServiceEnvironmentReadAccess( + ctx, + input.mariadbId, + () => findMariadbById(input.mariadbId), + "Mariadb", + ); + + return { + env: mariadb.env ?? "", + }; }), start: protectedProcedure @@ -315,9 +335,17 @@ export const mariadbRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.mariadbId, { envVars: ["write"], }); - const service = await updateMariadbById(input.mariadbId, { - env: input.env, - }); + const currentMariadb = await findMariadbById(input.mariadbId); + const service = await updateMariadbById( + input.mariadbId, + preserveSecretPlaceholderFields( + { + env: input.env, + }, + currentMariadb, + ["env"], + ), + ); if (!service) { throw new TRPCError({ @@ -372,9 +400,15 @@ export const mariadbRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, mariadbId, { service: ["create"], }); - const service = await updateMariadbById(mariadbId, { - ...rest, - }); + const currentMariadb = await findMariadbById(mariadbId); + const service = await updateMariadbById( + mariadbId, + preserveSecretPlaceholderFields(rest, currentMariadb, [ + "env", + "databasePassword", + "databaseRootPassword", + ]), + ); if (!service) { throw new TRPCError({ diff --git a/apps/dokploy/server/api/routers/mongo.ts b/apps/dokploy/server/api/routers/mongo.ts index de38435b44..0cc9f971ee 100644 --- a/apps/dokploy/server/api/routers/mongo.ts +++ b/apps/dokploy/server/api/routers/mongo.ts @@ -30,11 +30,16 @@ import { checkServicePermissionAndAccess, findMemberByUserId, } from "@dokploy/server/services/permission"; +import { + preserveSecretPlaceholderFields, + redactDatabaseServiceSecrets, +} from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; +import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; import { apiChangeMongoStatus, apiCreateMongo, @@ -136,7 +141,22 @@ export const mongoRouter = createTRPCRouter({ message: "You are not authorized to access this mongo", }); } - return mongo; + return redactDatabaseServiceSecrets(mongo); + }), + + revealEnvironment: protectedProcedure + .input(apiFindOneMongo) + .mutation(async ({ input, ctx }) => { + const mongo = await assertServiceEnvironmentReadAccess( + ctx, + input.mongoId, + () => findMongoById(input.mongoId), + "mongo", + ); + + return { + env: mongo.env ?? "", + }; }), start: protectedProcedure @@ -369,9 +389,17 @@ export const mongoRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.mongoId, { envVars: ["write"], }); - const service = await updateMongoById(input.mongoId, { - env: input.env, - }); + const currentMongo = await findMongoById(input.mongoId); + const service = await updateMongoById( + input.mongoId, + preserveSecretPlaceholderFields( + { + env: input.env, + }, + currentMongo, + ["env"], + ), + ); if (!service) { throw new TRPCError({ @@ -394,9 +422,14 @@ export const mongoRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, mongoId, { service: ["create"], }); - const service = await updateMongoById(mongoId, { - ...rest, - }); + const currentMongo = await findMongoById(mongoId); + const service = await updateMongoById( + mongoId, + preserveSecretPlaceholderFields(rest, currentMongo, [ + "env", + "databasePassword", + ]), + ); if (!service) { throw new TRPCError({ diff --git a/apps/dokploy/server/api/routers/mysql.ts b/apps/dokploy/server/api/routers/mysql.ts index 70c40520a3..9d0a95c5a9 100644 --- a/apps/dokploy/server/api/routers/mysql.ts +++ b/apps/dokploy/server/api/routers/mysql.ts @@ -30,11 +30,16 @@ import { checkServicePermissionAndAccess, findMemberByUserId, } from "@dokploy/server/services/permission"; +import { + preserveSecretPlaceholderFields, + redactDatabaseServiceSecrets, +} from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; +import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; import { apiChangeMySqlStatus, apiCreateMySql, @@ -136,7 +141,22 @@ export const mysqlRouter = createTRPCRouter({ message: "You are not authorized to access this MySQL", }); } - return mysql; + return redactDatabaseServiceSecrets(mysql); + }), + + revealEnvironment: protectedProcedure + .input(apiFindOneMySql) + .mutation(async ({ input, ctx }) => { + const mysql = await assertServiceEnvironmentReadAccess( + ctx, + input.mysqlId, + () => findMySqlById(input.mysqlId), + "MySQL", + ); + + return { + env: mysql.env ?? "", + }; }), start: protectedProcedure @@ -365,9 +385,17 @@ export const mysqlRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.mysqlId, { envVars: ["write"], }); - const service = await updateMySqlById(input.mysqlId, { - env: input.env, - }); + const currentMysql = await findMySqlById(input.mysqlId); + const service = await updateMySqlById( + input.mysqlId, + preserveSecretPlaceholderFields( + { + env: input.env, + }, + currentMysql, + ["env"], + ), + ); if (!service) { throw new TRPCError({ @@ -390,9 +418,15 @@ export const mysqlRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, mysqlId, { service: ["create"], }); - const service = await updateMySqlById(mysqlId, { - ...rest, - }); + const currentMysql = await findMySqlById(mysqlId); + const service = await updateMySqlById( + mysqlId, + preserveSecretPlaceholderFields(rest, currentMysql, [ + "env", + "databasePassword", + "databaseRootPassword", + ]), + ); if (!service) { throw new TRPCError({ diff --git a/apps/dokploy/server/api/routers/postgres.ts b/apps/dokploy/server/api/routers/postgres.ts index 9b1b3330a9..d861257446 100644 --- a/apps/dokploy/server/api/routers/postgres.ts +++ b/apps/dokploy/server/api/routers/postgres.ts @@ -31,11 +31,16 @@ import { checkServicePermissionAndAccess, findMemberByUserId, } from "@dokploy/server/services/permission"; +import { + preserveSecretPlaceholderFields, + redactDatabaseServiceSecrets, +} from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; +import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; import { apiChangePostgresStatus, apiCreatePostgres, @@ -140,7 +145,22 @@ export const postgresRouter = createTRPCRouter({ message: "You are not authorized to access this Postgres", }); } - return postgres; + return redactDatabaseServiceSecrets(postgres); + }), + + revealEnvironment: protectedProcedure + .input(apiFindOnePostgres) + .mutation(async ({ input, ctx }) => { + const postgres = await assertServiceEnvironmentReadAccess( + ctx, + input.postgresId, + () => findPostgresById(input.postgresId), + "Postgres", + ); + + return { + env: postgres.env ?? "", + }; }), start: protectedProcedure @@ -342,9 +362,17 @@ export const postgresRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.postgresId, { envVars: ["write"], }); - const service = await updatePostgresById(input.postgresId, { - env: input.env, - }); + const currentPostgres = await findPostgresById(input.postgresId); + const service = await updatePostgresById( + input.postgresId, + preserveSecretPlaceholderFields( + { + env: input.env, + }, + currentPostgres, + ["env"], + ), + ); if (!service) { throw new TRPCError({ @@ -400,9 +428,14 @@ export const postgresRouter = createTRPCRouter({ service: ["create"], }); - const service = await updatePostgresById(postgresId, { - ...rest, - }); + const currentPostgres = await findPostgresById(postgresId); + const service = await updatePostgresById( + postgresId, + preserveSecretPlaceholderFields(rest, currentPostgres, [ + "env", + "databasePassword", + ]), + ); if (!service) { throw new TRPCError({ diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index 2e35aee2a1..f5106aa456 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -38,6 +38,7 @@ import { checkProjectAccess, findMemberByUserId, } from "@dokploy/server/services/permission"; +import { preserveSecretPlaceholderFields } from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import type { AnyPgColumn } from "drizzle-orm/pg-core"; @@ -764,9 +765,10 @@ export const projectRouter = createTRPCRouter({ await checkPermission(ctx, { projectEnvVars: ["write"] }); } - const project = await updateProjectById(input.projectId, { - ...input, - }); + const project = await updateProjectById( + input.projectId, + preserveSecretPlaceholderFields(input, currentProject, ["env"]), + ); if (project) { await audit(ctx, { diff --git a/apps/dokploy/server/api/routers/redis.ts b/apps/dokploy/server/api/routers/redis.ts index 747cce4a31..31185e1727 100644 --- a/apps/dokploy/server/api/routers/redis.ts +++ b/apps/dokploy/server/api/routers/redis.ts @@ -29,11 +29,16 @@ import { checkServicePermissionAndAccess, findMemberByUserId, } from "@dokploy/server/services/permission"; +import { + preserveSecretPlaceholderFields, + redactDatabaseServiceSecrets, +} from "@dokploy/server/utils/security/redaction"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit"; +import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment"; import { apiChangeRedisStatus, apiCreateRedis, @@ -127,7 +132,22 @@ export const redisRouter = createTRPCRouter({ message: "You are not authorized to access this Redis", }); } - return redis; + return redactDatabaseServiceSecrets(redis); + }), + + revealEnvironment: protectedProcedure + .input(apiFindOneRedis) + .mutation(async ({ input, ctx }) => { + const redis = await assertServiceEnvironmentReadAccess( + ctx, + input.redisId, + () => findRedisById(input.redisId), + "Redis", + ); + + return { + env: redis.env ?? "", + }; }), start: protectedProcedure @@ -356,9 +376,17 @@ export const redisRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, input.redisId, { envVars: ["write"], }); - const updatedRedis = await updateRedisById(input.redisId, { - env: input.env, - }); + const currentRedis = await findRedisById(input.redisId); + const updatedRedis = await updateRedisById( + input.redisId, + preserveSecretPlaceholderFields( + { + env: input.env, + }, + currentRedis, + ["env"], + ), + ); if (!updatedRedis) { throw new TRPCError({ @@ -381,9 +409,14 @@ export const redisRouter = createTRPCRouter({ await checkServicePermissionAndAccess(ctx, redisId, { service: ["create"], }); - const redis = await updateRedisById(redisId, { - ...rest, - }); + const currentRedis = await findRedisById(redisId); + const redis = await updateRedisById( + redisId, + preserveSecretPlaceholderFields(rest, currentRedis, [ + "env", + "databasePassword", + ]), + ); if (!redis) { throw new TRPCError({ diff --git a/apps/dokploy/server/api/utils/application-env-upsert.ts b/apps/dokploy/server/api/utils/application-env-upsert.ts new file mode 100644 index 0000000000..c7cd1804bf --- /dev/null +++ b/apps/dokploy/server/api/utils/application-env-upsert.ts @@ -0,0 +1,19 @@ +import type { DeploymentJob } from "@/server/queues/queue-types"; + +type ApplicationEnvUpsertDeploymentTarget = { + applicationId: string; + serverId?: string | null; +}; + +export const buildApplicationEnvUpsertDeploymentJob = ({ + applicationId, + serverId, +}: ApplicationEnvUpsertDeploymentTarget): DeploymentJob => ({ + applicationId, + titleLog: "Rebuild deployment", + descriptionLog: "Environment variables updated", + type: "redeploy", + applicationType: "application", + server: !!serverId, + serverId: serverId ?? undefined, +}); diff --git a/apps/dokploy/server/api/utils/service-environment.ts b/apps/dokploy/server/api/utils/service-environment.ts new file mode 100644 index 0000000000..76b01bec35 --- /dev/null +++ b/apps/dokploy/server/api/utils/service-environment.ts @@ -0,0 +1,45 @@ +import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission"; +import { TRPCError } from "@trpc/server"; + +type ServiceEnvironmentContext = Parameters< + typeof checkServicePermissionAndAccess +>[0] & { + session: { + activeOrganizationId: string; + }; +}; + +type ServiceEnvironmentResource = { + environment: { + project: { + organizationId: string; + }; + }; +}; + +export const assertServiceEnvironmentReadAccess = async < + TService extends ServiceEnvironmentResource, +>( + ctx: ServiceEnvironmentContext, + serviceId: string, + findService: () => Promise, + resourceName: string, +) => { + await checkServicePermissionAndAccess(ctx, serviceId, { + envVars: ["read"], + }); + + const service = await findService(); + + if ( + service.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: `You are not authorized to access this ${resourceName}`, + }); + } + + return service; +}; diff --git a/apps/dokploy/server/server.ts b/apps/dokploy/server/server.ts index 5fe048c922..30e3066cd9 100644 --- a/apps/dokploy/server/server.ts +++ b/apps/dokploy/server/server.ts @@ -1,3 +1,4 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; import http from "node:http"; import { createDefaultMiddlewares, @@ -14,8 +15,10 @@ import { setupDirectories, } from "@dokploy/server"; import { config } from "dotenv"; +import type { NextApiRequest, NextApiResponse } from "next"; import next from "next"; import packageInfo from "../package.json"; +import { handleApplicationEnvUpsert } from "../pages/api/application.env.upsert"; import { setupDockerContainerLogsWebSocketServer } from "./wss/docker-container-logs"; import { setupDockerContainerTerminalWebSocketServer } from "./wss/docker-container-terminal"; import { setupDockerStatsMonitoringSocketServer } from "./wss/docker-stats"; @@ -39,10 +42,115 @@ if (process.env.NODE_ENV === "production" && !IS_CLOUD) { const app = next({ dev, turbopack: process.env.TURBOPACK === "1" }); const handle = app.getRequestHandler(); + +type NodeNextApiResponse = ServerResponse & { + status: (code: number) => NodeNextApiResponse; + json: (body: unknown) => NodeNextApiResponse; +}; + +const normalizeRequestPath = (value: string) => { + try { + return new URL(value, "http://localhost").pathname.replace(/\/+$/, ""); + } catch { + return ""; + } +}; + +const isApplicationEnvUpsertPath = (value: string) => { + const pathname = normalizeRequestPath(value); + + return ( + pathname === "/api/application/env/upsert" || + pathname === "/api/application.env.upsert" + ); +}; + +const getForwardedUriValues = (req: IncomingMessage) => { + const forwardedUri = req.headers["x-forwarded-uri"]; + + if (Array.isArray(forwardedUri)) { + return forwardedUri; + } + + return typeof forwardedUri === "string" ? [forwardedUri] : []; +}; + +const isApplicationEnvUpsertRequest = (req: IncomingMessage) => + [req.url ?? "/", ...getForwardedUriValues(req)].some((path) => + isApplicationEnvUpsertPath(path), + ); + +const readJsonBody = async (req: IncomingMessage) => + new Promise((resolve, reject) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + if (!body) { + resolve({}); + return; + } + + try { + resolve(JSON.parse(body)); + } catch (error) { + reject(error); + } + }); + req.on("error", reject); + }); + +const handleApplicationEnvUpsertRequest = async ( + req: IncomingMessage, + res: ServerResponse, +) => { + try { + (req as IncomingMessage & { body: unknown }).body = await readJsonBody(req); + } catch { + res.statusCode = 400; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ message: "Invalid request body" })); + return; + } + + const nextResponse = res as NodeNextApiResponse; + + nextResponse.status = (code: number) => { + res.statusCode = code; + return nextResponse; + }; + nextResponse.json = (body: unknown) => { + if (!res.headersSent) { + res.setHeader("Content-Type", "application/json"); + } + res.end(JSON.stringify(body)); + return nextResponse; + }; + + try { + await handleApplicationEnvUpsert( + req as NextApiRequest, + nextResponse as unknown as NextApiResponse, + ); + } catch { + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + } + res.end(JSON.stringify({ message: "Internal server error" })); + } +}; + void app.prepare().then(async () => { try { console.log("Running DokployVersion: ", packageInfo.version); const server = http.createServer((req, res) => { + if (isApplicationEnvUpsertRequest(req)) { + void handleApplicationEnvUpsertRequest(req, res); + return; + } + handle(req, res); }); diff --git a/packages/server/src/db/schema/application.ts b/packages/server/src/db/schema/application.ts index 59dfd37161..afcf6275fd 100644 --- a/packages/server/src/db/schema/application.ts +++ b/packages/server/src/db/schema/application.ts @@ -530,6 +530,43 @@ export const apiSaveEnvironmentVariables = createSchema }) .required(); +const ENV_VARIABLE_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export const apiUpsertApplicationEnv = z.object({ + applicationId: z.string().min(1), + variables: z + .record( + z + .string() + .regex( + ENV_VARIABLE_NAME_REGEX, + "Environment variable names must start with a letter or underscore and contain only letters, numbers, and underscores", + ), + z.string(), + ) + .refine((variables) => Object.keys(variables).length > 0, { + message: "At least one environment variable is required", + }), + redeploy: z.boolean().optional(), + dryRun: z.boolean().optional(), + expectedRevision: z.string().optional(), +}); + +export const apiUpsertApplicationEnvResponse = z.object({ + applicationId: z.string(), + changed: z.boolean(), + revision: z.string(), + dryRun: z.boolean(), + redeployed: z.boolean(), + variables: z.array( + z.object({ + name: z.string(), + action: z.enum(["created", "updated", "unchanged"]), + secret: z.boolean(), + }), + ), +}); + export const apiFindMonitoringStats = z.object({ appName: z.string().min(1), }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 165e1667eb..48568333f7 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -101,6 +101,7 @@ export * from "./utils/docker/compose/volume"; export * from "./utils/docker/domain"; export * from "./utils/docker/types"; export * from "./utils/docker/utils"; +export * from "./utils/env-upsert"; export * from "./utils/filesystem/directory"; export * from "./utils/filesystem/ssh"; export * from "./utils/git-branch-validation"; diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index c8ebb3be77..40a6398961 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -2,6 +2,7 @@ import { docker } from "@dokploy/server/constants"; import { db } from "@dokploy/server/db"; import { type apiCreateApplication, + type apiUpsertApplicationEnv, applications, buildAppName, } from "@dokploy/server/db/schema"; @@ -31,6 +32,10 @@ import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import type { z } from "zod"; import { encodeBase64 } from "../utils/docker/utils"; +import { + getApplicationEnvRevision, + upsertEnvVariables, +} from "../utils/env-upsert"; import { getDokployUrl } from "./admin"; import { createDeployment, @@ -146,6 +151,43 @@ export const updateApplication = async ( return application[0]; }; +export const upsertApplicationEnvironment = async ( + input: z.infer, +) => { + const application = await findApplicationById(input.applicationId); + const currentEnv = application.env ?? ""; + const currentRevision = getApplicationEnvRevision( + input.applicationId, + currentEnv, + ); + + if (input.expectedRevision && input.expectedRevision !== currentRevision) { + throw new TRPCError({ + code: "CONFLICT", + message: "Application environment revision does not match", + }); + } + + const result = upsertEnvVariables(currentEnv, input.variables); + const nextRevision = result.changed + ? getApplicationEnvRevision(input.applicationId, result.env) + : currentRevision; + + if (!input.dryRun && result.changed) { + await updateApplication(input.applicationId, { + env: result.env, + }); + } + + return { + applicationId: input.applicationId, + changed: result.changed, + revision: input.dryRun ? currentRevision : nextRevision, + dryRun: input.dryRun ?? false, + variables: result.variables, + }; +}; + export const updateApplicationStatus = async ( applicationId: string, applicationStatus: Application["applicationStatus"], diff --git a/packages/server/src/services/git-provider.ts b/packages/server/src/services/git-provider.ts index 942f94ed2c..f774f975f6 100644 --- a/packages/server/src/services/git-provider.ts +++ b/packages/server/src/services/git-provider.ts @@ -5,6 +5,77 @@ import { TRPCError } from "@trpc/server"; import { and, eq } from "drizzle-orm"; export type GitProvider = typeof gitProvider.$inferSelect; +type GitProviderSession = { + userId: string; + activeOrganizationId: string; +}; + +const githubSecretKeys = [ + "githubClientSecret", + "githubPrivateKey", + "githubWebhookSecret", +] as const; + +const gitlabSecretKeys = ["secret", "accessToken", "refreshToken"] as const; + +const giteaSecretKeys = [ + "clientSecret", + "accessToken", + "refreshToken", +] as const; + +const bitbucketSecretKeys = ["appPassword", "apiToken"] as const; + +const omitKeys = (value: T, keys: readonly string[]) => { + const redacted = { ...value } as Record; + for (const key of keys) { + delete redacted[key]; + } + return redacted as T; +}; + +const redactNullable = ( + value: T, + keys: readonly string[], +) => { + if (!value) { + return value; + } + return omitKeys(value, keys); +}; + +export const redactGithubProvider = ( + provider: T, +) => redactNullable(provider, githubSecretKeys); + +export const redactGitlabProvider = ( + provider: T, +) => redactNullable(provider, gitlabSecretKeys); + +export const redactGiteaProvider = ( + provider: T, +) => redactNullable(provider, giteaSecretKeys); + +export const redactBitbucketProvider = ( + provider: T, +) => redactNullable(provider, bitbucketSecretKeys); + +export const redactGitProviderSecrets = < + T extends { + github?: object | null; + gitlab?: object | null; + bitbucket?: object | null; + gitea?: object | null; + }, +>( + entity: T, +) => ({ + ...entity, + github: redactGithubProvider(entity.github), + gitlab: redactGitlabProvider(entity.gitlab), + bitbucket: redactBitbucketProvider(entity.bitbucket), + gitea: redactGiteaProvider(entity.gitea), +}); export const removeGitProvider = async (gitProviderId: string) => { const result = await db @@ -51,7 +122,7 @@ export const updateGitProvider = async ( // not to modify the git config of an existing deploy owned by someone else. export const canEditDeployGitSource = async ( gitProviderId: string, - session: { userId: string; activeOrganizationId: string }, + session: GitProviderSession, ): Promise => { const { userId, activeOrganizationId } = session; @@ -75,10 +146,9 @@ export const canEditDeployGitSource = async ( return provider.userId === userId || provider.sharedWithOrganization; }; -export const getAccessibleGitProviderIds = async (session: { - userId: string; - activeOrganizationId: string; -}): Promise> => { +export const getAccessibleGitProviderIds = async ( + session: GitProviderSession, +): Promise> => { const { userId, activeOrganizationId } = session; const allOrgProviders = await db.query.gitProvider.findMany({ diff --git a/packages/server/src/utils/env-upsert.ts b/packages/server/src/utils/env-upsert.ts new file mode 100644 index 0000000000..8c44ba8a77 --- /dev/null +++ b/packages/server/src/utils/env-upsert.ts @@ -0,0 +1,130 @@ +import { createHmac } from "node:crypto"; +import { parse } from "dotenv"; +import { betterAuthSecret } from "../lib/auth-secret"; + +export type EnvUpsertAction = "created" | "updated" | "unchanged"; + +export type EnvUpsertVariableResult = { + name: string; + action: EnvUpsertAction; + secret: boolean; +}; + +export type EnvUpsertResult = { + env: string; + changed: boolean; + variables: EnvUpsertVariableResult[]; +}; + +const ENV_ASSIGNMENT_REGEX = + /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(\s*=\s*)(.*)$/; + +const SECRET_NAME_REGEX = + /(^|_)(AUTH|CREDENTIAL|KEY|PASS|PASSWORD|PRIVATE|SECRET|TOKEN|WEBHOOK)($|_)/i; + +export const isSecretEnvName = (name: string) => SECRET_NAME_REGEX.test(name); + +export const getApplicationEnvRevision = ( + applicationId: string, + env: string | null | undefined, +) => + `env:${createHmac("sha256", betterAuthSecret) + .update(applicationId) + .update("\0") + .update(env ?? "") + .digest("base64url") + .slice(0, 32)}`; + +const serializeEnvValue = (value: string) => { + if (value === "") { + return ""; + } + + if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) { + return value; + } + + return `"${value + .replace(/\\/g, "\\\\") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/"/g, '\\"')}"`; +}; + +const splitEnvLines = (env: string) => { + const normalized = env.replace(/\r\n/g, "\n"); + const trailingNewline = normalized.endsWith("\n"); + const lines = normalized.length > 0 ? normalized.split("\n") : []; + + if (trailingNewline) { + lines.pop(); + } + + return { + lines, + trailingNewline, + }; +}; + +export const upsertEnvVariables = ( + currentEnv: string | null | undefined, + variables: Record, +): EnvUpsertResult => { + const env = currentEnv ?? ""; + const currentValues = parse(env); + const { lines, trailingNewline } = splitEnvLines(env); + const lastLineByName = new Map(); + + lines.forEach((line, index) => { + const match = ENV_ASSIGNMENT_REGEX.exec(line); + if (match) { + lastLineByName.set(match[2]!, index); + } + }); + + let changed = false; + const variableResults: EnvUpsertVariableResult[] = []; + + for (const [name, value] of Object.entries(variables)) { + const currentValue = currentValues[name]; + const action = + currentValue === undefined + ? "created" + : currentValue === value + ? "unchanged" + : "updated"; + + variableResults.push({ + name, + action, + secret: isSecretEnvName(name), + }); + + if (action === "unchanged") { + continue; + } + + changed = true; + const serializedValue = serializeEnvValue(value); + const existingLineIndex = lastLineByName.get(name); + + if (existingLineIndex !== undefined) { + const match = ENV_ASSIGNMENT_REGEX.exec(lines[existingLineIndex]!); + if (match) { + lines[existingLineIndex] = + `${match[1]!}${name}${match[3]!}${serializedValue}`; + } + continue; + } + + lines.push(`${name}=${serializedValue}`); + } + + const nextEnv = lines.join("\n"); + + return { + env: trailingNewline && nextEnv.length > 0 ? `${nextEnv}\n` : nextEnv, + changed, + variables: variableResults, + }; +}; diff --git a/packages/server/src/utils/security/redaction.ts b/packages/server/src/utils/security/redaction.ts new file mode 100644 index 0000000000..f8f8718c10 --- /dev/null +++ b/packages/server/src/utils/security/redaction.ts @@ -0,0 +1,156 @@ +export const REDACTED_SECRET_VALUE = "__DOKPLOY_REDACTED_SECRET__"; +const MCP_REDACTED_SECRET_VALUE = "[REDACTED]"; + +export type SecretRecord = Record; + +export const isSecretPlaceholderValue = (value: unknown) => + typeof value === "string" && + (value.includes(REDACTED_SECRET_VALUE) || + value.includes(MCP_REDACTED_SECRET_VALUE)); + +export const redactSecretValue = (value: T) => { + if (value === null || value === undefined || value === "") { + return value; + } + + return REDACTED_SECRET_VALUE; +}; + +export const redactSecretFields = ( + record: T, + fields: readonly string[], +) => { + if (!record) { + return record; + } + + const redacted = { ...record }; + + for (const field of fields) { + if (field in redacted) { + redacted[field] = redactSecretValue(redacted[field]); + } + } + + return redacted as T; +}; + +export function redactSensitiveText(value: string): string; +export function redactSensitiveText(value: null): null; +export function redactSensitiveText(value: undefined): undefined; +export function redactSensitiveText(value: string | null): string | null; +export function redactSensitiveText( + value: string | undefined, +): string | undefined; +export function redactSensitiveText( + value: string | null | undefined, +): string | null | undefined; +export function redactSensitiveText(value: string | null | undefined) { + if (typeof value !== "string" || value === "") { + return value; + } + + return value.replace( + /(\b[a-z][a-z0-9+.-]*:\/\/)([^@\s/?#]+)@/gi, + `$1${REDACTED_SECRET_VALUE}@`, + ); +} + +const redactNestedServerSecrets = (server: T): T => { + if (!server || typeof server !== "object") { + return server; + } + + const redacted = { ...(server as SecretRecord) }; + if ("command" in redacted) { + redacted.command = redactSecretValue(redacted.command); + } + if (redacted.metricsConfig && typeof redacted.metricsConfig === "object") { + const metricsConfig = { ...(redacted.metricsConfig as SecretRecord) }; + if (metricsConfig.server && typeof metricsConfig.server === "object") { + metricsConfig.server = redactSecretFields( + metricsConfig.server as SecretRecord, + ["token"], + ); + } + redacted.metricsConfig = metricsConfig; + } + if (redacted.sshKey && typeof redacted.sshKey === "object") { + redacted.sshKey = redactSecretFields(redacted.sshKey as SecretRecord, [ + "privateKey", + ]); + } + + return redacted as T; +}; + +export const redactDeployableServiceSecrets = < + T extends SecretRecord | null | undefined, +>( + record: T, +) => { + const redacted = redactSecretFields(record, [ + "env", + "previewEnv", + "buildArgs", + "buildSecrets", + "previewBuildArgs", + "previewBuildSecrets", + "password", + "refreshToken", + ]); + if (!redacted) { + return redacted; + } + + const withRelations = { ...redacted }; + for (const key of ["server", "buildServer"]) { + if (key in withRelations) { + withRelations[key] = redactNestedServerSecrets(withRelations[key]); + } + } + return withRelations as T; +}; + +export const redactDatabaseServiceSecrets = < + T extends SecretRecord | null | undefined, +>( + record: T, +) => { + const redacted = redactSecretFields(record, [ + "env", + "databasePassword", + "databaseRootPassword", + ]); + if (!redacted) { + return redacted; + } + + const withRelations = { ...redacted }; + if ("server" in withRelations) { + withRelations.server = redactNestedServerSecrets(withRelations.server); + } + return withRelations as T; +}; + +export const preserveSecretPlaceholderFields = < + TUpdate extends object, + TCurrent extends object, +>( + update: TUpdate, + current: TCurrent, + fields: readonly (keyof TUpdate & keyof TCurrent)[], +) => { + const next: Record = { + ...(update as unknown as Record), + }; + const currentRecord = current as Record; + + for (const field of fields) { + if (isSecretPlaceholderValue(next[field])) { + next[field] = currentRecord[field]; + } + } + + return next as TUpdate; +};