Commit 399d2f300e

399d2f300efc8573bf4799caedb52f4b2f1d63a7

parent: dce6f1b389

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 00:30 UTC

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

Layout: unified · split

cmd/gitbayd/main.go +26 −3
@@ -8,11 +8,12 @@ import (
8 "net" 8 "net"
9 "net/http" 9 "net/http"
10 "os" 10 "os"
11 "strings"
12 "path/filepath" 11 "path/filepath"
13 "strconv" 12 "strconv"
13 "strings"
14 14
15 "github.com/spf13/cobra" 15 "github.com/spf13/cobra"
16 "golang.org/x/crypto/acme/autocert"
16 "golang.org/x/crypto/ssh" 17 "golang.org/x/crypto/ssh"
17 18
18 "gitbay.org/gitbay/internal/config" 19 "gitbay.org/gitbay/internal/config"
@@ -146,8 +147,30 @@ func serveCmd() *cobra.Command {
146 errCh <- hs.ListenAndServe() 147 errCh <- hs.ListenAndServe()
147 case "files": 148 case "files":
148 errCh <- hs.ListenAndServeTLS(cfg.HTTP.CertFile, cfg.HTTP.KeyFile) 149 errCh <- hs.ListenAndServeTLS(cfg.HTTP.CertFile, cfg.HTTP.KeyFile)
149 default: 150 case "acme":
150 errCh <- fmt.Errorf("http.tls = %q not implemented yet; use \"files\" or \"off\"", cfg.HTTP.TLS) 151 host := cfg.SiteHost()
152 m := &autocert.Manager{
153 Prompt: autocert.AcceptTOS,
154 Cache: autocert.DirCache(filepath.Join(cfg.Server.Root, "acme")),
155 HostPolicy: autocert.HostWhitelist(host),
156 Email: cfg.HTTP.ACMEEmail,
157 }
158 // TLS-ALPN-01 rides the HTTPS port itself. The optional
159 // plain-HTTP listener adds HTTP-01 and a redirect; losing
160 // it (port 80 taken, no privileges) is not fatal.
161 if addr := cfg.HTTP.ACMEHTTPAddr; addr != "" && addr != "off" {
162 redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
163 http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusMovedPermanently)
164 })
165 go func() {
166 slog.Info("acme http listening", "addr", addr)
167 if err := http.ListenAndServe(addr, m.HTTPHandler(redirect)); err != nil {
168 slog.Warn("acme http listener failed; continuing with TLS-ALPN only", "err", err)
169 }
170 }()
171 }
172 hs.TLSConfig = m.TLSConfig()
173 errCh <- hs.ListenAndServeTLS("", "")
151 } 174 }
152 }() 175 }()
153 176
e2e/acme_test.go added +105
@@ -0,0 +1,105 @@
1package e2e
2
3import (
4 "crypto/tls"
5 "fmt"
6 "net"
7 "net/http"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "testing"
12 "time"
13)
14
15// TestACMEServe verifies the acme wiring offline: the HTTPS listener is up
16// with autocert answering handshakes, and the port-80-style helper listener
17// serves redirects. Actual issuance needs a reachable CA and a public DNS
18// name, which a test cannot have; what matters here is that the plumbing is
19// correct and failure to issue does not kill the daemon.
20func TestACMEServe(t *testing.T) {
21 inst := startInstanceWith(t, "") // helper for binary + keys; killed below
22 inst.proc.Process.Kill()
23 inst.proc.Wait()
24
25 httpsPort := freePort(t)
26 acmeHTTPPort := freePort(t)
27 cfg := fmt.Sprintf(`
28[server]
29root = %q
30site_url = "https://gitbay.example"
31[ssh]
32port = %d
33[http]
34addr = "127.0.0.1:%d"
35tls = "acme"
36acme_email = "noreply@gitbay.example"
37acme_http_addr = "127.0.0.1:%d"
38`, inst.root, inst.port, httpsPort, acmeHTTPPort)
39 if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil {
40 t.Fatal(err)
41 }
42 inst.proc = exec.Command(inst.gitbayd, "--config", inst.config, "serve")
43 inst.proc.Stderr = os.Stderr
44 if err := inst.proc.Start(); err != nil {
45 t.Fatal(err)
46 }
47 t.Cleanup(func() { inst.proc.Process.Kill(); inst.proc.Wait() })
48
49 wait := func(port int) {
50 t.Helper()
51 deadline := time.Now().Add(10 * time.Second)
52 for {
53 conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 200*time.Millisecond)
54 if err == nil {
55 conn.Close()
56 return
57 }
58 if time.Now().After(deadline) {
59 t.Fatalf("port %d never came up", port)
60 }
61 time.Sleep(50 * time.Millisecond)
62 }
63 }
64 wait(httpsPort)
65 wait(acmeHTTPPort)
66
67 // The helper listener redirects everything to the canonical HTTPS host.
68 client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
69 return http.ErrUseLastResponse
70 }}
71 resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/repo/log?x=1", acmeHTTPPort))
72 if err != nil {
73 t.Fatal(err)
74 }
75 resp.Body.Close()
76 if resp.StatusCode != http.StatusMovedPermanently ||
77 resp.Header.Get("Location") != "https://gitbay.example/alice/repo/log?x=1" {
78 t.Fatalf("redirect: %d %q", resp.StatusCode, resp.Header.Get("Location"))
79 }
80
81 // A TLS handshake reaches autocert, which tries (and fails) to issue —
82 // the handshake errors, the daemon survives, the listener stays up.
83 conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 3 * time.Second}, "tcp",
84 fmt.Sprintf("127.0.0.1:%d", httpsPort),
85 &tls.Config{ServerName: "gitbay.example", InsecureSkipVerify: true})
86 if err == nil {
87 conn.Close()
88 t.Fatal("handshake unexpectedly succeeded with no CA reachable")
89 }
90 wait(httpsPort) // still listening after the failed handshake
91
92 // Certificates cache under the server root.
93 if _, err := os.Stat(filepath.Join(inst.root, "acme")); err != nil {
94 t.Fatalf("acme cache dir: %v", err)
95 }
96
97 // A host outside the whitelist is refused before any issuance attempt.
98 conn2, err := tls.DialWithDialer(&net.Dialer{Timeout: 3 * time.Second}, "tcp",
99 fmt.Sprintf("127.0.0.1:%d", httpsPort),
100 &tls.Config{ServerName: "evil.example", InsecureSkipVerify: true})
101 if err == nil {
102 conn2.Close()
103 t.Fatal("handshake for non-whitelisted host succeeded")
104 }
105}
go.mod +2
@@ -26,7 +26,9 @@ require (
26 github.com/russross/blackfriday/v2 v2.1.0 // indirect 26 github.com/russross/blackfriday/v2 v2.1.0 // indirect
27 github.com/spf13/pflag v1.0.9 // indirect 27 github.com/spf13/pflag v1.0.9 // indirect
28 go.yaml.in/yaml/v3 v3.0.4 // indirect 28 go.yaml.in/yaml/v3 v3.0.4 // indirect
29 golang.org/x/net v0.57.0 // indirect
29 golang.org/x/sys v0.47.0 // indirect 30 golang.org/x/sys v0.47.0 // indirect
31 golang.org/x/text v0.41.0 // indirect
30 modernc.org/libc v1.74.4 // indirect 32 modernc.org/libc v1.74.4 // indirect
31 modernc.org/mathutil v1.7.1 // indirect 33 modernc.org/mathutil v1.7.1 // indirect
32 modernc.org/memory v1.11.0 // indirect 34 modernc.org/memory v1.11.0 // indirect
go.sum +10 −6
@@ -44,16 +44,20 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
44go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 44go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
45golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= 45golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
46golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= 46golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
47golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= 47golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
48golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= 48golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
49golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= 49golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
50golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 50golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
51golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
52golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
51golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= 53golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
52golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 54golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
53golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= 55golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
54golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= 56golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
55golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= 57golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
56golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= 58golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
59golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
60golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
57gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 61gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
58gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 62gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
59modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= 63modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
internal/config/config.go +30 −1
@@ -7,6 +7,7 @@ import (
7 "net" 7 "net"
8 "os" 8 "os"
9 "strconv" 9 "strconv"
10 "strings"
10 11
11 "github.com/BurntSushi/toml" 12 "github.com/BurntSushi/toml"
12) 13)
@@ -38,6 +39,12 @@ type HTTP struct {
38 TLS string `toml:"tls"` // acme | files | off 39 TLS string `toml:"tls"` // acme | files | off
39 CertFile string `toml:"cert_file"` 40 CertFile string `toml:"cert_file"`
40 KeyFile string `toml:"key_file"` 41 KeyFile string `toml:"key_file"`
42 // ACME (Let's Encrypt by default). Certificates are cached under
43 // server.root/acme. acme_http_addr serves HTTP-01 challenges and
44 // redirects to HTTPS; "off" disables it (TLS-ALPN-01 on the HTTPS
45 // port still works).
46 ACMEEmail string `toml:"acme_email"`
47 ACMEHTTPAddr string `toml:"acme_http_addr"`
41} 48}
42 49
43type GitDaemon struct { 50type GitDaemon struct {
@@ -73,7 +80,7 @@ func Default() Config {
73 return Config{ 80 return Config{
74 Server: Server{Root: "/var/lib/gitbay"}, 81 Server: Server{Root: "/var/lib/gitbay"},
75 SSH: SSH{Mode: "embedded", Port: 22}, 82 SSH: SSH{Mode: "embedded", Port: 22},
76 HTTP: HTTP{Addr: ":443", TLS: "acme"}, 83 HTTP: HTTP{Addr: ":443", TLS: "acme", ACMEHTTPAddr: ":80"},
77 Web: Web{Mode: "view_only"}, 84 Web: Web{Mode: "view_only"},
78 Registration: Registration{ 85 Registration: Registration{
79 Mode: "closed", 86 Mode: "closed",
@@ -133,6 +140,15 @@ func (c Config) Validate() error {
133 if c.HTTP.TLS == "files" && (c.HTTP.CertFile == "" || c.HTTP.KeyFile == "") { 140 if c.HTTP.TLS == "files" && (c.HTTP.CertFile == "" || c.HTTP.KeyFile == "") {
134 errs = append(errs, errors.New("http.tls = \"files\" requires cert_file and key_file")) 141 errs = append(errs, errors.New("http.tls = \"files\" requires cert_file and key_file"))
135 } 142 }
143 if c.HTTP.TLS == "acme" {
144 host := c.SiteHost()
145 switch {
146 case !strings.HasPrefix(c.Server.SiteURL, "https://"):
147 errs = append(errs, errors.New("http.tls = \"acme\" requires an https:// site_url: certificates are issued for that host"))
148 case host == "" || host == "localhost" || net.ParseIP(host) != nil:
149 errs = append(errs, fmt.Errorf("http.tls = \"acme\" cannot issue a certificate for %q: use a public DNS name in site_url", host))
150 }
151 }
136 if err := oneOf("web.mode", c.Web.Mode, "view_only", "accounts"); err != nil { 152 if err := oneOf("web.mode", c.Web.Mode, "view_only", "accounts"); err != nil {
137 errs = append(errs, err) 153 errs = append(errs, err)
138 } 154 }
@@ -165,6 +181,19 @@ func (c Config) Validate() error {
165 return errors.Join(errs...) 181 return errors.Join(errs...)
166} 182}
167 183
184// SiteHost returns the bare hostname from site_url (no scheme, port, path).
185func (c Config) SiteHost() string {
186 h := strings.TrimPrefix(strings.TrimPrefix(c.Server.SiteURL, "https://"), "http://")
187 h = strings.TrimSuffix(h, "/")
188 if i := strings.IndexByte(h, '/'); i >= 0 {
189 h = h[:i]
190 }
191 if host, _, err := net.SplitHostPort(h); err == nil {
192 return host
193 }
194 return h
195}
196
168// CheckHost performs environment probes that only make sense on the target 197// CheckHost performs environment probes that only make sense on the target
169// machine: port availability for the embedded listener and root existence. 198// machine: port availability for the embedded listener and root existence.
170func (c Config) CheckHost() error { 199func (c Config) CheckHost() error {
internal/config/config_test.go +4
@@ -115,6 +115,10 @@ func TestValidCombinations(t *testing.T) {
115 "closed registration, no smtp at all", 115 "closed registration, no smtp at all",
116 minimal, 116 minimal,
117 }, 117 },
118 {
119 "acme with public https host",
120 "[server]\nroot = \"/var/lib/gitbay\"\nsite_url = \"https://gitbay.org\"\n[http]\ntls = \"acme\"\nacme_email = \"noreply@gitbay.org\"\n",
121 },
118 } 122 }
119 for _, tc := range cases { 123 for _, tc := range cases {
120 t.Run(tc.name, func(t *testing.T) { 124 t.Run(tc.name, func(t *testing.T) {