ssh.go

  1package git
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"log"
  7	"os"
  8	"os/exec"
  9	"path/filepath"
 10	"strings"
 11
 12	"github.com/charmbracelet/wish"
 13	"github.com/gliderlabs/ssh"
 14	"github.com/go-git/go-git/v5"
 15	"github.com/go-git/go-git/v5/plumbing"
 16)
 17
 18// ErrNotAuthed represents unauthorized access.
 19var ErrNotAuthed = errors.New("you are not authorized to do this")
 20
 21// ErrSystemMalfunction represents a general system error returned to clients.
 22var ErrSystemMalfunction = errors.New("something went wrong")
 23
 24// ErrInvalidRepo represents an attempt to access a non-existent repo.
 25var ErrInvalidRepo = errors.New("invalid repo")
 26
 27// AccessLevel is the level of access allowed to a repo.
 28type AccessLevel int
 29
 30const (
 31	// NoAccess does not allow access to the repo.
 32	NoAccess AccessLevel = iota
 33
 34	// ReadOnlyAccess allows read-only access to the repo.
 35	ReadOnlyAccess
 36
 37	// ReadWriteAccess allows read and write access to the repo.
 38	ReadWriteAccess
 39
 40	// AdminAccess allows read, write, and admin access to the repo.
 41	AdminAccess
 42)
 43
 44// String implements the Stringer interface for AccessLevel.
 45func (a AccessLevel) String() string {
 46	switch a {
 47	case NoAccess:
 48		return "no-access"
 49	case ReadOnlyAccess:
 50		return "read-only"
 51	case ReadWriteAccess:
 52		return "read-write"
 53	case AdminAccess:
 54		return "admin-access"
 55	default:
 56		return ""
 57	}
 58}
 59
 60// Hooks is an interface that allows for custom authorization
 61// implementations and post push/fetch notifications. Prior to git access,
 62// AuthRepo will be called with the ssh.Session public key and the repo name.
 63// Implementers return the appropriate AccessLevel.
 64type Hooks interface {
 65	AuthRepo(string, ssh.PublicKey) AccessLevel
 66	Push(string, ssh.PublicKey)
 67	Fetch(string, ssh.PublicKey)
 68}
 69
 70// Middleware adds Git server functionality to the ssh.Server. Repos are stored
 71// in the specified repo directory. The provided Hooks implementation will be
 72// checked for access on a per repo basis for a ssh.Session public key.
 73// Hooks.Push and Hooks.Fetch will be called on successful completion of
 74// their commands.
 75func Middleware(repoDir string, gh Hooks) wish.Middleware {
 76	return func(sh ssh.Handler) ssh.Handler {
 77		return func(s ssh.Session) {
 78			cmd := s.Command()
 79			if len(cmd) == 2 {
 80				gc := cmd[0]
 81				// repo should be in the form of "repo.git"
 82				repo := strings.TrimPrefix(cmd[1], "/")
 83				repo = filepath.Clean(repo)
 84				if strings.Contains(repo, "/") {
 85					Fatal(s, fmt.Errorf("%s: %s", ErrInvalidRepo, "user repos not supported"))
 86				}
 87				// git bare repositories should end in ".git"
 88				// https://git-scm.com/docs/gitrepository-layout
 89				if !strings.HasSuffix(repo, ".git") {
 90					repo += ".git"
 91				}
 92				pk := s.PublicKey()
 93				access := gh.AuthRepo(repo, pk)
 94				switch gc {
 95				case "git-receive-pack":
 96					switch access {
 97					case ReadWriteAccess, AdminAccess:
 98						err := gitPack(s, gc, repoDir, repo)
 99						if err != nil {
100							Fatal(s, ErrSystemMalfunction)
101						} else {
102							gh.Push(repo, pk)
103						}
104					default:
105						Fatal(s, ErrNotAuthed)
106					}
107					return
108				case "git-upload-archive", "git-upload-pack":
109					switch access {
110					case ReadOnlyAccess, ReadWriteAccess, AdminAccess:
111						err := gitPack(s, gc, repoDir, repo)
112						switch err {
113						case ErrInvalidRepo:
114							Fatal(s, ErrInvalidRepo)
115						case nil:
116							gh.Fetch(repo, pk)
117						default:
118							log.Printf("unknown git error: %s", err)
119							Fatal(s, ErrSystemMalfunction)
120						}
121					default:
122						Fatal(s, ErrNotAuthed)
123					}
124					return
125				}
126			}
127			sh(s)
128		}
129	}
130}
131
132func gitPack(s ssh.Session, gitCmd string, repoDir string, repo string) error {
133	cmd := strings.TrimPrefix(gitCmd, "git-")
134	rp := filepath.Join(repoDir, repo)
135	switch gitCmd {
136	case "git-upload-archive", "git-upload-pack":
137		exists, err := fileExists(rp)
138		if !exists {
139			return ErrInvalidRepo
140		}
141		if err != nil {
142			return err
143		}
144		return runGit(s, "", cmd, rp)
145	case "git-receive-pack":
146		err := ensureRepo(repoDir, repo)
147		if err != nil {
148			return err
149		}
150		err = runGit(s, "", cmd, rp)
151		if err != nil {
152			return err
153		}
154		err = ensureDefaultBranch(s, rp)
155		if err != nil {
156			return err
157		}
158		// Needed for git dumb http server
159		return runGit(s, rp, "update-server-info")
160	default:
161		return fmt.Errorf("unknown git command: %s", gitCmd)
162	}
163}
164
165func fileExists(path string) (bool, error) {
166	_, err := os.Stat(path)
167	if err == nil {
168		return true, nil
169	}
170	if os.IsNotExist(err) {
171		return false, nil
172	}
173	return true, err
174}
175
176// Fatal prints to the session's STDOUT as a git response and exit 1.
177func Fatal(s ssh.Session, v ...interface{}) {
178	msg := fmt.Sprint(v...)
179	// hex length includes 4 byte length prefix and ending newline
180	pktLine := fmt.Sprintf("%04x%s\n", len(msg)+5, msg)
181	_, _ = wish.WriteString(s, pktLine)
182	s.Exit(1) // nolint: errcheck
183}
184
185func ensureRepo(dir string, repo string) error {
186	exists, err := fileExists(dir)
187	if err != nil {
188		return err
189	}
190	if !exists {
191		err = os.MkdirAll(dir, os.ModeDir|os.FileMode(0700))
192		if err != nil {
193			return err
194		}
195	}
196	rp := filepath.Join(dir, repo)
197	exists, err = fileExists(rp)
198	if err != nil {
199		return err
200	}
201	if !exists {
202		_, err := git.PlainInit(rp, true)
203		if err != nil {
204			return err
205		}
206	}
207	return nil
208}
209
210func runGit(s ssh.Session, dir string, args ...string) error {
211	usi := exec.CommandContext(s.Context(), "git", args...)
212	usi.Dir = dir
213	usi.Stdout = s
214	usi.Stdin = s
215	if err := usi.Run(); err != nil {
216		return err
217	}
218	return nil
219}
220
221func ensureDefaultBranch(s ssh.Session, repoPath string) error {
222	r, err := git.PlainOpen(repoPath)
223	if err != nil {
224		return err
225	}
226	brs, err := r.Branches()
227	if err != nil {
228		return err
229	}
230	defer brs.Close()
231	fb, err := brs.Next()
232	if err != nil {
233		return err
234	}
235	// Rename the default branch to the first branch available
236	_, err = r.Head()
237	if err == plumbing.ErrReferenceNotFound {
238		err = runGit(s, repoPath, "branch", "-M", fb.Name().Short())
239		if err != nil {
240			return err
241		}
242	}
243	if err != nil && err != plumbing.ErrReferenceNotFound {
244		return err
245	}
246	return nil
247}