// 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/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") } writer.Reject() //by default we reject == in case of errror we don't take new code defer func() { if err := ld.Close(); err != nil { session.logger.Error("handleReceivePack close error", err) } }() repoConfiguration, err := repo.Configuration() if err != nil { return errHandler.Wrapf(err, "repo configuration") } writerSideband := sideband.NewMuxer(sideband.Sideband64k, session.ch) commands := make([]*packp.Command, 0) back := backend.New(ld, backend.WithPreReceiveHook(func(c *packp.Command, options []string) error { 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 }), backend.WithPostReceiveHook(func(c *packp.Command, options []string) error { 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()) } if isForcePush { com, err := writer.GetLastCommit(c.New) if err != nil { return errors.New("not found previous commit") } p, err := com.Commit.Parents().Next() //TODO need to find all commits of force-pushed branch, only one in tests c.Old = p.Hash } } return nil }), backend.WithPostUpdateHook(func(refs []plumbing.ReferenceName, options []string) { for _, c := range commands { _, currentUser, err := repo.CanWrite(session.simpleUser.Ssh, c.Name.Short()) if err != nil { errUser := errors.New("error in finding right") pktline.WriteError(writerSideband, errUser) return } if currentUser.Group == nil && c.Action() != packp.Delete { // user push new branch session.logger.Info("Add user into branch", logger.NewLoggerPair("branch", c.Name.Short())) newCommitHash, err := writer.AddUserInfo(currentUser, c.Name.Short()) if err != nil { session.logger.Error("can't AddUserInfo", err) // errUser := errors.New("can't add user info") // pktline.WriteError(session.ch, errUser) // return oops.With("branch", c.Name.Short()).Wrapf(err, "can't add user info") } c.New = newCommitHash } } if slices.Contains(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" writerSideband.WriteChannel(sideband.ProgressMessage, []byte(newLine)) writerSideband.WriteChannel(sideband.ProgressMessage, []byte(green+"------------------"+reset+newLine)) writerSideband.WriteChannel(sideband.ProgressMessage, []byte(red+"WELCOME IN GITROOT"+reset+newLine)) writerSideband.WriteChannel(sideband.ProgressMessage, []byte(green+"------------------"+reset+newLine)) writerSideband.WriteChannel(sideband.ProgressMessage, []byte(newLine)) for i := range 5 { time.Sleep(1 * time.Second) if i == 0 { writerSideband.WriteChannel(sideband.ProgressMessage, []byte(yellow+"1 - ladybug run"+reset+newLine)) } else if i == 1 { writerSideband.WriteChannel(sideband.ProgressMessage, []byte(blue+"2 - silo run"+reset+newLine)) } else if i == 2 { writerSideband.WriteChannel(sideband.ProgressMessage, []byte(magenta+"3 - silo report `bad report`"+reset+newLine)) } else if i == 3 { writerSideband.WriteChannel(sideband.ProgressMessage, []byte(cyan+"4 - pollen run"+reset+newLine)) } else { writerSideband.WriteChannel(sideband.ProgressMessage, []byte(green+"FINISH \\o/"+reset+newLine)) writerSideband.WriteChannel(sideband.ProgressMessage, []byte(green+"------------------"+reset+newLine)) writerSideband.WriteChannel(sideband.ProgressMessage, []byte(newLine)) } } } })) 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") nopc := io.NopCloser(session.ch) err = back.Serve(session.ctx, nopc, ioutil.WriteNopCloser(session.ch), &gitReq) if err != nil { session.logger.Warn("can't serve", logger.NewLoggerPair("err", err.Error())) return err } writer.Accept() //after all if no error we accept changes session.logger.Info("backend.PostPush", logger.NewLoggerPair("commands", len(commands))) session.srv.backgroundManager.PostPush(session.simpleUser, repoName, commands) return 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 }