krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

a831d1ab09705002f965fb91989e86d1638c0ed8

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T22:37:06Z

M3: anonymous read transports over HTTP and git://

- smart HTTP upload-pack (stateless-rpc, protocol v2, gzip bodies) for
  public repos; private and nonexistent repos both answer 404
- push over HTTP refused with a pkt-line ERR in the receive-pack
  advertisement: git prints 'fatal: remote error: pushes go over SSH
  ...' and never falls into credential prompting; verified against
  multiple installed git versions in e2e
- native git:// listener, upload-pack only, gated on instance
  [git_daemon] enabled AND per-repo opt-in; refusals use protocol ERR
- repo settings git-daemon on|off (public repos only)
- http.tls = files|off implemented; cert/key validated in check-config
 cmd/forged/main.go        |  32 ++++++++-
 e2e/http_test.go          | 163 ++++++++++++++++++++++++++++++++++++++++++++++
 e2e/ssh_test.go           |  32 +++++----
 internal/config/config.go |   9 ++-
 internal/control/repo.go  |  29 ++++++++-
 internal/gitd/gitd.go     | 101 ++++++++++++++++++++++++++++
 internal/httpd/smart.go   | 130 ++++++++++++++++++++++++++++++++++++
 internal/store/repos.go   |   1 +
 8 files changed, 481 insertions(+), 16 deletions(-)

diff --git a/cmd/forged/main.go b/cmd/forged/main.go
index 6242fae..9c38553 100644
--- a/cmd/forged/main.go
+++ b/cmd/forged/main.go
@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"log/slog"
 	"net"
+	"net/http"
 	"os"
 	"path/filepath"
 	"strconv"
@@ -15,7 +16,9 @@ import (
 
 	"github.com/krazywarez/forge/internal/config"
 	"github.com/krazywarez/forge/internal/control"
+	"github.com/krazywarez/forge/internal/gitd"
 	"github.com/krazywarez/forge/internal/hookd"
+	"github.com/krazywarez/forge/internal/httpd"
 	"github.com/krazywarez/forge/internal/policy"
 	"github.com/krazywarez/forge/internal/sshd"
 	"github.com/krazywarez/forge/internal/store"
@@ -124,7 +127,34 @@ func serveCmd() *cobra.Command {
 				return err
 			}
 			slog.Info("ssh listening", "addr", ln.Addr())
-			return srv.Serve(ln)
+
+			errCh := make(chan error, 3)
+			go func() { errCh <- srv.Serve(ln) }()
+
+			web := httpd.New(cfg, st)
+			hs := &http.Server{Addr: cfg.HTTP.Addr, Handler: web.Handler()}
+			go func() {
+				slog.Info("http listening", "addr", cfg.HTTP.Addr, "tls", cfg.HTTP.TLS)
+				switch cfg.HTTP.TLS {
+				case "off":
+					errCh <- hs.ListenAndServe()
+				case "files":
+					errCh <- hs.ListenAndServeTLS(cfg.HTTP.CertFile, cfg.HTTP.KeyFile)
+				default:
+					errCh <- fmt.Errorf("http.tls = %q not implemented yet; use \"files\" or \"off\"", cfg.HTTP.TLS)
+				}
+			}()
+
+			if cfg.GitDaemon.Enabled {
+				gln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.GitDaemon.Port)))
+				if err != nil {
+					return err
+				}
+				slog.Info("git-daemon listening", "addr", gln.Addr())
+				go func() { errCh <- gitd.New(cfg, st).Serve(gln) }()
+			}
+
+			return <-errCh
 		},
 	}
 }
