diff --git a/apps/dokploy/__test__/env/compose-env-upsert.test.ts b/apps/dokploy/__test__/env/compose-env-upsert.test.ts index 9b54f0025cc..0071059858c 100644 --- a/apps/dokploy/__test__/env/compose-env-upsert.test.ts +++ b/apps/dokploy/__test__/env/compose-env-upsert.test.ts @@ -283,6 +283,29 @@ describe("deployCompose exact revision guard", () => { expect(dbMocks.set).toHaveBeenCalledWith({ composeStatus: "error" }); }); + it("fetches the exact revision inside the authenticated clone command", async () => { + await expect( + deployCompose({ + composeId: "compose_1", + titleLog: "Exact deployment", + descriptionLog: "", + operationId: "operation-1", + expectedRevision: requestedRevision, + }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + + expect(exactMocks.cloneGitRepository).toHaveBeenCalledWith( + expect.objectContaining({ checkoutRevision: requestedRevision }), + ); + const [, verifyCommand] = exactMocks.execAsync.mock.calls.map(([command]) => + String(command), + ); + // The stored remote carries no credentials, so a separate fetch would + // fail for private repositories. + expect(verifyCommand).not.toContain("fetch"); + expect(verifyCommand).toContain("rev-parse"); + }); + it("keeps successful deployment state when terminal finalization is ambiguous", async () => { vi.clearAllMocks(); exactMocks.execAsync.mockReset(); diff --git a/apps/dokploy/__test__/git-provider/clone-command-boundary.test.ts b/apps/dokploy/__test__/git-provider/clone-command-boundary.test.ts index c6802fb14c2..44cd63cea5c 100644 --- a/apps/dokploy/__test__/git-provider/clone-command-boundary.test.ts +++ b/apps/dokploy/__test__/git-provider/clone-command-boundary.test.ts @@ -263,7 +263,7 @@ describe("Git provider clone command boundary", () => { }); it("quotes GitHub clone metadata", async () => { - const expectedCloneUrl = `https://oauth2:${fixtures.githubToken}@github.com/${fixtures.githubOwner}/${fixtures.githubRepository}.git`; + const expectedCloneUrl = `https://github.com/${fixtures.githubOwner}/${fixtures.githubRepository}.git`; const command = await cloneGithubRepository({ appName: "app", branch: fixtures.branch, @@ -284,7 +284,7 @@ describe("Git provider clone command boundary", () => { it("quotes GitLab clone metadata", async () => { const repoClone = `${fixtures.gitlabBaseUrl.replace(/^https?:\/\//, "")}/${fixtures.gitlabNamespace}.git`; - const expectedCloneUrl = `https://oauth2:${fixtures.gitlabToken}@${repoClone}`; + const expectedCloneUrl = `https://${repoClone}`; const command = await cloneGitlabRepository({ appName: "app", enableSubmodules: false, @@ -306,7 +306,7 @@ describe("Git provider clone command boundary", () => { it("quotes Bitbucket clone metadata", async () => { const repoClone = `bitbucket.org/${fixtures.bitbucketOwner}/${fixtures.bitbucketRepository}.git`; - const expectedCloneUrl = `https://x-bitbucket-api-token-auth:${fixtures.bitbucketToken}@${repoClone}`; + const expectedCloneUrl = `https://${repoClone}`; const command = await cloneBitbucketRepository({ appName: "app", bitbucketBranch: fixtures.branch, @@ -567,7 +567,7 @@ describe("Git provider clone command boundary", () => { it("quotes Gitea clone metadata", async () => { const baseUrl = fixtures.giteaBaseUrl.replace(/^https?:\/\//, ""); - const expectedCloneUrl = `https://oauth2:${fixtures.giteaToken}@${baseUrl}/${fixtures.giteaOwner}/${fixtures.giteaRepository}.git`; + const expectedCloneUrl = `https://${baseUrl}/${fixtures.giteaOwner}/${fixtures.giteaRepository}.git`; const command = await cloneGiteaRepository({ appName: "app", enableSubmodules: false, diff --git a/apps/dokploy/__test__/git-provider/clone-credentials-persistence.test.ts b/apps/dokploy/__test__/git-provider/clone-credentials-persistence.test.ts new file mode 100644 index 00000000000..b9cffbbb65a --- /dev/null +++ b/apps/dokploy/__test__/git-provider/clone-credentials-persistence.test.ts @@ -0,0 +1,411 @@ +import { parse } from "shell-quote"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const tokens = vi.hoisted(() => ({ + github: "ghs_installation-token-value", + gitlab: "glpat-gitlab-oauth-token", + gitea: "gitea-oauth-token-value", + bitbucketApi: "bitbucket-api-token-value", + bitbucketAppPassword: "bitbucket-app-password", +})); + +const mocks = vi.hoisted(() => ({ + findBitbucketById: vi.fn(), + findGiteaById: vi.fn(), + findGithubById: vi.fn(), + findGitlabById: vi.fn(), + findSSHKeyById: vi.fn(), + updateSSHKeyById: vi.fn(), +})); + +vi.mock("@octokit/auth-app", () => ({ + createAppAuth: vi.fn(), +})); + +vi.mock("octokit", () => ({ + Octokit: class { + auth = vi.fn().mockResolvedValue({ token: tokens.github }); + }, +})); + +vi.mock("@dokploy/server/services/bitbucket", () => ({ + findBitbucketById: mocks.findBitbucketById, +})); + +vi.mock("@dokploy/server/services/gitea", () => ({ + findGiteaById: mocks.findGiteaById, + updateGitea: vi.fn(), +})); + +vi.mock("@dokploy/server/services/github", () => ({ + findGithubById: mocks.findGithubById, +})); + +vi.mock("@dokploy/server/services/gitlab", () => ({ + findGitlabById: mocks.findGitlabById, + updateGitlab: vi.fn(), +})); + +vi.mock("@dokploy/server/services/ssh-key", () => ({ + findSSHKeyById: mocks.findSSHKeyById, + updateSSHKeyById: mocks.updateSSHKeyById, +})); + +const { cloneBitbucketRepository } = await import( + "@dokploy/server/utils/providers/bitbucket" +); +const { cloneGitRepository } = await import( + "@dokploy/server/utils/providers/git" +); +const { cloneGiteaRepository } = await import( + "@dokploy/server/utils/providers/gitea" +); +const { cloneGithubRepository } = await import( + "@dokploy/server/utils/providers/github" +); +const { cloneGitlabRepository } = await import( + "@dokploy/server/utils/providers/gitlab" +); +const { buildGitCloneCommand } = await import( + "@dokploy/server/utils/providers/commands" +); +const { redactSensitiveText } = await import( + "@dokploy/server/utils/security/redaction" +); + +const outputPath = "/etc/dokploy/applications/app/code"; +const revision = "0123456789abcdef0123456789abcdef01234567"; + +const gitInvocations = (command: string) => { + const invocations: string[][] = []; + let current: string[] = []; + for (const token of parse(command)) { + if (typeof token === "string") { + current.push(token); + continue; + } + if (current.length) invocations.push(current); + current = []; + } + if (current.length) invocations.push(current); + return invocations + .map((args) => { + const start = args.findIndex((arg) => arg !== "if" && arg !== "!"); + return args.slice(start); + }) + .filter((args) => args[0] === "git"); +}; + +const subcommandIndex = (args: string[]) => { + let index = 1; + while (args[index] === "-c" || args[index] === "-C") { + index += 2; + } + return index; +}; + +const cloneInvocation = (command: string) => { + const invocation = gitInvocations(command).find( + (args) => args[subcommandIndex(args)] === "clone", + ); + if (!invocation) { + throw new Error("git clone invocation not found"); + } + return invocation; +}; + +const authHeaders = (args: string[]) => + args + .slice(0, subcommandIndex(args)) + .filter((arg) => /\.extraHeader=/i.test(arg)); + +const decodeBasicHeader = (configArg: string) => { + const match = configArg.match( + /^http\.(.+)\.extraHeader=Authorization: Basic (\S+)$/, + ); + if (!match) { + throw new Error(`unexpected auth config: ${configArg}`); + } + return { + scope: match[1], + credentials: Buffer.from(match[2] ?? "", "base64").toString("utf8"), + encoded: match[2] ?? "", + }; +}; + +const expectCredentialsOnlyInGitConfigFlag = ( + command: string, + expected: { + cloneUrl: string; + scope: string; + username: string; + secret: string; + }, +) => { + const args = cloneInvocation(command); + const storedRemote = args.at(-2) ?? ""; + + // git writes the positional clone URL to remote.origin.url. + expect(storedRemote).toBe(expected.cloneUrl); + expect(new URL(storedRemote).username).toBe(""); + expect(new URL(storedRemote).password).toBe(""); + + // `clone -c/--config` would persist values into .git/config. + const cloneArgs = args.slice(subcommandIndex(args)); + expect(cloneArgs).not.toContain("-c"); + expect(cloneArgs.some((arg) => arg.startsWith("--config"))).toBe(false); + + const [header, ...otherHeaders] = authHeaders(args); + expect(otherHeaders).toEqual([]); + const decoded = decodeBasicHeader(header ?? ""); + expect(decoded.scope).toBe(expected.scope); + expect(decoded.credentials).toBe(`${expected.username}:${expected.secret}`); + + expect(command).not.toContain(expected.secret); + expect(command).not.toContain(`${expected.username}:`); + + const redacted = redactSensitiveText(command); + expect(redacted).not.toContain(expected.secret); + expect(redacted).not.toContain(decoded.encoded); +}; + +describe("provider clone credentials are not persisted in the checkout", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.findGithubById.mockResolvedValue({ + githubAppId: 1, + githubInstallationId: 1, + githubPrivateKey: "private-key", + githubUrl: "https://github.com", + }); + mocks.findGitlabById.mockResolvedValue({ + accessToken: tokens.gitlab, + expiresAt: 4_102_444_800, + groupName: null, + gitlabInternalUrl: null, + gitlabUrl: "https://gitlab.example.com", + refreshToken: "refresh-token", + }); + mocks.findGiteaById.mockResolvedValue({ + accessToken: tokens.gitea, + giteaInternalUrl: null, + giteaUrl: "http://gitea.internal.example:3000", + organizationName: null, + }); + mocks.findBitbucketById.mockResolvedValue({ + apiToken: tokens.bitbucketApi, + bitbucketUsername: "workspace", + bitbucketWorkspaceName: "workspace", + }); + mocks.findSSHKeyById.mockResolvedValue({ + privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n", + }); + mocks.updateSSHKeyById.mockResolvedValue(undefined); + }); + + it("GitHub sends the installation token only as a scoped clone header", async () => { + const command = await cloneGithubRepository({ + appName: "app", + branch: "main", + enableSubmodules: true, + githubId: "github-1", + outputPathOverride: outputPath, + owner: "acme", + repository: "web", + serverId: null, + }); + + expectCredentialsOnlyInGitConfigFlag(command, { + cloneUrl: "https://github.com/acme/web.git", + scope: "https://github.com/", + username: "oauth2", + secret: tokens.github, + }); + expect(cloneInvocation(command)).toContain("--recurse-submodules"); + }); + + it("GitLab sends the OAuth access token only as a scoped clone header", async () => { + const command = await cloneGitlabRepository({ + appName: "app", + enableSubmodules: false, + gitlabBranch: "main", + gitlabId: "gitlab-1", + gitlabOwner: "group", + gitlabPathNamespace: "group/project", + gitlabRepository: "project", + outputPathOverride: outputPath, + serverId: "server-1", + } as Parameters[0]); + + expectCredentialsOnlyInGitConfigFlag(command, { + cloneUrl: "https://gitlab.example.com/group/project.git", + scope: "https://gitlab.example.com/", + username: "oauth2", + secret: tokens.gitlab, + }); + }); + + it("Gitea sends the OAuth access token only as a scoped clone header", async () => { + const command = await cloneGiteaRepository({ + appName: "app", + enableSubmodules: false, + giteaBranch: "main", + giteaId: "gitea-1", + giteaOwner: "owner", + giteaRepository: "repo", + outputPathOverride: outputPath, + serverId: null, + }); + + expectCredentialsOnlyInGitConfigFlag(command, { + cloneUrl: "http://gitea.internal.example:3000/owner/repo.git", + scope: "http://gitea.internal.example:3000/", + username: "oauth2", + secret: tokens.gitea, + }); + }); + + it("Bitbucket sends the API token only as a scoped clone header", async () => { + const command = await cloneBitbucketRepository({ + appName: "app", + bitbucketBranch: "main", + bitbucketId: "bitbucket-1", + bitbucketOwner: "workspace", + bitbucketRepository: "repo", + enableSubmodules: false, + outputPathOverride: outputPath, + serverId: null, + }); + + expectCredentialsOnlyInGitConfigFlag(command, { + cloneUrl: "https://bitbucket.org/workspace/repo.git", + scope: "https://bitbucket.org/", + username: "x-bitbucket-api-token-auth", + secret: tokens.bitbucketApi, + }); + }); + + it("Bitbucket sends the app password only as a scoped clone header", async () => { + mocks.findBitbucketById.mockResolvedValue({ + apiToken: null, + appPassword: tokens.bitbucketAppPassword, + bitbucketUsername: "workspace", + bitbucketWorkspaceName: "workspace", + }); + + const command = await cloneBitbucketRepository({ + appName: "app", + bitbucketBranch: "main", + bitbucketId: "bitbucket-1", + bitbucketOwner: "workspace", + bitbucketRepository: "repo", + enableSubmodules: false, + outputPathOverride: outputPath, + serverId: null, + }); + + expectCredentialsOnlyInGitConfigFlag(command, { + cloneUrl: "https://bitbucket.org/workspace/repo.git", + scope: "https://bitbucket.org/", + username: "workspace", + secret: tokens.bitbucketAppPassword, + }); + }); + + it("custom HTTPS URLs move embedded credentials out of the stored remote", async () => { + const command = await cloneGitRepository({ + appName: "app", + customGitBranch: "main", + customGitSSHKeyId: null, + customGitUrl: + "https://deploy-user:custom%40secret-value@git.example.com/org/repo.git", + enableSubmodules: true, + outputPathOverride: outputPath, + serverId: null, + }); + + expectCredentialsOnlyInGitConfigFlag(command, { + cloneUrl: "https://git.example.com/org/repo.git", + scope: "https://git.example.com/", + username: "deploy-user", + secret: "custom@secret-value", + }); + expect(command).not.toContain("custom%40secret-value"); + }); + + it("exact revision fetches reuse the per-invocation credentials", () => { + const credentials = { username: "oauth2", password: tokens.github }; + const command = buildGitCloneCommand({ + branch: "main", + checkoutRevision: revision, + cloneUrl: "https://github.com/acme/web.git", + credentials, + outputPath, + }); + + const invocations = gitInvocations(command); + const fetch = invocations.find( + (args) => args[subcommandIndex(args)] === "fetch", + ); + const checkout = invocations.find( + (args) => args[subcommandIndex(args)] === "checkout", + ); + + expect(fetch?.slice(subcommandIndex(fetch))).toEqual([ + "fetch", + "--depth", + "1", + "origin", + revision, + ]); + expect(fetch?.slice(0, subcommandIndex(fetch))).toEqual( + expect.arrayContaining(["-C", outputPath]), + ); + expect(authHeaders(fetch ?? []).map(decodeBasicHeader)).toEqual([ + expect.objectContaining({ + scope: "https://github.com/", + credentials: `oauth2:${tokens.github}`, + }), + ]); + expect(checkout?.slice(subcommandIndex(checkout))).toEqual([ + "checkout", + "--detach", + revision, + ]); + expect(command).not.toContain(tokens.github); + // A failed clone or fetch must fail the whole step under `set -e`. + expect(command.startsWith("(")).toBe(true); + expect(command.endsWith(")")).toBe(true); + }); + + it("custom SSH keys use a private per-run file that is removed after the clone", async () => { + const buildSshClone = () => + cloneGitRepository({ + appName: "app", + customGitBranch: "main", + customGitSSHKeyId: "ssh-key-1", + customGitUrl: "git@git.example.com:org/repo.git", + enableSubmodules: false, + outputPathOverride: outputPath, + serverId: null, + }); + + const command = await buildSshClone(); + const keyPathMatch = command.match(/base64 -d > (\S+?)\)?;/); + const keyPath = keyPathMatch?.[1] ?? ""; + + expect(keyPath).toMatch(/^\/tmp\/\S+$/); + expect(keyPath).not.toBe("/tmp/id_rsa"); + expect(command).not.toContain("/tmp/id_rsa"); + expect(command).toContain("(umask 077 && echo "); + expect(command).toContain(`ssh -i ${keyPath}`); + + const removeKey = `rm -rf -- ${keyPath};`; + const [cloneBlock = "", afterClone = ""] = command.split(/\n\s*fi\n/); + expect(cloneBlock.split(removeKey)).toHaveLength(2); + expect(afterClone).toContain(removeKey); + + const secondCommand = await buildSshClone(); + expect(secondCommand).not.toContain(keyPath); + }); +}); diff --git a/apps/dokploy/__test__/git-provider/github-clone-host.test.ts b/apps/dokploy/__test__/git-provider/github-clone-host.test.ts index 93e8d004975..303d80e31ea 100644 --- a/apps/dokploy/__test__/git-provider/github-clone-host.test.ts +++ b/apps/dokploy/__test__/git-provider/github-clone-host.test.ts @@ -53,9 +53,7 @@ describe("cloneGithubRepository host", () => { const command = await clone(); - expect(command).toContain( - "https://oauth2:gh-token@github.com/acme/web.git", - ); + expect(command).toContain("https://github.com/acme/web.git"); expect(command).not.toContain("ghe.com"); }); @@ -64,8 +62,9 @@ describe("cloneGithubRepository host", () => { const command = await clone(); + expect(command).toContain("https://acme.ghe.com/acme/web.git"); expect(command).toContain( - "https://oauth2:gh-token@acme.ghe.com/acme/web.git", + "http.https://acme.ghe.com/.extraHeader=Authorization: Basic ", ); expect(command).not.toContain("github.com"); }); @@ -77,9 +76,7 @@ describe("cloneGithubRepository host", () => { const command = await clone(); - expect(command).toContain( - "https://oauth2:gh-token@github.corp.acme.com/acme/web.git", - ); + expect(command).toContain("https://github.corp.acme.com/acme/web.git"); }); it("keeps an explicit port in the clone host", async () => { @@ -89,9 +86,11 @@ describe("cloneGithubRepository host", () => { const command = await clone(); + expect(command).toContain("https://github.acme.com:8443/acme/web.git"); expect(command).toContain( - "https://oauth2:gh-token@github.acme.com:8443/acme/web.git", + "http.https://github.acme.com:8443/.extraHeader=Authorization: Basic ", ); + expect(command).not.toContain("gh-token"); }); it("falls back to github.com for a provider stored before this feature", async () => { @@ -99,8 +98,6 @@ describe("cloneGithubRepository host", () => { const command = await clone(); - expect(command).toContain( - "https://oauth2:gh-token@github.com/acme/web.git", - ); + expect(command).toContain("https://github.com/acme/web.git"); }); }); diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index dc74ea314bf..e03288ef41c 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -376,6 +376,7 @@ export const deployCompose = async ({ const entity = { ...compose, type: "compose" as const, + checkoutRevision: operation ? expectedRevision : undefined, }; let command = "set -e;"; if (compose.sourceType === "github") { @@ -404,30 +405,16 @@ export const deployCompose = async ({ if (operationId && expectedRevision && operation) { const { COMPOSE_PATH } = paths(!!compose.serverId); const checkoutPath = join(COMPOSE_PATH, compose.appName, "code"); - const exactCheckoutCommand = [ - quoteShellArgs([ - "git", - "-C", - checkoutPath, - "fetch", - "--depth", - "1", - "origin", - expectedRevision, - ]), - quoteShellArgs([ - "git", - "-C", - checkoutPath, - "checkout", - "--detach", - expectedRevision, - ]), - quoteShellArgs(["git", "-C", checkoutPath, "rev-parse", "HEAD"]), - ].join(" && "); + const revParseCommand = quoteShellArgs([ + "git", + "-C", + checkoutPath, + "rev-parse", + "HEAD", + ]); const resolved = compose.serverId - ? await execAsyncRemote(compose.serverId, exactCheckoutCommand) - : await execAsync(exactCheckoutCommand); + ? await execAsyncRemote(compose.serverId, revParseCommand) + : await execAsync(revParseCommand); const resolvedRevision = resolved.stdout.trim().split(/\s+/).at(-1) ?? ""; if (resolvedRevision !== expectedRevision) { throw new TRPCError({ diff --git a/packages/server/src/utils/providers/bitbucket.ts b/packages/server/src/utils/providers/bitbucket.ts index a99452e9bfc..23ef4e43346 100644 --- a/packages/server/src/utils/providers/bitbucket.ts +++ b/packages/server/src/utils/providers/bitbucket.ts @@ -16,6 +16,7 @@ import { buildGitCloneCommand, buildProviderEchoCommand, buildRemovePathCommand, + type GitHttpCredentials, } from "./commands"; export type ApplicationWithBitbucket = InferResultType< @@ -28,7 +29,7 @@ export type ComposeWithBitbucket = InferResultType< { bitbucket: true } >; -export const getBitbucketCloneUrl = ( +export const getBitbucketCloneCredentials = ( bitbucketProvider: { apiToken?: string | null; bitbucketUsername?: string | null; @@ -36,14 +37,16 @@ export const getBitbucketCloneUrl = ( bitbucketEmail?: string | null; bitbucketWorkspaceName?: string | null; } | null, - repoClone: string, -) => { +): GitHttpCredentials => { if (!bitbucketProvider) { throw new Error("Bitbucket provider is required"); } if (bitbucketProvider.apiToken) { - return `https://x-bitbucket-api-token-auth:${bitbucketProvider.apiToken}@${repoClone}`; + return { + username: "x-bitbucket-api-token-auth", + password: bitbucketProvider.apiToken, + }; } // For app passwords, use username:app_password format @@ -52,7 +55,10 @@ export const getBitbucketCloneUrl = ( "Username and app password are required when not using API token", ); } - return `https://${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}@${repoClone}`; + return { + username: bitbucketProvider.bitbucketUsername, + password: bitbucketProvider.appPassword, + }; }; export const getBitbucketHeaders = (bitbucketProvider: Bitbucket) => { @@ -116,6 +122,7 @@ interface CloneBitbucketRepository { serverId: string | null; type?: "application" | "compose"; outputPathOverride?: string; + checkoutRevision?: string; } export const cloneBitbucketRepository = async ({ @@ -132,6 +139,7 @@ export const cloneBitbucketRepository = async ({ enableSubmodules, serverId, outputPathOverride, + checkoutRevision, } = entity; const { COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId); @@ -152,13 +160,15 @@ export const cloneBitbucketRepository = async ({ command += buildCreateDirectoryCommand(outputPath); const repoToUse = entity.bitbucketRepositorySlug || bitbucketRepository; const repoclone = `bitbucket.org/${bitbucketOwner}/${repoToUse}.git`; - const cloneUrl = getBitbucketCloneUrl(bitbucket, repoclone); + const credentials = getBitbucketCloneCredentials(bitbucket); command += buildProviderEchoCommand( `Cloning Repo ${repoclone} to ${outputPath}: ✅`, ); command += `${buildGitCloneCommand({ branch: bitbucketBranch!, - cloneUrl, + checkoutRevision, + cloneUrl: `https://${repoclone}`, + credentials, enableSubmodules, outputPath, })};`; diff --git a/packages/server/src/utils/providers/commands.ts b/packages/server/src/utils/providers/commands.ts index def5316aa54..cca93cb0a27 100644 --- a/packages/server/src/utils/providers/commands.ts +++ b/packages/server/src/utils/providers/commands.ts @@ -13,27 +13,53 @@ export const buildRemovePathCommand = (targetPath: string) => export const buildCreateDirectoryCommand = (targetPath: string) => `${quoteShellArgs(["mkdir", "-p", "--", targetPath])};`; +export type GitHttpCredentials = { + username: string; + password: string; +}; + +// Credentials embedded in the clone URL end up in .git/config (and in every +// submodule config resolved from it). `git -c` values are never persisted, +// they still reach submodule clones, and the URL scope keeps the header away +// from submodules hosted elsewhere. +const buildGitHttpAuthArgs = ( + cloneUrl: string, + credentials?: GitHttpCredentials, +) => { + if (!credentials) { + return []; + } + + const { origin } = new URL(cloneUrl); + const basic = Buffer.from( + `${credentials.username}:${credentials.password}`, + ).toString("base64"); + + return ["-c", `http.${origin}/.extraHeader=Authorization: Basic ${basic}`]; +}; + export const buildGitCloneCommand = ({ branch, + checkoutRevision, cloneUrl, + credentials, enableSubmodules, outputPath, }: { branch: string; + checkoutRevision?: string; cloneUrl: string; + credentials?: GitHttpCredentials; enableSubmodules?: boolean; outputPath: string; }) => { - const args = [ + const gitArgs = [ "git", "-c", "http.followRedirects=false", - "clone", - "--branch", - branch, - "--depth", - "1", + ...buildGitHttpAuthArgs(cloneUrl, credentials), ]; + const args = [...gitArgs, "clone", "--branch", branch, "--depth", "1"]; if (enableSubmodules) { args.push("--recurse-submodules"); @@ -41,7 +67,36 @@ export const buildGitCloneCommand = ({ args.push("--progress", "--", cloneUrl, outputPath); - return quoteShellArgs(args); + const cloneCommand = quoteShellArgs(args); + + if (!checkoutRevision) { + return cloneCommand; + } + + // The fetch needs the same per-invocation credentials because the stored + // remote has none. The subshell makes a failure anywhere in the chain fail + // the whole command under `set -e`. + return `(${[ + cloneCommand, + quoteShellArgs([ + ...gitArgs, + "-C", + outputPath, + "fetch", + "--depth", + "1", + "origin", + checkoutRevision, + ]), + quoteShellArgs([ + "git", + "-C", + outputPath, + "checkout", + "--detach", + checkoutRevision, + ]), + ].join(" && ")})`; }; export const buildKnownHostsCommand = ({ @@ -61,7 +116,7 @@ export const buildPrivateKeyWriteCommand = ( ) => { const encodedKey = Buffer.from(privateKey).toString("base64"); - return `echo ${quoteShellArgument(encodedKey)} | base64 -d > ${quoteShellArgument(targetPath)};`; + return `(umask 077 && echo ${quoteShellArgument(encodedKey)} | base64 -d > ${quoteShellArgument(targetPath)});`; }; export const buildGitSshEnvironmentCommand = ({ diff --git a/packages/server/src/utils/providers/git.ts b/packages/server/src/utils/providers/git.ts index 9a493d888aa..7b665d5639f 100644 --- a/packages/server/src/utils/providers/git.ts +++ b/packages/server/src/utils/providers/git.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import path, { join } from "node:path"; import { IS_CLOUD, paths } from "@dokploy/server/constants"; import { @@ -19,6 +20,7 @@ import { buildPrivateKeyWriteCommand, buildProviderEchoCommand, buildRemovePathCommand, + type GitHttpCredentials, } from "./commands"; interface CloneGitRepository { @@ -30,6 +32,7 @@ interface CloneGitRepository { serverId: string | null; type?: "application" | "compose"; outputPathOverride?: string; + checkoutRevision?: string; } const customGitUrlHostname = (customGitUrl: string) => { @@ -80,6 +83,7 @@ export const cloneGitRepository = async ({ enableSubmodules, serverId, outputPathOverride, + checkoutRevision, } = entity; const { SSH_PATH, COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId); @@ -90,15 +94,16 @@ export const cloneGitRepository = async ({ await assertCustomGitUrlAllowed(customGitUrl); const redactedCustomGitUrl = redactSensitiveText(customGitUrl); + const { cloneUrl, credentials } = splitHttpUrlCredentials(customGitUrl); - const temporalKeyPath = path.join("/tmp", "id_rsa"); - - if (customGitSSHKeyId) { - const sshKey = await findSSHKeyById(customGitSSHKeyId); - - command += buildPrivateKeyWriteCommand(sshKey.privateKey, temporalKeyPath); - command += `${quoteShellArgs(["chmod", "600", temporalKeyPath])};`; - } + // A fixed key path would be shared by concurrent deployments. + const temporalKeyPath = path.join( + "/tmp", + `dokploy-git-ssh-key-${randomUUID()}`, + ); + const sshKey = customGitSSHKeyId + ? await findSSHKeyById(customGitSSHKeyId) + : null; const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH; const outputPath = outputPathOverride ?? join(basePath, appName, "code"); const knownHostsPath = path.join(SSH_PATH, "known_hosts"); @@ -123,28 +128,72 @@ export const cloneGitRepository = async ({ }); } - if (customGitSSHKeyId) { + const removeKeyCommand = sshKey + ? buildRemovePathCommand(temporalKeyPath) + : ""; + if (sshKey) { const { port } = sanitizeRepoPathSSH(customGitUrl); command += buildGitSshEnvironmentCommand({ knownHostsPath, port, privateKeyPath: temporalKeyPath, }); + command += buildPrivateKeyWriteCommand(sshKey.privateKey, temporalKeyPath); } command += `if ! ${buildGitCloneCommand({ branch: customGitBranch, - cloneUrl: customGitUrl, + checkoutRevision, + cloneUrl, + credentials, enableSubmodules, outputPath, })}; then + ${removeKeyCommand} ${buildProviderEchoCommand(`❌ [ERROR] Fail to clone the repository ${redactedCustomGitUrl}`)} exit 1; fi + ${removeKeyCommand} `; return command; }; +const decodeUrlComponent = (value: string) => { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +const splitHttpUrlCredentials = ( + repositoryUrl: string, +): { cloneUrl: string; credentials?: GitHttpCredentials } => { + if (!isHttpOrHttps(repositoryUrl)) { + return { cloneUrl: repositoryUrl }; + } + + let url: URL; + try { + url = new URL(repositoryUrl); + } catch { + return { cloneUrl: repositoryUrl }; + } + + if (!url.password) { + return { cloneUrl: repositoryUrl }; + } + + const credentials = { + username: decodeUrlComponent(url.username), + password: decodeUrlComponent(url.password), + }; + url.username = ""; + url.password = ""; + + return { cloneUrl: url.toString(), credentials }; +}; + const isHttpOrHttps = (url: string): boolean => { const regex = /^https?:\/\//; return regex.test(url); diff --git a/packages/server/src/utils/providers/gitea.ts b/packages/server/src/utils/providers/gitea.ts index 097e4a7d29d..6d87a992e54 100644 --- a/packages/server/src/utils/providers/gitea.ts +++ b/packages/server/src/utils/providers/gitea.ts @@ -135,15 +135,13 @@ export const refreshGiteaToken = async (giteaProviderId: string) => { const buildGiteaCloneUrl = ( giteaUrl: string, - accessToken: string, owner: string, repository: string, ) => { const protocol = giteaUrl.startsWith("http://") ? "http" : "https"; const baseUrl = giteaUrl.replace(/^https?:\/\//, ""); const repoClone = `${owner}/${repository}.git`; - const cloneUrl = `${protocol}://oauth2:${accessToken}@${baseUrl}/${repoClone}`; - return cloneUrl; + return `${protocol}://${baseUrl}/${repoClone}`; }; export type ApplicationWithGitea = InferResultType< @@ -168,6 +166,7 @@ interface CloneGiteaRepository { serverId: string | null; type?: "application" | "compose"; outputPathOverride?: string; + checkoutRevision?: string; } export const cloneGiteaRepository = async ({ @@ -184,6 +183,7 @@ export const cloneGiteaRepository = async ({ enableSubmodules, serverId, outputPathOverride, + checkoutRevision, } = entity; const { APPLICATIONS_PATH, COMPOSE_PATH } = paths(!!serverId); @@ -208,19 +208,19 @@ export const cloneGiteaRepository = async ({ const repoClone = `${giteaOwner}/${giteaRepository}.git`; const baseUrl = await getGiteaProviderBaseUrl(giteaProvider); - const cloneUrl = buildGiteaCloneUrl( - baseUrl, - giteaProvider.accessToken!, - giteaOwner!, - giteaRepository!, - ); + const cloneUrl = buildGiteaCloneUrl(baseUrl, giteaOwner!, giteaRepository!); command += buildProviderEchoCommand( `Cloning Repo ${repoClone} to ${outputPath}: ✅`, ); command += `${buildGitCloneCommand({ branch: giteaBranch!, + checkoutRevision, cloneUrl, + credentials: { + username: "oauth2", + password: giteaProvider.accessToken ?? "", + }, enableSubmodules, outputPath, })};`; diff --git a/packages/server/src/utils/providers/github.ts b/packages/server/src/utils/providers/github.ts index 58fe863a557..5c3282e7be0 100644 --- a/packages/server/src/utils/providers/github.ts +++ b/packages/server/src/utils/providers/github.ts @@ -192,6 +192,7 @@ interface CloneGithubRepository { enableSubmodules: boolean; serverId: string | null; outputPathOverride?: string; + checkoutRevision?: string; } export const cloneGithubRepository = async ({ type = "application", @@ -208,6 +209,7 @@ export const cloneGithubRepository = async ({ enableSubmodules, serverId, outputPathOverride, + checkoutRevision, } = entity; const { APPLICATIONS_PATH, COMPOSE_PATH } = paths(!!serverId); @@ -238,14 +240,16 @@ export const cloneGithubRepository = async ({ const repoclone = `${cloneBase.host}/${owner}/${repository}.git`; command += buildRemovePathCommand(outputPath); command += buildCreateDirectoryCommand(outputPath); - const cloneUrl = `${cloneBase.protocol}//oauth2:${token}@${repoclone}`; + const cloneUrl = `${cloneBase.protocol}//${repoclone}`; command += buildProviderEchoCommand( `Cloning Repo ${repoclone} to ${outputPath}: ✅`, ); command += `${buildGitCloneCommand({ branch: branch!, + checkoutRevision, cloneUrl, + credentials: { username: "oauth2", password: token }, enableSubmodules, outputPath, })};`; diff --git a/packages/server/src/utils/providers/gitlab.ts b/packages/server/src/utils/providers/gitlab.ts index 1a5e0664c94..55c511c5b4a 100644 --- a/packages/server/src/utils/providers/gitlab.ts +++ b/packages/server/src/utils/providers/gitlab.ts @@ -173,14 +173,9 @@ const getGitlabProjectPathNamespace = (input: { }) => input.pathNamespace || [input.owner, input.repo].filter(Boolean).join("/"); -const getGitlabCloneUrl = ( - gitlab: GitlabInfo, - baseUrl: string, - repoClone: string, -) => { +const getGitlabCloneUrl = (baseUrl: string, repoClone: string) => { const isSecure = baseUrl.startsWith("https://"); - const cloneUrl = `http${isSecure ? "s" : ""}://oauth2:${gitlab?.accessToken}@${repoClone}`; - return cloneUrl; + return `http${isSecure ? "s" : ""}://${repoClone}`; }; interface CloneGitlabRepository { @@ -192,6 +187,7 @@ interface CloneGitlabRepository { serverId: string | null; type?: "application" | "compose"; outputPathOverride?: string; + checkoutRevision?: string; } export const cloneGitlabRepository = async ({ @@ -207,6 +203,7 @@ export const cloneGitlabRepository = async ({ enableSubmodules, serverId, outputPathOverride, + checkoutRevision, } = entity; const { COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId); @@ -237,13 +234,15 @@ export const cloneGitlabRepository = async ({ command += buildCreateDirectoryCommand(outputPath); const baseUrl = await getGitlabProviderBaseUrl(gitlab); const repoClone = getGitlabRepoClone(baseUrl, gitlabPathNamespace); - const cloneUrl = getGitlabCloneUrl(gitlab, baseUrl, repoClone); + const cloneUrl = getGitlabCloneUrl(baseUrl, repoClone); command += buildProviderEchoCommand( `Cloning Repo ${repoClone} to ${outputPath}: ✅`, ); command += `${buildGitCloneCommand({ branch: gitlabBranch!, + checkoutRevision, cloneUrl, + credentials: { username: "oauth2", password: gitlab.accessToken ?? "" }, enableSubmodules, outputPath, })};`;