Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add env var for ssh private key #396

Merged
merged 7 commits into from
Oct 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/envbuilder/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ func envbuilderCmd() serpent.Command {
}
}

if o.GitSSHPrivateKeyPath != "" && o.GitSSHPrivateKeyBase64 != "" {
return errors.New("cannot have both GIT_SSH_PRIVATE_KEY_PATH and GIT_SSH_PRIVATE_KEY_BASE64 set")
}

if o.GetCachedImage {
img, err := envbuilder.RunCacheProbe(inv.Context(), o)
if err != nil {
Expand Down
3 changes: 2 additions & 1 deletion docs/env-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
| `--git-clone-single-branch` | `ENVBUILDER_GIT_CLONE_SINGLE_BRANCH` | | Clone only a single branch of the Git repository. |
| `--git-username` | `ENVBUILDER_GIT_USERNAME` | | The username to use for Git authentication. This is optional. |
| `--git-password` | `ENVBUILDER_GIT_PASSWORD` | | The password to use for Git authentication. This is optional. |
| `--git-ssh-private-key-path` | `ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH` | | Path to an SSH private key to be used for Git authentication. |
| `--git-ssh-private-key-path` | `ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH` | | Path to an SSH private key to be used for Git authentication. If this is set, then GIT_SSH_PRIVATE_KEY_BASE64 cannot be set. |
| `--git-ssh-private-key-base64` | `ENVBUILDER_GIT_SSH_PRIVATE_KEY_BASE64` | | Base64 encoded SSH private key to be used for Git authentication. If this is set, then GIT_SSH_PRIVATE_KEY_PATH cannot be set. |
| `--git-http-proxy-url` | `ENVBUILDER_GIT_HTTP_PROXY_URL` | | The URL for the HTTP proxy. This is optional. |
| `--workspace-folder` | `ENVBUILDER_WORKSPACE_FOLDER` | | The path to the workspace folder that will be built. This is optional. |
| `--ssl-cert-base64` | `ENVBUILDER_SSL_CERT_BASE64` | | The content of an SSL cert file. This is useful for self-signed certificates. |
Expand Down
28 changes: 28 additions & 0 deletions git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package git

import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -181,6 +182,22 @@ func ReadPrivateKey(path string) (gossh.Signer, error) {
return k, nil
}

// DecodeBase64PrivateKey attempts to decode a base64 encoded private
// key and returns an ssh.Signer
func DecodeBase64PrivateKey(key string) (gossh.Signer, error) {
bs, err := base64.StdEncoding.DecodeString(key)
if err != nil {
return nil, fmt.Errorf("decode base64: %w", err)
}

k, err := gossh.ParsePrivateKey(bs)
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}

return k, nil
}

// LogHostKeyCallback is a HostKeyCallback that just logs host keys
// and does nothing else.
func LogHostKeyCallback(logger func(string, ...any)) gossh.HostKeyCallback {
Expand Down Expand Up @@ -273,6 +290,17 @@ func SetupRepoAuth(logf func(string, ...any), options *options.Options) transpor
}
}

// If no path was provided, fall back to the environment variable
if options.GitSSHPrivateKeyBase64 != "" {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be worth thinking about what the behavior should be if both key path and key base64 is given. Do we exit immediately (invalid input)? Do we prefer one over the other? How do we inform the user?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest, I think we should error if both are provided. Removes any ambiguity.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed with erroring, have made that change 👍

s, err := DecodeBase64PrivateKey(options.GitSSHPrivateKeyBase64)
if err != nil {
logf("❌ Failed to decode base 64 private key: %s", err.Error())
} else {
logf("🔑 Using %s key!", s.PublicKey().Type())
signer = s
}
}

