1package git
2
3import (
4 "errors"
5 "fmt"
6 "log"
7 "os"
8 "path/filepath"
9 "strings"
10
11 "github.com/charmbracelet/soft-serve/git"
12 "github.com/charmbracelet/wish"
13 "github.com/gliderlabs/ssh"
14 g "github.com/gogs/git-module"
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 c := g.NewCommand(args...)
219 return c.RunInDirWithOptions(dir, g.RunInDirOptions{
220 Stdout: s,
221 Stdin: s,
222 Stderr: s.Stderr(),
223 })
224}
225
226func ensureDefaultBranch(s ssh.Session, repoPath string) error {
227 r, err := git.Open(repoPath)
228 if err != nil {
229 return err
230 }
231 brs, err := r.Branches()
232 if err != nil {
233 return err
234 }
235 if len(brs) == 0 {
236 return fmt.Errorf("no branches found")
237 }
238 // Rename the default branch to the first branch available
239 _, err = r.HEAD()
240 if err == git.ErrReferenceNotExist {
241 err = runGit(s, repoPath, "branch", "-M", brs[0])
242 if err != nil {
243 return err
244 }
245 }
246 if err != nil && err != git.ErrReferenceNotExist {
247 return err
248 }
249 return nil
250}