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
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(-) @@ -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 }, } } new file mode 100644 @@ -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) + } +} @@ -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) } @@ -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) } @@ -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) } new file mode 100644 @@ -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) +} new file mode 100644 @@ -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 +} @@ -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.