From 0bb17cd1b83753aff4de1aa6a3f5ffd280991694 Mon Sep 17 00:00:00 2001 From: Alberto Cortés Date: Tue, 17 Nov 2015 15:51:40 +0100 Subject: ssh client --- clients/ssh/auth_method.go | 136 +++++++++++++++++++++ clients/ssh/auth_method_test.go | 94 +++++++++++++++ clients/ssh/git_upload_pack.go | 205 +++++++++++++++++++++++++++++++ clients/ssh/git_upload_pack_test.go | 235 ++++++++++++++++++++++++++++++++++++ 4 files changed, 670 insertions(+) create mode 100644 clients/ssh/auth_method.go create mode 100644 clients/ssh/auth_method_test.go create mode 100644 clients/ssh/git_upload_pack.go create mode 100644 clients/ssh/git_upload_pack_test.go (limited to 'clients') diff --git a/clients/ssh/auth_method.go b/clients/ssh/auth_method.go new file mode 100644 index 0000000..445d502 --- /dev/null +++ b/clients/ssh/auth_method.go @@ -0,0 +1,136 @@ +package ssh + +import ( + "fmt" + + "golang.org/x/crypto/ssh" + "gopkg.in/src-d/go-git.v2/clients/common" +) + +// AuthMethod is the interface all auth methods for the ssh client +// must implement. The clientConfig method returns the ssh client +// configuration needed to establish an ssh connection. +type AuthMethod interface { + common.AuthMethod + clientConfig() *ssh.ClientConfig +} + +// The names of the AuthMethod implementations. To be returned by the +// Name() method. Most git servers only allow PublicKeysName and +// PublicKeysCallbackName. +const ( + KeyboardInteractiveName = "ssh-keyboard-interactive" + PasswordName = "ssh-password" + PasswordCallbackName = "ssh-password-callback" + PublicKeysName = "ssh-public-keys" + PublicKeysCallbackName = "ssh-public-key-callback" +) + +// KeyboardInteractive implements AuthMethod by using a +// prompt/response sequence controlled by the server. +type KeyboardInteractive struct { + User string + Challenge ssh.KeyboardInteractiveChallenge +} + +func (a *KeyboardInteractive) Name() string { + return KeyboardInteractiveName +} + +func (a *KeyboardInteractive) String() string { + return fmt.Sprintf("user: %s, name: %s", a.User, a.Name()) +} + +func (a *KeyboardInteractive) clientConfig() *ssh.ClientConfig { + return &ssh.ClientConfig{ + User: a.User, + Auth: []ssh.AuthMethod{ssh.KeyboardInteractiveChallenge(a.Challenge)}, + } +} + +// Password implements AuthMethod by using the given password. +type Password struct { + User string + Pass string +} + +func (a *Password) Name() string { + return PasswordName +} + +func (a *Password) String() string { + return fmt.Sprintf("user: %s, name: %s", a.User, a.Name()) +} + +func (a *Password) clientConfig() *ssh.ClientConfig { + return &ssh.ClientConfig{ + User: a.User, + Auth: []ssh.AuthMethod{ssh.Password(a.Pass)}, + } +} + +// PasswordCallback implements AuthMethod by using a callback +// to fetch the password. +type PasswordCallback struct { + User string + Callback func() (pass string, err error) +} + +func (a *PasswordCallback) Name() string { + return PasswordCallbackName +} + +func (a *PasswordCallback) String() string { + return fmt.Sprintf("user: %s, name: %s", a.User, a.Name()) +} + +func (a *PasswordCallback) clientConfig() *ssh.ClientConfig { + return &ssh.ClientConfig{ + User: a.User, + Auth: []ssh.AuthMethod{ssh.PasswordCallback(a.Callback)}, + } +} + +// PublicKeys implements AuthMethod by using the given +// key pairs. +type PublicKeys struct { + User string + Signer ssh.Signer +} + +func (a *PublicKeys) Name() string { + return PublicKeysName +} + +func (a *PublicKeys) String() string { + return fmt.Sprintf("user: %s, name: %s", a.User, a.Name()) +} + +func (a *PublicKeys) clientConfig() *ssh.ClientConfig { + return &ssh.ClientConfig{ + User: a.User, + Auth: []ssh.AuthMethod{ssh.PublicKeys(a.Signer)}, + } +} + +// PublicKeysCallback implements AuthMethod by asking a +// ssh.agent.Agent to act as a signer. +type PublicKeysCallback struct { + User string + Callback func() (signers []ssh.Signer, err error) +} + +func (a *PublicKeysCallback) Name() string { + return PublicKeysCallbackName +} + +func (a *PublicKeysCallback) String() string { + return fmt.Sprintf("user: %s, name: %s", a.User, a.Name()) +} + +func (a *PublicKeysCallback) clientConfig() *ssh.ClientConfig { + return &ssh.ClientConfig{ + User: a.User, + Auth: []ssh.AuthMethod{ssh.PublicKeysCallback(a.Callback)}, + } +} diff --git a/clients/ssh/auth_method_test.go b/clients/ssh/auth_method_test.go new file mode 100644 index 0000000..ca4558d --- /dev/null +++ b/clients/ssh/auth_method_test.go @@ -0,0 +1,94 @@ +package ssh + +import ( + "fmt" + "testing" + + . "gopkg.in/check.v1" +) + +func Test(t *testing.T) { TestingT(t) } + +type SuiteCommon struct{} + +var _ = Suite(&SuiteCommon{}) + +func (s *SuiteRemote) TestKeyboardInteractiveName(c *C) { + a := &KeyboardInteractive{ + User: "test", + Challenge: nil, + } + c.Assert(a.Name(), Equals, KeyboardInteractiveName) +} + +func (s *SuiteRemote) TestKeyboardInteractiveString(c *C) { + a := &KeyboardInteractive{ + User: "test", + Challenge: nil, + } + c.Assert(a.String(), Equals, fmt.Sprintf("user: test, name: %s", KeyboardInteractiveName)) +} + +func (s *SuiteRemote) TestPasswordName(c *C) { + a := &Password{ + User: "test", + Pass: "", + } + c.Assert(a.Name(), Equals, PasswordName) +} + +func (s *SuiteRemote) TestPasswordString(c *C) { + a := &Password{ + User: "test", + Pass: "", + } + c.Assert(a.String(), Equals, fmt.Sprintf("user: test, name: %s", PasswordName)) +} + +func (s *SuiteRemote) TestPasswordCallbackName(c *C) { + a := &PasswordCallback{ + User: "test", + Callback: nil, + } + c.Assert(a.Name(), Equals, PasswordCallbackName) +} + +func (s *SuiteRemote) TestPasswordCallbackString(c *C) { + a := &PasswordCallback{ + User: "test", + Callback: nil, + } + c.Assert(a.String(), Equals, fmt.Sprintf("user: test, name: %s", PasswordCallbackName)) +} + +func (s *SuiteRemote) TestPublicKeysName(c *C) { + a := &PublicKeys{ + User: "test", + Signer: nil, + } + c.Assert(a.Name(), Equals, PublicKeysName) +} + +func (s *SuiteRemote) TestPublicKeysString(c *C) { + a := &PublicKeys{ + User: "test", + Signer: nil, + } + c.Assert(a.String(), Equals, fmt.Sprintf("user: test, name: %s", PublicKeysName)) +} + +func (s *SuiteRemote) TestPublicKeysCallbackName(c *C) { + a := &PublicKeysCallback{ + User: "test", + Callback: nil, + } + c.Assert(a.Name(), Equals, PublicKeysCallbackName) +} + +func (s *SuiteRemote) TestPublicKeysCallbackString(c *C) { + a := &PublicKeysCallback{ + User: "test", + Callback: nil, + } + c.Assert(a.String(), Equals, fmt.Sprintf("user: test, name: %s", PublicKeysCallbackName)) +} diff --git a/clients/ssh/git_upload_pack.go b/clients/ssh/git_upload_pack.go new file mode 100644 index 0000000..09cb5ab --- /dev/null +++ b/clients/ssh/git_upload_pack.go @@ -0,0 +1,205 @@ +// Package ssh implements a ssh client for go-git. +// +// The Connect() method is not allowed in ssh, use ConnectWithAuth() instead. +package ssh + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "io/ioutil" + "net/url" + + "gopkg.in/src-d/go-git.v2/clients/common" + "gopkg.in/src-d/go-git.v2/formats/pktline" + + "github.com/sourcegraph/go-vcsurl" + "golang.org/x/crypto/ssh" +) + +// New errors introduced by this package. +var ( + ErrInvalidAuthMethod = errors.New("invalid ssh auth method") + ErrAuthRequired = errors.New("cannot connect: auth required") + ErrNotConnected = errors.New("not connected") + ErrAlreadyConnected = errors.New("already connected") + ErrUploadPackAnswerFormat = errors.New("git-upload-pack bad answer format") + ErrUnsupportedVCS = errors.New("only git is supported") + ErrUnsupportedRepo = errors.New("only github.com is supported") +) + +// GitUploadPackService holds the service information. +// The zero value is safe to use. +// TODO: remove NewGitUploadPackService(). +type GitUploadPackService struct { + connected bool + vcs *vcsurl.RepoInfo + client *ssh.Client + auth AuthMethod +} + +// NewGitUploadPackService initialises a GitUploadPackService. +// TODO: remove this, as the struct is zero-value safe. +func NewGitUploadPackService() *GitUploadPackService { + return &GitUploadPackService{} +} + +// Connect cannot be used with SSH clients and always return +// ErrAuthRequired. Use ConnectWithAuth instead. +func (s *GitUploadPackService) Connect(ep common.Endpoint) (err error) { + return ErrAuthRequired +} + +// ConnectWithAuth connects to ep using SSH. Authentication is handled +// by auth. +func (s *GitUploadPackService) ConnectWithAuth(ep common.Endpoint, auth common.AuthMethod) (err error) { + if s.connected { + return ErrAlreadyConnected + } + + s.vcs, err = vcsurl.Parse(string(ep)) + if err != nil { + return err + } + + url, err := vcsToURL(s.vcs) + if err != nil { + return + } + + var ok bool + s.auth, ok = auth.(AuthMethod) + if !ok { + return ErrInvalidAuthMethod + } + + s.client, err = ssh.Dial("tcp", url.Host, s.auth.clientConfig()) + if err != nil { + return err + } + + s.connected = true + return +} + +func vcsToURL(vcs *vcsurl.RepoInfo) (u *url.URL, err error) { + if vcs.VCS != vcsurl.Git { + return nil, ErrUnsupportedVCS + } + if vcs.RepoHost != vcsurl.GitHub { + return nil, ErrUnsupportedRepo + } + s := "ssh://git@" + string(vcs.RepoHost) + ":22/" + vcs.FullName + u, err = url.Parse(s) + return +} + +// Info returns the GitUploadPackInfo of the repository. +// The client must be connected with the repository (using +// the ConnectWithAuth() method) before using this +// method. +func (s *GitUploadPackService) Info() (i *common.GitUploadPackInfo, err error) { + if !s.connected { + return nil, ErrNotConnected + } + + session, err := s.client.NewSession() + if err != nil { + return nil, err + } + defer func() { + // the session can be closed by the other endpoint, + // therefore we must ignore a close error. + _ = session.Close() + }() + + out, err := session.Output("git-upload-pack " + s.vcs.FullName + ".git") + if err != nil { + return nil, err + } + + i = common.NewGitUploadPackInfo() + return i, i.Decode(pktline.NewDecoder(bytes.NewReader(out))) +} + +// Disconnect the SSH client. +func (s *GitUploadPackService) Disconnect() (err error) { + if !s.connected { + return ErrNotConnected + } + s.connected = false + return s.client.Close() +} + +// Fetch retrieves the GitUploadPack form the repository. +// You must be connected to the repository before using this method +// (using the ConnectWithAuth() method). +// TODO: fetch should really reuse the info session instead of openning a new +// one +func (s *GitUploadPackService) Fetch(r *common.GitUploadPackRequest) (rc io.ReadCloser, err error) { + if !s.connected { + return nil, ErrNotConnected + } + + session, err := s.client.NewSession() + if err != nil { + return nil, err + } + defer func() { + // the session can be closed by the other endpoint, + // therefore we must ignore a close error. + _ = session.Close() + }() + + si, err := session.StdinPipe() + if err != nil { + return nil, err + } + + so, err := session.StdoutPipe() + if err != nil { + return nil, err + } + + go func() { + fmt.Fprintln(si, r.String()) + err = si.Close() + }() + + err = session.Start("git-upload-pack " + s.vcs.FullName + ".git") + if err != nil { + return nil, err + } + // TODO: inestigate this *ExitError type (command fails or + // doesn't complete successfully), as it is happenning all + // the time, but everyting seems to work fine. + err = session.Wait() + if err != nil { + if _, ok := err.(*ssh.ExitError); !ok { + return nil, err + } + } + + // read until the header of the second answer + soBuf := bufio.NewReader(so) + token := "0000" + for { + var line string + line, err = soBuf.ReadString('\n') + if err == io.EOF { + return nil, ErrUploadPackAnswerFormat + } + if line[0:len(token)] == token { + break + } + } + + data, err := ioutil.ReadAll(soBuf) + if err != nil { + return nil, err + } + buf := bytes.NewBuffer(data) + return ioutil.NopCloser(buf), nil +} diff --git a/clients/ssh/git_upload_pack_test.go b/clients/ssh/git_upload_pack_test.go new file mode 100644 index 0000000..62a8596 --- /dev/null +++ b/clients/ssh/git_upload_pack_test.go @@ -0,0 +1,235 @@ +package ssh + +import ( + "fmt" + "io/ioutil" + "net" + "os" + + "golang.org/x/crypto/ssh/agent" + + . "gopkg.in/check.v1" + "gopkg.in/src-d/go-git.v2/clients/common" + "gopkg.in/src-d/go-git.v2/core" +) + +type SuiteRemote struct{} + +var _ = Suite(&SuiteRemote{}) + +const ( + fixRepo = "git@github.com:tyba/git-fixture.git" + fixRepoBadVcs = "www.example.com" + fixRepoNonGit = "https://code.google.com/p/go" + fixGitRepoNonGithub = "https://bitbucket.org/user/repo.git" +) + +func (s *SuiteRemote) TestConnect(c *C) { + fmt.Println("TestConnect") + r := NewGitUploadPackService() + c.Assert(r.Connect(fixRepo), Equals, ErrAuthRequired) +} + +// We will use a running ssh agent for testing +// ssh authentication. +type sshAgentConn struct { + pipe net.Conn + auth *PublicKeysCallback +} + +// Opens a pipe with the ssh agent and uses the pipe +// as the implementer of the public key callback function. +func newSSHAgentConn() (*sshAgentConn, error) { + pipe, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")) + if err != nil { + return nil, err + } + return &sshAgentConn{ + pipe: pipe, + auth: &PublicKeysCallback{ + User: "git", + Callback: agent.NewClient(pipe).Signers, + }, + }, nil +} + +// Closes the pipe with the ssh agent +func (c *sshAgentConn) close() error { + return c.pipe.Close() +} + +func (s *SuiteRemote) TestConnectWithPublicKeysCallback(c *C) { + fmt.Println("TestConnectWithPublicKeysCallback") + agent, err := newSSHAgentConn() + c.Assert(err, IsNil) + defer func() { c.Assert(agent.close(), IsNil) }() + + r := NewGitUploadPackService() + c.Assert(r.ConnectWithAuth(fixRepo, agent.auth), IsNil) + defer func() { c.Assert(r.Disconnect(), IsNil) }() + c.Assert(r.connected, Equals, true) + c.Assert(r.auth, Equals, agent.auth) +} + +func (s *SuiteRemote) TestConnectBadVcs(c *C) { + fmt.Println("TestConnectBadVcs") + r := NewGitUploadPackService() + c.Assert(r.ConnectWithAuth(fixRepoBadVcs, nil), ErrorMatches, fmt.Sprintf(".*%s.*", fixRepoBadVcs)) +} + +func (s *SuiteRemote) TestConnectNonGit(c *C) { + fmt.Println("TestConnectNonGit") + r := NewGitUploadPackService() + c.Assert(r.ConnectWithAuth(fixRepoNonGit, nil), Equals, ErrUnsupportedVCS) +} + +func (s *SuiteRemote) TestConnectNonGithub(c *C) { + fmt.Println("TestConnectNonGit") + r := NewGitUploadPackService() + c.Assert(r.ConnectWithAuth(fixGitRepoNonGithub, nil), Equals, ErrUnsupportedRepo) +} + +// A mock implementation of client.common.AuthMethod +// to test non ssh auth method detection. +type mockAuth struct{} + +func (*mockAuth) Name() string { return "" } +func (*mockAuth) String() string { return "" } + +func (s *SuiteRemote) TestConnectWithAuthWrongType(c *C) { + fmt.Println("TestConnectWithAuthWrongType") + r := NewGitUploadPackService() + c.Assert(r.ConnectWithAuth(fixRepo, &mockAuth{}), Equals, ErrInvalidAuthMethod) + c.Assert(r.connected, Equals, false) +} + +func (s *SuiteRemote) TestAlreadyConnected(c *C) { + fmt.Println("TestAlreadyConnected") + agent, err := newSSHAgentConn() + c.Assert(err, IsNil) + defer func() { c.Assert(agent.close(), IsNil) }() + + r := NewGitUploadPackService() + c.Assert(r.ConnectWithAuth(fixRepo, agent.auth), IsNil) + defer func() { c.Assert(r.Disconnect(), IsNil) }() + c.Assert(r.ConnectWithAuth(fixRepo, agent.auth), Equals, ErrAlreadyConnected) + c.Assert(r.connected, Equals, true) +} + +func (s *SuiteRemote) TestDisconnect(c *C) { + fmt.Println("TestDisconnect") + agent, err := newSSHAgentConn() + c.Assert(err, IsNil) + defer func() { c.Assert(agent.close(), IsNil) }() + + r := NewGitUploadPackService() + c.Assert(r.ConnectWithAuth(fixRepo, agent.auth), IsNil) + c.Assert(r.Disconnect(), IsNil) + c.Assert(r.connected, Equals, false) +} + +func (s *SuiteRemote) TestDisconnectedWhenNonConnected(c *C) { + fmt.Println("TestDisconnectedWhenNonConnected") + r := NewGitUploadPackService() + c.Assert(r.Disconnect(), Equals, ErrNotConnected) +} + +func (s *SuiteRemote) TestAlreadyDisconnected(c *C) { + fmt.Println("TestAlreadyDisconnected") + agent, err := newSSHAgentConn() + c.Assert(err, IsNil) + defer func() { c.Assert(agent.close(), IsNil) }() + + r := NewGitUploadPackService() + c.Assert(r.ConnectWithAuth(fixRepo, agent.auth), IsNil) + c.Assert(r.Disconnect(), IsNil) + c.Assert(r.Disconnect(), Equals, ErrNotConnected) + c.Assert(r.connected, Equals, false) +} + +func (s *SuiteRemote) TestServeralConnections(c *C) { + fmt.Println("TestServeralConnections") + agent, err := newSSHAgentConn() + c.Assert(err, IsNil) + defer func() { c.Assert(agent.close(), IsNil) }() + + r := NewGitUploadPackService() + c.Assert(r.ConnectWithAuth(fixRepo, agent.auth), IsNil) + c.Assert(r.Disconnect(), IsNil) + + c.Assert(r.ConnectWithAuth(fixRepo, agent.auth), IsNil) + c.Assert(r.connected, Equals, true) + c.Assert(r.Disconnect(), IsNil) + c.Assert(r.connected, Equals, false) + + c.Assert(r.ConnectWithAuth(fixRepo, agent.auth), IsNil) + c.Assert(r.connected, Equals, true) + c.Assert(r.Disconnect(), IsNil) + c.Assert(r.connected, Equals, false) +} + +func (s *SuiteRemote) TestInfoNotConnected(c *C) { + fmt.Println("TestInfoNotConnected") + r := NewGitUploadPackService() + _, err := r.Info() + c.Assert(err, Equals, ErrNotConnected) +} + +func (s *SuiteRemote) TestDefaultBranch(c *C) { + fmt.Println("TestDefaultBranch") + agent, err := newSSHAgentConn() + c.Assert(err, IsNil) + defer func() { c.Assert(agent.close(), IsNil) }() + + r := NewGitUploadPackService() + c.Assert(r.ConnectWithAuth(fixRepo, agent.auth), IsNil) + defer func() { c.Assert(r.Disconnect(), IsNil) }() + + info, err := r.Info() + c.Assert(err, IsNil) + c.Assert(info.Capabilities.SymbolicReference("HEAD"), Equals, "refs/heads/master") +} + +func (s *SuiteRemote) TestCapabilities(c *C) { + fmt.Println("TestCapabilities") + agent, err := newSSHAgentConn() + c.Assert(err, IsNil) + defer func() { c.Assert(agent.close(), IsNil) }() + + r := NewGitUploadPackService() + c.Assert(r.ConnectWithAuth(fixRepo, agent.auth), IsNil) + defer func() { c.Assert(r.Disconnect(), IsNil) }() + + info, err := r.Info() + c.Assert(err, IsNil) + c.Assert(info.Capabilities.Get("agent").Values, HasLen, 1) +} + +func (s *SuiteRemote) TestFetchNotConnected(c *C) { + fmt.Println("TestFetchNotConnected") + r := NewGitUploadPackService() + pr := &common.GitUploadPackRequest{} + pr.Want(core.NewHash("6ecf0ef2c2dffb796033e5a02219af86ec6584e5")) + _, err := r.Fetch(pr) + c.Assert(err, Equals, ErrNotConnected) +} + +func (s *SuiteRemote) TestFetch(c *C) { + fmt.Println("TestFetch") + agent, err := newSSHAgentConn() + c.Assert(err, IsNil) + defer func() { c.Assert(agent.close(), IsNil) }() + + r := NewGitUploadPackService() + c.Assert(r.ConnectWithAuth(fixRepo, agent.auth), IsNil) + defer func() { c.Assert(r.Disconnect(), IsNil) }() + + pr := &common.GitUploadPackRequest{} + pr.Want(core.NewHash("6ecf0ef2c2dffb796033e5a02219af86ec6584e5")) + reader, err := r.Fetch(pr) + c.Assert(err, IsNil) + + b, err := ioutil.ReadAll(reader) + c.Assert(err, IsNil) + c.Assert(b, HasLen, 85374) +} -- cgit