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}