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}