diff options
author | Santiago M. Mola <santi@mola.io> | 2016-11-23 15:30:34 +0100 |
---|---|---|
committer | Máximo Cuadros <mcuadros@gmail.com> | 2016-11-23 15:38:12 +0100 |
commit | 08e08d771ef03df80248c80d81475fe7c5ea6fe7 (patch) | |
tree | d12e9befa22409e8cf50c5bbc4895e69fd8a5f48 /plumbing/transport/ssh | |
parent | 844169a739fb8bf1f252d416f10d8c7034db9fe2 (diff) | |
download | go-git-08e08d771ef03df80248c80d81475fe7c5ea6fe7.tar.gz |
transport: create Client interface (#132)
* plumbing: move plumbing/client package to plumbing/transport.
* transport: create Client interface.
* A Client can instantiate any client transport service.
* InstallProtocol installs a Client for a given protocol,
instead of just a UploadPackService.
* A Client can open a session for fetch-pack or send-pack
for a specific Endpoint.
* Adapt ssh and http clients to the new client interface.
* updated doc
Diffstat (limited to 'plumbing/transport/ssh')
-rw-r--r-- | plumbing/transport/ssh/auth_method.go | 157 | ||||
-rw-r--r-- | plumbing/transport/ssh/auth_method_test.go | 91 | ||||
-rw-r--r-- | plumbing/transport/ssh/common.go | 151 | ||||
-rw-r--r-- | plumbing/transport/ssh/common_test.go | 17 | ||||
-rw-r--r-- | plumbing/transport/ssh/fetch_pack.go | 202 | ||||
-rw-r--r-- | plumbing/transport/ssh/fetch_pack_test.go | 100 | ||||
-rw-r--r-- | plumbing/transport/ssh/send_pack.go | 30 |
7 files changed, 748 insertions, 0 deletions
diff --git a/plumbing/transport/ssh/auth_method.go b/plumbing/transport/ssh/auth_method.go new file mode 100644 index 0000000..9c3d6f3 --- /dev/null +++ b/plumbing/transport/ssh/auth_method.go @@ -0,0 +1,157 @@ +package ssh + +import ( + "fmt" + "net" + "os" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +// 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 { + 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)}, + } +} + +const DefaultSSHUsername = "git" + +// Opens a pipe with the ssh agent and uses the pipe +// as the implementer of the public key callback function. +func NewSSHAgentAuth(user string) (*PublicKeysCallback, error) { + if user == "" { + user = DefaultSSHUsername + } + + pipe, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")) + if err != nil { + return nil, err + } + + return &PublicKeysCallback{ + User: user, + Callback: agent.NewClient(pipe).Signers, + }, nil +} diff --git a/plumbing/transport/ssh/auth_method_test.go b/plumbing/transport/ssh/auth_method_test.go new file mode 100644 index 0000000..f9e7dec --- /dev/null +++ b/plumbing/transport/ssh/auth_method_test.go @@ -0,0 +1,91 @@ +package ssh + +import ( + "fmt" + + . "gopkg.in/check.v1" +) + +type SuiteCommon struct{} + +var _ = Suite(&SuiteCommon{}) + +func (s *SuiteCommon) TestKeyboardInteractiveName(c *C) { + a := &KeyboardInteractive{ + User: "test", + Challenge: nil, + } + c.Assert(a.Name(), Equals, KeyboardInteractiveName) +} + +func (s *SuiteCommon) TestKeyboardInteractiveString(c *C) { + a := &KeyboardInteractive{ + User: "test", + Challenge: nil, + } + c.Assert(a.String(), Equals, fmt.Sprintf("user: test, name: %s", KeyboardInteractiveName)) +} + +func (s *SuiteCommon) TestPasswordName(c *C) { + a := &Password{ + User: "test", + Pass: "", + } + c.Assert(a.Name(), Equals, PasswordName) +} + +func (s *SuiteCommon) TestPasswordString(c *C) { + a := &Password{ + User: "test", + Pass: "", + } + c.Assert(a.String(), Equals, fmt.Sprintf("user: test, name: %s", PasswordName)) +} + +func (s *SuiteCommon) TestPasswordCallbackName(c *C) { + a := &PasswordCallback{ + User: "test", + Callback: nil, + } + c.Assert(a.Name(), Equals, PasswordCallbackName) +} + +func (s *SuiteCommon) TestPasswordCallbackString(c *C) { + a := &PasswordCallback{ + User: "test", + Callback: nil, + } + c.Assert(a.String(), Equals, fmt.Sprintf("user: test, name: %s", PasswordCallbackName)) +} + +func (s *SuiteCommon) TestPublicKeysName(c *C) { + a := &PublicKeys{ + User: "test", + Signer: nil, + } + c.Assert(a.Name(), Equals, PublicKeysName) +} + +func (s *SuiteCommon) TestPublicKeysString(c *C) { + a := &PublicKeys{ + User: "test", + Signer: nil, + } + c.Assert(a.String(), Equals, fmt.Sprintf("user: test, name: %s", PublicKeysName)) +} + +func (s *SuiteCommon) TestPublicKeysCallbackName(c *C) { + a := &PublicKeysCallback{ + User: "test", + Callback: nil, + } + c.Assert(a.Name(), Equals, PublicKeysCallbackName) +} + +func (s *SuiteCommon) TestPublicKeysCallbackString(c *C) { + a := &PublicKeysCallback{ + User: "test", + Callback: nil, + } + c.Assert(a.String(), Equals, fmt.Sprintf("user: test, name: %s", PublicKeysCallbackName)) +} diff --git a/plumbing/transport/ssh/common.go b/plumbing/transport/ssh/common.go new file mode 100644 index 0000000..6f0f3d4 --- /dev/null +++ b/plumbing/transport/ssh/common.go @@ -0,0 +1,151 @@ +package ssh + +import ( + "errors" + "fmt" + "io" + "strings" + + "gopkg.in/src-d/go-git.v4/plumbing/transport" + + "golang.org/x/crypto/ssh" +) + +// New errors introduced by this package. +var ( + ErrAdvertistedReferencesAlreadyCalled = errors.New("cannot call AdvertisedReference twice") + ErrAlreadyConnected = errors.New("ssh session already created") + ErrAuthRequired = errors.New("cannot connect: auth required") + ErrNotConnected = errors.New("not 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") +) + +type Client struct{} + +var DefaultClient = NewClient() + +func NewClient() transport.Client { + return &Client{} +} + +func (c *Client) NewFetchPackSession(ep transport.Endpoint) ( + transport.FetchPackSession, error) { + + return newFetchPackSession(ep) +} + +func (c *Client) NewSendPackSession(ep transport.Endpoint) ( + transport.SendPackSession, error) { + + return newSendPackSession(ep) +} + +type session struct { + connected bool + endpoint transport.Endpoint + client *ssh.Client + session *ssh.Session + stdin io.WriteCloser + stdout io.Reader + sessionDone chan error + auth AuthMethod +} + +func (s *session) SetAuth(auth transport.AuthMethod) error { + a, ok := auth.(AuthMethod) + if !ok { + return transport.ErrInvalidAuthMethod + } + + s.auth = a + return nil +} + +// Close closes the SSH session. +func (s *session) Close() error { + if !s.connected { + return nil + } + + s.connected = false + return s.client.Close() +} + +// ensureConnected connects to the SSH server, unless a AuthMethod was set with +// SetAuth method, by default uses an auth method based on PublicKeysCallback, +// it connects to a SSH agent, using the address stored in the SSH_AUTH_SOCK +// environment var. +func (s *session) connect() error { + if s.connected { + return ErrAlreadyConnected + } + + if err := s.setAuthFromEndpoint(); err != nil { + return err + } + + var err error + s.client, err = ssh.Dial("tcp", s.getHostWithPort(), s.auth.clientConfig()) + if err != nil { + return err + } + + if err := s.openSSHSession(); err != nil { + _ = s.client.Close() + return err + } + + s.connected = true + return nil +} + +func (s *session) getHostWithPort() string { + host := s.endpoint.Host + if strings.Index(s.endpoint.Host, ":") == -1 { + host += ":22" + } + + return host +} + +func (s *session) setAuthFromEndpoint() error { + var u string + if info := s.endpoint.User; info != nil { + u = info.Username() + } + + var err error + s.auth, err = NewSSHAgentAuth(u) + return err +} + +func (s *session) openSSHSession() error { + var err error + s.session, err = s.client.NewSession() + if err != nil { + return fmt.Errorf("cannot open SSH session: %s", err) + } + + s.stdin, err = s.session.StdinPipe() + if err != nil { + return fmt.Errorf("cannot pipe remote stdin: %s", err) + } + + s.stdout, err = s.session.StdoutPipe() + if err != nil { + return fmt.Errorf("cannot pipe remote stdout: %s", err) + } + + return nil +} + +func (s *session) runCommand(cmd string) chan error { + done := make(chan error) + go func() { + done <- s.session.Run(cmd) + }() + + return done +} diff --git a/plumbing/transport/ssh/common_test.go b/plumbing/transport/ssh/common_test.go new file mode 100644 index 0000000..ac4d03e --- /dev/null +++ b/plumbing/transport/ssh/common_test.go @@ -0,0 +1,17 @@ +package ssh + +import ( + "testing" + + . "gopkg.in/check.v1" +) + +func Test(t *testing.T) { TestingT(t) } + +type ClientSuite struct{} + +var _ = Suite(&ClientSuite{}) + +func (s *ClientSuite) TestNewClient(c *C) { + c.Assert(DefaultClient, DeepEquals, NewClient()) +} diff --git a/plumbing/transport/ssh/fetch_pack.go b/plumbing/transport/ssh/fetch_pack.go new file mode 100644 index 0000000..bda4edf --- /dev/null +++ b/plumbing/transport/ssh/fetch_pack.go @@ -0,0 +1,202 @@ +// Package ssh implements a ssh client for go-git. +package ssh + +import ( + "bytes" + "fmt" + "io" + + "gopkg.in/src-d/go-git.v4/plumbing/format/packp/pktline" + "gopkg.in/src-d/go-git.v4/plumbing/format/packp/ulreq" + "gopkg.in/src-d/go-git.v4/plumbing/transport" + + "golang.org/x/crypto/ssh" +) + +type fetchPackSession struct { + *session + cmdRun bool + advRefsRun bool + done chan error +} + +func newFetchPackSession(ep transport.Endpoint) (*fetchPackSession, error) { + s := &fetchPackSession{ + session: &session{ + endpoint: ep, + }, + } + if err := s.connect(); err != nil { + return nil, err + } + + return s, nil +} + +func (s *fetchPackSession) AdvertisedReferences() (*transport.UploadPackInfo, error) { + if s.advRefsRun { + return nil, ErrAdvertistedReferencesAlreadyCalled + } + + if err := s.ensureRunCommand(); err != nil { + return nil, err + } + + defer func() { s.advRefsRun = true }() + + i := transport.NewUploadPackInfo() + return i, i.Decode(s.stdout) +} + +// FetchPack returns a packfile for a given upload request. +// Closing the returned reader will close the SSH session. +func (s *fetchPackSession) FetchPack(req *transport.UploadPackRequest) ( + io.ReadCloser, error) { + + if !s.advRefsRun { + if _, err := s.AdvertisedReferences(); err != nil { + return nil, err + } + } + + if err := fetchPack(s.stdin, s.stdout, req); err != nil { + return nil, err + } + + return &fetchSession{ + Reader: s.stdout, + session: s.session.session, + done: s.done, + }, nil +} + +func (s *fetchPackSession) ensureRunCommand() error { + if s.cmdRun { + return nil + } + + s.cmdRun = true + s.done = s.runCommand(s.getCommand()) + return nil +} + +type fetchSession struct { + io.Reader + session *ssh.Session + done <-chan error +} + +// Close closes the session and collects the output state of the remote +// SSH command. +// +// If both the remote command and the closing of the session completes +// susccessfully it returns nil. +// +// If the remote command completes unsuccessfully or is interrupted by a +// signal, it returns the corresponding *ExitError. +// +// Otherwise, if clossing the SSH session fails it returns the close +// error. Closing the session when the other has already close it is +// not cosidered an error. +func (f *fetchSession) Close() (err error) { + if err := <-f.done; err != nil { + return err + } + + if err := f.session.Close(); err != nil && err != io.EOF { + return err + } + + return nil +} + +func (s *fetchPackSession) getCommand() string { + directory := s.endpoint.Path + directory = directory[1:] + + return fmt.Sprintf("git-upload-pack '%s'", directory) +} + +var ( + nak = []byte("NAK") + eol = []byte("\n") +) + +// FetchPack implements the git-fetch-pack protocol. +// +// TODO support multi_ack mode +// TODO support multi_ack_detailed mode +// TODO support acks for common objects +// TODO build a proper state machine for all these processing options +func fetchPack(w io.WriteCloser, r io.Reader, + req *transport.UploadPackRequest) error { + + if err := sendUlReq(w, req); err != nil { + return fmt.Errorf("sending upload-req message: %s", err) + } + + if err := sendHaves(w, req); err != nil { + return fmt.Errorf("sending haves message: %s", err) + } + + if err := sendDone(w); err != nil { + return fmt.Errorf("sending done message: %s", err) + } + + if err := w.Close(); err != nil { + return fmt.Errorf("closing input: %s", err) + } + + if err := readNAK(r); err != nil { + return fmt.Errorf("reading NAK: %s", err) + } + + return nil +} + +func sendUlReq(w io.Writer, req *transport.UploadPackRequest) error { + ur := ulreq.New() + ur.Wants = req.Wants + ur.Depth = ulreq.DepthCommits(req.Depth) + e := ulreq.NewEncoder(w) + + return e.Encode(ur) +} + +func sendHaves(w io.Writer, req *transport.UploadPackRequest) error { + e := pktline.NewEncoder(w) + for _, have := range req.Haves { + if err := e.Encodef("have %s\n", have); err != nil { + return fmt.Errorf("sending haves for %q: %s", have, err) + } + } + + if len(req.Haves) != 0 { + if err := e.Flush(); err != nil { + return fmt.Errorf("sending flush-pkt after haves: %s", err) + } + } + + return nil +} + +func sendDone(w io.Writer) error { + e := pktline.NewEncoder(w) + + return e.Encodef("done\n") +} + +func readNAK(r io.Reader) error { + s := pktline.NewScanner(r) + if !s.Scan() { + return s.Err() + } + + b := s.Bytes() + b = bytes.TrimSuffix(b, eol) + if !bytes.Equal(b, nak) { + return fmt.Errorf("expecting NAK, found %q instead", string(b)) + } + + return nil +} diff --git a/plumbing/transport/ssh/fetch_pack_test.go b/plumbing/transport/ssh/fetch_pack_test.go new file mode 100644 index 0000000..3d62e57 --- /dev/null +++ b/plumbing/transport/ssh/fetch_pack_test.go @@ -0,0 +1,100 @@ +package ssh + +import ( + "io/ioutil" + "os" + + "gopkg.in/src-d/go-git.v4/plumbing" + "gopkg.in/src-d/go-git.v4/plumbing/transport" + + . "gopkg.in/check.v1" +) + +type FetchPackSuite struct { + Endpoint transport.Endpoint +} + +var _ = Suite(&FetchPackSuite{}) + +func (s *FetchPackSuite) SetUpSuite(c *C) { + var err error + s.Endpoint, err = transport.NewEndpoint("git@github.com:git-fixtures/basic.git") + c.Assert(err, IsNil) + + if os.Getenv("SSH_AUTH_SOCK") == "" { + c.Skip("SSH_AUTH_SOCK is not set") + } +} + +func (s *FetchPackSuite) TestDefaultBranch(c *C) { + r, err := DefaultClient.NewFetchPackSession(s.Endpoint) + c.Assert(err, IsNil) + defer func() { c.Assert(r.Close(), IsNil) }() + + info, err := r.AdvertisedReferences() + c.Assert(err, IsNil) + c.Assert(info.Capabilities.SymbolicReference("HEAD"), Equals, "refs/heads/master") +} + +func (s *FetchPackSuite) TestCapabilities(c *C) { + r, err := DefaultClient.NewFetchPackSession(s.Endpoint) + c.Assert(err, IsNil) + defer func() { c.Assert(r.Close(), IsNil) }() + + info, err := r.AdvertisedReferences() + c.Assert(err, IsNil) + c.Assert(info.Capabilities.Get("agent").Values, HasLen, 1) +} + +func (s *FetchPackSuite) TestFullFetchPack(c *C) { + r, err := DefaultClient.NewFetchPackSession(s.Endpoint) + c.Assert(err, IsNil) + defer func() { c.Assert(r.Close(), IsNil) }() + + _, err = r.AdvertisedReferences() + c.Assert(err, IsNil) + + req := &transport.UploadPackRequest{} + req.Want(plumbing.NewHash("6ecf0ef2c2dffb796033e5a02219af86ec6584e5")) + req.Want(plumbing.NewHash("e8d3ffab552895c19b9fcf7aa264d277cde33881")) + reader, err := r.FetchPack(req) + c.Assert(err, IsNil) + + defer func() { c.Assert(reader.Close(), IsNil) }() + + b, err := ioutil.ReadAll(reader) + c.Assert(err, IsNil) + c.Check(len(b), Equals, 85585) +} + +func (s *FetchPackSuite) TestFetchPack(c *C) { + r, err := DefaultClient.NewFetchPackSession(s.Endpoint) + c.Assert(err, IsNil) + defer func() { c.Assert(r.Close(), IsNil) }() + + req := &transport.UploadPackRequest{} + req.Want(plumbing.NewHash("6ecf0ef2c2dffb796033e5a02219af86ec6584e5")) + req.Want(plumbing.NewHash("e8d3ffab552895c19b9fcf7aa264d277cde33881")) + reader, err := r.FetchPack(req) + c.Assert(err, IsNil) + defer func() { c.Assert(reader.Close(), IsNil) }() + + b, err := ioutil.ReadAll(reader) + c.Assert(err, IsNil) + c.Check(len(b), Equals, 85585) +} + +func (s *FetchPackSuite) TestFetchError(c *C) { + r, err := DefaultClient.NewFetchPackSession(s.Endpoint) + c.Assert(err, IsNil) + defer func() { c.Assert(r.Close(), IsNil) }() + + req := &transport.UploadPackRequest{} + req.Want(plumbing.NewHash("1111111111111111111111111111111111111111")) + + reader, err := r.FetchPack(req) + c.Assert(err, IsNil) + + err = reader.Close() + c.Assert(err, Not(IsNil)) +} diff --git a/plumbing/transport/ssh/send_pack.go b/plumbing/transport/ssh/send_pack.go new file mode 100644 index 0000000..afe7510 --- /dev/null +++ b/plumbing/transport/ssh/send_pack.go @@ -0,0 +1,30 @@ +package ssh + +import ( + "errors" + "io" + + "gopkg.in/src-d/go-git.v4/plumbing/transport" +) + +var errSendPackNotSupported = errors.New("send-pack not supported yet") + +type sendPackSession struct { + *session +} + +func newSendPackSession(ep transport.Endpoint) (transport.SendPackSession, + error) { + + return &sendPackSession{&session{}}, nil +} + +func (s *sendPackSession) AdvertisedReferences() (*transport.UploadPackInfo, + error) { + + return nil, errSendPackNotSupported +} + +func (s *sendPackSession) SendPack() (io.WriteCloser, error) { + return nil, errSendPackNotSupported +} |