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/pktline"
 22	"github.com/go-git/go-git/v5/plumbing/object"
 23	"github.com/go-git/go-git/v5/plumbing/protocol/packp"
 24	"github.com/go-git/go-git/v5/plumbing/protocol/packp/capability"
 25	"github.com/go-git/go-git/v5/plumbing/server"
 26	"github.com/go-git/go-git/v5/plumbing/transport"
 27	"github.com/samber/oops"
 28	"gitroot.dev/server/background"
 29	"gitroot.dev/server/configuration"
 30	"gitroot.dev/server/logger"
 31	"gitroot.dev/server/plugin"
 32	"gitroot.dev/server/repository"
 33	"gitroot.dev/server/user"
 34	"golang.org/x/crypto/ssh"
 35)
 36
 37const (
 38	SSH_EXTENSIONS_KEY_PUBKEY_FP = "pubkey-fp"
 39)
 40
 41type sshServer struct {
 42	keys              map[string]ssh.PublicKey
 43	logger            *logger.Logger
 44	conf              *configuration.Configuration
 45	pluginManager     *plugin.Manager
 46	repoManager       *repository.Manager
 47	userManager       *user.Manager
 48	backgroundManager *background.Manager
 49}
 50
 51func NewServerSsh(conf *configuration.Configuration, repoManager *repository.Manager, userManager *user.Manager, pluginManager *plugin.Manager, backgroundManager *background.Manager) *sshServer {
 52	return &sshServer{
 53		keys:              make(map[string]ssh.PublicKey),
 54		logger:            logger.NewLoggerCtx(logger.SSH_SERVER_LOGGER_NAME, context.Background()),
 55		conf:              conf,
 56		pluginManager:     pluginManager,
 57		repoManager:       repoManager,
 58		userManager:       userManager,
 59		backgroundManager: backgroundManager,
 60	}
 61}
 62
 63func (srv *sshServer) ListenAndServe() error {
 64	config := &ssh.ServerConfig{
 65		NoClientAuth: false,
 66		PublicKeyCallback: func(c ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) {
 67			sha256fp := ssh.FingerprintSHA256(pubKey)
 68			srv.keys[sha256fp] = pubKey
 69			return &ssh.Permissions{
 70				// Record the public key used for authentication.
 71				Extensions: map[string]string{
 72					SSH_EXTENSIONS_KEY_PUBKEY_FP: sha256fp,
 73				},
 74			}, nil
 75		},
 76	}
 77
 78	config.AddHostKey(srv.userManager.RootCommiter().Signer.Signer())
 79
 80	srv.logger.Warn("starting SSH server on", logger.NewLoggerPair("addr", srv.conf.SshAddr))
 81
 82	lis, err := net.Listen("tcp", srv.conf.SshAddr)
 83	if err != nil {
 84		return err
 85	}
 86	defer lis.Close()
 87	for {
 88		conn, err := lis.Accept()
 89		srv.logger.PrintMemUsage()
 90		if err != nil {
 91			return err
 92		}
 93
 94		go func(conn net.Conn) {
 95			defer conn.Close()
 96
 97			sshConn, newChanChan, newReq, err := ssh.NewServerConn(conn, config)
 98			if err != nil {
 99				srv.logger.Error("error when creating server conn", err)
100				return
101			}
102			defer sshConn.Close()
103			srv.logger.Info("new ssh connexion", logger.NewLoggerPair("user", sshConn.Conn.User()), logger.NewLoggerPair("key", sshConn.Permissions.Extensions[SSH_EXTENSIONS_KEY_PUBKEY_FP]))
104			go ssh.DiscardRequests(newReq)
105			for newChan := range newChanChan {
106				if newChan.ChannelType() == "session" {
107					ch, reqc, err := newChan.Accept()
108					if err != nil {
109						srv.logger.Error("error when accepting session", err)
110						return
111					}
112					srv.newSession(ch, reqc, sshConn.Conn.User(), sshConn.Permissions.Extensions[SSH_EXTENSIONS_KEY_PUBKEY_FP]).handle()
113				} else {
114					srv.logger.Error("unknown channel type", errors.New("channel unknown"), logger.NewLoggerPair("pair", newChan.ChannelType()))
115				}
116			}
117		}(conn)
118	}
119}
120
121func (srv *sshServer) newSession(ch ssh.Channel, reqc <-chan *ssh.Request, pseudo string, sshKeyFp string) *sshSession {
122	ctx, cnlCtx := context.WithCancel(context.Background())
123	return &sshSession{
124		srv:        srv,
125		logger:     *srv.logger.NewSubLoggerCtx("SshSession", ctx),
126		ch:         ch,
127		reqc:       reqc,
128		ctx:        ctx,
129		cnlCtx:     cnlCtx,
130		simpleUser: user.SimpleUser{Pseudo: pseudo, Ssh: string(bytes.TrimSuffix(ssh.MarshalAuthorizedKey(srv.keys[sshKeyFp]), []byte("\n")))},
131	}
132}
133
134type sshSession struct {
135	srv        *sshServer
136	logger     logger.Logger
137	ch         ssh.Channel
138	reqc       <-chan *ssh.Request
139	ctx        context.Context
140	cnlCtx     context.CancelFunc
141	simpleUser user.SimpleUser
142}
143
144func (session *sshSession) handle() {
145	var exitCode uint32 = 0
146	defer func() {
147		b := ssh.Marshal(struct{ Value uint32 }{exitCode})
148		_, err := session.ch.SendRequest("exit-status", false, b)
149		if err != nil {
150			session.logger.Error("SendRequest exit-status error", err, logger.NewLoggerPair("exitCode", exitCode))
151		}
152		time.Sleep(10 * time.Millisecond)
153		session.ch.Close()
154		session.cnlCtx()
155	}()
156
157	envs := make(map[string]string)
158	for req := range session.reqc {
159		switch req.Type {
160		case "env":
161			payload := struct{ Key, Value string }{}
162			ssh.Unmarshal(req.Payload, &payload)
163			envs[payload.Key] = payload.Value
164			req.Reply(true, nil)
165		case "exec":
166			payload := struct{ Value string }{}
167			ssh.Unmarshal(req.Payload, &payload)
168			args, err := shlex.Split(payload.Value, true)
169			if err != nil {
170				session.logger.Error("shlex args", err)
171				exitCode = 1
172				return
173			}
174
175			cmd := args[0]
176			name := strings.TrimPrefix(args[1], "/")
177			if name == "" {
178				name = session.srv.conf.ForgeConfigName()
179			}
180			dir, err := securejoin.SecureJoin(session.srv.conf.PathRepositories(), name)
181			if err != nil {
182				session.logger.Error("invalid repo upload pack", err, logger.NewLoggerPair("arg", name))
183				exitCode = 1
184				return
185			}
186
187			session.logger.Info("ssh request", logger.NewLoggerPair("cmd", cmd), logger.NewLoggerPair("dir", dir), logger.NewLoggerPair("name", name))
188
189			switch cmd {
190			case "git-upload-pack": // read
191				// if gp := envs["GIT_PROTOCOL"]; gp != "version=2" {
192				// 	log.Println("unhandled GIT_PROTOCOL", gp)
193				// 	exitCode = 1
194				// 	return
195				// }
196				err = session.handleUploadPack(dir, name)
197				if err != nil {
198					session.logger.Error("handle upload pack error", err)
199					pktline.WriteError(session.ch, err)
200					pktline.WriteFlush(session.ch)
201					exitCode = 1
202					return
203				}
204
205				session.logger.Info("finish upload plack", logger.NewLoggerPair("path", name))
206				if err := req.Reply(true, nil); err != nil {
207					session.logger.Error("req reply error upload pack", err)
208				}
209				return
210			case "git-receive-pack": // write
211				session.logger.Info("start receive plack")
212
213				err = session.handleReceivePack(dir, name)
214				if err != nil {
215					session.logger.Error("handle receive pack error", err)
216					exitCode = 1
217					return
218				}
219
220				session.logger.Info("finish receive plack", logger.NewLoggerPair("path", name))
221				if err := req.Reply(true, nil); err != nil {
222					session.logger.Error("req reply error receive pack", err)
223				}
224				return
225			default:
226				session.logger.Error("unhandled cmd", errors.New("unknown cmd"), logger.NewLoggerPair("cmd", cmd))
227				req.Reply(false, nil)
228				exitCode = 1
229				return
230			}
231		case "auth-agent-req@openssh.com":
232			if req.WantReply {
233				req.Reply(true, nil)
234			}
235		default:
236			session.logger.Error("unhandled req type", errors.New("unknown req type"), logger.NewLoggerPair("type", req.Type))
237			req.Reply(false, nil)
238			exitCode = 1
239			return
240		}
241	}
242}
243
244func (session *sshSession) handleReceivePack(dir string, repoName string) error {
245	errHandler := oops.In("sshSession").Code("handleReceivePack").With("session", session.simpleUser.Pseudo)
246
247	ep, err := transport.NewEndpoint(dir)
248	if err != nil {
249		return errHandler.Wrapf(err, "create transport endpoint")
250	}
251	ld, repo, writer, err := repository.NewGitRootFsLoader(session.ctx, repoName, session.srv.repoManager)
252	if err != nil {
253		return errHandler.Wrapf(err, "repo not found")
254	}
255	writer.Reject() //by default we reject == in case of errror we don't take new code
256	defer ld.Close()
257	svr := server.NewServer(ld)
258	session.logger.Info("NewReceivePackSession")
259	sess, err := svr.NewReceivePackSession(ep, nil)
260	if err != nil {
261		return errHandler.Wrapf(err, "create receive-pack session")
262	}
263
264	ar, err := sess.AdvertisedReferencesContext(session.ctx)
265	if err != nil {
266		return errHandler.Wrapf(err, "get advertised references")
267	}
268	if err := ar.Encode(session.ch); err != nil {
269		return errHandler.Wrapf(err, "encode advertised references")
270	}
271
272	rur := packp.NewReferenceUpdateRequest()
273	defer func() {
274		if rur.Packfile != nil {
275			rur.Packfile.Close()
276		}
277	}()
278	if err := rur.Decode(io.NopCloser(session.ch)); err != nil {
279		if errors.Is(err, packp.ErrEmptyCommands) || err.Error() == "capabilities delimiter not found" {
280			return pktline.WriteResponseEnd(session.ch)
281		}
282		return errHandler.Wrapf(err, "decode reference-update request")
283	}
284
285	onlyDelete := true
286	for _, cmd := range rur.Commands {
287		onlyDelete = onlyDelete && cmd.Action() == "delete"
288	}
289	if onlyDelete {
290		rur.Packfile.Close()
291		rur.Packfile = nil
292	}
293
294	res, err := sess.ReceivePack(session.ctx, rur)
295	if errors.Is(err, packp.ErrEmpty) {
296		//nothing pushed == nothing todo
297		return nil
298	} else if err != nil {
299		return errHandler.Wrapf(err, "create receive-pack response")
300	}
301
302	repoConfiguration, err := repo.Configuration()
303	if err != nil {
304		return errHandler.Wrapf(err, "repo configuration")
305	}
306
307	for _, c := range rur.Commands {
308		session.logger.Info("Command", logger.NewLoggerPair("branch", c.Name.Short()), logger.NewLoggerPair("from", c.Old.String()), logger.NewLoggerPair("to", c.New.String()))
309		canWrite, currentUser, err := repo.CanWrite(session.simpleUser.Ssh, c.Name.Short())
310		if err != nil {
311			errUser := errors.New("error in finding right")
312			pktline.WriteError(session.ch, errUser)
313			return oops.Wrapf(err, "error in finding right")
314		}
315		if !canWrite {
316			err := fmt.Errorf("you can't write in %s", c.Name.Short())
317			pktline.WriteError(session.ch, err)
318			session.logger.Info("user can't write in branch", logger.NewLoggerPair("branch", c.Name))
319			return nil
320		}
321
322		if c.Action() == packp.Update {
323			isForcePush, err := writer.IsForcePush(c.Name, c.Old)
324			session.logger.Info("ForcePush??", logger.NewLoggerPair("isForcePush", isForcePush), logger.NewLoggerPair("err", err))
325			if isForcePush && repoConfiguration.IsNoPushBranch(c.Name) {
326				err := errors.New("you can't force-push on main")
327				pktline.WriteError(session.ch, err)
328				return nil //don't return error
329			}
330			if isForcePush {
331				com, err := writer.GetLastCommit(c.New)
332				if err != nil {
333					errUser := errors.New("not found previous commit")
334					pktline.WriteError(session.ch, errUser)
335					return oops.Wrapf(err, "error force-push previous commit")
336				}
337				p, err := com.Commit.Parents().Next() //TODO need to find all commits of force-pushed branch, only one in tests
338				c.Old = p.Hash
339			}
340		}
341
342		err = repo.WalkCommit(c.Old, c.New, func(commit *object.Commit) error {
343			if commit.PGPSignature == "" {
344				err := errors.New("you need to sign your commit")
345				pktline.WriteError(session.ch, err)
346				return oops.With("no signed commit", commit.Hash.String()).Wrap(errors.New("commit not signed"))
347			}
348
349			if err = Verify(session.simpleUser.Ssh, commit); err != nil {
350				errUser := errors.New("ssh/pgp signature not good")
351				pktline.WriteError(session.ch, errUser)
352				return oops.With("commit", commit.Hash).Wrapf(err, "error in key and signature")
353			}
354
355			return nil
356		})
357		if err != nil {
358			return errHandler.With("from", c.Old.String()).With("to", c.New.String()).Wrapf(err, "can't iter over commits")
359		}
360
361		if currentUser.Group == nil && c.Action() != packp.Delete { // user push new branch
362			session.logger.Info("Add user into branch", logger.NewLoggerPair("branch", c.Name.Short()))
363			if err := writer.AddUserInfo(currentUser, c.Name.Short()); err != nil {
364				session.logger.Error("can't AddUserInfo", err)
365			}
366		}
367	}
368
369	if err := res.Encode(session.ch); err != nil {
370		return errHandler.Wrapf(err, "encode receive-pack response")
371	}
372
373	writer.Accept() //after all if no error we accept changes
374
375	session.srv.backgroundManager.PostPush(session.simpleUser, repoName, rur.Commands)
376	return nil
377}
378
379func (session *sshSession) handleUploadPack(dir string, name string) error {
380	errHandler := oops.In("sshSession").Code("handleUploadPack").With("session", session.simpleUser.Pseudo).With("dir", dir)
381
382	if dir == "/" {
383		dir = session.srv.conf.RootRepositoryName
384	}
385
386	if name == session.srv.conf.ForgeConfigName() {
387		if err := session.srv.repoManager.ForgeRepoNeedOwner(session.ctx, session.simpleUser); err != nil {
388			return errHandler.Wrapf(err, "NeedOwner")
389		}
390	}
391
392	repo, err := session.srv.repoManager.Open(logger.AddCaller(session.ctx, "handleUploadPack"), name)
393	if err != nil {
394		return errHandler.With("name", name).Wrapf(err, "can't open repo")
395	}
396	defer repo.Close()
397
398	ep, err := transport.NewEndpoint("/")
399	if err != nil {
400		return errHandler.Wrapf(err, "create transport endpoint")
401	}
402	bfs := osfs.New(dir)
403	ld := server.NewFilesystemLoader(bfs)
404	svr := server.NewServer(ld)
405	sess, err := svr.NewUploadPackSession(ep, nil)
406	if err != nil {
407		return errHandler.Wrapf(err, "create upload-pack session")
408	}
409	defer sess.Close()
410
411	ar, err := sess.AdvertisedReferencesContext(session.ctx)
412	if err != nil {
413		return errHandler.Wrapf(err, "get advertised references")
414	}
415	if err := ar.Capabilities.Add(capability.ThinPack); err != nil {
416		return errHandler.Wrapf(err, "set advertised capabilities")
417	}
418	if err := ar.Capabilities.Add(capability.OFSDelta); err != nil {
419		return errHandler.Wrapf(err, "set advertised capabilities")
420	}
421	if err := ar.Capabilities.Add(capability.MultiACK); err != nil {
422		return errHandler.Wrapf(err, "set advertised capabilities")
423	}
424	if err := ar.Capabilities.Add(capability.MultiACKDetailed); err != nil {
425		return errHandler.Wrapf(err, "set advertised capabilities")
426	}
427	if err := ar.Capabilities.Add(capability.Shallow); err != nil {
428		return errHandler.Wrapf(err, "set advertised capabilities")
429	}
430	if err := ar.Capabilities.Add(capability.DeepenRelative); err != nil {
431		return errHandler.Wrapf(err, "set advertised capabilities")
432	}
433	if err := ar.Capabilities.Add(capability.DeepenSince); err != nil {
434		return errHandler.Wrapf(err, "set advertised capabilities")
435	}
436	if err := ar.Capabilities.Add(capability.DeepenNot); err != nil {
437		return errHandler.Wrapf(err, "set advertised capabilities")
438	}
439	// if err := ar.Capabilities.Add(capability.Sideband64k); err != nil {
440	// 	return errHandler.Wrapf(err, "set advertised capabilities")
441	// }
442	err = ar.Encode(session.ch)
443	if err != nil {
444		return errHandler.Wrapf(err, "encode advertised references")
445	}
446
447	capa := capability.NewList()
448	capa.Add(capability.Shallow)
449	upr := packp.NewUploadPackRequestFromCapabilities(capa)
450	err = upr.Decode(session.ch)
451	if err != nil {
452		return errHandler.Wrapf(err, "decode upload-pack request")
453	}
454	session.logger.Info("upr", logger.NewLoggerPair("upr", upr))
455
456	res, err := sess.UploadPack(session.ctx, upr)
457	if err != nil && !errors.Is(err, zlib.ErrHeader) {
458		if errors.Is(err, transport.ErrEmptyUploadPackRequest) {
459			return nil
460		}
461		return errHandler.Wrapf(err, "create upload-pack response")
462	}
463	session.logger.Info("res", logger.NewLoggerPair("res", res))
464	err = res.Encode(session.ch)
465	if err != nil {
466		return errHandler.Wrapf(err, "encode upload-pack response")
467	}
468
469	return res.Close()
470}