krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
399d2f300efc8573bf4799caedb52f4b2f1d63a7
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-24T00:30:10Z
cmd/gitbayd/main.go | 29 ++++++++++-- e2e/acme_test.go | 105 +++++++++++++++++++++++++++++++++++++++++ go.mod | 2 + go.sum | 16 ++++--- internal/config/config.go | 31 +++++++++++- internal/config/config_test.go | 4 ++ 6 files changed, 177 insertions(+), 10 deletions(-) @@ -8,11 +8,12 @@ import ( "net" "net/http" "os" - "strings" "path/filepath" "strconv" + "strings" "github.com/spf13/cobra" + "golang.org/x/crypto/acme/autocert" "golang.org/x/crypto/ssh" "gitbay.org/gitbay/internal/config" @@ -146,8 +147,30 @@ func serveCmd() *cobra.Command { 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) + case "acme": + host := cfg.SiteHost() + m := &autocert.Manager{ + Prompt: autocert.AcceptTOS, + Cache: autocert.DirCache(filepath.Join(cfg.Server.Root, "acme")), + HostPolicy: autocert.HostWhitelist(host), + Email: cfg.HTTP.ACMEEmail, + } + // TLS-ALPN-01 rides the HTTPS port itself. The optional + // plain-HTTP listener adds HTTP-01 and a redirect; losing + // it (port 80 taken, no privileges) is not fatal. + if addr := cfg.HTTP.ACMEHTTPAddr; addr != "" && addr != "off" { + redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusMovedPermanently) + }) + go func() { + slog.Info("acme http listening", "addr", addr) + if err := http.ListenAndServe(addr, m.HTTPHandler(redirect)); err != nil { + slog.Warn("acme http listener failed; continuing with TLS-ALPN only", "err", err) + } + }() + } + hs.TLSConfig = m.TLSConfig() + errCh <- hs.ListenAndServeTLS("", "") } }() new file mode 100644 @@ -0,0 +1,105 @@ +package e2e + +import ( + "crypto/tls" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +// TestACMEServe verifies the acme wiring offline: the HTTPS listener is up +// with autocert answering handshakes, and the port-80-style helper listener +// serves redirects. Actual issuance needs a reachable CA and a public DNS +// name, which a test cannot have; what matters here is that the plumbing is +// correct and failure to issue does not kill the daemon. +func TestACMEServe(t *testing.T) { + inst := startInstanceWith(t, "") // helper for binary + keys; killed below + inst.proc.Process.Kill() + inst.proc.Wait() + + httpsPort := freePort(t) + acmeHTTPPort := freePort(t) + cfg := fmt.Sprintf(` +[server] +root = %q +site_url = "https://gitbay.example" +[ssh] +port = %d +[http] +addr = "127.0.0.1:%d" +tls = "acme" +acme_email = "noreply@gitbay.example" +acme_http_addr = "127.0.0.1:%d" +`, inst.root, inst.port, httpsPort, acmeHTTPPort) + if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + inst.proc = exec.Command(inst.gitbayd, "--config", inst.config, "serve") + inst.proc.Stderr = os.Stderr + if err := inst.proc.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { inst.proc.Process.Kill(); inst.proc.Wait() }) + + wait := func(port int) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 200*time.Millisecond) + if err == nil { + conn.Close() + return + } + if time.Now().After(deadline) { + t.Fatalf("port %d never came up", port) + } + time.Sleep(50 * time.Millisecond) + } + } + wait(httpsPort) + wait(acmeHTTPPort) + + // The helper listener redirects everything to the canonical HTTPS host. + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/repo/log?x=1", acmeHTTPPort)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusMovedPermanently || + resp.Header.Get("Location") != "https://gitbay.example/alice/repo/log?x=1" { + t.Fatalf("redirect: %d %q", resp.StatusCode, resp.Header.Get("Location")) + } + + // A TLS handshake reaches autocert, which tries (and fails) to issue — + // the handshake errors, the daemon survives, the listener stays up. + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 3 * time.Second}, "tcp", + fmt.Sprintf("127.0.0.1:%d", httpsPort), + &tls.Config{ServerName: "gitbay.example", InsecureSkipVerify: true}) + if err == nil { + conn.Close() + t.Fatal("handshake unexpectedly succeeded with no CA reachable") + } + wait(httpsPort) // still listening after the failed handshake + + // Certificates cache under the server root. + if _, err := os.Stat(filepath.Join(inst.root, "acme")); err != nil { + t.Fatalf("acme cache dir: %v", err) + } + + // A host outside the whitelist is refused before any issuance attempt. + conn2, err := tls.DialWithDialer(&net.Dialer{Timeout: 3 * time.Second}, "tcp", + fmt.Sprintf("127.0.0.1:%d", httpsPort), + &tls.Config{ServerName: "evil.example", InsecureSkipVerify: true}) + if err == nil { + conn2.Close() + t.Fatal("handshake for non-whitelisted host succeeded") + } +} @@ -26,7 +26,9 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.57.0 // indirect golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect @@ -44,16 +44,20 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= @@ -7,6 +7,7 @@ import ( "net" "os" "strconv" + "strings" "github.com/BurntSushi/toml" ) @@ -38,6 +39,12 @@ type HTTP struct { TLS string `toml:"tls"` // acme | files | off CertFile string `toml:"cert_file"` KeyFile string `toml:"key_file"` + // ACME (Let's Encrypt by default). Certificates are cached under + // server.root/acme. acme_http_addr serves HTTP-01 challenges and + // redirects to HTTPS; "off" disables it (TLS-ALPN-01 on the HTTPS + // port still works). + ACMEEmail string `toml:"acme_email"` + ACMEHTTPAddr string `toml:"acme_http_addr"` } type GitDaemon struct { @@ -73,7 +80,7 @@ func Default() Config { return Config{ Server: Server{Root: "/var/lib/gitbay"}, SSH: SSH{Mode: "embedded", Port: 22}, - HTTP: HTTP{Addr: ":443", TLS: "acme"}, + HTTP: HTTP{Addr: ":443", TLS: "acme", ACMEHTTPAddr: ":80"}, Web: Web{Mode: "view_only"}, Registration: Registration{ Mode: "closed", @@ -133,6 +140,15 @@ func (c Config) Validate() error { 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 c.HTTP.TLS == "acme" { + host := c.SiteHost() + switch { + case !strings.HasPrefix(c.Server.SiteURL, "https://"): + errs = append(errs, errors.New("http.tls = \"acme\" requires an https:// site_url: certificates are issued for that host")) + case host == "" || host == "localhost" || net.ParseIP(host) != nil: + errs = append(errs, fmt.Errorf("http.tls = \"acme\" cannot issue a certificate for %q: use a public DNS name in site_url", host)) + } + } if err := oneOf("web.mode", c.Web.Mode, "view_only", "accounts"); err != nil { errs = append(errs, err) } @@ -165,6 +181,19 @@ func (c Config) Validate() error { return errors.Join(errs...) } +// SiteHost returns the bare hostname from site_url (no scheme, port, path). +func (c Config) SiteHost() string { + h := strings.TrimPrefix(strings.TrimPrefix(c.Server.SiteURL, "https://"), "http://") + h = strings.TrimSuffix(h, "/") + if i := strings.IndexByte(h, '/'); i >= 0 { + h = h[:i] + } + if host, _, err := net.SplitHostPort(h); err == nil { + return host + } + return h +} + // CheckHost performs environment probes that only make sense on the target // machine: port availability for the embedded listener and root existence. func (c Config) CheckHost() error { @@ -115,6 +115,10 @@ func TestValidCombinations(t *testing.T) { "closed registration, no smtp at all", minimal, }, + { + "acme with public https host", + "[server]\nroot = \"/var/lib/gitbay\"\nsite_url = \"https://gitbay.org\"\n[http]\ntls = \"acme\"\nacme_email = \"noreply@gitbay.org\"\n", + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) {