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