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