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}