// Package ssh implements a ssh client for go-git.
package ssh
import (
"bytes"
"errors"
"fmt"
"io"
"strings"
"gopkg.in/src-d/go-git.v4/clients/common"
"gopkg.in/src-d/go-git.v4/formats/packp/advrefs"
"gopkg.in/src-d/go-git.v4/formats/packp/pktline"
"gopkg.in/src-d/go-git.v4/formats/packp/ulreq"
"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")
nak = []byte("NAK")
eol = []byte("\n")
)
// GitUploadPackService holds the service information.
// The zero value is safe to use.
type GitUploadPackService struct {
connected bool
endpoint common.Endpoint
client *ssh.Client
auth AuthMethod
}
// NewGitUploadPackService initialises a GitUploadPackService,
func NewGitUploadPackService(endpoint common.Endpoint) common.GitUploadPackService {
return &GitUploadPackService{endpoint: endpoint}
}
// Connect 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 *GitUploadPackService) 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
}
s.connected = true
return nil
}
func (s *GitUploadPackService) getHostWithPort() string {
host := s.endpoint.Host
if strings.Index(s.endpoint.Host, ":") == -1 {
host += ":22"
}
return host
}
func (s *GitUploadPackService) setAuthFromEndpoint() error {
var u string
if info := s.endpoint.User; info != nil {
u = info.Username()
}
var err error
s.auth, err = NewSSHAgentAuth(u)
if err != nil {
return err
}
return nil
}
// SetAuth sets the AuthMethod
func (s *GitUploadPackService) SetAuth(auth common.AuthMethod) error {
var ok bool
s.auth, ok = auth.(AuthMethod)
if !ok {
return ErrInvalidAuthMethod
}
return nil
}
// 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(s.getCommand())
if err != nil {
return nil, err
}
i = common.NewGitUploadPackInfo()
return i, i.Decode(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 returns a packfile for a given upload request. It opens a new
// SSH session on a connected GitUploadPackService, sends the given
// upload request to the server and returns a reader for the received
// packfile. Closing the returned reader will close the SSH session.
func (s *GitUploadPackService) Fetch(req *common.GitUploadPackRequest) (rc io.ReadCloser, err error) {
if !s.connected {
return nil, ErrNotConnected
}
session, i, o, done, err := openSSHSession(s.client, s.getCommand())
if err != nil {
return nil, fmt.Errorf("cannot open SSH session: %s", err)
}
if err := talkPackProtocol(i, o, req); err != nil {
return nil, err
}
return &fetchSession{
Reader: o,
session: session,
done: done,
}, nil
}
func openSSHSession(c *ssh.Client, cmd string) (
*ssh.Session, io.WriteCloser, io.Reader, <-chan error, error) {
session, err := c.NewSession()
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("cannot open SSH session: %s", err)
}
i, err := session.StdinPipe()
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("cannot pipe remote stdin: %s", err)
}
o, err := session.StdoutPipe()
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("cannot pipe remote stdout: %s", err)
}
done := make(chan error)
go func() {
done <- session.Run(cmd)
}()
return session, i, o, done, nil
}
// 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 talkPackProtocol(w io.WriteCloser, r io.Reader,
req *common.GitUploadPackRequest) error {
if err := skipAdvRef(r); err != nil {
return fmt.Errorf("skipping advertised-refs: %s", err)
}
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 skipAdvRef(r io.Reader) error {
d := advrefs.NewDecoder(r)
ar := advrefs.New()
return d.Decode(ar)
}
func sendUlReq(w io.Writer, req *common.GitUploadPackRequest) 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 *common.GitUploadPackRequest) 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: err ", 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
}
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 *GitUploadPackService) getCommand() string {
directory := s.endpoint.Path
directory = directory[1:]
return fmt.Sprintf("git-upload-pack '%s'", directory)
}