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 && !errors.Is(err, io.EOF) {
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" && session.srv.conf.IsSftpActive() {
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 } else {
251 req.Reply(false, nil)
252 exitCode = 1
253 return
254 }
255 default:
256 session.logger.Error("unhandled req type", errors.New("unknown req type"), logger.NewLoggerPair("type", req.Type))
257 req.Reply(false, nil)
258 exitCode = 1
259 return
260 }
261 }
262}
263
264func (session *sshSession) handleReceivePack(dir string, repoName string) error {
265 errHandler := oops.In("sshSession").Code("handleReceivePack").With("session", session.simpleUser.Pseudo)
266
267 ld, repo, writer, err := session.srv.repoManager.NewGitRootFsLoader(session.ctx, repoName)
268 if err != nil {
269 return errHandler.Wrapf(err, "repo not found")
270 }
271 defer func() {
272 if err := ld.Close(); err != nil {
273 session.logger.Error("handleReceivePack close error", err)
274 }
275 }()
276 writer.Reject() //by default we reject == in case of errror we don't take new code
277
278 repoConfiguration, err := repo.Configuration()
279 if err != nil {
280 return errHandler.Wrapf(err, "repo configuration")
281 }
282
283 commands := make([]*packp.Command, 0)
284 pfo := &packfileObserver{log: &session.logger}
285 back := backend.New(ld, backend.WithParserObserver(pfo), backend.WithHook(transport.Hooks{
286 PreReceive: func(ctx context.Context, env transport.HookEnv, cmds []*packp.Command) error {
287 session.logger.Debug("prereceive")
288 for _, c := range cmds {
289 session.logger.Info("Command", logger.NewLoggerPair("branch", c.Name.Short()), logger.NewLoggerPair("from", c.Old.String()), logger.NewLoggerPair("to", c.New.String()))
290 canWrite, pusher, err := repo.CanWrite(session.simpleUser.Ssh, c.Name.Short())
291 if err != nil {
292 return errors.New("error in finding right")
293 }
294 if pusher.IsAnonymous() && pusher.NeedToBeCreated(c.Name.Short()) {
295 if err := repoConfiguration.CanCreateAnonymousBranch(repo, session.srv.conf.NbMaxBranchesPerProjectBeforeRejectingAnonymous); err != nil {
296 return err
297 }
298 }
299 if !canWrite {
300 return fmt.Errorf("you can't write in %s", c.Name.Short())
301 }
302 commands = append(commands, c)
303 }
304 return nil
305 },
306 PostReceive: func(ctx context.Context, env transport.HookEnv, cmds []*packp.Command) error {
307 for _, c := range cmds {
308 if c.Action() == packp.Update {
309 isForcePush, err := writer.IsForcePush(c.Old, c.New)
310 session.logger.Info("ForcePush??", logger.NewLoggerPair("isForcePush", isForcePush), logger.NewLoggerPair("err", err))
311 if isForcePush && repoConfiguration.IsNoPushBranch(c.Name) {
312 return fmt.Errorf("you can't force-push on %s", c.Name.Short())
313 }
314 }
315 }
316 return nil
317 },
318 PostUpdate: func(ctx context.Context, env transport.HookEnv, updatedRefs []plumbing.ReferenceName) error {
319 session.logger.Info("updatedRefs", logger.NewLoggerPair("refs", updatedRefs))
320 if env.Writer != nil && slices.Contains(env.PushOptions.Options, "resume") {
321 reset := "\033[0m"
322 red := "\033[31m"
323 green := "\033[32m"
324 yellow := "\033[33m"
325 blue := "\033[34m"
326 magenta := "\033[35m"
327 cyan := "\033[36m"
328 newLine := "\n"
329 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(newLine))
330 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(green+"------------------"+reset+newLine))
331 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(red+"WELCOME IN GITROOT"+reset+newLine))
332 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(green+"------------------"+reset+newLine))
333 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(newLine))
334 for i := range 5 {
335 time.Sleep(1 * time.Second)
336 if i == 0 {
337 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(yellow+"1 - ladybug run"+reset+newLine))
338 } else if i == 1 {
339 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(blue+"2 - silo run"+reset+newLine))
340 } else if i == 2 {
341 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(magenta+"3 - silo report `bad report`"+reset+newLine))
342 } else if i == 3 {
343 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(cyan+"4 - pollen run"+reset+newLine))
344 } else {
345 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(green+"FINISH \\o/"+reset+newLine))
346 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(green+"------------------"+reset+newLine))
347 env.Writer.WriteChannel(sideband.ProgressMessage, []byte(newLine))
348 }
349 }
350 }
351
352 commitsByRef, err := pfo.Finalize(writer, updatedRefs)
353 if err != nil {
354 session.logger.Warn("can't Finalize pfo", logger.NewLoggerPair("err", err.Error()))
355 return err
356 }
357
358 writer.Accept() //after all if no error we accept changes
359
360 session.srv.backgroundManager.PostPush(session.simpleUser, repoName, commands, commitsByRef)
361
362 return nil
363 }}))
364
365 url, err := url.Parse("test/test")
366 if err != nil {
367 session.logger.Error("can't url", err)
368 }
369
370 gitReq := backend.Request{
371 URL: url,
372 Service: "git-receive-pack",
373 GitProtocol: "version=1",
374 }
375 session.logger.Info("backend.ServeTCP")
376
377 err = back.Serve(session.ctx, io.NopCloser(session.ch), ioutil.WriteNopCloser(session.ch), &gitReq)
378 if err != nil {
379 session.logger.Warn("can't serve", logger.NewLoggerPair("err", err.Error()))
380 return err
381 }
382
383 return nil
384}
385
386type packfileObserver struct {
387 previousType plumbing.ObjectType
388 log *logger.Logger
389 allCommits [][]plumbing.Hash
390 commitCurrentIndex int
391}
392
393func (o *packfileObserver) OnHeader(count uint32) error {
394 if o.allCommits == nil {
395 o.allCommits = make([][]plumbing.Hash, 1)
396 o.allCommits[0] = make([]plumbing.Hash, 0)
397 o.commitCurrentIndex = 0
398 } else {
399 o.allCommits = append(o.allCommits, make([]plumbing.Hash, 0))
400 o.commitCurrentIndex = len(o.allCommits) - 1
401 }
402 return nil
403}
404
405func (o *packfileObserver) OnInflatedObjectHeader(t plumbing.ObjectType, objSize, pos int64) error {
406 o.previousType = t
407 if o.previousType == plumbing.CommitObject {
408 o.log.Debug("NEW HASH IN PACKFILE OBJ HEADER", logger.NewLoggerPair("type", t.String()), logger.NewLoggerPair("pos", pos))
409 }
410 return nil
411}
412func (o *packfileObserver) OnInflatedObjectContent(h plumbing.Hash, pos int64, crc uint32, content []byte) error {
413 if o.previousType == plumbing.CommitObject {
414 o.log.Debug("NEW HASH IN PACKFILE OBJECT", logger.NewLoggerPair("hash", h.String()))
415 o.allCommits[o.commitCurrentIndex] = append(o.allCommits[o.commitCurrentIndex], h)
416 }
417 return nil
418}
419
420func (po *packfileObserver) OnFooter(h plumbing.Hash) error {
421 return nil
422}
423
424func (po *packfileObserver) Finalize(repo *repository.GitRootRepositoryWrite, refs []plumbing.ReferenceName) (map[plumbing.ReferenceName][]plumbing.Hash, error) {
425 res := make(map[plumbing.ReferenceName][]plumbing.Hash)
426 for _, r := range refs {
427 po.log.Info("updated ref", logger.NewLoggerPair("ref", r.Short()))
428 b, err := repo.Storer().Reference(r)
429 if err != nil {
430 po.log.Info("can't find ref", logger.NewLoggerPair("ref", r.Short()))
431 res[r] = []plumbing.Hash{}
432 continue
433 }
434 for _, c := range po.allCommits {
435 if len(c) > 0 {
436 if b.Hash().Equal(c[0]) {
437 res[r] = c
438 break
439 } else if b.Hash().Equal(c[len(c)-1]) {
440 slices.Reverse(c)
441 res[r] = c
442 break
443 } else {
444 po.log.Debug("hash not good", logger.NewLoggerPair("branch", r), logger.NewLoggerPair("branchHash", b.Hash().String()), logger.NewLoggerPair("com", c))
445 }
446 }
447 }
448 if len(res[r]) == 0 {
449 po.log.Warn("No commit for", logger.NewLoggerPair("branch", r))
450 }
451 }
452 if len(res) == 0 {
453 po.log.Warn("No branch for")
454 }
455 return res, nil
456}
457
458func (session *sshSession) handleUploadPack(dir string, name string) error {
459 errHandler := oops.In("sshSession").Code("handleUploadPack").With("session", session.simpleUser.Pseudo).With("dir", dir)
460
461 if dir == "/" {
462 dir = session.srv.conf.RootRepositoryName
463 }
464
465 if name == session.srv.conf.ForgeConfigName() {
466 if err := session.srv.repoManager.ForgeRepoNeedOwner(session.ctx, session.simpleUser); err != nil {
467 return errHandler.Wrapf(err, "NeedOwner")
468 }
469 }
470
471 session.logger.Info("handleUploadPack before fsLoader")
472 ld, err := session.srv.repoManager.NewGitRootReadFsLoader(session.ctx, name)
473 if err != nil {
474 return errHandler.Wrapf(err, "repo not found")
475 }
476 defer func() {
477 if err := ld.Close(); err != nil {
478 session.logger.Error("handleUploadPack close error", err)
479 }
480 }()
481 session.logger.Info("handleUploadPack after fsLoader")
482
483 url, err := url.Parse("test/test")
484 if err != nil {
485 session.logger.Error("can't url", err)
486 }
487
488 gitReq := backend.Request{
489 URL: url,
490 Service: "git-upload-pack",
491 GitProtocol: "version=1",
492 }
493
494 svr := backend.New(ld)
495 nopc := io.NopCloser(session.ch)
496 session.logger.Info("handleUploadPack before serve")
497 err = svr.Serve(session.ctx, nopc, ioutil.WriteNopCloser(session.ch), &gitReq)
498 session.logger.Info("handleUploadPack after serve")
499 return err
500}