GitRoot
Craft your forge, Build your project, Grow your community freely
1// SPDX-FileCopyrightText: 2025 Romain Maneschi <romain@gitroot.dev>
2//
3// SPDX-License-Identifier: EUPL-1.2
4
5package main
6
7import (
8 "bytes"
9 "context"
10 "errors"
11 "fmt"
12 "io"
13 "net"
14 "net/url"
15 "slices"
16 "strings"
17 "time"
18
19 "github.com/anmitsu/go-shlex"
20 securejoin "github.com/cyphar/filepath-securejoin"
21 "github.com/go-git/go-git/v6/backend"
22 "github.com/go-git/go-git/v6/plumbing"
23 "github.com/go-git/go-git/v6/plumbing/format/pktline"
24 "github.com/go-git/go-git/v6/plumbing/protocol/packp"
25 "github.com/go-git/go-git/v6/plumbing/protocol/packp/sideband"
26 "github.com/go-git/go-git/v6/plumbing/transport"
27 "github.com/go-git/go-git/v6/utils/ioutil"
28 "github.com/pkg/sftp"
29 "github.com/samber/oops"
30 "gitroot.dev/server/background"
31 "gitroot.dev/server/configuration"
32 "gitroot.dev/server/logger"
33 "gitroot.dev/server/plugin"
34 "gitroot.dev/server/repository"
35 "gitroot.dev/server/user"
36 "golang.org/x/crypto/ssh"
37)
38
39const (
40 SSH_EXTENSIONS_KEY_PUBKEY_FP = "pubkey-fp"
41)
42
43type sshServer struct {
44 keys map[string]ssh.PublicKey
45 logger *logger.Logger
46 conf *configuration.Configuration
47 pluginManager *plugin.Manager
48 repoManager *repository.Manager
49 userManager *user.Manager
50 backgroundManager *background.Manager
51}
52
53func NewServerSsh(conf *configuration.Configuration, repoManager *repository.Manager, userManager *user.Manager, pluginManager *plugin.Manager, backgroundManager *background.Manager) *sshServer {
54 return &sshServer{
55 keys: make(map[string]ssh.PublicKey),
56 logger: logger.NewLoggerCtx(logger.SSH_SERVER_LOGGER_NAME, context.Background()),
57 conf: conf,
58 pluginManager: pluginManager,
59 repoManager: repoManager,
60 userManager: userManager,
61 backgroundManager: backgroundManager,
62 }
63}
64
65func (srv *sshServer) ListenAndServe() error {
66 config := &ssh.ServerConfig{
67 NoClientAuth: false,
68 PublicKeyCallback: func(c ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) {
69 sha256fp := ssh.FingerprintSHA256(pubKey)
70 srv.keys[sha256fp] = pubKey
71 return &ssh.Permissions{
72 // Record the public key used for authentication.
73 Extensions: map[string]string{
74 SSH_EXTENSIONS_KEY_PUBKEY_FP: sha256fp,
75 },
76 }, nil
77 },
78 }
79
80 config.AddHostKey(srv.userManager.RootCommiter().Signer.Signer())
81
82 srv.logger.Warn("starting SSH server on", logger.NewLoggerPair("addr", srv.conf.SshAddr))
83
84 lis, err := net.Listen("tcp", srv.conf.SshAddr)
85 if err != nil {
86 return err
87 }
88 defer lis.Close()
89 for {
90 conn, err := lis.Accept()
91 srv.logger.PrintMemUsage()
92 if err != nil {
93 return err
94 }
95
96 go func(conn net.Conn) {
97 defer conn.Close()
98
99 sshConn, newChanChan, newReq, err := ssh.NewServerConn(conn, config)
100 if err != nil {
101 srv.logger.Error("error when creating server conn", err)
102 return
103 }
104 defer sshConn.Close()
105 srv.logger.Info("new ssh connexion", logger.NewLoggerPair("user", sshConn.Conn.User()), logger.NewLoggerPair("key", sshConn.Permissions.Extensions[SSH_EXTENSIONS_KEY_PUBKEY_FP]))
106 go ssh.DiscardRequests(newReq)
107 for newChan := range newChanChan {
108 if newChan.ChannelType() == "session" {
109 ch, reqc, err := newChan.Accept()
110 if err != nil {
111 srv.logger.Error("error when accepting session", err)
112 return
113 }
114 srv.newSession(ch, reqc, sshConn.Conn.User(), sshConn.Permissions.Extensions[SSH_EXTENSIONS_KEY_PUBKEY_FP]).handle()
115 } else {
116 srv.logger.Error("unknown channel type", errors.New("channel unknown"), logger.NewLoggerPair("pair", newChan.ChannelType()))
117 }
118 }
119 }(conn)
120 }
121}
122
123func (srv *sshServer) newSession(ch ssh.Channel, reqc <-chan *ssh.Request, pseudo string, sshKeyFp string) *sshSession {
124 ctx, cnlCtx := context.WithCancel(context.Background())
125 return &sshSession{
126 srv: srv,
127 logger: *srv.logger.NewSubLoggerCtx("SshSession", ctx),
128 ch: ch,
129 reqc: reqc,
130 ctx: ctx,
131 cnlCtx: cnlCtx,
132 simpleUser: user.SimpleUser{Pseudo: pseudo, Ssh: string(bytes.TrimSuffix(ssh.MarshalAuthorizedKey(srv.keys[sshKeyFp]), []byte("\n")))},
133 }
134}
135
136type sshSession struct {
137 srv *sshServer
138 logger logger.Logger
139 ch ssh.Channel
140 reqc <-chan *ssh.Request
141 ctx context.Context
142 cnlCtx context.CancelFunc
143 simpleUser user.SimpleUser
144}
145
146func (session *sshSession) handle() {
147 var exitCode uint32 = 0
148 defer func() {
149 b := ssh.Marshal(struct{ Value uint32 }{exitCode})
150 _, err := session.ch.SendRequest("exit-status", false, b)
151 if err != nil {
152 session.logger.Error("SendRequest exit-status error", err, logger.NewLoggerPair("exitCode", exitCode))
153 }
154 time.Sleep(10 * time.Millisecond)
155 session.ch.Close()
156 session.cnlCtx()
157 }()
158
159 envs := make(map[string]string)
160 for req := range session.reqc {
161 switch req.Type {
162 case "env":
163 payload := struct{ Key, Value string }{}
164 ssh.Unmarshal(req.Payload, &payload)
165 envs[payload.Key] = payload.Value
166 req.Reply(true, nil)
167 case "exec":
168 payload := struct{ Value string }{}
169 ssh.Unmarshal(req.Payload, &payload)
170 args, err := shlex.Split(payload.Value, true)
171 if err != nil {
172 session.logger.Error("shlex args", err)
173 exitCode = 1
174 return
175 }
176
177 cmd := args[0]
178 name := strings.TrimPrefix(args[1], "/")
179 if name == "" {
180 name = session.srv.conf.ForgeConfigName()
181 }
182 dir, err := securejoin.SecureJoin(session.srv.conf.PathRepositories(), name)
183 if err != nil {
184 session.logger.Error("invalid repo upload pack", err, logger.NewLoggerPair("arg", name))
185 exitCode = 1
186 return
187 }
188
189 session.logger.Info("ssh request", logger.NewLoggerPair("cmd", cmd), logger.NewLoggerPair("dir", dir), logger.NewLoggerPair("name", name))
190
191 switch cmd {
192 case "git-upload-pack": // read
193 // if gp := envs["GIT_PROTOCOL"]; gp != "version=2" {
194 // log.Println("unhandled GIT_PROTOCOL", gp)
195 // exitCode = 1
196 // return
197 // }
198 err = session.handleUploadPack(dir, name)
199 if err != nil {
200 session.logger.Error("handle upload pack error", err)
201 pktline.WriteError(session.ch, err)
202 pktline.WriteFlush(session.ch)
203 exitCode = 1
204 return
205 }
206
207 session.logger.Info("finish upload plack", logger.NewLoggerPair("path", name))
208 if err := req.Reply(true, nil); err != nil {
209 session.logger.Error("req reply error upload pack", err)
210 }
211 return
212 case "git-receive-pack": // write
213 session.logger.Info("start receive plack")
214
215 err = session.handleReceivePack(dir, name)
216 if err != nil {
217 session.logger.Warn("handle receive pack error", logger.NewLoggerPair("err", err.Error()))
218 writerSideband := sideband.NewMuxer(sideband.Sideband64k, session.ch)
219 pktline.WriteError(writerSideband, err)
220 exitCode = 1
221 return
222 }
223
224 session.logger.Info("finish receive plack", logger.NewLoggerPair("path", name))
225 if err := req.Reply(true, nil); err != nil {
226 session.logger.Error("req reply error receive pack", err)
227 }
228 return
229 default:
230 session.logger.Error("unhandled cmd", errors.New("unknown cmd"), logger.NewLoggerPair("cmd", cmd))
231 req.Reply(false, nil)
232 exitCode = 1
233 return
234 }
235 case "auth-agent-req@openssh.com":
236 if req.WantReply {
237 req.Reply(true, nil)
238 }
239 case "subsystem":
240 if string(req.Payload[4:]) == "sftp" {
241 req.Reply(true, nil)
242 root := RepoHandler(session.ctx, session.srv.conf, session.srv.repoManager, session.simpleUser)
243 server := sftp.NewRequestServer(session.ch, root)
244 if err := server.Serve(); err != nil {
245 if err != io.EOF {
246 session.logger.Error("sftp server completed with error", err)
247 }
248 }
249 server.Close()
250 }
251 default:
252 session.logger.Error("unhandled req type", errors.New("unknown req type"), logger.NewLoggerPair("type", req.Type))
253 req.Reply(false, nil)
254 exitCode = 1
255 return
256 }
257 }
258}
259
260func (session *sshSession) handleReceivePack(dir string, repoName string) error {
261 errHandler := oops.In("sshSession").Code("handleReceivePack").With("session", session.simpleUser.Pseudo)
262
263 ld, repo, writer, err := session.srv.repoManager.NewGitRootFsLoader(session.ctx, repoName)
264 if err != nil {
265 return errHandler.Wrapf(err, "repo not found")
266 }
267 defer func() {
268 if err := ld.Close(); err != nil {
269 session.logger.Error("handleReceivePack close error", err)
270 }
271 }()
272 writer.Reject() //by default we reject == in case of errror we don't take new code
273
274 repoConfiguration, err := repo.Configuration()
275 if err != nil {
276 return errHandler.Wrapf(err, "repo configuration")
277 }
278
279 commands := make([]*packp.Command, 0)
280 pfo := &packfileObserver{log: &session.logger}
281 back := backend.New(ld, backend.WithParserObserver(pfo), backend.WithHook(transport.Hooks{
282 PreReceive: func(ctx context.Context, env transport.HookEnv, cmds []*packp.Command) error {
283 session.logger.Debug("prereceive")
284 for _, c := range cmds {
285 session.logger.Info("Command", logger.NewLoggerPair("branch", c.Name.Short()), logger.NewLoggerPair("from", c.Old.String()), logger.NewLoggerPair("to", c.New.String()))
286 canWrite, pusher, err := repo.CanWrite(session.simpleUser.Ssh, c.Name.Short())
287 if err != nil {
288 return errors.New("error in finding right")
289 }
290 if pusher.IsAnonymous() && pusher.NeedToBeCreated(c.Name.Short()) {
291 if err := repoConfiguration.CanCreateAnonymousBranch(repo, session.srv.conf.NbMaxBranchesPerProjectBeforeRejectingAnonymous); err != nil {
292 return err
293 }
294 }
295 if !canWrite {
296 return fmt.Errorf("you can't write in %s", c.Name.Short())
297 }
298 commands = append(commands, c)
299 }
300 return nil
301 },
302 PostReceive: func(ctx context.Context, env transport.HookEnv, cmds []*packp.Command) error {
303 for _, c := range cmds {
304 if c.Action() == packp.Update {
305 isForcePush, err := writer.IsForcePush(c.Old, c.New)
306 session.logger.Info("ForcePush??", logger.NewLoggerPair("isForcePush", isForcePush), logger.NewLoggerPair("err", err))
307 if isForcePush && repoConfiguration.IsNoPushBranch(c.Name) {
308 return fmt.Errorf("you can't force-push on %s", c.Name.Short())
309 }
310 }
311 }
312 return nil
313 },
314 PostUpdate: func(ctx context.Context, env transport.HookEnv, updatedRefs []plumbing.ReferenceName) error {
315 session.logger.Info("updatedRefs", logger.NewLoggerPair("refs", updatedRefs))
316 if env.Writer != nil && slices.Contains(env.PushOptions.Options, "resume") {
317 reset := "\033[0m"
318 red := "\033[31m"
319 green := "\033[32m"
320 yellow := "\033[33m"
321 blue := "\033[34m"
322 magenta := "\033[35m"
323 cyan := "\033[36m"
324 newLine := "\n"
325 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(newLine))
326 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(green+"------------------"+reset+newLine))
327 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(red+"WELCOME IN GITROOT"+reset+newLine))
328 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(green+"------------------"+reset+newLine))
329 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(newLine))
330 for i := range 5 {
331 time.Sleep(1 * time.Second)
332 if i == 0 {
333 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(yellow+"1 - ladybug run"+reset+newLine))
334 } else if i == 1 {
335 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(blue+"2 - silo run"+reset+newLine))
336 } else if i == 2 {
337 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(magenta+"3 - silo report `bad report`"+reset+newLine))
338 } else if i == 3 {
339 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(cyan+"4 - pollen run"+reset+newLine))
340 } else {
341 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(green+"FINISH \\o/"+reset+newLine))
342 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(green+"------------------"+reset+newLine))
343 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(newLine))
344 }
345 }
346 }
347
348 commitsByRef, err := pfo.Finalize(writer, updatedRefs)
349 if err != nil {
350 session.logger.Warn("can't Finalize pfo", logger.NewLoggerPair("err", err.Error()))
351 return err
352 }
353
354 writer.Accept() //after all if no error we accept changes
355
356 session.srv.backgroundManager.PostPush(session.simpleUser, repoName, commands, commitsByRef)
357
358 return nil
359 }}))
360
361 url, err := url.Parse("test/test")
362 if err != nil {
363 session.logger.Error("can't url", err)
364 }
365
366 gitReq := backend.Request{
367 URL: url,
368 Service: "git-receive-pack",
369 GitProtocol: "version=1",
370 }
371 session.logger.Info("backend.ServeTCP")
372
373 err = back.Serve(session.ctx, io.NopCloser(session.ch), ioutil.WriteNopCloser(session.ch), &gitReq)
374 if err != nil {
375 session.logger.Warn("can't serve", logger.NewLoggerPair("err", err.Error()))
376 return err
377 }
378
379 return nil
380}
381
382type packfileObserver struct {
383 previousType plumbing.ObjectType
384 log *logger.Logger
385 allCommits [][]plumbing.Hash
386 commitCurrentIndex int
387}
388
389func (o *packfileObserver) OnHeader(count uint32) error {
390 if o.allCommits == nil {
391 o.allCommits = make([][]plumbing.Hash, 1)
392 o.allCommits[0] = make([]plumbing.Hash, 0)
393 o.commitCurrentIndex = 0
394 } else {
395 o.allCommits = append(o.allCommits, make([]plumbing.Hash, 0))
396 o.commitCurrentIndex = len(o.allCommits) - 1
397 }
398 return nil
399}
400
401func (o *packfileObserver) OnInflatedObjectHeader(t plumbing.ObjectType, objSize, pos int64) error {
402 o.previousType = t
403 if o.previousType == plumbing.CommitObject {
404 o.log.Debug("NEW HASH IN PACKFILE OBJ HEADER", logger.NewLoggerPair("type", t.String()), logger.NewLoggerPair("pos", pos))
405 }
406 return nil
407}
408func (o *packfileObserver) OnInflatedObjectContent(h plumbing.Hash, pos int64, crc uint32, content []byte) error {
409 if o.previousType == plumbing.CommitObject {
410 o.log.Debug("NEW HASH IN PACKFILE OBJECT", logger.NewLoggerPair("hash", h.String()))
411 o.allCommits[o.commitCurrentIndex] = append(o.allCommits[o.commitCurrentIndex], h)
412 }
413 return nil
414}
415
416func (po *packfileObserver) OnFooter(h plumbing.Hash) error {
417 return nil
418}
419
420func (po *packfileObserver) Finalize(repo *repository.GitRootRepositoryWrite, refs []plumbing.ReferenceName) (map[plumbing.ReferenceName][]plumbing.Hash, error) {
421 res := make(map[plumbing.ReferenceName][]plumbing.Hash)
422 for _, r := range refs {
423 po.log.Info("updated ref", logger.NewLoggerPair("ref", r.Short()))
424 b, err := repo.Storer().Reference(r)
425 if err != nil {
426 po.log.Info("can't find ref", logger.NewLoggerPair("ref", r.Short()))
427 res[r] = []plumbing.Hash{}
428 continue
429 }
430 for _, c := range po.allCommits {
431 if len(c) > 0 {
432 if b.Hash().Equal(c[0]) {
433 res[r] = c
434 break
435 } else if b.Hash().Equal(c[len(c)-1]) {
436 slices.Reverse(c)
437 res[r] = c
438 break
439 } else {
440 po.log.Debug("hash not good", logger.NewLoggerPair("branch", r), logger.NewLoggerPair("branchHash", b.Hash().String()), logger.NewLoggerPair("com", c))
441 }
442 }
443 }
444 if len(res[r]) == 0 {
445 po.log.Warn("No commit for", logger.NewLoggerPair("branch", r))
446 }
447 }
448 if len(res) == 0 {
449 po.log.Warn("No branch for")
450 }
451 return res, nil
452}
453
454func (session *sshSession) handleUploadPack(dir string, name string) error {
455 errHandler := oops.In("sshSession").Code("handleUploadPack").With("session", session.simpleUser.Pseudo).With("dir", dir)
456
457 if dir == "/" {
458 dir = session.srv.conf.RootRepositoryName
459 }
460
461 if name == session.srv.conf.ForgeConfigName() {
462 if err := session.srv.repoManager.ForgeRepoNeedOwner(session.ctx, session.simpleUser); err != nil {
463 return errHandler.Wrapf(err, "NeedOwner")
464 }
465 }
466
467 session.logger.Info("handleUploadPack before fsLoader")
468 ld, err := session.srv.repoManager.NewGitRootReadFsLoader(session.ctx, name)
469 if err != nil {
470 return errHandler.Wrapf(err, "repo not found")
471 }
472 defer func() {
473 if err := ld.Close(); err != nil {
474 session.logger.Error("handleUploadPack close error", err)
475 }
476 }()
477 session.logger.Info("handleUploadPack after fsLoader")
478
479 url, err := url.Parse("test/test")
480 if err != nil {
481 session.logger.Error("can't url", err)
482 }
483
484 gitReq := backend.Request{
485 URL: url,
486 Service: "git-upload-pack",
487 GitProtocol: "version=1",
488 }
489
490 svr := backend.New(ld)
491 nopc := io.NopCloser(session.ch)
492 session.logger.Info("handleUploadPack before serve")
493 err = svr.Serve(session.ctx, nopc, ioutil.WriteNopCloser(session.ch), &gitReq)
494 session.logger.Info("handleUploadPack after serve")
495 return err
496}