Skip to content

feat: support cloning over SSH via private key auth #170

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

Merged
merged 19 commits into from
May 3, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
add SSH_PRIVATE_KEY_PATH option, add some tests
  • Loading branch information
johnstcn committed May 2, 2024
commit acff47a999d7eb99b5a9f8353368aa7c844e1837
86 changes: 86 additions & 0 deletions git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package envbuilder_test

import (
"context"
"crypto/ed25519"
"fmt"
"io"
"net/http/httptest"
Expand All @@ -14,8 +15,11 @@ import (
"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) {
Expand Down Expand Up @@ -159,6 +163,78 @@ func TestCloneRepo(t *testing.T) {
}
}

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

t.Run("PrivateKeyOK", func(t *testing.T) {
t.Parallel()
t.Skip("TODO: need to figure out how to properly add advertised refs")
// TODO: Can't we use a memfs here?
tmpDir := t.TempDir()
srvFS := osfs.New(tmpDir, osfs.WithChrootOS())

signer := randKeygen(t)
_ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "README.md", "Hello, world!", "Wow!"))
tr := gittest.NewServerSSH(t, srvFS, signer.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: signer,
HostKeyCallbackHelper: gitssh.HostKeyCallbackHelper{
HostKeyCallback: gossh.InsecureIgnoreHostKey(), // TODO: known_hosts
},
},
})
require.NoError(t, err) // TODO: error: repository not found
require.True(t, cloned)

readme := mustRead(t, clientFS, "/workspace/README.md")
require.Equal(t, "Hello, world!", readme)
gitConfig := mustRead(t, clientFS, "/workspace/.git/config")
// Ensure we do not modify the git URL that folks pass in.
require.Regexp(t, fmt.Sprintf(`(?m)^\s+url\s+=\s+%s\s*$`, regexp.QuoteMeta(gitURL)), gitConfig)
})

t.Run("PrivateKeyError", func(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
srvFS := osfs.New(tmpDir, osfs.WithChrootOS())

signer := randKeygen(t)
anotherSigner := randKeygen(t)
_ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "README.md", "Hello, world!", "Wow!"))
tr := gittest.NewServerSSH(t, srvFS, signer.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: anotherSigner,
HostKeyCallbackHelper: gitssh.HostKeyCallbackHelper{
HostKeyCallback: gossh.InsecureIgnoreHostKey(), // TODO: known_hosts
},
},
})
require.ErrorContains(t, err, "handshake failed")
require.False(t, cloned)
})

t.Run("PrivateKeyUnknownHost", func(t *testing.T) {
t.Parallel()
t.Skip("TODO: add host key checking")
})
}

func mustRead(t *testing.T, fs billy.Filesystem, path string) string {
t.Helper()
f, err := fs.OpenFile(path, os.O_RDONLY, 0644)
Expand All @@ -167,3 +243,13 @@ 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
}
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type Options struct {
GitCloneSingleBranch bool
GitUsername string
GitPassword string
GitSSHPrivateKeyPath string
GitHTTPProxyURL string
WorkspaceFolder string
SSLCertBase64 string
Expand Down Expand Up @@ -242,6 +243,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 a SSH private key to be used for Git authentication.",
},
{
Flag: "git-http-proxy-url",
Env: "GIT_HTTP_PROXY_URL",
Expand Down
99 changes: 99 additions & 0 deletions testutil/gittest/gittest.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)

Expand Down