Commit a831d1ab09
Verified · cmc
Layout: unified · split
cmd/forged/main.go +31 −1
| @@ -6,6 +6,7 @@ import ( | ||
| 6 | 6 | "fmt" |
| 7 | 7 | "log/slog" |
| 8 | 8 | "net" |
| 9 | "net/http" | |
| 9 | 10 | "os" |
| 10 | 11 | "path/filepath" |
| 11 | 12 | "strconv" |
| @@ -15,7 +16,9 @@ import ( | ||
| 15 | 16 | |
| 16 | 17 | "github.com/krazywarez/forge/internal/config" |
| 17 | 18 | "github.com/krazywarez/forge/internal/control" |
| 19 | "github.com/krazywarez/forge/internal/gitd" | |
| 18 | 20 | "github.com/krazywarez/forge/internal/hookd" |
| 21 | "github.com/krazywarez/forge/internal/httpd" | |
| 19 | 22 | "github.com/krazywarez/forge/internal/policy" |
| 20 | 23 | "github.com/krazywarez/forge/internal/sshd" |
| 21 | 24 | "github.com/krazywarez/forge/internal/store" |
| @@ -124,7 +127,34 @@ func serveCmd() *cobra.Command { | ||
| 124 | 127 | return err |
| 125 | 128 | } |
| 126 | 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 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 16 | func 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. | |
| 26 | func 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. | |
| 51 | func 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 | ||
| 62 | func (i *instance) httpURL(repo string) string { | |
| 63 | return fmt.Sprintf("http://127.0.0.1:%d/%s.git", i.httpPort, repo) | |
| 64 | } | |
| 65 | ||
| 66 | func 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 | ||
| 129 | func 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 | |
| 16 | 16 | type instance struct { |
| 17 | forged string // path to built binary | |
| 18 | root string | |
| 19 | config string | |
| 20 | port int | |
| 21 | proc *exec.Cmd | |
| 22 | sshDir string // per-user client keys live here | |
| 17 | forged string // path to built binary | |
| 18 | root string | |
| 19 | config string | |
| 20 | port int | |
| 21 | httpPort int | |
| 22 | gitPort int | |
| 23 | proc *exec.Cmd | |
| 24 | sshDir string // per-user client keys live here | |
| 23 | 25 | } |
| 24 | 26 | |
| 25 | 27 | func buildForged(t *testing.T) string { |
| @@ -46,10 +48,12 @@ func freePort(t *testing.T) int { | ||
| 46 | 48 | func startInstance(t *testing.T) *instance { |
| 47 | 49 | t.Helper() |
| 48 | 50 | inst := &instance{ |
| 49 | forged: buildForged(t), | |
| 50 | root: t.TempDir(), | |
| 51 | port: freePort(t), | |
| 52 | sshDir: t.TempDir(), | |
| 51 | forged: buildForged(t), | |
| 52 | root: t.TempDir(), | |
| 53 | port: freePort(t), | |
| 54 | httpPort: freePort(t), | |
| 55 | gitPort: freePort(t), | |
| 56 | sshDir: t.TempDir(), | |
| 53 | 57 | } |
| 54 | 58 | inst.config = filepath.Join(inst.root, "config.toml") |
| 55 | 59 | cfg := fmt.Sprintf(` |
| @@ -58,7 +62,13 @@ root = %q | ||
| 58 | 62 | site_url = "https://forge.test" |
| 59 | 63 | [ssh] |
| 60 | 64 | port = %d |
| 61 | `, inst.root, inst.port) | |
| 65 | [http] | |
| 66 | addr = "127.0.0.1:%d" | |
| 67 | tls = "off" | |
| 68 | [git_daemon] | |
| 69 | enabled = true | |
| 70 | port = %d | |
| 71 | `, inst.root, inst.port, inst.httpPort, inst.gitPort) | |
| 62 | 72 | if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil { |
| 63 | 73 | t.Fatal(err) |
| 64 | 74 | } |
internal/config/config.go +7 −2
| @@ -34,8 +34,10 @@ type SSH struct { | ||
| 34 | 34 | } |
| 35 | 35 | |
| 36 | 36 | type HTTP struct { |
| 37 | Addr string `toml:"addr"` | |
| 38 | TLS string `toml:"tls"` // acme | files | off | |
| 37 | Addr string `toml:"addr"` | |
| 38 | TLS string `toml:"tls"` // acme | files | off | |
| 39 | CertFile string `toml:"cert_file"` | |
| 40 | KeyFile string `toml:"key_file"` | |
| 39 | 41 | } |
| 40 | 42 | |
| 41 | 43 | type GitDaemon struct { |
| @@ -126,6 +128,9 @@ func (c Config) Validate() error { | ||
| 126 | 128 | if err := oneOf("http.tls", c.HTTP.TLS, "acme", "files", "off"); err != nil { |
| 127 | 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 | 134 | if err := oneOf("web.mode", c.Web.Mode, "view_only", "accounts"); err != nil { |
| 130 | 135 | errs = append(errs, err) |
| 131 | 136 | } |
internal/control/repo.go +27 −2
| @@ -44,6 +44,8 @@ func init() { | ||
| 44 | 44 | Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect}) |
| 45 | 45 | register(Command{Path: []string{"repo", "settings", "unprotect"}, |
| 46 | 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 | 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 | 276 | return code |
| 275 | 277 | } |
| 276 | 278 | return c.emit(repo.Settings, func(w io.Writer) { |
| 277 | fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\n", | |
| 278 | strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits) | |
| 279 | fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\n", | |
| 280 | strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon) | |
| 279 | 281 | }) |
| 280 | 282 | } |
| 281 | 283 | |
| 284 | func 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 | ||
| 282 | 307 | func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) } |
| 283 | 308 | func 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. | |
| 4 | package gitd | |
| 5 | ||
| 6 | import ( | |
| 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 | ||
| 21 | type Server struct { | |
| 22 | cfg config.Config | |
| 23 | st *store.Store | |
| 24 | } | |
| 25 | ||
| 26 | func New(cfg config.Config, st *store.Store) *Server { return &Server{cfg: cfg, st: st} } | |
| 27 | ||
| 28 | func (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 | ||
| 38 | func (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 | ||
| 79 | func 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 | ||
| 98 | func 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. | |
| 6 | package httpd | |
| 7 | ||
| 8 | import ( | |
| 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 | ||
| 22 | type Server struct { | |
| 23 | cfg config.Config | |
| 24 | st *store.Store | |
| 25 | } | |
| 26 | ||
| 27 | func New(cfg config.Config, st *store.Store) *Server { | |
| 28 | return &Server{cfg: cfg, st: st} | |
| 29 | } | |
| 30 | ||
| 31 | func (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. | |
| 44 | func (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 | ||
| 52 | func pktLine(w io.Writer, s string) { | |
| 53 | fmt.Fprintf(w, "%04x%s", len(s)+4, s) | |
| 54 | } | |
| 55 | ||
| 56 | func pktFlush(w io.Writer) { io.WriteString(w, "0000") } | |
| 57 | ||
| 58 | func (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 | ||
| 64 | func (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 | ||
| 97 | func (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. | |
| 125 | func 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 { | ||
| 22 | 22 | type RepoSettings struct { |
| 23 | 23 | ProtectedBranches []string `json:"protected_branches,omitempty"` |
| 24 | 24 | RequireSignedCommits bool `json:"require_signed_commits,omitempty"` |
| 25 | GitDaemon bool `json:"git_daemon,omitempty"` | |
| 25 | 26 | } |
| 26 | 27 | |
| 27 | 28 | // Path returns the canonical owner/name form. |