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}