// If no SSH key set, fall back to agent auth.
if signer == nil {
logf("🔑 No SSH key found, falling back to agent!")
Expand Down
21 changes: 21 additions & 0 deletions git/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package git_test
import (
"context"
"crypto/ed25519"
"encoding/base64"
"fmt"
"io"
"net/http/httptest"
Expand Down Expand Up @@ -433,6 +434,22 @@ func TestSetupRepoAuth(t *testing.T) {
require.Equal(t, actualSigner, pk.Signer)
})

t.Run("SSH/Base64PrivateKey", func(t *testing.T) {
opts := &options.Options{
GitURL: "ssh://git@host.tld:repo/path",
GitSSHPrivateKeyBase64: base64EncodeTestPrivateKey(),
}
auth := git.SetupRepoAuth(t.Logf, opts)

pk, ok := auth.(*gitssh.PublicKeys)
require.True(t, ok)
require.NotNil(t, pk.Signer)

actualSigner, err := gossh.ParsePrivateKey([]byte(testKey))
require.NoError(t, err)
require.Equal(t, actualSigner, pk.Signer)
})

t.Run("SSH/NoAuthMethods", func(t *testing.T) {
opts := &options.Options{
GitURL: "ssh://git@host.tld:repo/path",
Expand Down Expand Up @@ -502,3 +519,7 @@ func writeTestPrivateKey(t *testing.T) string {
require.NoError(t, os.WriteFile(kPath, []byte(testKey), 0o600))
return kPath
}

func base64EncodeTestPrivateKey() string {
return base64.StdEncoding.EncodeToString([]byte(testKey))
}
61 changes: 61 additions & 0 deletions integration/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"encoding/pem"
Expand Down Expand Up @@ -32,6 +33,8 @@ import (
"github.com/coder/envbuilder/testutil/gittest"
"github.com/coder/envbuilder/testutil/mwtest"
"github.com/coder/envbuilder/testutil/registrytest"
"github.com/go-git/go-billy/v5/osfs"
gossh "golang.org/x/crypto/ssh"

clitypes "github.com/docker/cli/cli/config/types"
"github.com/docker/docker/api/types"
Expand All @@ -58,6 +61,16 @@ const (
testContainerLabel = "envbox-integration-test"
testImageAlpine = "localhost:5000/envbuilder-test-alpine:latest"
testImageUbuntu = "localhost:5000/envbuilder-test-ubuntu:latest"

// nolint:gosec // Throw-away key for testing. DO NOT REUSE.
testSSHKey = `-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBXOGgAge/EbcejqASqZa6s8PFXZle56DiGEt0VYnljuwAAAKgM05mUDNOZ
lAAAAAtzc2gtZWQyNTUxOQAAACBXOGgAge/EbcejqASqZa6s8PFXZle56DiGEt0VYnljuw
AAAEDCawwtjrM4AGYXD1G6uallnbsgMed4cfkFsQ+mLZtOkFc4aACB78Rtx6OoBKplrqzw
8VdmV7noOIYS3RVieWO7AAAAHmNpYW5AY2RyLW1icC1mdmZmdzBuOHEwNXAuaG9tZQECAw
QFBgc=
-----END OPENSSH PRIVATE KEY-----`
)

func TestLogs(t *testing.T) {
Expand Down Expand Up @@ -378,6 +391,54 @@ func TestSucceedsGitAuth(t *testing.T) {
require.Contains(t, gitConfig, srv.URL)
}

func TestGitSSHAuth(t *testing.T) {
t.Parallel()

base64Key := base64.StdEncoding.EncodeToString([]byte(testSSHKey))

t.Run("Base64/Success", func(t *testing.T) {
signer, err := gossh.ParsePrivateKey([]byte(testSSHKey))
require.NoError(t, err)
require.NotNil(t, signer)

tmpDir := t.TempDir()
srvFS := osfs.New(tmpDir, osfs.WithChrootOS())

_ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "Dockerfile", "FROM "+testImageAlpine, "Initial commit"))
tr := gittest.NewServerSSH(t, srvFS, signer.PublicKey())

_, err = runEnvbuilder(t, runOpts{env: []string{
envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"),
envbuilderEnv("GIT_URL", tr.String()+"."),
envbuilderEnv("GIT_SSH_PRIVATE_KEY_BASE64", base64Key),
}})
// TODO: Ensure it actually clones but this does mean we have
// successfully authenticated.
require.ErrorContains(t, err, "repository not found")
})

t.Run("Base64/Failure", func(t *testing.T) {
_, randomKey, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
signer, err := gossh.NewSignerFromKey(randomKey)
require.NoError(t, err)
require.NotNil(t, signer)

tmpDir := t.TempDir()
srvFS := osfs.New(tmpDir, osfs.WithChrootOS())

_ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "Dockerfile", "FROM "+testImageAlpine, "Initial commit"))
tr := gittest.NewServerSSH(t, srvFS, signer.PublicKey())

_, err = runEnvbuilder(t, runOpts{env: []string{
envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"),
envbuilderEnv("GIT_URL", tr.String()+"."),
envbuilderEnv("GIT_SSH_PRIVATE_KEY_BASE64", base64Key),
}})
require.ErrorContains(t, err, "handshake failed")
})
}

func TestSucceedsGitAuthInURL(t *testing.T) {
t.Parallel()
srv := gittest.CreateGitServer(t, gittest.Options{
Expand Down
19 changes: 15 additions & 4 deletions options/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ type Options struct {
// GitSSHPrivateKeyPath is the path to an SSH private key to be used for
// Git authentication.
GitSSHPrivateKeyPath string
// GitSSHPrivateKeyBase64 is the content of an SSH private key to be used
// for Git authentication.
GitSSHPrivateKeyBase64 string
// GitHTTPProxyURL is the URL for the HTTP proxy. This is optional.
GitHTTPProxyURL string
// WorkspaceFolder is the path to the workspace folder that will be built.
Expand Down Expand Up @@ -358,10 +361,18 @@ func (o *Options) CLI() serpent.OptionSet {
Description: "The password to use for Git authentication. This is optional.",
},
{
Flag: "git-ssh-private-key-path",
Env: WithEnvPrefix("GIT_SSH_PRIVATE_KEY_PATH"),
Value: serpent.StringOf(&o.GitSSHPrivateKeyPath),
Description: "Path to an SSH private key to be used for Git authentication.",
Flag: "git-ssh-private-key-path",
Env: WithEnvPrefix("GIT_SSH_PRIVATE_KEY_PATH"),
Value: serpent.StringOf(&o.GitSSHPrivateKeyPath),
Description: "Path to an SSH private key to be used for Git authentication." +
" If this is set, then GIT_SSH_PRIVATE_KEY_BASE64 cannot be set.",
},
{
Flag: "git-ssh-private-key-base64",
Env: WithEnvPrefix("GIT_SSH_PRIVATE_KEY_BASE64"),
Value: serpent.StringOf(&o.GitSSHPrivateKeyBase64),
Description: "Base64 encoded SSH private key to be used for Git authentication." +
" If this is set, then GIT_SSH_PRIVATE_KEY_PATH cannot be set.",
},
{
Flag: "git-http-proxy-url",
Expand Down
7 changes: 6 additions & 1 deletion options/testdata/options.golden
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,13 @@ OPTIONS:
--git-password string, $ENVBUILDER_GIT_PASSWORD
The password to use for Git authentication. This is optional.

--git-ssh-private-key-base64 string, $ENVBUILDER_GIT_SSH_PRIVATE_KEY_BASE64
Base64 encoded SSH private key to be used for Git authentication. If
this is set, then GIT_SSH_PRIVATE_KEY_PATH cannot be set.

--git-ssh-private-key-path string, $ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH
Path to an SSH private key to be used for Git authentication.
Path to an SSH private key to be used for Git authentication. If this
is set, then GIT_SSH_PRIVATE_KEY_BASE64 cannot be set.

--git-url string, $ENVBUILDER_GIT_URL
The URL of a Git repository containing a Devcontainer or Docker image
Expand Down