diff --git a/e2e/http_test.go b/e2e/http_test.go
new file mode 100644
index 0000000..268dd20
--- /dev/null
+++ b/e2e/http_test.go
@@ -0,0 +1,163 @@
+package e2e
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// gitBinaries returns every distinct git on this machine, so transport
+// behavior is verified against more than one client version.
+func gitBinaries() []string {
+	bins := []string{"git"}
+	if _, err := os.Stat("/usr/bin/git"); err == nil {
+		bins = append(bins, "/usr/bin/git")
+	}
+	return bins
+}
+
+// setupPublicRepo creates alice with a public repo containing one commit and
+// returns her key path.
+func setupPublicRepo(t *testing.T, inst *instance, repo string) string {
+	t.Helper()
+	aliceKey := inst.newKey(t, "alice")
+	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
+	_, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", repo)
+	if code != 0 {
+		t.Fatalf("repo create: %s", errOut)
+	}
+	work := t.TempDir()
+	env := inst.gitEnv(aliceKey)
+	mustGit(t, work, env, "clone", inst.sshURL(repo), "w")
+	dir := filepath.Join(work, "w")
+	if err := os.WriteFile(filepath.Join(dir, "README"), []byte("public\n"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
+	mustGit(t, dir, env, "add", "README")
+	mustGit(t, dir, env, "commit", "-q", "-m", "init")
+	mustGit(t, dir, env, "push", "-q", "origin", "main")
+	return aliceKey
+}
+
+// anonEnv is a git environment with no credentials and prompting hard-failed:
+// if git ever tries to ask for a username or password, the command errors
+// with a distinctive message instead of hanging.
+func anonEnv() []string {
+	return append(os.Environ(),
+		"GIT_TERMINAL_PROMPT=0",
+		"GIT_ASKPASS=false",
+		"GIT_CONFIG_NOSYSTEM=1",
+		"HOME=/nonexistent-forge-e2e", // no ~/.gitconfig credential helpers
+		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
+		"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test",
+	)
+}
+
+func (i *instance) httpURL(repo string) string {
+	return fmt.Sprintf("http://127.0.0.1:%d/%s.git", i.httpPort, repo)
+}
+
+func TestHTTPTransport(t *testing.T) {
+	inst := startInstance(t)
+	aliceKey := setupPublicRepo(t, inst, "alice/pub")
+
+	// Anonymous clone of a public repo over HTTP.
+	work := t.TempDir()
+	mustGit(t, work, anonEnv(), "clone", inst.httpURL("alice/pub"), "c")
+	dir := filepath.Join(work, "c")
+	if data, err := os.ReadFile(filepath.Join(dir, "README")); err != nil || string(data) != "public\n" {
+		t.Fatalf("cloned content wrong: %q, %v", data, err)
+	}
+
+	// Push over HTTP: fatal remote error with the SSH URL, no credential
+	// prompting of any kind — checked against every git version on this
+	// machine (the pkt-line ERR mechanism must be version-independent).
+	mustGit(t, dir, anonEnv(), "commit", "-q", "--allow-empty", "-m", "x")
+	for _, gitBin := range gitBinaries() {
+		cmd := exec.Command(gitBin, "push", "origin", "main")
+		cmd.Dir = dir
+		cmd.Env = anonEnv()
+		rawOut, err := cmd.CombinedOutput()
+		out := string(rawOut)
+		if err == nil {
+			t.Fatalf("[%s] push over http succeeded", gitBin)
+		}
+		if !strings.Contains(out, "remote error:") ||
+			!strings.Contains(out, "pushes to this forge go over SSH") ||
+			!strings.Contains(out, "git@forge.test:alice/pub.git") {
+			t.Fatalf("[%s] push refusal output:\n%s", gitBin, out)
+		}
+		for _, banned := range []string{"Username", "Password", "Authentication failed", "terminal prompts disabled", "401", "403"} {
+			if strings.Contains(out, banned) {
+				t.Fatalf("[%s] push refusal fell into credential path (%q):\n%s", gitBin, banned, out)
+			}
+		}
+	}
+
+	// Private repo: 404 on the wire for anonymous HTTP, for both services
+	// and for a nonexistent repo — all indistinguishable.
+	_, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private")
+	if code != 0 {
+		t.Fatalf("create private: %s", errOut)
+	}
+	for _, u := range []string{
+		inst.httpURL("alice/secret") + "/info/refs?service=git-upload-pack",
+		inst.httpURL("alice/secret") + "/info/refs?service=git-receive-pack",
+		inst.httpURL("alice/nonexistent") + "/info/refs?service=git-upload-pack",
+	} {
+		resp, err := http.Get(u)
+		if err != nil {
+			t.Fatal(err)
+		}
+		body, _ := io.ReadAll(resp.Body)
+		resp.Body.Close()
+		if resp.StatusCode != http.StatusNotFound {
+			t.Fatalf("GET %s = %d, want 404\n%s", u, resp.StatusCode, body)
+		}
+	}
+	if out, code := gitRun(t, t.TempDir(), anonEnv(), "clone", inst.httpURL("alice/secret")); code == 0 {
+		t.Fatalf("anonymous clone of private repo succeeded:\n%s", out)
+	}
+}
+
+func TestGitDaemon(t *testing.T) {
+	inst := startInstance(t)
+	aliceKey := setupPublicRepo(t, inst, "alice/pub")
+	gitURL := func(repo string) string {
+		return fmt.Sprintf("git://127.0.0.1:%d/%s.git", inst.gitPort, repo)
+	}
+
+	// Not opted in yet: refused even though public.
+	if out, code := gitRun(t, t.TempDir(), anonEnv(), "clone", gitURL("alice/pub")); code == 0 {
+		t.Fatalf("git:// clone before opt-in succeeded:\n%s", out)
+	} else if !strings.Contains(out, "repository not exported") {
+		t.Fatalf("opt-out message:\n%s", out)
+	}
+
+	// Opt in, clone works.
+	_, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "git-daemon", "alice/pub", "on")
+	if code != 0 {
+		t.Fatalf("git-daemon on: %s", errOut)
+	}
+	work := t.TempDir()
+	mustGit(t, work, anonEnv(), "clone", gitURL("alice/pub"), "c")
+	if data, _ := os.ReadFile(filepath.Join(work, "c", "README")); string(data) != "public\n" {
+		t.Fatalf("git:// clone content wrong: %q", data)
+	}
+
+	// Private repos cannot be opted in.
+	_, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private")
+	if code != 0 {
+		t.Fatal("create private failed")
+	}
+	_, errOut, code = inst.ssh(t, aliceKey, "", "repo", "settings", "git-daemon", "alice/secret", "on")
+	if code != 2 || !strings.Contains(errOut, "only public repositories") {
+		t.Fatalf("private opt-in: exit %d, %s", code, errOut)
+	}
+}
diff --git a/e2e/ssh_test.go b/e2e/ssh_test.go
index 44592e7..9877ec2 100644
--- a/e2e/ssh_test.go
+++ b/e2e/ssh_test.go
@@ -14,12 +14,14 @@ import (
 )
 
 type instance struct {
-	forged  string // path to built binary
-	root    string
-	config  string
-	port    int
-	proc    *exec.Cmd
-	sshDir  string // per-user client keys live here
+	forged   string // path to built binary
+	root     string
+	config   string
+	port     int
+	httpPort int
+	gitPort  int
+	proc     *exec.Cmd
+	sshDir   string // per-user client keys live here
 }
 
 func buildForged(t *testing.T) string {
@@ -46,10 +48,12 @@ func freePort(t *testing.T) int {
 func startInstance(t *testing.T) *instance {
 	t.Helper()
 	inst := &instance{
-		forged: buildForged(t),
-		root:   t.TempDir(),
-		port:   freePort(t),
-		sshDir: t.TempDir(),
+		forged:   buildForged(t),
+		root:     t.TempDir(),
+		port:     freePort(t),
+		httpPort: freePort(t),
+		gitPort:  freePort(t),
+		sshDir:   t.TempDir(),
 	}
 	inst.config = filepath.Join(inst.root, "config.toml")
 	cfg := fmt.Sprintf(`
@@ -58,7 +62,13 @@ root = %q
 site_url = "https://forge.test"
 [ssh]
 port = %d
-`, inst.root, inst.port)
+[http]
+addr = "127.0.0.1:%d"
+tls = "off"
+[git_daemon]
+enabled = true
+port = %d
+`, inst.root, inst.port, inst.httpPort, inst.gitPort)
 	if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil {
 		t.Fatal(err)
 	}
diff --git a/internal/config/config.go b/internal/config/config.go
index cee65fd..847b00c 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -34,8 +34,10 @@ type SSH struct {
 }
 
 type HTTP struct {
-	Addr string `toml:"addr"`
-	TLS  string `toml:"tls"` // acme | files | off
+	Addr     string `toml:"addr"`
+	TLS      string `toml:"tls"` // acme | files | off
+	CertFile string `toml:"cert_file"`
+	KeyFile  string `toml:"key_file"`
 }
 
 type GitDaemon struct {
@@ -126,6 +128,9 @@ func (c Config) Validate() error {
 	if err := oneOf("http.tls", c.HTTP.TLS, "acme", "files", "off"); err != nil {
 		errs = append(errs, err)
 	}
+	if c.HTTP.TLS == "files" && (c.HTTP.CertFile == "" || c.HTTP.KeyFile == "") {
+		errs = append(errs, errors.New("http.tls = \"files\" requires cert_file and key_file"))
+	}
 	if err := oneOf("web.mode", c.Web.Mode, "view_only", "accounts"); err != nil {
 		errs = append(errs, err)
 	}
diff --git a/internal/control/repo.go b/internal/control/repo.go
index 2d6f833..f779144 100644
--- a/internal/control/repo.go
+++ b/internal/control/repo.go
@@ -44,6 +44,8 @@ func init() {
 		Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect})
 	register(Command{Path: []string{"repo", "settings", "unprotect"},
 		Summary: "unprotect a branch: repo settings unprotect <owner/name> <branch>", Run: runUnprotect})
+	register(Command{Path: []string{"repo", "settings", "git-daemon"},
+		Summary: "expose over git://: repo settings git-daemon <owner/name> on|off", Run: runGitDaemon})
 }
 
 // resolveRepo loads a repo and checks the given permission for c.User.
@@ -274,11 +276,34 @@ func runSettingsShow(c *Ctx, args []string) int {
 		return code
 	}
 	return c.emit(repo.Settings, func(w io.Writer) {
-		fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\n",
-			strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits)
+		fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\n",
+			strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon)
 	})
 }
 
+func runGitDaemon(c *Ctx, args []string) int {
+	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
+		return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off")
+	}
+	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
+	if code >= 0 {
+		return code
+	}
+	on := args[1] == "on"
+	if on && repo.Visibility != "public" {
+		return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path())
+	}
+	if on && !c.Cfg.GitDaemon.Enabled {
+		return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
+	}
+	s := repo.Settings
+	s.GitDaemon = on
+	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
+}
+
 func runProtect(c *Ctx, args []string) int   { return setProtect(c, args, true) }
 func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
 
diff --git a/internal/gitd/gitd.go b/internal/gitd/gitd.go
new file mode 100644
index 0000000..01c13e0
--- /dev/null
+++ b/internal/gitd/gitd.go
@@ -0,0 +1,101 @@
+// Package gitd implements the anonymous git:// protocol listener. Read-only
+// upload-pack, and only for repositories that are public AND have opted in
+// via settings — on an instance where [git_daemon] is enabled at all.
+package gitd
+
+import (
+	"fmt"
+	"io"
+	"net"
+	"os"
+	"os/exec"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/krazywarez/forge/internal/config"
+	"github.com/krazywarez/forge/internal/control"
+	"github.com/krazywarez/forge/internal/store"
+)
+
+type Server struct {
+	cfg config.Config
+	st  *store.Store
+}
+
+func New(cfg config.Config, st *store.Store) *Server { return &Server{cfg: cfg, st: st} }
+
+func (s *Server) Serve(ln net.Listener) error {
+	for {
+		conn, err := ln.Accept()
+		if err != nil {
+			return err
+		}
+		go s.handle(conn)
+	}
+}
+
+func (s *Server) handle(conn net.Conn) {
+	defer conn.Close()
+	conn.SetReadDeadline(time.Now().Add(30 * time.Second))
+
+	req, err := readPktLine(conn)
+	if err != nil {
+		return
+	}
+	conn.SetReadDeadline(time.Time{})
+
+	// Request form: "git-upload-pack /owner/name.git\0host=...\0[\0extra\0]"
+	service, rest, ok := strings.Cut(req, " ")
+	if !ok || service != "git-upload-pack" {
+		writeErr(conn, "only git-upload-pack is available over git://")
+		return
+	}
+	parts := strings.Split(rest, "\x00")
+	path := parts[0]
+	var protoEnv []string
+	for _, p := range parts[1:] {
+		if v, ok := strings.CutPrefix(p, "version="); ok {
+			protoEnv = []string{"GIT_PROTOCOL=version=" + v}
+		}
+	}
+
+	repo, err := s.st.RepoByPath(path)
+	if err != nil || repo.Visibility != "public" || !repo.Settings.GitDaemon {
+		// One answer for missing, private, and not-opted-in.
+		writeErr(conn, "repository not exported")
+		return
+	}
+
+	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
+	cmd := exec.Command("git", "upload-pack", dir)
+	cmd.Env = append(os.Environ(), protoEnv...)
+	cmd.Stdin = conn
+	cmd.Stdout = conn
+	cmd.Stderr = io.Discard
+	cmd.Run()
+}
+
+func readPktLine(r io.Reader) (string, error) {
+	var lenHex [4]byte
+	if _, err := io.ReadFull(r, lenHex[:]); err != nil {
+		return "", err
+	}
+	n, err := strconv.ParseUint(string(lenHex[:]), 16, 16)
+	if err != nil || n < 4 || n > 65520 {
+		return "", fmt.Errorf("bad pkt length %q", lenHex)
+	}
+	if n == 4 {
+		return "", nil // flush-pkt
+	}
+	buf := make([]byte, n-4)
+	if _, err := io.ReadFull(r, buf); err != nil {
+		return "", err
+	}
+	return strings.TrimSuffix(string(buf), "\n"), nil
+}
+
+func writeErr(w io.Writer, msg string) {
+	line := "ERR " + msg + "\n"
+	fmt.Fprintf(w, "%04x%s", len(line)+4, line)
+}
diff --git a/internal/httpd/smart.go b/internal/httpd/smart.go
new file mode 100644
index 0000000..593b9ae
--- /dev/null
+++ b/internal/httpd/smart.go
@@ -0,0 +1,130 @@
+// Package httpd serves the HTTP listener: anonymous smart-HTTP git reads for
+// public repositories, and (from M5) the web UI. There is no authentication
+// on this listener by design — private repositories answer 404 everywhere,
+// and pushes are refused with a pkt-line ERR so no git version ever falls
+// back to asking for credentials.
+package httpd
+
+import (
+	"compress/gzip"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"os/exec"
+	"strings"
+
+	"github.com/krazywarez/forge/internal/config"
+	"github.com/krazywarez/forge/internal/control"
+	"github.com/krazywarez/forge/internal/store"
+)
+
+type Server struct {
+	cfg config.Config
+	st  *store.Store
+}
+
+func New(cfg config.Config, st *store.Store) *Server {
+	return &Server{cfg: cfg, st: st}
+}
+
+func (s *Server) Handler() http.Handler {
+	mux := http.NewServeMux()
+	mux.HandleFunc("GET /{owner}/{repo}/info/refs", s.infoRefs)
+	mux.HandleFunc("POST /{owner}/{repo}/git-upload-pack", s.uploadPack)
+	// Push endpoints exist only to fail legibly.
+	mux.HandleFunc("POST /{owner}/{repo}/git-receive-pack", func(w http.ResponseWriter, r *http.Request) {
+		http.Error(w, s.pushRefusalMessage(r.PathValue("owner"), r.PathValue("repo")), http.StatusForbidden)
+	})
+	return mux
+}
+
+// publicRepo resolves owner/name and returns it only if it exists and is
+// public. Every failure mode is the same 404.
+func (s *Server) publicRepo(owner, name string) (store.Repo, bool) {
+	repo, err := s.st.RepoByPath(owner + "/" + name)
+	if err != nil || repo.Visibility != "public" {
+		return store.Repo{}, false
+	}
+	return repo, true
+}
+
+func pktLine(w io.Writer, s string) {
+	fmt.Fprintf(w, "%04x%s", len(s)+4, s)
+}
+
+func pktFlush(w io.Writer) { io.WriteString(w, "0000") }
+
+func (s *Server) pushRefusalMessage(owner, repo string) string {
+	host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://"), "/")
+	name := strings.TrimSuffix(repo, ".git")
+	return fmt.Sprintf("pushes to this forge go over SSH: git remote set-url --push origin git@%s:%s/%s.git", host, owner, name)
+}
+
+func (s *Server) infoRefs(w http.ResponseWriter, r *http.Request) {
+	owner, name := r.PathValue("owner"), r.PathValue("repo")
+	repo, ok := s.publicRepo(owner, name)
+	if !ok {
+		http.NotFound(w, r)
+		return
+	}
+	switch service := r.URL.Query().Get("service"); service {
+	case "git-upload-pack":
+		w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement")
+		w.Header().Set("Cache-Control", "no-cache")
+		pktLine(w, "# service=git-upload-pack\n")
+		pktFlush(w)
+		dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
+		cmd := exec.CommandContext(r.Context(), "git", "upload-pack", "--stateless-rpc", "--advertise-refs", dir)
+		cmd.Env = append(os.Environ(), gitProtocolEnv(r)...)
+		cmd.Stdout = w
+		cmd.Run()
+	case "git-receive-pack":
+		// HTTP 200 with a pkt-line ERR: every git version renders this as
+		// "fatal: remote error: ..." and never falls back to credential
+		// prompting the way a 401/403 would.
+		w.Header().Set("Content-Type", "application/x-git-receive-pack-advertisement")
+		w.Header().Set("Cache-Control", "no-cache")
+		pktLine(w, "# service=git-receive-pack\n")
+		pktFlush(w)
+		pktLine(w, "ERR "+s.pushRefusalMessage(owner, name)+"\n")
+	default:
+		// Dumb-protocol clients are not supported.
+		http.NotFound(w, r)
+	}
+}
+
+func (s *Server) uploadPack(w http.ResponseWriter, r *http.Request) {
+	repo, ok := s.publicRepo(r.PathValue("owner"), r.PathValue("repo"))
+	if !ok {
+		http.NotFound(w, r)
+		return
+	}
+	body := io.Reader(r.Body)
+	if r.Header.Get("Content-Encoding") == "gzip" {
+		gz, err := gzip.NewReader(body)
+		if err != nil {
+			http.Error(w, "bad gzip body", http.StatusBadRequest)
+			return
+		}
+		defer gz.Close()
+		body = gz
+	}
+	w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
+	w.Header().Set("Cache-Control", "no-cache")
+	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
+	cmd := exec.CommandContext(r.Context(), "git", "upload-pack", "--stateless-rpc", dir)
+	cmd.Env = append(os.Environ(), gitProtocolEnv(r)...)
+	cmd.Stdin = body
+	cmd.Stdout = w
+	cmd.Run()
+}
+
+// gitProtocolEnv forwards the client's protocol negotiation header so
+// protocol v2 works over stateless HTTP.
+func gitProtocolEnv(r *http.Request) []string {
+	if p := r.Header.Get("Git-Protocol"); p != "" {
+		return []string{"GIT_PROTOCOL=" + p}
+	}
+	return nil
+}
diff --git a/internal/store/repos.go b/internal/store/repos.go
index 4a48a4b..68d5022 100644
--- a/internal/store/repos.go
+++ b/internal/store/repos.go
@@ -22,6 +22,7 @@ type Repo struct {
 type RepoSettings struct {
 	ProtectedBranches    []string `json:"protected_branches,omitempty"`
 	RequireSignedCommits bool     `json:"require_signed_commits,omitempty"`
+	GitDaemon            bool     `json:"git_daemon,omitempty"`
 }
 
 // Path returns the canonical owner/name form.