internal/gitd/gitd.go

8dcfa45a8ac03a5ff9c36828274d05acadcf846c
gitbay/internal/gitd/gitd.go history · blame · raw

133 lines · 3478 bytes

  1// Package gitd implements the anonymous git:// protocol listener. Read-only
  2// upload-pack, and only for repositories that are public AND have opted in
  3// via settings — on an instance where [git_daemon] is enabled at all.
  4package gitd
  5
  6import (
  7	"fmt"
  8	"io"
  9	"net"
 10	"strconv"
 11	"strings"
 12	"time"
 13
 14	"gitbay.org/gitbay/internal/config"
 15	"gitbay.org/gitbay/internal/control"
 16	"gitbay.org/gitbay/internal/gitutil"
 17	"gitbay.org/gitbay/internal/packlimit"
 18	"gitbay.org/gitbay/internal/store"
 19)
 20
 21type Server struct {
 22	cfg   config.Config
 23	st    *store.Store
 24	packs *packlimit.Limiter
 25}
 26
 27func New(cfg config.Config, st *store.Store, packs *packlimit.Limiter) *Server {
 28	return &Server{cfg: cfg, st: st, packs: packs}
 29}
 30
 31func (s *Server) Serve(ln net.Listener) error {
 32	for {
 33		conn, err := ln.Accept()
 34		if err != nil {
 35			return err
 36		}
 37		go s.handle(conn)
 38	}
 39}
 40
 41func (s *Server) handle(conn net.Conn) {
 42	defer conn.Close()
 43	conn.SetReadDeadline(time.Now().Add(30 * time.Second))
 44
 45	req, err := readPktLine(conn)
 46	if err != nil {
 47		return
 48	}
 49	conn.SetReadDeadline(time.Time{})
 50
 51	// Request form: "git-upload-pack /owner/name.git\0host=...\0[\0extra\0]"
 52	service, rest, ok := strings.Cut(req, " ")
 53	if !ok || service != "git-upload-pack" {
 54		writeErr(conn, "only git-upload-pack is available over git://")
 55		return
 56	}
 57	parts := strings.Split(rest, "\x00")
 58	path := parts[0]
 59	var protoEnv []string
 60	for _, p := range parts[1:] {
 61		if v, ok := strings.CutPrefix(p, "version="); ok {
 62			protoEnv = []string{"GIT_PROTOCOL=version=" + v}
 63		}
 64	}
 65
 66	repo, err := s.st.RepoByPath(path)
 67	if err != nil || repo.Visibility != "public" || !repo.Settings.GitDaemon {
 68		// One answer for missing, private, and not-opted-in.
 69		writeErr(conn, "repository not exported")
 70		return
 71	}
 72
 73	// A nil done: a queued client that leaves, or a restart, does not
 74	// end the wait; only the limiter's wait does.
 75	p := principal(conn.RemoteAddr())
 76	release, err := s.packs.Acquire(nil, p)
 77	if err != nil {
 78		s.packs.Refused("git", p, err)
 79		writeErr(conn, err.Error())
 80		return
 81	}
 82	// Deferred before git runs, so it fires after git has exited and
 83	// been waited for.
 84	defer release()
 85	out, stalled, unwatch := s.packs.Watch(conn)
 86	defer unwatch()
 87	kill := make(chan struct{})
 88	finished := make(chan struct{})
 89	defer close(finished)
 90	go func() {
 91		select {
 92		case <-finished:
 93		case <-stalled:
 94			close(kill)
 95			// A write blocked on a client that stopped reading outlives
 96			// git; closing the connection ends it and the stdin copy.
 97			conn.Close()
 98		}
 99	}()
100
101	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
102	gitutil.Transport("git-upload-pack", dir, conn, out, io.Discard, protoEnv, 0, kill)
103}
104
105// principal is the pack-limit principal for a client at addr.
106func principal(addr net.Addr) string {
107	host, _, _ := net.SplitHostPort(addr.String())
108	return packlimit.AddrPrincipal(host)
109}
110
111func readPktLine(r io.Reader) (string, error) {
112	var lenHex [4]byte
113	if _, err := io.ReadFull(r, lenHex[:]); err != nil {
114		return "", err
115	}
116	n, err := strconv.ParseUint(string(lenHex[:]), 16, 16)
117	if err != nil || n < 4 || n > 65520 {
118		return "", fmt.Errorf("bad pkt length %q", lenHex)
119	}
120	if n == 4 {
121		return "", nil // flush-pkt
122	}
123	buf := make([]byte, n-4)
124	if _, err := io.ReadFull(r, buf); err != nil {
125		return "", err
126	}
127	return strings.TrimSuffix(string(buf), "\n"), nil
128}
129
130func writeErr(w io.Writer, msg string) {
131	line := "ERR " + msg + "\n"
132	fmt.Fprintf(w, "%04x%s", len(line)+4, line)
133}