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 "github.com/charmbracelet/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// NewDaemon 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 listener, err := net.Listen("tcp", d.addr)83 if err != nil {84 return err85 }86 return d.Serve(listener)87}8889// Serve listens on the TCP network address and serves Git requests.90func (d *GitDaemon) Serve(listener net.Listener) error {91 if d.done.Load() {92 return ErrServerClosed93 }9495 d.wg.Add(1)96 defer d.wg.Done()97 d.liMu.Lock()98 d.listeners = append(d.listeners, listener)99 d.liMu.Unlock()100101 var tempDelay time.Duration102 for {103 conn, err := listener.Accept()104 if err != nil {105 select {106 case <-d.finished:107 return ErrServerClosed108 default:109 d.logger.Debugf("git: error accepting connection: %v", err)110 }111 if ne, ok := err.(net.Error); ok && ne.Temporary() { // nolint: staticcheck112 if tempDelay == 0 {113 tempDelay = 5 * time.Millisecond114 } else {115 tempDelay *= 2116 }117 if max := 1 * time.Second; tempDelay > max { //nolint:revive118 tempDelay = max119 }120 time.Sleep(tempDelay)121 continue122 }123 return err124 }125126 // Close connection if there are too many open connections.127 if d.conns.Size()+1 >= d.cfg.Git.MaxConnections {128 d.logger.Debugf("git: max connections reached, closing %s", conn.RemoteAddr())129 d.fatal(conn, git.ErrMaxConnections)130 continue131 }132133 d.wg.Add(1)134 go func() {135 d.handleClient(conn)136 d.wg.Done()137 }()138 }139}140141func (d *GitDaemon) fatal(c net.Conn, err error) {142 git.WritePktlineErr(c, err) // nolint: errcheck143 if err := c.Close(); err != nil {144 d.logger.Debugf("git: error closing connection: %v", err)145 }146}147148// handleClient handles a git protocol client.149func (d *GitDaemon) handleClient(conn net.Conn) {150 ctx, cancel := context.WithCancel(context.Background())151 idleTimeout := time.Duration(d.cfg.Git.IdleTimeout) * time.Second152 c := &serverConn{153 Conn: conn,154 idleTimeout: idleTimeout,155 closeCanceler: cancel,156 }157 if d.cfg.Git.MaxTimeout > 0 {158 dur := time.Duration(d.cfg.Git.MaxTimeout) * time.Second159 c.maxDeadline = time.Now().Add(dur)160 }161 d.conns.Add(c)162 defer func() {163 d.conns.Close(c) // nolint: errcheck164 }()165166 errc := make(chan error, 1)167168 s := pktline.NewScanner(c)169 go func() {170 if !s.Scan() {171 if err := s.Err(); err != nil {172 errc <- err173 }174 }175 errc <- nil176 }()177178 select {179 case <-ctx.Done():180 if err := ctx.Err(); err != nil {181 d.logger.Debugf("git: connection context error: %v", err)182 d.fatal(c, git.ErrTimeout)183 }184 return185 case err := <-errc:186 if nerr, ok := err.(net.Error); ok && nerr.Timeout() {187 d.fatal(c, git.ErrTimeout)188 return189 } else if err != nil {190 d.logger.Debugf("git: error scanning pktline: %v", err)191 d.fatal(c, git.ErrSystemMalfunction)192 return193 }194195 line := s.Bytes()196 split := bytes.SplitN(line, []byte{' '}, 2)197 if len(split) != 2 {198 d.fatal(c, git.ErrInvalidRequest)199 return200 }201202 var counter *prometheus.CounterVec203 service := git.Service(split[0])204 switch service {205 case git.UploadPackService:206 counter = uploadPackGitCounter207 case git.UploadArchiveService:208 counter = uploadArchiveGitCounter209 default:210 d.fatal(c, git.ErrInvalidRequest)211 return212 }213214 opts := bytes.SplitN(split[1], []byte{0}, 3)215 if len(opts) < 2 {216 d.fatal(c, git.ErrInvalidRequest) // nolint: errcheck217 return218 }219220 host := strings.TrimPrefix(string(opts[1]), "host=")221 extraParams := map[string]string{}222223 if len(opts) > 2 {224 buf := bytes.TrimPrefix(opts[2], []byte{0})225 for _, o := range bytes.Split(buf, []byte{0}) {226 opt := string(o)227 if opt == "" {228 continue229 }230231 kv := strings.SplitN(opt, "=", 2)232 if len(kv) != 2 {233 d.logger.Errorf("git: invalid option %q", opt)234 continue235 }236237 extraParams[kv[0]] = kv[1]238 }239240 version := extraParams["version"]241 if version != "" {242 d.logger.Debugf("git: protocol version %s", version)243 }244 }245246 be := d.be247 if !be.AllowKeyless(ctx) {248 d.fatal(c, git.ErrNotAuthed)249 return250 }251252 name := utils.SanitizeRepo(string(opts[0]))253 d.logger.Debugf("git: connect %s %s %s", c.RemoteAddr(), service, name)254 defer d.logger.Debugf("git: disconnect %s %s %s", c.RemoteAddr(), service, name)255256 // git bare repositories should end in ".git"257 // https://git-scm.com/docs/gitrepository-layout258 repo := name + ".git"259 reposDir := filepath.Join(d.cfg.DataPath, "repos")260 if err := git.EnsureWithin(reposDir, repo); err != nil {261 d.logger.Debugf("git: error ensuring repo path: %v", err)262 d.fatal(c, git.ErrInvalidRepo)263 return264 }265266 if _, err := d.be.Repository(ctx, repo); err != nil {267 d.fatal(c, git.ErrInvalidRepo)268 return269 }270271 auth := be.AccessLevel(ctx, name, "")272 if auth < access.ReadOnlyAccess {273 d.fatal(c, git.ErrNotAuthed)274 return275 }276277 // Environment variables to pass down to git hooks.278 envs := []string{279 "SOFT_SERVE_REPO_NAME=" + name,280 "SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),281 "SOFT_SERVE_HOST=" + host,282 "SOFT_SERVE_LOG_PATH=" + filepath.Join(d.cfg.DataPath, "log", "hooks.log"),283 }284285 // Add git protocol environment variable.286 if len(extraParams) > 0 {287 var gitProto string288 for k, v := range extraParams {289 if len(gitProto) > 0 {290 gitProto += ":"291 }292 gitProto += k + "=" + v293 }294 envs = append(envs, "GIT_PROTOCOL="+gitProto)295 }296297 envs = append(envs, d.cfg.Environ()...)298299 cmd := git.ServiceCommand{300 Stdin: c,301 Stdout: c,302 Stderr: c,303 Env: envs,304 Dir: filepath.Join(reposDir, repo),305 }306307 if err := service.Handler(ctx, cmd); err != nil {308 d.logger.Debugf("git: error handling request: %v", err)309 d.fatal(c, err)310 return311 }312313 counter.WithLabelValues(name)314 }315}316317// Close closes the underlying listener.318func (d *GitDaemon) Close() error {319 err := d.closeListener()320 d.conns.CloseAll() // nolint: errcheck321 return err322}323324// closeListener closes the listener and the finished channel.325func (d *GitDaemon) closeListener() error {326 if d.done.Load() {327 return ErrServerClosed328 }329 var err error330 d.liMu.Lock()331 for _, l := range d.listeners {332 if err = l.Close(); err != nil {333 err = errors.Join(err, fmt.Errorf("close listener %s: %w", l.Addr(), err))334 }335 }336 d.listeners = d.listeners[:0]337 d.liMu.Unlock()338 d.once.Do(func() {339 d.done.Store(true)340 close(d.finished)341 })342 return err343}344345// Shutdown gracefully shuts down the daemon.346func (d *GitDaemon) Shutdown(ctx context.Context) error {347 if d.done.Load() {348 return ErrServerClosed349 }350351 err := d.closeListener()352 finished := make(chan struct{}, 1)353 go func() {354 defer close(finished)355 d.wg.Wait()356 }()357 select {358 case <-ctx.Done():359 return ctx.Err()360 case <-finished:361 return err362 }363}