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 && strings.HasPrefix(cmd[0], "git") {
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}