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

tls: acme mode via autocert

- http.tls = "acme": certificates from Let's Encrypt (or any RFC 8555
  CA autocert supports) via TLS-ALPN-01 on the HTTPS port; cache under
  server.root/acme; HostWhitelist pinned to the site_url host
- optional acme_http_addr listener (default :80, "off" to disable)
  answers HTTP-01 challenges and 301-redirects everything to the
  canonical https host; failure to bind it is a warning, not fatal
- acme_email for the CA account; check-config rejects acme with an
  http:// site_url, localhost, or an IP literal
- e2e (offline-safe): redirect listener verified exactly, failed
  issuance handshakes leave the daemon and listener alive, cache dir
  created, non-whitelisted SNI refused before any issuance attempt
 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(-)

diff --git a/cmd/gitbayd/main.go b/cmd/gitbayd/main.go
index 1dbcf3d..5ae179e 100644
--- a/cmd/gitbayd/main.go
+++ b/cmd/gitbayd/main.go
@@ -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("", "")
 				}
 			}()
 
diff --git a/e2e/acme_test.go b/e2e/acme_test.go
new file mode 100644
index 0000000..374b824
--- /dev/null
+++ b/e2e/acme_test.go
@@ -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")
+	}
+}
diff --git a/go.mod b/go.mod
index 36ec733..d0e4b15 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 65019dd..6d35b53 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/internal/config/config.go b/internal/config/config.go
index 46dd369..7b55c5f 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -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 {
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index c1d45a8..4c8af79 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -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) {