diff --git a/README.md b/README.md index e9f0abcc..0b230cb8 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,12 @@ DOCKER_CONFIG_BASE64=ewoJImF1dGhzIjogewoJCSJodHRwczovL2luZGV4LmRvY2tlci5pby92MS8 ## Git Authentication -`GIT_USERNAME` and `GIT_PASSWORD` are environment variables to provide Git authentication for private repositories. +Two methods of authentication are supported: + +### HTTP Authentication + +If the `GIT_URL` supplied starts with `http://` or `https://`, envbuilder will +supply HTTP basic authentication using `GIT_USERNAME` and `GIT_PASSWORD`, if set. For access token-based authentication, follow the following schema (if empty, there's no need to provide the field): @@ -161,6 +166,42 @@ resource "docker_container" "dev" { } ``` +### SSH Authentication + +If the `GIT_URL` supplied does not start with `http://` or `https://`, +envbuilder will assume SSH authentication. You have the following options: + +1. Public/Private key authentication: set `GIT_SSH_KEY_PATH` to the path of an + SSH private key mounted inside the container. Envbuilder will use this SSH + key to authenticate. Example: + + ```bash + docker run -it --rm \ + -v /tmp/envbuilder:/workspaces \ + -e GIT_URL=git@example.com:path/to/private/repo.git \ + -e GIT_SSH_KEY_PATH=/.ssh/id_rsa \ + -v /home/user/id_rsa:/.ssh/id_rsa \ + -e INIT_SCRIPT=bash \ + ghcr.io/coder/envbuilder + ``` + +1. Agent-based authentication: set `SSH_AUTH_SOCK` and mount in your agent socket, for example: + + ```bash + docker run -it --rm \ + -v /tmp/envbuilder:/workspaces \ + -e GIT_URL=git@example.com:path/to/private/repo.git \ + -e INIT_SCRIPT=bash \ + -e SSH_AUTH_SOCK=/tmp/ssh-auth-sock \ + -v $SSH_AUTH_SOCK:/tmp/ssh-auth-sock \ + ghcr.io/coder/envbuilder + ``` + +> Note: by default, envbuilder will accept and log all host keys. If you need +> strict host key checking, set `SSH_KNOWN_HOSTS` and mount in a `known_hosts` +> file. + + ## Layer Caching Cache layers in a container registry to speed up builds. To enable caching, [authenticate with your registry](#container-registry-authentication) and set the `CACHE_REPO` environment variable. @@ -288,6 +329,7 @@ On MacOS or Windows systems, we recommend either using a VM or the provided `.de | `--git-clone-single-branch` | `GIT_CLONE_SINGLE_BRANCH` | | Clone only a single branch of the Git repository. | | `--git-username` | `GIT_USERNAME` | | The username to use for Git authentication. This is optional. | | `--git-password` | `GIT_PASSWORD` | | The password to use for Git authentication. This is optional. | +| `--git-ssh-private-key-path` | `GIT_SSH_PRIVATE_KEY_PATH` | | Path to an SSH private key to be used for Git authentication. | | `--git-http-proxy-url` | `GIT_HTTP_PROXY_URL` | | The URL for the HTTP proxy. This is optional. | | `--workspace-folder` | `WORKSPACE_FOLDER` | | The path to the workspace folder that will be built. This is optional. | | `--ssl-cert-base64` | `SSL_CERT_BASE64` | | The content of an SSL cert file. This is useful for self-signed certificates. | diff --git a/envbuilder.go b/envbuilder.go index 83ca67d9..eb1fafea 100644 --- a/envbuilder.go +++ b/envbuilder.go @@ -45,7 +45,6 @@ import ( "github.com/go-git/go-billy/v5" "github.com/go-git/go-billy/v5/osfs" "github.com/go-git/go-git/v5/plumbing/transport" - githttp "github.com/go-git/go-git/v5/plumbing/transport/http" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/sirupsen/logrus" @@ -195,14 +194,7 @@ func Run(ctx context.Context, options Options) error { CABundle: caBundle, } - if options.GitUsername != "" || options.GitPassword != "" { - // NOTE: we previously inserted the credentials into the repo URL. - // This was removed in https://github.com/coder/envbuilder/pull/141 - cloneOpts.RepoAuth = &githttp.BasicAuth{ - Username: options.GitUsername, - Password: options.GitPassword, - } - } + cloneOpts.RepoAuth = SetupRepoAuth(&options) if options.GitHTTPProxyURL != "" { cloneOpts.ProxyOptions = transport.ProxyOptions{ URL: options.GitHTTPProxyURL, diff --git a/git.go b/git.go index 9f542add..692434bd 100644 --- a/git.go +++ b/git.go @@ -4,8 +4,13 @@ import ( "context" "errors" "fmt" + "io" + "net" "net/url" + "os" + "strings" + "github.com/coder/coder/v2/codersdk" "github.com/go-git/go-billy/v5" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" @@ -13,7 +18,12 @@ import ( "github.com/go-git/go-git/v5/plumbing/protocol/packp/capability" "github.com/go-git/go-git/v5/plumbing/protocol/packp/sideband" "github.com/go-git/go-git/v5/plumbing/transport" + githttp "github.com/go-git/go-git/v5/plumbing/transport/http" + gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh" "github.com/go-git/go-git/v5/storage/filesystem" + "github.com/skeema/knownhosts" + "golang.org/x/crypto/ssh" + gossh "golang.org/x/crypto/ssh" ) type CloneRepoOptions struct { @@ -113,3 +123,131 @@ func CloneRepo(ctx context.Context, opts CloneRepoOptions) (bool, error) { } return true, nil } + +// ReadPrivateKey attempts to read an SSH private key from path +// and returns an ssh.Signer. +func ReadPrivateKey(path string) (gossh.Signer, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open private key file: %w", err) + } + defer f.Close() + bs, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("read private key file: %w", err) + } + k, err := gossh.ParsePrivateKey(bs) + if err != nil { + return nil, fmt.Errorf("parse private key file: %w", err) + } + return k, nil +} + +// LogHostKeyCallback is a HostKeyCallback that just logs host keys +// and does nothing else. +func LogHostKeyCallback(log LoggerFunc) gossh.HostKeyCallback { + return func(hostname string, remote net.Addr, key gossh.PublicKey) error { + var sb strings.Builder + _ = knownhosts.WriteKnownHost(&sb, hostname, remote, key) + // skeema/knownhosts uses a fake public key to determine the host key + // algorithms. Ignore this one. + if s := sb.String(); !strings.Contains(s, "fake-public-key ZmFrZSBwdWJsaWMga2V5") { + log(codersdk.LogLevelInfo, "#1: 🔑 Got host key: %s", strings.TrimSpace(s)) + } + return nil + } +} + +// SetupRepoAuth determines the desired AuthMethod based on options.GitURL: +// +// | Git URL format | GIT_USERNAME | GIT_PASSWORD | Auth Method | +// | ------------------------|--------------|--------------|-------------| +// | https?://host.tld/repo | Not Set | Not Set | None | +// | https?://host.tld/repo | Not Set | Set | HTTP Basic | +// | https?://host.tld/repo | Set | Not Set | HTTP Basic | +// | https?://host.tld/repo | Set | Set | HTTP Basic | +// | All other formats | - | - | SSH | +// +// For SSH authentication, the default username is "git" but will honour +// GIT_USERNAME if set. +// +// If SSH_PRIVATE_KEY_PATH is set, an SSH private key will be read from +// that path and the SSH auth method will be configured with that key. +// +// If SSH_KNOWN_HOSTS is not set, the SSH auth method will be configured +// to accept and log all host keys. Otherwise, host key checking will be +// performed as usual. +func SetupRepoAuth(options *Options) transport.AuthMethod { + if options.GitURL == "" { + options.Logger(codersdk.LogLevelInfo, "#1: ❔ No Git URL supplied!") + return nil + } + if strings.HasPrefix(options.GitURL, "http://") || strings.HasPrefix(options.GitURL, "https://") { + // Special case: no auth + if options.GitUsername == "" && options.GitPassword == "" { + options.Logger(codersdk.LogLevelInfo, "#1: 👤 Using no authentication!") + return nil + } + // Basic Auth + // NOTE: we previously inserted the credentials into the repo URL. + // This was removed in https://github.com/coder/envbuilder/pull/141 + options.Logger(codersdk.LogLevelInfo, "#1: 🔒 Using HTTP basic authentication!") + return &githttp.BasicAuth{ + Username: options.GitUsername, + Password: options.GitPassword, + } + } + + // Generally git clones over SSH use the 'git' user, but respect + // GIT_USERNAME if set. + if options.GitUsername == "" { + options.GitUsername = "git" + } + + // Assume SSH auth for all other formats. + options.Logger(codersdk.LogLevelInfo, "#1: 🔑 Using SSH authentication!") + + var signer ssh.Signer + if options.GitSSHPrivateKeyPath != "" { + s, err := ReadPrivateKey(options.GitSSHPrivateKeyPath) + if err != nil { + options.Logger(codersdk.LogLevelError, "#1: ❌ Failed to read private key from %s: %s", options.GitSSHPrivateKeyPath, err.Error()) + } else { + options.Logger(codersdk.LogLevelInfo, "#1: 🔑 Using %s key!", s.PublicKey().Type()) + signer = s + } + } + + // If no SSH key set, fall back to agent auth. + if signer == nil { + options.Logger(codersdk.LogLevelError, "#1: 🔑 No SSH key found, falling back to agent!") + auth, err := gitssh.NewSSHAgentAuth(options.GitUsername) + if err != nil { + options.Logger(codersdk.LogLevelError, "#1: ❌ Failed to connect to SSH agent: %s", err.Error()) + return nil // nothing else we can do + } + if os.Getenv("SSH_KNOWN_HOSTS") == "" { + options.Logger(codersdk.LogLevelWarn, "#1: 🔓 SSH_KNOWN_HOSTS not set, accepting all host keys!") + auth.HostKeyCallback = LogHostKeyCallback(options.Logger) + } + return auth + } + + auth := &gitssh.PublicKeys{ + User: options.GitUsername, + Signer: signer, + } + + // Generally git clones over SSH use the 'git' user, but respect + // GIT_USERNAME if set. + if auth.User == "" { + auth.User = "git" + } + + // Duplicated code due to Go's type system. + if os.Getenv("SSH_KNOWN_HOSTS") == "" { + options.Logger(codersdk.LogLevelWarn, "#1: 🔓 SSH_KNOWN_HOSTS not set, accepting all host keys!") + auth.HostKeyCallback = LogHostKeyCallback(options.Logger) + } + return auth +} diff --git a/git_test.go b/git_test.go index 0d034728..5a575723 100644 --- a/git_test.go +++ b/git_test.go @@ -2,20 +2,26 @@ package envbuilder_test import ( "context" + "crypto/ed25519" "fmt" "io" "net/http/httptest" "net/url" "os" + "path/filepath" "regexp" "testing" + "github.com/coder/coder/v2/codersdk" "github.com/coder/envbuilder" "github.com/coder/envbuilder/testutil/gittest" "github.com/go-git/go-billy/v5" "github.com/go-git/go-billy/v5/memfs" + "github.com/go-git/go-billy/v5/osfs" githttp "github.com/go-git/go-git/v5/plumbing/transport/http" + gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh" "github.com/stretchr/testify/require" + gossh "golang.org/x/crypto/ssh" ) func TestCloneRepo(t *testing.T) { @@ -159,6 +165,225 @@ func TestCloneRepo(t *testing.T) { } } +func TestCloneRepoSSH(t *testing.T) { + t.Parallel() + + t.Run("AuthSuccess", func(t *testing.T) { + t.Parallel() + + // TODO: test the rest of the cloning flow. This just tests successful auth. + tmpDir := t.TempDir() + srvFS := osfs.New(tmpDir, osfs.WithChrootOS()) + + _ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "README.md", "Hello, world!", "Wow!")) + key := randKeygen(t) + tr := gittest.NewServerSSH(t, srvFS, key.PublicKey()) + gitURL := tr.String() + clientFS := memfs.New() + + cloned, err := envbuilder.CloneRepo(context.Background(), envbuilder.CloneRepoOptions{ + Path: "/workspace", + RepoURL: gitURL, + Storage: clientFS, + RepoAuth: &gitssh.PublicKeys{ + User: "", + Signer: key, + HostKeyCallbackHelper: gitssh.HostKeyCallbackHelper{ + // Not testing host keys here. + HostKeyCallback: gossh.InsecureIgnoreHostKey(), + }, + }, + }) + // TODO: ideally, we want to test the entire cloning flow. + // For now, this indicates successful ssh key auth. + require.ErrorContains(t, err, "repository not found") + require.False(t, cloned) + }) + + t.Run("AuthFailure", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + srvFS := osfs.New(tmpDir, osfs.WithChrootOS()) + + _ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "README.md", "Hello, world!", "Wow!")) + key := randKeygen(t) + tr := gittest.NewServerSSH(t, srvFS, key.PublicKey()) + gitURL := tr.String() + clientFS := memfs.New() + + anotherKey := randKeygen(t) + cloned, err := envbuilder.CloneRepo(context.Background(), envbuilder.CloneRepoOptions{ + Path: "/workspace", + RepoURL: gitURL, + Storage: clientFS, + RepoAuth: &gitssh.PublicKeys{ + User: "", + Signer: anotherKey, + HostKeyCallbackHelper: gitssh.HostKeyCallbackHelper{ + // Not testing host keys here. + HostKeyCallback: gossh.InsecureIgnoreHostKey(), + }, + }, + }) + require.ErrorContains(t, err, "handshake failed") + require.False(t, cloned) + }) + + // nolint: paralleltest // t.Setenv + t.Run("PrivateKeyHostKeyMismatch", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + srvFS := osfs.New(tmpDir, osfs.WithChrootOS()) + + _ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "README.md", "Hello, world!", "Wow!")) + key := randKeygen(t) + tr := gittest.NewServerSSH(t, srvFS, key.PublicKey()) + gitURL := tr.String() + clientFS := memfs.New() + + cloned, err := envbuilder.CloneRepo(context.Background(), envbuilder.CloneRepoOptions{ + Path: "/workspace", + RepoURL: gitURL, + Storage: clientFS, + RepoAuth: &gitssh.PublicKeys{ + User: "", + Signer: key, + HostKeyCallbackHelper: gitssh.HostKeyCallbackHelper{ + HostKeyCallback: gossh.FixedHostKey(randKeygen(t).PublicKey()), + }, + }, + }) + require.ErrorContains(t, err, "ssh: host key mismatch") + require.False(t, cloned) + }) +} + +// nolint:paralleltest // t.Setenv for SSH_AUTH_SOCK +func TestSetupRepoAuth(t *testing.T) { + t.Setenv("SSH_AUTH_SOCK", "") + t.Run("Empty", func(t *testing.T) { + opts := &envbuilder.Options{ + Logger: testLog(t), + } + auth := envbuilder.SetupRepoAuth(opts) + require.Nil(t, auth) + }) + + t.Run("HTTP/NoAuth", func(t *testing.T) { + opts := &envbuilder.Options{ + GitURL: "http://host.tld/repo", + Logger: testLog(t), + } + auth := envbuilder.SetupRepoAuth(opts) + require.Nil(t, auth) + }) + + t.Run("HTTP/BasicAuth", func(t *testing.T) { + opts := &envbuilder.Options{ + GitURL: "http://host.tld/repo", + GitUsername: "user", + GitPassword: "pass", + Logger: testLog(t), + } + auth := envbuilder.SetupRepoAuth(opts) + ba, ok := auth.(*githttp.BasicAuth) + require.True(t, ok) + require.Equal(t, opts.GitUsername, ba.Username) + require.Equal(t, opts.GitPassword, ba.Password) + }) + + t.Run("HTTPS/BasicAuth", func(t *testing.T) { + opts := &envbuilder.Options{ + GitURL: "https://host.tld/repo", + GitUsername: "user", + GitPassword: "pass", + Logger: testLog(t), + } + auth := envbuilder.SetupRepoAuth(opts) + ba, ok := auth.(*githttp.BasicAuth) + require.True(t, ok) + require.Equal(t, opts.GitUsername, ba.Username) + require.Equal(t, opts.GitPassword, ba.Password) + }) + + t.Run("SSH/WithScheme", func(t *testing.T) { + kPath := writeTestPrivateKey(t) + opts := &envbuilder.Options{ + GitURL: "ssh://host.tld/repo", + GitSSHPrivateKeyPath: kPath, + Logger: testLog(t), + } + auth := envbuilder.SetupRepoAuth(opts) + _, ok := auth.(*gitssh.PublicKeys) + require.True(t, ok) + }) + + t.Run("SSH/NoScheme", func(t *testing.T) { + kPath := writeTestPrivateKey(t) + opts := &envbuilder.Options{ + GitURL: "git@host.tld:repo/path", + GitSSHPrivateKeyPath: kPath, + Logger: testLog(t), + } + auth := envbuilder.SetupRepoAuth(opts) + _, ok := auth.(*gitssh.PublicKeys) + require.True(t, ok) + }) + + t.Run("SSH/OtherScheme", func(t *testing.T) { + // Anything that is not https:// or http:// is treated as SSH. + kPath := writeTestPrivateKey(t) + opts := &envbuilder.Options{ + GitURL: "git://git@host.tld:repo/path", + GitSSHPrivateKeyPath: kPath, + Logger: testLog(t), + } + auth := envbuilder.SetupRepoAuth(opts) + _, ok := auth.(*gitssh.PublicKeys) + require.True(t, ok) + }) + + t.Run("SSH/GitUsername", func(t *testing.T) { + kPath := writeTestPrivateKey(t) + opts := &envbuilder.Options{ + GitURL: "host.tld:12345/repo/path", + GitSSHPrivateKeyPath: kPath, + GitUsername: "user", + Logger: testLog(t), + } + auth := envbuilder.SetupRepoAuth(opts) + _, ok := auth.(*gitssh.PublicKeys) + require.True(t, ok) + }) + + t.Run("SSH/PrivateKey", func(t *testing.T) { + kPath := writeTestPrivateKey(t) + opts := &envbuilder.Options{ + GitURL: "ssh://git@host.tld:repo/path", + GitSSHPrivateKeyPath: kPath, + Logger: testLog(t), + } + auth := envbuilder.SetupRepoAuth(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 := &envbuilder.Options{ + GitURL: "ssh://git@host.tld:repo/path", + Logger: testLog(t), + } + auth := envbuilder.SetupRepoAuth(opts) + require.Nil(t, auth) // TODO: actually test SSH_AUTH_SOCK + }) +} + func mustRead(t *testing.T, fs billy.Filesystem, path string) string { t.Helper() f, err := fs.OpenFile(path, os.O_RDONLY, 0644) @@ -167,3 +392,37 @@ func mustRead(t *testing.T, fs billy.Filesystem, path string) string { require.NoError(t, err) return string(content) } + +// generates a random ed25519 private key +func randKeygen(t *testing.T) gossh.Signer { + t.Helper() + _, key, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + signer, err := gossh.NewSignerFromKey(key) + require.NoError(t, err) + return signer +} + +func testLog(t *testing.T) envbuilder.LoggerFunc { + return func(_ codersdk.LogLevel, format string, args ...interface{}) { + t.Logf(format, args...) + } +} + +// nolint:gosec // Throw-away key for testing. DO NOT REUSE. +var testKey = `-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBXOGgAge/EbcejqASqZa6s8PFXZle56DiGEt0VYnljuwAAAKgM05mUDNOZ +lAAAAAtzc2gtZWQyNTUxOQAAACBXOGgAge/EbcejqASqZa6s8PFXZle56DiGEt0VYnljuw +AAAEDCawwtjrM4AGYXD1G6uallnbsgMed4cfkFsQ+mLZtOkFc4aACB78Rtx6OoBKplrqzw +8VdmV7noOIYS3RVieWO7AAAAHmNpYW5AY2RyLW1icC1mdmZmdzBuOHEwNXAuaG9tZQECAw +QFBgc= +-----END OPENSSH PRIVATE KEY-----` + +func writeTestPrivateKey(t *testing.T) string { + t.Helper() + tmpDir := t.TempDir() + kPath := filepath.Join(tmpDir, "test.key") + require.NoError(t, os.WriteFile(kPath, []byte(testKey), 0o600)) + return kPath +} diff --git a/go.mod b/go.mod index 00b3dfc2..ee0d2b86 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/docker/cli v26.1.0+incompatible github.com/docker/docker v23.0.8+incompatible github.com/fatih/color v1.16.0 + github.com/gliderlabs/ssh v0.3.7 github.com/go-git/go-billy/v5 v5.5.0 github.com/go-git/go-git/v5 v5.12.0 github.com/google/go-containerregistry v0.15.2 @@ -36,6 +37,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.9.0 github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a + golang.org/x/crypto v0.21.0 golang.org/x/sync v0.7.0 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 ) @@ -70,6 +72,7 @@ require ( github.com/agext/levenshtein v1.2.3 // indirect github.com/akutz/memconn v0.1.0 // indirect github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 // indirect + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aws/aws-sdk-go-v2 v1.20.3 // indirect @@ -261,7 +264,6 @@ require ( go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516 // indirect go4.org/unsafe/assume-no-moving-gc v0.0.0-20230525183740-e7c30c78aeb2 // indirect - golang.org/x/crypto v0.21.0 // indirect golang.org/x/exp v0.0.0-20240213143201-ec583247a57a // indirect golang.org/x/mod v0.15.0 // indirect golang.org/x/net v0.23.0 // indirect diff --git a/options.go b/options.go index 7f2f039d..1ba42f6f 100644 --- a/options.go +++ b/options.go @@ -104,6 +104,9 @@ type Options struct { // GitPassword is the password to use for Git authentication. This is // optional. GitPassword string + // GitSSHPrivateKeyPath is the path to an SSH private key to be used for + // Git authentication. + GitSSHPrivateKeyPath 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. @@ -325,6 +328,12 @@ func (o *Options) CLI() serpent.OptionSet { Value: serpent.StringOf(&o.GitPassword), Description: "The password to use for Git authentication. This is optional.", }, + { + Flag: "git-ssh-private-key-path", + Env: "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-http-proxy-url", Env: "GIT_HTTP_PROXY_URL", diff --git a/testdata/options.golden b/testdata/options.golden index ac814cf0..0beffb6e 100644 --- a/testdata/options.golden +++ b/testdata/options.golden @@ -90,6 +90,9 @@ OPTIONS: --git-password string, $GIT_PASSWORD The password to use for Git authentication. This is optional. + --git-ssh-private-key-path string, $GIT_SSH_PRIVATE_KEY_PATH + Path to an SSH private key to be used for Git authentication. + --git-url string, $GIT_URL The URL of the Git repository to clone. This is optional. diff --git a/testutil/gittest/gittest.go b/testutil/gittest/gittest.go index 28629fee..95805f6c 100644 --- a/testutil/gittest/gittest.go +++ b/testutil/gittest/gittest.go @@ -1,12 +1,20 @@ package gittest import ( + "fmt" + "io" "log" + "net" "net/http" "os" + "os/exec" + "sync" "testing" "time" + gossh "golang.org/x/crypto/ssh" + + "github.com/gliderlabs/ssh" "github.com/go-git/go-billy/v5" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" @@ -97,6 +105,97 @@ func NewServer(fs billy.Filesystem) http.Handler { return mux } +func NewServerSSH(t *testing.T, fs billy.Filesystem, pubkeys ...gossh.PublicKey) *transport.Endpoint { + t.Helper() + + l, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + t.Cleanup(func() { _ = l.Close() }) + + srvOpts := []ssh.Option{ + ssh.PublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { + for _, pk := range pubkeys { + if ssh.KeysEqual(pk, key) { + return true + } + } + return false + }), + } + + done := make(chan struct{}, 1) + go func() { + _ = ssh.Serve(l, handleSession, srvOpts...) + close(done) + }() + t.Cleanup(func() { + _ = l.Close() + <-done + }) + + addr, ok := l.Addr().(*net.TCPAddr) + require.True(t, ok) + tr, err := transport.NewEndpoint(fmt.Sprintf("ssh://git@%s:%d/", addr.IP, addr.Port)) + require.NoError(t, err) + return tr +} + +func handleSession(sess ssh.Session) { + c := sess.Command() + if len(c) < 1 { + _, _ = fmt.Fprintf(os.Stderr, "invalid command: %q\n", c) + } + + cmd := exec.Command(c[0], c[1:]...) + stdout, err := cmd.StdoutPipe() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "cmd stdout pipe: %s\n", err.Error()) + return + } + + stdin, err := cmd.StdinPipe() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "cmd stdin pipe: %s\n", err.Error()) + return + } + + stderr, err := cmd.StderrPipe() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "cmd stderr pipe: %s\n", err.Error()) + return + } + + err = cmd.Start() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "start cmd: %s\n", err.Error()) + return + } + + go func() { + defer stdin.Close() + _, _ = io.Copy(stdin, sess) + }() + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + _, _ = io.Copy(sess.Stderr(), stderr) + }() + + go func() { + defer wg.Done() + _, _ = io.Copy(sess, stdout) + }() + + wg.Wait() + + if err := cmd.Wait(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "wait cmd: %s\n", err.Error()) + } +} + // CommitFunc commits to a repo. type CommitFunc func(billy.Filesystem, *git.Repository)