// SPDX-FileCopyrightText: 2025 Romain Maneschi // // SPDX-License-Identifier: EUPL-1.2 package main import ( "bytes" "context" "errors" "fmt" "io" "net" "net/url" "slices" "strings" "time" "github.com/anmitsu/go-shlex" securejoin "github.com/cyphar/filepath-securejoin" "github.com/go-git/go-git/v6/backend" "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/plumbing/format/pktline" "github.com/go-git/go-git/v6/plumbing/protocol/packp" "github.com/go-git/go-git/v6/plumbing/protocol/packp/sideband" "github.com/go-git/go-git/v6/plumbing/transport" "github.com/go-git/go-git/v6/utils/ioutil" "github.com/samber/oops" "gitroot.dev/server/background" "gitroot.dev/server/configuration" "gitroot.dev/server/logger" "gitroot.dev/server/plugin" "gitroot.dev/server/repository" "gitroot.dev/server/user" "golang.org/x/crypto/ssh" ) const ( SSH_EXTENSIONS_KEY_PUBKEY_FP = "pubkey-fp" ) type sshServer struct { keys map[string]ssh.PublicKey logger *logger.Logger conf *configuration.Configuration pluginManager *plugin.Manager repoManager *repository.Manager userManager *user.Manager backgroundManager *background.Manager } func NewServerSsh(conf *configuration.Configuration, repoManager *repository.Manager, userManager *user.Manager, pluginManager *plugin.Manager, backgroundManager *background.Manager) *sshServer { return &sshServer{ keys: make(map[string]ssh.PublicKey), logger: logger.NewLoggerCtx(logger.SSH_SERVER_LOGGER_NAME, context.Background()), conf: conf, pluginManager: pluginManager, repoManager: repoManager, userManager: userManager, backgroundManager: backgroundManager, } } func (srv *sshServer) ListenAndServe() error { config := &ssh.ServerConfig{ NoClientAuth: false, PublicKeyCallback: func(c ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) { sha256fp := ssh.FingerprintSHA256(pubKey) srv.keys[sha256fp] = pubKey return &ssh.Permissions{ // Record the public key used for authentication. Extensions: map[string]string{ SSH_EXTENSIONS_KEY_PUBKEY_FP: sha256fp, }, }, nil }, } config.AddHostKey(srv.userManager.RootCommiter().Signer.Signer()) srv.logger.Warn("starting SSH server on", logger.NewLoggerPair("addr", srv.conf.SshAddr)) lis, err := net.Listen("tcp", srv.conf.SshAddr) if err != nil { return err } defer lis.Close() for { conn, err := lis.Accept() srv.logger.PrintMemUsage() if err != nil { return err } go func(conn net.Conn) { defer conn.Close() sshConn, newChanChan, newReq, err := ssh.NewServerConn(conn, config) if err != nil { srv.logger.Error("error when creating server conn", err) return } defer sshConn.Close() srv.logger.Info("new ssh connexion", logger.NewLoggerPair("user", sshConn.Conn.User()), logger.NewLoggerPair("key", sshConn.Permissions.Extensions[SSH_EXTENSIONS_KEY_PUBKEY_FP])) go ssh.DiscardRequests(newReq) for newChan := range newChanChan { if newChan.ChannelType() == "session" { ch, reqc, err := newChan.Accept() if err != nil { srv.logger.Error("error when accepting session", err) return } srv.newSession(ch, reqc, sshConn.Conn.User(), sshConn.Permissions.Extensions[SSH_EXTENSIONS_KEY_PUBKEY_FP]).handle() } else { srv.logger.Error("unknown channel type", errors.New("channel unknown"), logger.NewLoggerPair("pair", newChan.ChannelType())) } } }(conn) } } func (srv *sshServer) newSession(ch ssh.Channel, reqc <-chan *ssh.Request, pseudo string, sshKeyFp string) *sshSession { ctx, cnlCtx := context.WithCancel(context.Background()) return &sshSession{ srv: srv, logger: *srv.logger.NewSubLoggerCtx("SshSession", ctx), ch: ch, reqc: reqc, ctx: ctx, cnlCtx: cnlCtx, simpleUser: user.SimpleUser{Pseudo: pseudo, Ssh: string(bytes.TrimSuffix(ssh.MarshalAuthorizedKey(srv.keys[sshKeyFp]), []byte("\n")))}, } } type sshSession struct { srv *sshServer logger logger.Logger ch ssh.Channel reqc <-chan *ssh.Request ctx context.Context cnlCtx context.CancelFunc simpleUser user.SimpleUser } func (session *sshSession) handle() { var exitCode uint32 = 0 defer func() { b := ssh.Marshal(struct{ Value uint32 }{exitCode}) _, err := session.ch.SendRequest("exit-status", false, b) if err != nil { session.logger.Error("SendRequest exit-status error", err, logger.NewLoggerPair("exitCode", exitCode)) } time.Sleep(10 * time.Millisecond) session.ch.Close() session.cnlCtx() }() envs := make(map[string]string) for req := range session.reqc { switch req.Type { case "env": payload := struct{ Key, Value string }{} ssh.Unmarshal(req.Payload, &payload) envs[payload.Key] = payload.Value req.Reply(true, nil) case "exec": payload := struct{ Value string }{} ssh.Unmarshal(req.Payload, &payload) args, err := shlex.Split(payload.Value, true) if err != nil { session.logger.Error("shlex args", err) exitCode = 1 return } cmd := args[0] name := strings.TrimPrefix(args[1], "/") if name == "" { name = session.srv.conf.ForgeConfigName() } dir, err := securejoin.SecureJoin(session.srv.conf.PathRepositories(), name) if err != nil { session.logger.Error("invalid repo upload pack", err, logger.NewLoggerPair("arg", name)) exitCode = 1 return } session.logger.Info("ssh request", logger.NewLoggerPair("cmd", cmd), logger.NewLoggerPair("dir", dir), logger.NewLoggerPair("name", name)) switch cmd { case "git-upload-pack": // read // if gp := envs["GIT_PROTOCOL"]; gp != "version=2" { // log.Println("unhandled GIT_PROTOCOL", gp) // exitCode = 1 // return // } err = session.handleUploadPack(dir, name) if err != nil { session.logger.Error("handle upload pack error", err) pktline.WriteError(session.ch, err) pktline.WriteFlush(session.ch) exitCode = 1 return } session.logger.Info("finish upload plack", logger.NewLoggerPair("path", name)) if err := req.Reply(true, nil); err != nil { session.logger.Error("req reply error upload pack", err) } return case "git-receive-pack": // write session.logger.Info("start receive plack") err = session.handleReceivePack(dir, name) if err != nil { session.logger.Warn("handle receive pack error", logger.NewLoggerPair("err", err.Error())) writerSideband := sideband.NewMuxer(sideband.Sideband64k, session.ch) pktline.WriteError(writerSideband, err) exitCode = 1 return } session.logger.Info("finish receive plack", logger.NewLoggerPair("path", name)) if err := req.Reply(true, nil); err != nil { session.logger.Error("req reply error receive pack", err) } return default: session.logger.Error("unhandled cmd", errors.New("unknown cmd"), logger.NewLoggerPair("cmd", cmd)) req.Reply(false, nil) exitCode = 1 return } case "auth-agent-req@openssh.com": if req.WantReply { req.Reply(true, nil) } default: session.logger.Error("unhandled req type", errors.New("unknown req type"), logger.NewLoggerPair("type", req.Type)) req.Reply(false, nil) exitCode = 1 return } } } func (session *sshSession) handleReceivePack(dir string, repoName string) error { errHandler := oops.In("sshSession").Code("handleReceivePack").With("session", session.simpleUser.Pseudo) ld, repo, writer, err := session.srv.repoManager.NewGitRootFsLoader(session.ctx, repoName) if err != nil { return errHandler.Wrapf(err, "repo not found") } defer func() { if err := ld.Close(); err != nil { session.logger.Error("handleReceivePack close error", err) } }() writer.Reject() //by default we reject == in case of errror we don't take new code repoConfiguration, err := repo.Configuration() if err != nil { return errHandler.Wrapf(err, "repo configuration") } commands := make([]*packp.Command, 0) pfo := &packfileObserver{log: &session.logger} back := backend.New(ld, backend.WithParserObserver(pfo), backend.WithHook(transport.Hooks{ PreReceive: func(ctx context.Context, env transport.HookEnv, cmds []*packp.Command) error { session.logger.Debug("prereceive") for _, c := range cmds { session.logger.Info("Command", logger.NewLoggerPair("branch", c.Name.Short()), logger.NewLoggerPair("from", c.Old.String()), logger.NewLoggerPair("to", c.New.String())) canWrite, _, err := repo.CanWrite(session.simpleUser.Ssh, c.Name.Short()) if err != nil { return errors.New("error in finding right") } if !canWrite { return fmt.Errorf("you can't write in %s", c.Name.Short()) } commands = append(commands, c) } return nil }, PostReceive: func(ctx context.Context, env transport.HookEnv, cmds []*packp.Command) error { for _, c := range cmds { if c.Action() == packp.Update { isForcePush, err := writer.IsForcePush(c.Old, c.New) session.logger.Info("ForcePush??", logger.NewLoggerPair("isForcePush", isForcePush), logger.NewLoggerPair("err", err)) if isForcePush && repoConfiguration.IsNoPushBranch(c.Name) { return fmt.Errorf("you can't force-push on %s", c.Name.Short()) } } } return nil }, PostUpdate: func(ctx context.Context, env transport.HookEnv, updatedRefs []plumbing.ReferenceName) error { session.logger.Info("updatedRefs", logger.NewLoggerPair("refs", updatedRefs)) if env.Writer != nil && slices.Contains(env.PushOptions.Options, "resume") { reset := "\033[0m" red := "\033[31m" green := "\033[32m" yellow := "\033[33m" blue := "\033[34m" magenta := "\033[35m" cyan := "\033[36m" newLine := "\n" env.Writer.WriteChannel(sideband.ProgressMessage, []byte(newLine)) env.Writer.WriteChannel(sideband.ProgressMessage, []byte(green+"------------------"+reset+newLine)) env.Writer.WriteChannel(sideband.ProgressMessage, []byte(red+"WELCOME IN GITROOT"+reset+newLine)) env.Writer.WriteChannel(sideband.ProgressMessage, []byte(green+"------------------"+reset+newLine)) env.Writer.WriteChannel(sideband.ProgressMessage, []byte(newLine)) for i := range 5 { time.Sleep(1 * time.Second) if i == 0 { env.Writer.WriteChannel(sideband.ProgressMessage, []byte(yellow+"1 - ladybug run"+reset+newLine)) } else if i == 1 { env.Writer.WriteChannel(sideband.ProgressMessage, []byte(blue+"2 - silo run"+reset+newLine)) } else if i == 2 { env.Writer.WriteChannel(sideband.ProgressMessage, []byte(magenta+"3 - silo report `bad report`"+reset+newLine)) } else if i == 3 { env.Writer.WriteChannel(sideband.ProgressMessage, []byte(cyan+"4 - pollen run"+reset+newLine)) } else { env.Writer.WriteChannel(sideband.ProgressMessage, []byte(green+"FINISH \\o/"+reset+newLine)) env.Writer.WriteChannel(sideband.ProgressMessage, []byte(green+"------------------"+reset+newLine)) env.Writer.WriteChannel(sideband.ProgressMessage, []byte(newLine)) } } } commitsByRef, err := pfo.Finalize(writer, updatedRefs) if err != nil { session.logger.Warn("can't Finalize pfo", logger.NewLoggerPair("err", err.Error())) return err } writer.Accept() //after all if no error we accept changes session.srv.backgroundManager.PostPush(session.simpleUser, repoName, commands, commitsByRef) return nil }})) url, err := url.Parse("test/test") if err != nil { session.logger.Error("can't url", err) } gitReq := backend.Request{ URL: url, Service: "git-receive-pack", GitProtocol: "version=1", } session.logger.Info("backend.ServeTCP") err = back.Serve(session.ctx, io.NopCloser(session.ch), ioutil.WriteNopCloser(session.ch), &gitReq) if err != nil { session.logger.Warn("can't serve", logger.NewLoggerPair("err", err.Error())) return err } return nil } type packfileObserver struct { previousType plumbing.ObjectType log *logger.Logger allCommits [][]plumbing.Hash commitCurrentIndex int } func (o *packfileObserver) OnHeader(count uint32) error { if o.allCommits == nil { o.allCommits = make([][]plumbing.Hash, 1) o.allCommits[0] = make([]plumbing.Hash, 0) o.commitCurrentIndex = 0 } else { o.allCommits = append(o.allCommits, make([]plumbing.Hash, 0)) o.commitCurrentIndex = len(o.allCommits) - 1 } return nil } func (o *packfileObserver) OnInflatedObjectHeader(t plumbing.ObjectType, objSize, pos int64) error { o.previousType = t if o.previousType == plumbing.CommitObject { o.log.Debug("NEW HASH IN PACKFILE OBJ HEADER", logger.NewLoggerPair("type", t.String()), logger.NewLoggerPair("pos", pos)) } return nil } func (o *packfileObserver) OnInflatedObjectContent(h plumbing.Hash, pos int64, crc uint32, content []byte) error { if o.previousType == plumbing.CommitObject { o.log.Debug("NEW HASH IN PACKFILE OBJECT", logger.NewLoggerPair("hash", h.String())) o.allCommits[o.commitCurrentIndex] = append(o.allCommits[o.commitCurrentIndex], h) } return nil } func (po *packfileObserver) OnFooter(h plumbing.Hash) error { return nil } func (po *packfileObserver) Finalize(repo *repository.GitRootRepositoryWrite, refs []plumbing.ReferenceName) (map[plumbing.ReferenceName][]plumbing.Hash, error) { res := make(map[plumbing.ReferenceName][]plumbing.Hash) for _, r := range refs { po.log.Info("updated ref", logger.NewLoggerPair("ref", r.Short())) b, err := repo.Storer().Reference(r) if err != nil { po.log.Info("can't find ref", logger.NewLoggerPair("ref", r.Short())) res[r] = []plumbing.Hash{} continue } for _, c := range po.allCommits { if len(c) > 0 { if b.Hash().Equal(c[0]) { res[r] = c break } else if b.Hash().Equal(c[len(c)-1]) { slices.Reverse(c) res[r] = c break } else { po.log.Debug("hash not good", logger.NewLoggerPair("branch", r), logger.NewLoggerPair("branchHash", b.Hash().String()), logger.NewLoggerPair("com", c)) } } } if len(res[r]) == 0 { po.log.Warn("No commit for", logger.NewLoggerPair("branch", r)) } } if len(res) == 0 { po.log.Warn("No branch for") } return res, nil } func (session *sshSession) handleUploadPack(dir string, name string) error { errHandler := oops.In("sshSession").Code("handleUploadPack").With("session", session.simpleUser.Pseudo).With("dir", dir) if dir == "/" { dir = session.srv.conf.RootRepositoryName } if name == session.srv.conf.ForgeConfigName() { if err := session.srv.repoManager.ForgeRepoNeedOwner(session.ctx, session.simpleUser); err != nil { return errHandler.Wrapf(err, "NeedOwner") } } session.logger.Info("handleUploadPack before fsLoader") ld, err := session.srv.repoManager.NewGitRootReadFsLoader(session.ctx, name) if err != nil { return errHandler.Wrapf(err, "repo not found") } defer func() { if err := ld.Close(); err != nil { session.logger.Error("handleUploadPack close error", err) } }() session.logger.Info("handleUploadPack after fsLoader") url, err := url.Parse("test/test") if err != nil { session.logger.Error("can't url", err) } gitReq := backend.Request{ URL: url, Service: "git-upload-pack", GitProtocol: "version=1", } svr := backend.New(ld) nopc := io.NopCloser(session.ch) session.logger.Info("handleUploadPack before serve") err = svr.Serve(session.ctx, nopc, ioutil.WriteNopCloser(session.ch), &gitReq) session.logger.Info("handleUploadPack after serve") return err }