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}