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/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 errors.Is(err, packfile.ErrEmptyPackfile) {
299		res.UnpackStatus = "ok"
300		rur.Packfile.Close()
301		rur.Packfile = nil
302	} else if err != nil {
303		return errHandler.Wrapf(err, "create receive-pack response")
304	}
305
306	repoConfiguration, err := repo.Configuration()
307	if err != nil {
308		return errHandler.Wrapf(err, "repo configuration")
309	}
310
311	for _, c := range rur.Commands {
312		session.logger.Info("Command", logger.NewLoggerPair("branch", c.Name.Short()), logger.NewLoggerPair("from", c.Old.String()), logger.NewLoggerPair("to", c.New.String()))
313		canWrite, currentUser, err := repo.CanWrite(session.simpleUser.Ssh, c.Name.Short())
314		if err != nil {
315			errUser := errors.New("error in finding right")
316			pktline.WriteError(session.ch, errUser)
317			return oops.Wrapf(err, "error in finding right")
318		}
319		if !canWrite {
320			err := fmt.Errorf("you can't write in %s", c.Name.Short())
321			pktline.WriteError(session.ch, err)
322			session.logger.Info("user can't write in branch", logger.NewLoggerPair("branch", c.Name))
323			return nil
324		}
325
326		if c.Action() == packp.Update {
327			isForcePush, err := writer.IsForcePush(c.Name, c.Old)
328			session.logger.Info("ForcePush??", logger.NewLoggerPair("isForcePush", isForcePush), logger.NewLoggerPair("err", err))
329			if isForcePush && repoConfiguration.IsNoPushBranch(c.Name) {
330				err := errors.New("you can't force-push on main")
331				pktline.WriteError(session.ch, err)
332				return nil //don't return error
333			}
334			if isForcePush {
335				com, err := writer.GetLastCommit(c.New)
336				if err != nil {
337					errUser := errors.New("not found previous commit")
338					pktline.WriteError(session.ch, errUser)
339					return oops.Wrapf(err, "error force-push previous commit")
340				}
341				p, err := com.Commit.Parents().Next() //TODO need to find all commits of force-pushed branch, only one in tests
342				c.Old = p.Hash
343			}
344		}
345
346		if currentUser.Group == nil && c.Action() != packp.Delete { // user push new branch
347			session.logger.Info("Add user into branch", logger.NewLoggerPair("branch", c.Name.Short()))
348			newCommitHash, err := writer.AddUserInfo(currentUser, c.Name.Short())
349			if err != nil {
350				session.logger.Error("can't AddUserInfo", err)
351				// errUser := errors.New("can't add user info")
352				// pktline.WriteError(session.ch, errUser)
353				// return oops.With("branch", c.Name.Short()).Wrapf(err, "can't add user info")
354			}
355			c.New = newCommitHash
356		}
357	}
358
359	if err := res.Encode(session.ch); err != nil {
360		return errHandler.Wrapf(err, "encode receive-pack response")
361	}
362
363	writer.Accept() //after all if no error we accept changes
364
365	session.srv.backgroundManager.PostPush(session.simpleUser, repoName, rur.Commands)
366	return nil
367}
368
369func (session *sshSession) handleUploadPack(dir string, name string) error {
370	errHandler := oops.In("sshSession").Code("handleUploadPack").With("session", session.simpleUser.Pseudo).With("dir", dir)
371
372	if dir == "/" {
373		dir = session.srv.conf.RootRepositoryName
374	}
375
376	if name == session.srv.conf.ForgeConfigName() {
377		if err := session.srv.repoManager.ForgeRepoNeedOwner(session.ctx, session.simpleUser); err != nil {
378			return errHandler.Wrapf(err, "NeedOwner")
379		}
380	}
381
382	repo, err := session.srv.repoManager.Open(logger.AddCaller(session.ctx, "handleUploadPack"), name)
383	if err != nil {
384		return errHandler.With("name", name).Wrapf(err, "can't open repo")
385	}
386	defer repo.Close()
387
388	ep, err := transport.NewEndpoint("/")
389	if err != nil {
390		return errHandler.Wrapf(err, "create transport endpoint")
391	}
392	bfs := osfs.New(dir)
393	ld := server.NewFilesystemLoader(bfs)
394	svr := server.NewServer(ld)
395	sess, err := svr.NewUploadPackSession(ep, nil)
396	if err != nil {
397		return errHandler.Wrapf(err, "create upload-pack session")
398	}
399	defer sess.Close()
400
401	ar, err := sess.AdvertisedReferencesContext(session.ctx)
402	if err != nil {
403		return errHandler.Wrapf(err, "get advertised references")
404	}
405	if err := ar.Capabilities.Add(capability.ThinPack); err != nil {
406		return errHandler.Wrapf(err, "set advertised capabilities")
407	}
408	if err := ar.Capabilities.Add(capability.OFSDelta); err != nil {
409		return errHandler.Wrapf(err, "set advertised capabilities")
410	}
411	if err := ar.Capabilities.Add(capability.MultiACK); err != nil {
412		return errHandler.Wrapf(err, "set advertised capabilities")
413	}
414	if err := ar.Capabilities.Add(capability.MultiACKDetailed); err != nil {
415		return errHandler.Wrapf(err, "set advertised capabilities")
416	}
417	if err := ar.Capabilities.Add(capability.Shallow); err != nil {
418		return errHandler.Wrapf(err, "set advertised capabilities")
419	}
420	if err := ar.Capabilities.Add(capability.DeepenRelative); err != nil {
421		return errHandler.Wrapf(err, "set advertised capabilities")
422	}
423	if err := ar.Capabilities.Add(capability.DeepenSince); err != nil {
424		return errHandler.Wrapf(err, "set advertised capabilities")
425	}
426	if err := ar.Capabilities.Add(capability.DeepenNot); err != nil {
427		return errHandler.Wrapf(err, "set advertised capabilities")
428	}
429	// if err := ar.Capabilities.Add(capability.Sideband64k); err != nil {
430	// 	return errHandler.Wrapf(err, "set advertised capabilities")
431	// }
432	err = ar.Encode(session.ch)
433	if err != nil {
434		return errHandler.Wrapf(err, "encode advertised references")
435	}
436
437	capa := capability.NewList()
438	capa.Add(capability.Shallow)
439	upr := packp.NewUploadPackRequestFromCapabilities(capa)
440	err = upr.Decode(session.ch)
441	if err != nil {
442		return errHandler.Wrapf(err, "decode upload-pack request")
443	}
444	session.logger.Info("upr", logger.NewLoggerPair("upr", upr))
445
446	res, err := sess.UploadPack(session.ctx, upr)
447	if err != nil && !errors.Is(err, zlib.ErrHeader) {
448		if errors.Is(err, transport.ErrEmptyUploadPackRequest) {
449			return nil
450		}
451		return errHandler.Wrapf(err, "create upload-pack response")
452	}
453	session.logger.Info("res", logger.NewLoggerPair("res", res))
454	err = res.Encode(session.ch)
455	if err != nil {
456		return errHandler.Wrapf(err, "encode upload-pack response")
457	}
458
459	return res.Close()
460}