diff --git a/registry/coder/modules/git-clone/README.md b/registry/coder/modules/git-clone/README.md index b234da1f1..da0b616d0 100644 --- a/registry/coder/modules/git-clone/README.md +++ b/registry/coder/modules/git-clone/README.md @@ -14,7 +14,7 @@ This module allows you to automatically clone a repository by URL and skip if it module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "2.0.3" + version = "2.0.4" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" } @@ -28,7 +28,7 @@ module "git-clone" { module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "2.0.3" + version = "2.0.4" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" base_dir = "~/projects/coder" @@ -43,7 +43,7 @@ To use with [Git Authentication](https://coder.com/docs/v2/latest/admin/git-prov module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "2.0.3" + version = "2.0.4" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" } @@ -70,7 +70,7 @@ data "coder_parameter" "git_repo" { module "git_clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "2.0.3" + version = "2.0.4" agent_id = coder_agent.example.id url = data.coder_parameter.git_repo.value } @@ -105,7 +105,7 @@ Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `g module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "2.0.3" + version = "2.0.4" agent_id = coder_agent.example.id url = "https://github.example.com/coder/coder/tree/feat/example" git_providers = { @@ -125,7 +125,7 @@ To GitLab clone with a specific branch like `feat/example` module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "2.0.3" + version = "2.0.4" agent_id = coder_agent.example.id url = "https://gitlab.com/coder/coder/-/tree/feat/example" } @@ -137,7 +137,7 @@ Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com` module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "2.0.3" + version = "2.0.4" agent_id = coder_agent.example.id url = "https://gitlab.example.com/coder/coder/-/tree/feat/example" git_providers = { @@ -159,7 +159,7 @@ For example, to clone the `feat/example` branch: module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "2.0.3" + version = "2.0.4" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" branch_name = "feat/example" @@ -177,7 +177,7 @@ For example, this will clone into the `~/projects/coder/coder-dev` folder: module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "2.0.3" + version = "2.0.4" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" folder_name = "coder-dev" @@ -202,7 +202,7 @@ fetches, or partial clones. module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "2.0.3" + version = "2.0.4" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" extra_args = [ @@ -223,7 +223,7 @@ This is useful for preparing the environment or validating prerequisites before module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "2.0.3" + version = "2.0.4" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" pre_clone_script = <<-EOT @@ -246,7 +246,7 @@ This is useful for running initialization tasks like installing dependencies or module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "2.0.3" + version = "2.0.4" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" post_clone_script = <<-EOT diff --git a/registry/coder/modules/git-clone/main.test.ts b/registry/coder/modules/git-clone/main.test.ts index 8e670bd0d..d906f5ad5 100644 --- a/registry/coder/modules/git-clone/main.test.ts +++ b/registry/coder/modules/git-clone/main.test.ts @@ -492,6 +492,75 @@ describe("git-clone", async () => { expect(output.exitCode).toBe(0); }); + it("does not treat an scp-style repository path as an SSH port", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "git@forgejo.example.com:2222/example/repo.git", + base_dir: "/tmp", + }); + const setupFakeTools = [ + installFakeGit, + "cat > /usr/local/bin/ssh-keyscan <<'SHIM'", + "#!/bin/sh", + 'for arg in "$@"; do', + " [ \"$arg\" = '-p' ] && exit 1", + "done", + "echo 'forgejo.example.com ssh-rsa AAAAB3NzaC1yc2EFAKE'", + "SHIM", + "chmod +x /usr/local/bin/ssh-keyscan", + ].join("\n"); + const output = await executeScriptInContainer( + state, + "alpine/git", + setupFakeTools, + ); + expect(output.stdout).toContain( + "Adding host key for forgejo.example.com to known_hosts...", + ); + expect(output.stdout.join("\n")).not.toContain("ssh-keyscan failed"); + expect(output.exitCode).toBe(0); + }); + + it("adds SSH host key from a non-default port to known_hosts", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "ssh://git@forgejo.example.com:2222/example/repo.git", + base_dir: "/tmp", + }); + const setupFakeTools = [ + installFakeGit, + "cat > /usr/local/bin/ssh-keyscan <<'SHIM'", + "#!/bin/sh", + "port=''", + "host=''", + 'while [ "$#" -gt 0 ]; do', + ' case "$1" in', + ' -p) port="$2"; shift 2 ;;', + " -t) shift 2 ;;", + " -H) shift ;;", + ' *) host="$1"; shift ;;', + " esac", + "done", + '[ "$port" = "2222" ] && [ "$host" = "forgejo.example.com" ] || exit 1', + "echo '[forgejo.example.com]:2222 ssh-rsa AAAAB3NzaC1yc2EFAKE'", + "SHIM", + "chmod +x /usr/local/bin/ssh-keyscan", + ].join("\n"); + const output = await executeScriptInContainer( + state, + "alpine/git", + setupFakeTools, + ); + expect(output.stdout).toContain( + "Adding host key for [forgejo.example.com]:2222 to known_hosts...", + ); + expect(output.stdout).toContain( + "Host key for [forgejo.example.com]:2222 added to known_hosts.", + ); + expect(output.stdout.join("\n")).not.toContain("ssh-keyscan failed"); + expect(output.exitCode).toBe(0); + }); + it("uses StrictHostKeyChecking=accept-new when ssh-keyscan is unavailable for SSH URLs", async () => { const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", @@ -539,6 +608,57 @@ describe("git-clone", async () => { expect(output.exitCode).toBe(0); }); + it("skips non-default SSH port scan when host is already in known_hosts", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "ssh://git@forgejo.example.com:2222/example/repo.git", + base_dir: "/tmp", + }); + const setupWithExistingKey = [ + installFakeGit, + "mkdir -p /root/.ssh && chmod 700 /root/.ssh", + "echo '[forgejo.example.com]:2222 ssh-rsa AAAAB3NzaC1yc2EEXISTING' >> /root/.ssh/known_hosts", + "chmod 600 /root/.ssh/known_hosts", + ].join("\n"); + const output = await executeScriptInContainer( + state, + "alpine/git", + setupWithExistingKey, + ); + expect(output.stdout).not.toContain( + "Adding host key for [forgejo.example.com]:2222 to known_hosts...", + ); + expect(output.exitCode).toBe(0); + }); + + it("does not reuse a default-port key for a non-default SSH port", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "ssh://git@forgejo.example.com:2222/example/repo.git", + base_dir: "/tmp", + }); + const setupWithDefaultPortKey = [ + installFakeGit, + "mkdir -p /root/.ssh && chmod 700 /root/.ssh", + "echo 'forgejo.example.com ssh-rsa AAAAB3NzaC1yc2EEXISTING' >> /root/.ssh/known_hosts", + "chmod 600 /root/.ssh/known_hosts", + "cat > /usr/local/bin/ssh-keyscan <<'SHIM'", + "#!/bin/sh", + "echo '[forgejo.example.com]:2222 ssh-rsa AAAAB3NzaC1yc2EFAKE'", + "SHIM", + "chmod +x /usr/local/bin/ssh-keyscan", + ].join("\n"); + const output = await executeScriptInContainer( + state, + "alpine/git", + setupWithDefaultPortKey, + ); + expect(output.stdout).toContain( + "Adding host key for [forgejo.example.com]:2222 to known_hosts...", + ); + expect(output.exitCode).toBe(0); + }); + it("fails when post-clone script fails", async () => { const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", diff --git a/registry/coder/modules/git-clone/run.sh b/registry/coder/modules/git-clone/run.sh index dc1343915..b9d2ff83a 100644 --- a/registry/coder/modules/git-clone/run.sh +++ b/registry/coder/modules/git-clone/run.sh @@ -53,19 +53,55 @@ fi # For SSH URLs, populate known_hosts before cloning to prevent "Host key verification failed" # on new workspaces where known_hosts is empty. if echo "$REPO_URL" | grep -qE '^git@|^ssh://'; then - SSH_HOST=$(echo "$REPO_URL" | sed -E 's|^(ssh://)?([^@/]+@)?([^:/]+).*|\3|') + SSH_PORT="" + if [[ "$REPO_URL" == ssh://* ]]; then + SSH_AUTHORITY="$${REPO_URL#ssh://}" + SSH_AUTHORITY="$${SSH_AUTHORITY%%/*}" + SSH_AUTHORITY="$${SSH_AUTHORITY##*@}" + if [[ "$SSH_AUTHORITY" == \[*\]* ]]; then + SSH_HOST="$${SSH_AUTHORITY%%]*}" + SSH_HOST="$${SSH_HOST#[}" + SSH_PORT="$${SSH_AUTHORITY#*]}" + SSH_PORT="$${SSH_PORT#:}" + else + SSH_HOST="$${SSH_AUTHORITY%%:*}" + if [[ "$SSH_AUTHORITY" == *:* ]]; then + SSH_PORT="$${SSH_AUTHORITY##*:}" + fi + fi + if [[ "$SSH_PORT" == *[!0-9]* ]]; then + SSH_PORT="" + fi + while [[ "$SSH_PORT" == 0* && "$SSH_PORT" != "0" ]]; do + SSH_PORT="$${SSH_PORT#0}" + done + else + SSH_HOST="$${REPO_URL#*@}" + SSH_HOST="$${SSH_HOST%%:*}" + SSH_HOST="$${SSH_HOST%%/*}" + fi + + SSH_KNOWN_HOST="$SSH_HOST" + if [ -n "$SSH_PORT" ] && [ "$SSH_PORT" != "22" ]; then + SSH_KNOWN_HOST="[$SSH_HOST]:$SSH_PORT" + fi + mkdir -p "$HOME/.ssh" chmod 700 "$HOME/.ssh" touch "$HOME/.ssh/known_hosts" chmod 600 "$HOME/.ssh/known_hosts" - if ! ssh-keygen -F "$SSH_HOST" > /dev/null 2>&1; then - echo "Adding host key for $SSH_HOST to known_hosts..." + if ! ssh-keygen -F "$SSH_KNOWN_HOST" > /dev/null 2>&1; then + echo "Adding host key for $SSH_KNOWN_HOST to known_hosts..." if command -v ssh-keyscan > /dev/null 2>&1; then - if KNOWN_HOST_ENTRY=$(ssh-keyscan -H -t rsa,ecdsa,ed25519 "$SSH_HOST" 2> /dev/null) && [ -n "$KNOWN_HOST_ENTRY" ]; then + ssh_keyscan_args=(-H -t "rsa,ecdsa,ed25519") + if [ -n "$SSH_PORT" ]; then + ssh_keyscan_args+=(-p "$SSH_PORT") + fi + if KNOWN_HOST_ENTRY=$(ssh-keyscan "$${ssh_keyscan_args[@]}" "$SSH_HOST" 2> /dev/null) && [ -n "$KNOWN_HOST_ENTRY" ]; then printf '%s\n' "$KNOWN_HOST_ENTRY" >> "$HOME/.ssh/known_hosts" - echo "Host key for $SSH_HOST added to known_hosts." + echo "Host key for $SSH_KNOWN_HOST added to known_hosts." else - echo "WARNING: ssh-keyscan failed for $SSH_HOST. Clone may fail if host key is not trusted." + echo "WARNING: ssh-keyscan failed for $SSH_KNOWN_HOST. Clone may fail if host key is not trusted." fi else echo "ssh-keyscan not available. Using StrictHostKeyChecking=accept-new."