1package daemon23import (4 "bytes"5 "context"6 "errors"7 "fmt"8 "net"9 "path/filepath"10 "strings"11 "sync"12 "sync/atomic"13 "time"1415 "charm.land/log/v2"16 "github.com/charmbracelet/soft-serve/pkg/access"17 "github.com/charmbracelet/soft-serve/pkg/backend"18 "github.com/charmbracelet/soft-serve/pkg/config"19 "github.com/charmbracelet/soft-serve/pkg/git"20 "github.com/charmbracelet/soft-serve/pkg/utils"21 "github.com/go-git/go-git/v5/plumbing/format/pktline"22 "github.com/prometheus/client_golang/prometheus"23 "github.com/prometheus/client_golang/prometheus/promauto"24)2526var (27 uploadPackGitCounter = promauto.NewCounterVec(prometheus.CounterOpts{28 Namespace: "soft_serve",29 Subsystem: "git",30 Name: "git_upload_pack_total",31 Help: "The total number of git-upload-pack requests",32 }, []string{"repo"})3334 uploadArchiveGitCounter = promauto.NewCounterVec(prometheus.CounterOpts{35 Namespace: "soft_serve",36 Subsystem: "git",37 Name: "git_upload_archive_total",38 Help: "The total number of git-upload-archive requests",39 }, []string{"repo"})40)4142// ErrServerClosed indicates that the server has been closed.43var ErrServerClosed = fmt.Errorf("git: %w", net.ErrClosed)4445// GitDaemon represents a Git daemon.46type GitDaemon struct {47 ctx context.Context48 addr string49 finished chan struct{}50 conns connections51 cfg *config.Config52 be *backend.Backend53 wg sync.WaitGroup54 once sync.Once55 logger *log.Logger56 done atomic.Bool // indicates if the server has been closed57 listeners []net.Listener58 liMu sync.Mutex59}6061// NewGitDaemon returns a new Git daemon.62func NewGitDaemon(ctx context.Context) (*GitDaemon, error) {63 cfg := config.FromContext(ctx)64 addr := cfg.Git.ListenAddr65 d := &GitDaemon{66 ctx: ctx,67 addr: addr,68 finished: make(chan struct{}, 1),69 cfg: cfg,70 be: backend.FromContext(ctx),71 conns: connections{m: make(map[net.Conn]struct{})},72 logger: log.FromContext(ctx).WithPrefix("gitdaemon"),73 }74 return d, nil75}7677// ListenAndServe starts the Git TCP daemon.78func (d *GitDaemon) ListenAndServe() error {79 if d.done.Load() {80 return ErrServerClosed81 }82 var cfg net.ListenConfig83 listener, err := cfg.Listen(d.ctx, "tcp", d.addr)84 if err != nil {85 return err86 }87 return d.Serve(listener)88}8990// Serve listens on the TCP network address and serves Git requests.91func (d *GitDaemon) Serve(listener net.Listener) error {92 if d.done.Load() {93 return ErrServerClosed94 }9596 d.wg.Add(1)97 defer d.wg.Done()98 d.liMu.Lock()99 d.listeners = append(d.listeners, listener)100 d.liMu.Unlock()101102 var tempDelay time.Duration103 for {104 conn, err := listener.Accept()105 if err != nil {106 select {107 case <-d.finished:108 return ErrServerClosed109 default:110 d.logger.Debugf("git: error accepting connection: %v", err)111 }112 if ne, ok := err.(net.Error); ok && ne.Temporary() {113 if tempDelay == 0 {114 tempDelay = 5 * time.Millisecond115 } else {116 tempDelay *= 2117 }118 if max := 1 * time.Second; tempDelay > max { //nolint:revive119 tempDelay = max120 }121 time.Sleep(tempDelay)122 continue123 }124 return err125 }126127 // Close connection if there are too many open connections.128 if d.conns.Size()+1 >= d.cfg.Git.MaxConnections {129 d.logger.Debugf("git: max connections reached, closing %s", conn.RemoteAddr())130 d.fatal(conn, git.ErrMaxConnections)131 continue132 }133134 d.wg.Add(1)135 go func() {136 d.handleClient(conn)137 d.wg.Done()138 }()139 }140}141142func (d *GitDaemon) fatal(c net.Conn, err error) {143 git.WritePktlineErr(c, err) //nolint: errcheck144 if err := c.Close(); err != nil {145 d.logger.Debugf("git: error closing connection: %v", err)146 }147}148149// handleClient handles a git protocol client.150func (d *GitDaemon) handleClient(conn net.Conn) {151 ctx, cancel := context.WithCancel(context.Background())152 idleTimeout := time.Duration(d.cfg.Git.IdleTimeout) * time.Second153 c := &serverConn{154 Conn: conn,155 idleTimeout: idleTimeout,156 closeCanceler: cancel,157 }158 if d.cfg.Git.MaxTimeout > 0 {159 dur := time.Duration(d.cfg.Git.MaxTimeout) * time.Second160 c.maxDeadline = time.Now().Add(dur)161 }162 d.conns.Add(c)163 defer func() {164 d.conns.Close(c) //nolint: errcheck165 }()166167 errc := make(chan error, 1)168169 s := pktline.NewScanner(c)170 go func() {171 if !s.Scan() {172 if err := s.Err(); err != nil {173 errc <- err174 }175 }176 errc <- nil177 }()178179 select {180 case <-ctx.Done():181 if err := ctx.Err(); err != nil {182 d.logger.Debugf("git: connection context error: %v", err)183 d.fatal(c, git.ErrTimeout)184 }185 return186 case err := <-errc:187 if nerr, ok := err.(net.Error); ok && nerr.Timeout() {188 d.fatal(c, git.ErrTimeout)189 return190 } else if err != nil {191 d.logger.Debugf("git: error scanning pktline: %v", err)192 d.fatal(c, git.ErrSystemMalfunction)193 return194 }195196 line := s.Bytes()197 split := bytes.SplitN(line, []byte{' '}, 2)198 if len(split) != 2 {199 d.fatal(c, git.ErrInvalidRequest)200 return201 }202203 var counter *prometheus.CounterVec204 service := git.Service(split[0])205 switch service {206 case git.UploadPackService:207 counter = uploadPackGitCounter208 case git.UploadArchiveService:209 counter = uploadArchiveGitCounter210 default:211 d.fatal(c, git.ErrInvalidRequest)212 return213 }214215 opts := bytes.SplitN(split[1], []byte{0}, 3)216 if len(opts) < 2 {217 d.fatal(c, git.ErrInvalidRequest) //nolint: errcheck218 return219 }220221 host := strings.TrimPrefix(string(opts[1]), "host=")222 extraParams := map[string]string{}223224 if len(opts) > 2 {225 buf := bytes.TrimPrefix(opts[2], []byte{0})226 for _, o := range bytes.Split(buf, []byte{0}) {227 opt := string(o)228 if opt == "" {229 continue230 }231232 kv := strings.SplitN(opt, "=", 2)233 if len(kv) != 2 {234 d.logger.Errorf("git: invalid option %q", opt)235 continue236 }237238 extraParams[kv[0]] = kv[1]239 }240241 version := extraParams["version"]242 if version != "" {243 d.logger.Debugf("git: protocol version %s", version)244 }245 }246247 be := d.be248 if !be.AllowKeyless(ctx) {249 d.fatal(c, git.ErrNotAuthed)250 return251 }252253 name := utils.SanitizeRepo(string(opts[0]))254 d.logger.Debugf("git: connect %s %s %s", c.RemoteAddr(), service, name)255 defer d.logger.Debugf("git: disconnect %s %s %s", c.RemoteAddr(), service, name)256257 // git bare repositories should end in ".git"258 // https://git-scm.com/docs/gitrepository-layout259 repo := name + ".git"260 reposDir := filepath.Join(d.cfg.DataPath, "repos")261 if err := git.EnsureWithin(reposDir, repo); err != nil {262 d.logger.Debugf("git: error ensuring repo path: %v", err)263 d.fatal(c, git.ErrInvalidRepo)264 return265 }266267 if _, err := d.be.Repository(ctx, repo); err != nil {268 d.fatal(c, git.ErrInvalidRepo)269 return270 }271272 auth := be.AccessLevel(ctx, name, "")273 if auth < access.ReadOnlyAccess {274 d.fatal(c, git.ErrNotAuthed)275 return276 }277278 // Environment variables to pass down to git hooks.279 envs := []string{280 "SOFT_SERVE_REPO_NAME=" + name,281 "SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),282 "SOFT_SERVE_HOST=" + host,283 "SOFT_SERVE_LOG_PATH=" + filepath.Join(d.cfg.DataPath, "log", "hooks.log"),284 }285286 // Add git protocol environment variable.287 if len(extraParams) > 0 {288 var gitProto string289 for k, v := range extraParams {290 if len(gitProto) > 0 {291 gitProto += ":"292 }293 gitProto += k + "=" + v294 }295 envs = append(envs, "GIT_PROTOCOL="+gitProto)296 }297298 envs = append(envs, d.cfg.Environ()...)299300 cmd := git.ServiceCommand{301 Stdin: c,302 Stdout: c,303 Stderr: c,304 Env: envs,305 Dir: filepath.Join(reposDir, repo),306 }307308 if err := service.Handler(ctx, cmd); err != nil {309 d.logger.Debugf("git: error handling request: %v", err)310 d.fatal(c, err)311 return312 }313314 counter.WithLabelValues(name)315 }316}317318// Close closes the underlying listener.319func (d *GitDaemon) Close() error {320 err := d.closeListener()321 d.conns.CloseAll() //nolint: errcheck322 return err323}324325// closeListener closes the listener and the finished channel.326func (d *GitDaemon) closeListener() error {327 if d.done.Load() {328 return ErrServerClosed329 }330 var err error331 d.liMu.Lock()332 for _, l := range d.listeners {333 if err = l.Close(); err != nil {334 err = errors.Join(err, fmt.Errorf("close listener %s: %w", l.Addr(), err))335 }336 }337 d.listeners = d.listeners[:0]338 d.liMu.Unlock()339 d.once.Do(func() {340 d.done.Store(true)341 close(d.finished)342 })343 return err344}345346// Shutdown gracefully shuts down the daemon.347func (d *GitDaemon) Shutdown(ctx context.Context) error {348 if d.done.Load() {349 return ErrServerClosed350 }351352 err := d.closeListener()353 finished := make(chan struct{}, 1)354 go func() {355 defer close(finished)356 d.wg.Wait()357 }()358 select {359 case <-ctx.Done():360 return ctx.Err()361 case <-finished:362 return err363 }364}