Commit a831d1ab09

a831d1ab09705002f965fb91989e86d1638c0ed8

parent: d47a2ef73f

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-23 22:37 UTC

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

Layout: unified · split

cmd/forged/main.go +31 −1
@@ -6,6 +6,7 @@ import (
6 "fmt" 6 "fmt"
7 "log/slog" 7 "log/slog"
8 "net" 8 "net"
9 "net/http"
9 "os" 10 "os"
10 "path/filepath" 11 "path/filepath"
11 "strconv" 12 "strconv"
@@ -15,7 +16,9 @@ import (
15 16
16 "github.com/krazywarez/forge/internal/config" 17 "github.com/krazywarez/forge/internal/config"
17 "github.com/krazywarez/forge/internal/control" 18 "github.com/krazywarez/forge/internal/control"
19 "github.com/krazywarez/forge/internal/gitd"
18 "github.com/krazywarez/forge/internal/hookd" 20 "github.com/krazywarez/forge/internal/hookd"
21 "github.com/krazywarez/forge/internal/httpd"
19 "github.com/krazywarez/forge/internal/policy" 22 "github.com/krazywarez/forge/internal/policy"
20 "github.com/krazywarez/forge/internal/sshd" 23 "github.com/krazywarez/forge/internal/sshd"
21 "github.com/krazywarez/forge/internal/store" 24 "github.com/krazywarez/forge/internal/store"
@@ -124,7 +127,34 @@ func serveCmd() *cobra.Command {
124 return err 127 return err
125 } 128 }
126 slog.Info("ssh listening", "addr", ln.Addr()) 129 slog.Info("ssh listening", "addr", ln.Addr())
127 return srv.Serve(ln) 130
131 errCh := make(chan error, 3)
132 go func() { errCh <- srv.Serve(ln) }()
133
134 web := httpd.New(cfg, st)
135 hs := &http.Server{Addr: cfg.HTTP.Addr, Handler: web.Handler()}
136 go func() {
137 slog.Info("http listening", "addr", cfg.HTTP.Addr, "tls", cfg.HTTP.TLS)
138 switch cfg.HTTP.TLS {
139 case "off":
140 errCh <- hs.ListenAndServe()
141 case "files":
142 errCh <- hs.ListenAndServeTLS(cfg.HTTP.CertFile, cfg.HTTP.KeyFile)
143 default:
144 errCh <- fmt.Errorf("http.tls = %q not implemented yet; use \"files\" or \"off\"", cfg.HTTP.TLS)
145 }
146 }()
147
148 if cfg.GitDaemon.Enabled {
149 gln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.GitDaemon.Port)))
150 if err != nil {
151 return err
152 }
153 slog.Info("git-daemon listening", "addr", gln.Addr())
154 go func() { errCh <- gitd.New(cfg, st).Serve(gln) }()
155 }
156
157 return <-errCh
128 }, 158 },
129 } 159 }
130} 160}
e2e/http_test.go added +163
@@ -0,0 +1,163 @@
1package e2e
2
3import (
4 "fmt"
5 "io"
6 "net/http"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "testing"
12)
13
14// gitBinaries returns every distinct git on this machine, so transport
15// behavior is verified against more than one client version.
16func gitBinaries() []string {
17 bins := []string{"git"}
18 if _, err := os.Stat("/usr/bin/git"); err == nil {
19 bins = append(bins, "/usr/bin/git")
20 }
21 return bins
22}
23
24// setupPublicRepo creates alice with a public repo containing one commit and
25// returns her key path.
26func setupPublicRepo(t *testing.T, inst *instance, repo string) string {
27 t.Helper()
28 aliceKey := inst.newKey(t, "alice")
29 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
30 _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", repo)
31 if code != 0 {
32 t.Fatalf("repo create: %s", errOut)
33 }
34 work := t.TempDir()
35 env := inst.gitEnv(aliceKey)
36 mustGit(t, work, env, "clone", inst.sshURL(repo), "w")
37 dir := filepath.Join(work, "w")
38 if err := os.WriteFile(filepath.Join(dir, "README"), []byte("public\n"), 0o644); err != nil {
39 t.Fatal(err)
40 }
41 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
42 mustGit(t, dir, env, "add", "README")
43 mustGit(t, dir, env, "commit", "-q", "-m", "init")
44 mustGit(t, dir, env, "push", "-q", "origin", "main")
45 return aliceKey
46}
47
48// anonEnv is a git environment with no credentials and prompting hard-failed:
49// if git ever tries to ask for a username or password, the command errors
50// with a distinctive message instead of hanging.
51func anonEnv() []string {
52 return append(os.Environ(),
53 "GIT_TERMINAL_PROMPT=0",
54 "GIT_ASKPASS=false",
55 "GIT_CONFIG_NOSYSTEM=1",
56 "HOME=/nonexistent-forge-e2e", // no ~/.gitconfig credential helpers
57 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
58 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test",
59 )
60}
61
62func (i *instance) httpURL(repo string) string {
63 return fmt.Sprintf("http://127.0.0.1:%d/%s.git", i.httpPort, repo)
64}
65
66func TestHTTPTransport(t *testing.T) {
67 inst := startInstance(t)
68 aliceKey := setupPublicRepo(t, inst, "alice/pub")
69
70 // Anonymous clone of a public repo over HTTP.
71 work := t.TempDir()
72 mustGit(t, work, anonEnv(), "clone", inst.httpURL("alice/pub"), "c")
73 dir := filepath.Join(work, "c")
74 if data, err := os.ReadFile(filepath.Join(dir, "README")); err != nil || string(data) != "public\n" {
75 t.Fatalf("cloned content wrong: %q, %v", data, err)
76 }
77
78 // Push over HTTP: fatal remote error with the SSH URL, no credential
79 // prompting of any kind — checked against every git version on this
80 // machine (the pkt-line ERR mechanism must be version-independent).
81 mustGit(t, dir, anonEnv(), "commit", "-q", "--allow-empty", "-m", "x")
82 for _, gitBin := range gitBinaries() {
83 cmd := exec.Command(gitBin, "push", "origin", "main")
84 cmd.Dir = dir
85 cmd.Env = anonEnv()
86 rawOut, err := cmd.CombinedOutput()
87 out := string(rawOut)
88 if err == nil {
89 t.Fatalf("[%s] push over http succeeded", gitBin)
90 }
91 if !strings.Contains(out, "remote error:") ||
92 !strings.Contains(out, "pushes to this forge go over SSH") ||
93 !strings.Contains(out, "git@forge.test:alice/pub.git") {
94 t.Fatalf("[%s] push refusal output:\n%s", gitBin, out)
95 }
96 for _, banned := range []string{"Username", "Password", "Authentication failed", "terminal prompts disabled", "401", "403"} {
97 if strings.Contains(out, banned) {
98 t.Fatalf("[%s] push refusal fell into credential path (%q):\n%s", gitBin, banned, out)
99 }
100 }
101 }
102
103 // Private repo: 404 on the wire for anonymous HTTP, for both services
104 // and for a nonexistent repo — all indistinguishable.
105 _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private")
106 if code != 0 {
107 t.Fatalf("create private: %s", errOut)
108 }
109 for _, u := range []string{
110 inst.httpURL("alice/secret") + "/info/refs?service=git-upload-pack",
111 inst.httpURL("alice/secret") + "/info/refs?service=git-receive-pack",
112 inst.httpURL("alice/nonexistent") + "/info/refs?service=git-upload-pack",
113 } {
114 resp, err := http.Get(u)
115 if err != nil {
116 t.Fatal(err)
117 }
118 body, _ := io.ReadAll(resp.Body)
119 resp.Body.Close()
120 if resp.StatusCode != http.StatusNotFound {
121 t.Fatalf("GET %s = %d, want 404\n%s", u, resp.StatusCode, body)
122 }
123 }
124 if out, code := gitRun(t, t.TempDir(), anonEnv(), "clone", inst.httpURL("alice/secret")); code == 0 {
125 t.Fatalf("anonymous clone of private repo succeeded:\n%s", out)
126 }
127}
128
129func TestGitDaemon(t *testing.T) {
130 inst := startInstance(t)
131 aliceKey := setupPublicRepo(t, inst, "alice/pub")
132 gitURL := func(repo string) string {
133 return fmt.Sprintf("git://127.0.0.1:%d/%s.git", inst.gitPort, repo)
134 }
135
136 // Not opted in yet: refused even though public.
137 if out, code := gitRun(t, t.TempDir(), anonEnv(), "clone", gitURL("alice/pub")); code == 0 {
138 t.Fatalf("git:// clone before opt-in succeeded:\n%s", out)
139 } else if !strings.Contains(out, "repository not exported") {
140 t.Fatalf("opt-out message:\n%s", out)
141 }
142
143 // Opt in, clone works.
144 _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "git-daemon", "alice/pub", "on")
145 if code != 0 {
146 t.Fatalf("git-daemon on: %s", errOut)
147 }
148 work := t.TempDir()
149 mustGit(t, work, anonEnv(), "clone", gitURL("alice/pub"), "c")
150 if data, _ := os.ReadFile(filepath.Join(work, "c", "README")); string(data) != "public\n" {
151 t.Fatalf("git:// clone content wrong: %q", data)
152 }
153
154 // Private repos cannot be opted in.
155 _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private")
156 if code != 0 {
157 t.Fatal("create private failed")
158 }
159 _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "settings", "git-daemon", "alice/secret", "on")
160 if code != 2 || !strings.Contains(errOut, "only public repositories") {
161 t.Fatalf("private opt-in: exit %d, %s", code, errOut)
162 }
163}
e2e/ssh_test.go +21 −11
@@ -14,12 +14,14 @@ import (
14) 14)
15 15
16type instance struct { 16type instance struct {
17 forged string // path to built binary 17 forged string // path to built binary
18 root string 18 root string
19 config string 19 config string
20 port int 20 port int
21 proc *exec.Cmd 21 httpPort int
22 sshDir string // per-user client keys live here 22 gitPort int
23 proc *exec.Cmd
24 sshDir string // per-user client keys live here
23} 25}
24 26
25func buildForged(t *testing.T) string { 27func buildForged(t *testing.T) string {
@@ -46,10 +48,12 @@ func freePort(t *testing.T) int {
46func startInstance(t *testing.T) *instance { 48func startInstance(t *testing.T) *instance {
47 t.Helper() 49 t.Helper()
48 inst := &instance{ 50 inst := &instance{
49 forged: buildForged(t), 51 forged: buildForged(t),
50 root: t.TempDir(), 52 root: t.TempDir(),
51 port: freePort(t), 53 port: freePort(t),
52 sshDir: t.TempDir(), 54 httpPort: freePort(t),
55 gitPort: freePort(t),
56 sshDir: t.TempDir(),
53 } 57 }
54 inst.config = filepath.Join(inst.root, "config.toml") 58 inst.config = filepath.Join(inst.root, "config.toml")
55 cfg := fmt.Sprintf(` 59 cfg := fmt.Sprintf(`
@@ -58,7 +62,13 @@ root = %q
58site_url = "https://forge.test" 62site_url = "https://forge.test"
59[ssh] 63[ssh]
60port = %d 64port = %d
61`, inst.root, inst.port) 65[http]
66addr = "127.0.0.1:%d"
67tls = "off"
68[git_daemon]
69enabled = true
70port = %d
71`, inst.root, inst.port, inst.httpPort, inst.gitPort)
62 if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil { 72 if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil {
63 t.Fatal(err) 73 t.Fatal(err)
64 } 74 }
internal/config/config.go +7 −2
@@ -34,8 +34,10 @@ type SSH struct {
34} 34}
35 35
36type HTTP struct { 36type HTTP struct {
37 Addr string `toml:"addr"` 37 Addr string `toml:"addr"`
38 TLS string `toml:"tls"` // acme | files | off 38 TLS string `toml:"tls"` // acme | files | off
39 CertFile string `toml:"cert_file"`
40 KeyFile string `toml:"key_file"`
39} 41}
40 42
41type GitDaemon struct { 43type GitDaemon struct {
@@ -126,6 +128,9 @@ func (c Config) Validate() error {
126 if err := oneOf("http.tls", c.HTTP.TLS, "acme", "files", "off"); err != nil { 128 if err := oneOf("http.tls", c.HTTP.TLS, "acme", "files", "off"); err != nil {
127 errs = append(errs, err) 129 errs = append(errs, err)
128 } 130 }
131 if c.HTTP.TLS == "files" && (c.HTTP.CertFile == "" || c.HTTP.KeyFile == "") {
132 errs = append(errs, errors.New("http.tls = \"files\" requires cert_file and key_file"))
133 }
129 if err := oneOf("web.mode", c.Web.Mode, "view_only", "accounts"); err != nil { 134 if err := oneOf("web.mode", c.Web.Mode, "view_only", "accounts"); err != nil {
130 errs = append(errs, err) 135 errs = append(errs, err)
131 } 136 }
internal/control/repo.go +27 −2
@@ -44,6 +44,8 @@ func init() {
44 Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect}) 44 Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect})
45 register(Command{Path: []string{"repo", "settings", "unprotect"}, 45 register(Command{Path: []string{"repo", "settings", "unprotect"},
46 Summary: "unprotect a branch: repo settings unprotect <owner/name> <branch>", Run: runUnprotect}) 46 Summary: "unprotect a branch: repo settings unprotect <owner/name> <branch>", Run: runUnprotect})
47 register(Command{Path: []string{"repo", "settings", "git-daemon"},
48 Summary: "expose over git://: repo settings git-daemon <owner/name> on|off", Run: runGitDaemon})
47} 49}
48 50
49// resolveRepo loads a repo and checks the given permission for c.User. 51// resolveRepo loads a repo and checks the given permission for c.User.
@@ -274,11 +276,34 @@ func runSettingsShow(c *Ctx, args []string) int {
274 return code 276 return code
275 } 277 }
276 return c.emit(repo.Settings, func(w io.Writer) { 278 return c.emit(repo.Settings, func(w io.Writer) {
277 fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\n", 279 fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\n",
278 strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits) 280 strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon)
279 }) 281 })
280} 282}
281 283
284func runGitDaemon(c *Ctx, args []string) int {
285 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
286 return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off")
287 }
288 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
289 if code >= 0 {
290 return code
291 }
292 on := args[1] == "on"
293 if on && repo.Visibility != "public" {
294 return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path())
295 }
296 if on && !c.Cfg.GitDaemon.Enabled {
297 return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
298 }
299 s := repo.Settings
300 s.GitDaemon = on
301 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
302 return c.fail(protocol.ExitFailure, "%v", err)
303 }
304 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
305}
306
282func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) } 307func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) }
283func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) } 308func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
284 309
internal/gitd/gitd.go added +101
@@ -0,0 +1,101 @@
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 "os"
11 "os/exec"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/krazywarez/forge/internal/config"
17 "github.com/krazywarez/forge/internal/control"
18 "github.com/krazywarez/forge/internal/store"
19)
20
21type Server struct {
22 cfg config.Config
23 st *store.Store
24}
25
26func New(cfg config.Config, st *store.Store) *Server { return &Server{cfg: cfg, st: st} }
27
28func (s *Server) Serve(ln net.Listener) error {
29 for {
30 conn, err := ln.Accept()
31 if err != nil {
32 return err
33 }
34 go s.handle(conn)
35 }
36}
37
38func (s *Server) handle(conn net.Conn) {
39 defer conn.Close()
40 conn.SetReadDeadline(time.Now().Add(30 * time.Second))
41
42 req, err := readPktLine(conn)
43 if err != nil {
44 return
45 }
46 conn.SetReadDeadline(time.Time{})
47
48 // Request form: "git-upload-pack /owner/name.git\0host=...\0[\0extra\0]"
49 service, rest, ok := strings.Cut(req, " ")
50 if !ok || service != "git-upload-pack" {
51 writeErr(conn, "only git-upload-pack is available over git://")
52 return
53 }
54 parts := strings.Split(rest, "\x00")
55 path := parts[0]
56 var protoEnv []string
57 for _, p := range parts[1:] {
58 if v, ok := strings.CutPrefix(p, "version="); ok {
59 protoEnv = []string{"GIT_PROTOCOL=version=" + v}
60 }
61 }
62
63 repo, err := s.st.RepoByPath(path)
64 if err != nil || repo.Visibility != "public" || !repo.Settings.GitDaemon {
65 // One answer for missing, private, and not-opted-in.
66 writeErr(conn, "repository not exported")
67 return
68 }
69
70 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
71 cmd := exec.Command("git", "upload-pack", dir)
72 cmd.Env = append(os.Environ(), protoEnv...)
73 cmd.Stdin = conn
74 cmd.Stdout = conn
75 cmd.Stderr = io.Discard
76 cmd.Run()
77}
78
79func readPktLine(r io.Reader) (string, error) {
80 var lenHex [4]byte
81 if _, err := io.ReadFull(r, lenHex[:]); err != nil {
82 return "", err
83 }
84 n, err := strconv.ParseUint(string(lenHex[:]), 16, 16)
85 if err != nil || n < 4 || n > 65520 {
86 return "", fmt.Errorf("bad pkt length %q", lenHex)
87 }
88 if n == 4 {
89 return "", nil // flush-pkt
90 }
91 buf := make([]byte, n-4)
92 if _, err := io.ReadFull(r, buf); err != nil {
93 return "", err
94 }
95 return strings.TrimSuffix(string(buf), "\n"), nil
96}
97
98func writeErr(w io.Writer, msg string) {
99 line := "ERR " + msg + "\n"
100 fmt.Fprintf(w, "%04x%s", len(line)+4, line)
101}
internal/httpd/smart.go added +130
@@ -0,0 +1,130 @@
1// Package httpd serves the HTTP listener: anonymous smart-HTTP git reads for
2// public repositories, and (from M5) the web UI. There is no authentication
3// on this listener by design — private repositories answer 404 everywhere,
4// and pushes are refused with a pkt-line ERR so no git version ever falls
5// back to asking for credentials.
6package httpd
7
8import (
9 "compress/gzip"
10 "fmt"
11 "io"
12 "net/http"
13 "os"
14 "os/exec"
15 "strings"
16
17 "github.com/krazywarez/forge/internal/config"
18 "github.com/krazywarez/forge/internal/control"
19 "github.com/krazywarez/forge/internal/store"
20)
21
22type Server struct {
23 cfg config.Config
24 st *store.Store
25}
26
27func New(cfg config.Config, st *store.Store) *Server {
28 return &Server{cfg: cfg, st: st}
29}
30
31func (s *Server) Handler() http.Handler {
32 mux := http.NewServeMux()
33 mux.HandleFunc("GET /{owner}/{repo}/info/refs", s.infoRefs)
34 mux.HandleFunc("POST /{owner}/{repo}/git-upload-pack", s.uploadPack)
35 // Push endpoints exist only to fail legibly.
36 mux.HandleFunc("POST /{owner}/{repo}/git-receive-pack", func(w http.ResponseWriter, r *http.Request) {
37 http.Error(w, s.pushRefusalMessage(r.PathValue("owner"), r.PathValue("repo")), http.StatusForbidden)
38 })
39 return mux
40}
41
42// publicRepo resolves owner/name and returns it only if it exists and is
43// public. Every failure mode is the same 404.
44func (s *Server) publicRepo(owner, name string) (store.Repo, bool) {
45 repo, err := s.st.RepoByPath(owner + "/" + name)
46 if err != nil || repo.Visibility != "public" {
47 return store.Repo{}, false
48 }
49 return repo, true
50}
51
52func pktLine(w io.Writer, s string) {
53 fmt.Fprintf(w, "%04x%s", len(s)+4, s)
54}
55
56func pktFlush(w io.Writer) { io.WriteString(w, "0000") }
57
58func (s *Server) pushRefusalMessage(owner, repo string) string {
59 host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://"), "/")
60 name := strings.TrimSuffix(repo, ".git")
61 return fmt.Sprintf("pushes to this forge go over SSH: git remote set-url --push origin git@%s:%s/%s.git", host, owner, name)
62}
63
64func (s *Server) infoRefs(w http.ResponseWriter, r *http.Request) {
65 owner, name := r.PathValue("owner"), r.PathValue("repo")
66 repo, ok := s.publicRepo(owner, name)
67 if !ok {
68 http.NotFound(w, r)
69 return
70 }
71 switch service := r.URL.Query().Get("service"); service {
72 case "git-upload-pack":
73 w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement")
74 w.Header().Set("Cache-Control", "no-cache")
75 pktLine(w, "# service=git-upload-pack\n")
76 pktFlush(w)
77 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
78 cmd := exec.CommandContext(r.Context(), "git", "upload-pack", "--stateless-rpc", "--advertise-refs", dir)
79 cmd.Env = append(os.Environ(), gitProtocolEnv(r)...)
80 cmd.Stdout = w
81 cmd.Run()
82 case "git-receive-pack":
83 // HTTP 200 with a pkt-line ERR: every git version renders this as
84 // "fatal: remote error: ..." and never falls back to credential
85 // prompting the way a 401/403 would.
86 w.Header().Set("Content-Type", "application/x-git-receive-pack-advertisement")
87 w.Header().Set("Cache-Control", "no-cache")
88 pktLine(w, "# service=git-receive-pack\n")
89 pktFlush(w)
90 pktLine(w, "ERR "+s.pushRefusalMessage(owner, name)+"\n")
91 default:
92 // Dumb-protocol clients are not supported.
93 http.NotFound(w, r)
94 }
95}
96
97func (s *Server) uploadPack(w http.ResponseWriter, r *http.Request) {
98 repo, ok := s.publicRepo(r.PathValue("owner"), r.PathValue("repo"))
99 if !ok {
100 http.NotFound(w, r)
101 return
102 }
103 body := io.Reader(r.Body)
104 if r.Header.Get("Content-Encoding") == "gzip" {
105 gz, err := gzip.NewReader(body)
106 if err != nil {
107 http.Error(w, "bad gzip body", http.StatusBadRequest)
108 return
109 }
110 defer gz.Close()
111 body = gz
112 }
113 w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
114 w.Header().Set("Cache-Control", "no-cache")
115 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
116 cmd := exec.CommandContext(r.Context(), "git", "upload-pack", "--stateless-rpc", dir)
117 cmd.Env = append(os.Environ(), gitProtocolEnv(r)...)
118 cmd.Stdin = body
119 cmd.Stdout = w
120 cmd.Run()
121}
122
123// gitProtocolEnv forwards the client's protocol negotiation header so
124// protocol v2 works over stateless HTTP.
125func gitProtocolEnv(r *http.Request) []string {
126 if p := r.Header.Get("Git-Protocol"); p != "" {
127 return []string{"GIT_PROTOCOL=" + p}
128 }
129 return nil
130}
internal/store/repos.go +1
@@ -22,6 +22,7 @@ type Repo struct {
22type RepoSettings struct { 22type RepoSettings struct {
23 ProtectedBranches []string `json:"protected_branches,omitempty"` 23 ProtectedBranches []string `json:"protected_branches,omitempty"`
24 RequireSignedCommits bool `json:"require_signed_commits,omitempty"` 24 RequireSignedCommits bool `json:"require_signed_commits,omitempty"`
25 GitDaemon bool `json:"git_daemon,omitempty"`
25} 26}
26 27
27// Path returns the canonical owner/name form. 28// Path returns the canonical owner/name form.