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	"compress/zlib"
 10	"context"
 11	"errors"
 12	"fmt"
 13	"io"
 14	"net"
 15	"strings"
 16	"time"
 17
 18	"github.com/anmitsu/go-shlex"
 19	securejoin "github.com/cyphar/filepath-securejoin"
 20	"github.com/go-git/go-billy/v5/osfs"
 21	"github.com/go-git/go-git/v5/plumbing/format/packfile"
 22	"github.com/go-git/go-git/v5/plumbing/format/pktline"
 23	"github.com/go-git/go-git/v5/plumbing/object"
 24	"github.com/go-git/go-git/v5/plumbing/protocol/packp"
 25	"github.com/go-git/go-git/v5/plumbing/protocol/packp/capability"
 26	"github.com/go-git/go-git/v5/plumbing/server"
 27	"github.com/go-git/go-git/v5/plumbing/transport"
 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.Error("handle receive pack error", err)
217					exitCode = 1
218					return
219				}
220
221				session.logger.Info("finish receive plack", logger.NewLoggerPair("path", name))
222				if err := req.Reply(true, nil); err != nil {
223					session.logger.Error("req reply error receive pack", err)
224				}
225				return
226			default:
227				session.logger.Error("unhandled cmd", errors.New("unknown cmd"), logger.NewLoggerPair("cmd", cmd))
228				req.Reply(false, nil)
229				exitCode = 1
230				return
231			}
232		case "auth-agent-req@openssh.com":
233			if req.WantReply {
234				req.Reply(true, nil)
235			}
236		default:
237			session.logger.Error("unhandled req type", errors.New("unknown req type"), logger.NewLoggerPair("type", req.Type))
238			req.Reply(false, nil)
239			exitCode = 1
240			return
241		}
242	}
243}
244
245func (session *sshSession) handleReceivePack(dir string, repoName string) error {
246	errHandler := oops.In("sshSession").Code("handleReceivePack").With("session", session.simpleUser.Pseudo)
247
248	ep, err := transport.NewEndpoint(dir)
249	if err != nil {
250		return errHandler.Wrapf(err, "create transport endpoint")
251	}
252	ld, repo, writer, err := repository.NewGitRootFsLoader(session.ctx, repoName, session.srv.repoManager)
253	if err != nil {
254		return errHandler.Wrapf(err, "repo not found")
255	}
256	writer.Reject() //by default we reject == in case of errror we don't take new code
257	defer ld.Close()
258	svr := server.NewServer(ld)
259	session.logger.Info("NewReceivePackSession")
260	sess, err := svr.NewReceivePackSession(ep, nil)
261	if err != nil {
262		return errHandler.Wrapf(err, "create receive-pack session")
263	}
264
265	ar, err := sess.AdvertisedReferencesContext(session.ctx)
266	if err != nil {
267		return errHandler.Wrapf(err, "get advertised references")
268	}
269	if err := ar.Encode(session.ch); err != nil {
270		return errHandler.Wrapf(err, "encode advertised references")
271	}
272
273	rur := packp.NewReferenceUpdateRequest()
274	defer func() {
275		if rur.Packfile != nil {
276			rur.Packfile.Close()
277		}
278	}()
279	if err := rur.Decode(io.NopCloser(session.ch)); err != nil {
280		if errors.Is(err, packp.ErrEmptyCommands) || err.Error() == "capabilities delimiter not found" {
281			return pktline.WriteResponseEnd(session.ch)
282		}
283		return errHandler.Wrapf(err, "decode reference-update request")
284	}
285
286	onlyDelete := true
287	for _, cmd := range rur.Commands {
288		onlyDelete = onlyDelete && cmd.Action() == "delete"
289	}
290	if onlyDelete {
291		rur.Packfile.Close()
292		rur.Packfile = nil
293	}
294
295	res, err := sess.ReceivePack(session.ctx, rur)
296	if errors.Is(err, packp.ErrEmpty) {
297		//nothing pushed == nothing todo
298		return nil
299	} else if errors.Is(err, packfile.ErrEmptyPackfile) {
300		res.UnpackStatus = "ok"
301		rur.Packfile.Close()
302		rur.Packfile = nil
303	} else if err != nil {
304		return errHandler.Wrapf(err, "create receive-pack response")
305	}
306
307	repoConfiguration, err := repo.Configuration()
308	if err != nil {
309		return errHandler.Wrapf(err, "repo configuration")
310	}
311
312	for _, c := range rur.Commands {
313		session.logger.Info("Command", logger.NewLoggerPair("branch", c.Name.Short()), logger.NewLoggerPair("from", c.Old.String()), logger.NewLoggerPair("to", c.New.String()))
314		canWrite, currentUser, err := repo.CanWrite(session.simpleUser.Ssh, c.Name.Short())
315		if err != nil {
316			errUser := errors.New("error in finding right")
317			pktline.WriteError(session.ch, errUser)
318			return oops.Wrapf(err, "error in finding right")
319		}
320		if !canWrite {
321			err := fmt.Errorf("you can't write in %s", c.Name.Short())
322			pktline.WriteError(session.ch, err)
323			session.logger.Info("user can't write in branch", logger.NewLoggerPair("branch", c.Name))
324			return nil
325		}
326
327		if c.Action() == packp.Update {
328			isForcePush, err := writer.IsForcePush(c.Name, c.Old)
329			session.logger.Info("ForcePush??", logger.NewLoggerPair("isForcePush", isForcePush), logger.NewLoggerPair("err", err))
330			if isForcePush && repoConfiguration.IsNoPushBranch(c.Name) {
331				err := errors.New("you can't force-push on main")
332				pktline.WriteError(session.ch, err)
333				return nil //don't return error
334			}
335			if isForcePush {
336				com, err := writer.GetLastCommit(c.New)
337				if err != nil {
338					errUser := errors.New("not found previous commit")
339					pktline.WriteError(session.ch, errUser)
340					return oops.Wrapf(err, "error force-push previous commit")
341				}
342				p, err := com.Commit.Parents().Next() //TODO need to find all commits of force-pushed branch, only one in tests
343				c.Old = p.Hash
344			}
345		}
346
347		err = repo.WalkCommit(c.Old, c.New, func(commit *object.Commit) error {
348			if commit.PGPSignature == "" {
349				err := errors.New("you need to sign your commit")
350				pktline.WriteError(session.ch, err)
351				return oops.With("no signed commit", commit.Hash.String()).Wrap(errors.New("commit not signed"))
352			}
353
354			if err = Verify(session.simpleUser.Ssh, commit); err != nil {
355				errUser := errors.New("ssh/pgp signature not good")
356				pktline.WriteError(session.ch, errUser)
357				return oops.With("commit", commit.Hash).Wrapf(err, "error in key and signature")
358			}
359
360			return nil
361		})
362		if err != nil {
363			return errHandler.With("from", c.Old.String()).With("to", c.New.String()).Wrapf(err, "can't iter over commits")
364		}
365
366		if currentUser.Group == nil && c.Action() != packp.Delete { // user push new branch
367			session.logger.Info("Add user into branch", logger.NewLoggerPair("branch", c.Name.Short()))
368			if err := writer.AddUserInfo(currentUser, c.Name.Short()); err != nil {
369				session.logger.Error("can't AddUserInfo", err)
370				// errUser := errors.New("can't add user info")
371				// pktline.WriteError(session.ch, errUser)
372				// return oops.With("branch", c.Name.Short()).Wrapf(err, "can't add user info")
373			}
374		}
375	}
376
377	if err := res.Encode(session.ch); err != nil {
378		return errHandler.Wrapf(err, "encode receive-pack response")
379	}
380
381	writer.Accept() //after all if no error we accept changes
382
383	session.srv.backgroundManager.PostPush(session.simpleUser, repoName, rur.Commands)
384	return nil
385}
386
387func (session *sshSession) handleUploadPack(dir string, name string) error {
388	errHandler := oops.In("sshSession").Code("handleUploadPack").With("session", session.simpleUser.Pseudo).With("dir", dir)
389
390	if dir == "/" {
391		dir = session.srv.conf.RootRepositoryName
392	}
393
394	if name == session.srv.conf.ForgeConfigName() {
395		if err := session.srv.repoManager.ForgeRepoNeedOwner(session.ctx, session.simpleUser); err != nil {
396			return errHandler.Wrapf(err, "NeedOwner")
397		}
398	}
399
400	repo, err := session.srv.repoManager.Open(logger.AddCaller(session.ctx, "handleUploadPack"), name)
401	if err != nil {
402		return errHandler.With("name", name).Wrapf(err, "can't open repo")
403	}
404	defer repo.Close()
405
406	ep, err := transport.NewEndpoint("/")
407	if err != nil {
408		return errHandler.Wrapf(err, "create transport endpoint")
409	}
410	bfs := osfs.New(dir)
411	ld := server.NewFilesystemLoader(bfs)
412	svr := server.NewServer(ld)
413	sess, err := svr.NewUploadPackSession(ep, nil)
414	if err != nil {
415		return errHandler.Wrapf(err, "create upload-pack session")
416	}
417	defer sess.Close()
418
419	ar, err := sess.AdvertisedReferencesContext(session.ctx)
420	if err != nil {
421		return errHandler.Wrapf(err, "get advertised references")
422	}
423	if err := ar.Capabilities.Add(capability.ThinPack); err != nil {
424		return errHandler.Wrapf(err, "set advertised capabilities")
425	}
426	if err := ar.Capabilities.Add(capability.OFSDelta); err != nil {
427		return errHandler.Wrapf(err, "set advertised capabilities")
428	}
429	if err := ar.Capabilities.Add(capability.MultiACK); err != nil {
430		return errHandler.Wrapf(err, "set advertised capabilities")
431	}
432	if err := ar.Capabilities.Add(capability.MultiACKDetailed); err != nil {
433		return errHandler.Wrapf(err, "set advertised capabilities")
434	}
435	if err := ar.Capabilities.Add(capability.Shallow); err != nil {
436		return errHandler.Wrapf(err, "set advertised capabilities")
437	}
438	if err := ar.Capabilities.Add(capability.DeepenRelative); err != nil {
439		return errHandler.Wrapf(err, "set advertised capabilities")
440	}
441	if err := ar.Capabilities.Add(capability.DeepenSince); err != nil {
442		return errHandler.Wrapf(err, "set advertised capabilities")
443	}
444	if err := ar.Capabilities.Add(capability.DeepenNot); err != nil {
445		return errHandler.Wrapf(err, "set advertised capabilities")
446	}
447	// if err := ar.Capabilities.Add(capability.Sideband64k); err != nil {
448	// 	return errHandler.Wrapf(err, "set advertised capabilities")
449	// }
450	err = ar.Encode(session.ch)
451	if err != nil {
452		return errHandler.Wrapf(err, "encode advertised references")
453	}
454
455	capa := capability.NewList()
456	capa.Add(capability.Shallow)
457	upr := packp.NewUploadPackRequestFromCapabilities(capa)
458	err = upr.Decode(session.ch)
459	if err != nil {
460		return errHandler.Wrapf(err, "decode upload-pack request")
461	}
462	session.logger.Info("upr", logger.NewLoggerPair("upr", upr))
463
464	res, err := sess.UploadPack(session.ctx, upr)
465	if err != nil && !errors.Is(err, zlib.ErrHeader) {
466		if errors.Is(err, transport.ErrEmptyUploadPackRequest) {
467			return nil
468		}
469		return errHandler.Wrapf(err, "create upload-pack response")
470	}
471	session.logger.Info("res", logger.NewLoggerPair("res", res))
472	err = res.Encode(session.ch)
473	if err != nil {
474		return errHandler.Wrapf(err, "encode upload-pack response")
475	}
476
477	return res.Close()
478}