Commit 399d2f300e
Verified · cmc
Layout: unified · split
cmd/gitbayd/main.go +26 −3
| @@ -8,11 +8,12 @@ import ( | ||
| 8 | 8 | "net" |
| 9 | 9 | "net/http" |
| 10 | 10 | "os" |
| 11 | "strings" | |
| 12 | 11 | "path/filepath" |
| 13 | 12 | "strconv" |
| 13 | "strings" | |
| 14 | 14 | |
| 15 | 15 | "github.com/spf13/cobra" |
| 16 | "golang.org/x/crypto/acme/autocert" | |
| 16 | 17 | "golang.org/x/crypto/ssh" |
| 17 | 18 | |
| 18 | 19 | "gitbay.org/gitbay/internal/config" |
| @@ -146,8 +147,30 @@ func serveCmd() *cobra.Command { | ||
| 146 | 147 | errCh <- hs.ListenAndServe() |
| 147 | 148 | case "files": |
| 148 | 149 | errCh <- hs.ListenAndServeTLS(cfg.HTTP.CertFile, cfg.HTTP.KeyFile) |
| 149 | default: | |
| 150 | errCh <- fmt.Errorf("http.tls = %q not implemented yet; use \"files\" or \"off\"", cfg.HTTP.TLS) | |
| 150 | case "acme": | |
| 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 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 20 | func 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] | |
| 29 | root = %q | |
| 30 | site_url = "https://gitbay.example" | |
| 31 | [ssh] | |
| 32 | port = %d | |
| 33 | [http] | |
| 34 | addr = "127.0.0.1:%d" | |
| 35 | tls = "acme" | |
| 36 | acme_email = "noreply@gitbay.example" | |
| 37 | acme_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 | 26 | github.com/russross/blackfriday/v2 v2.1.0 // indirect |
| 27 | 27 | github.com/spf13/pflag v1.0.9 // indirect |
| 28 | 28 | go.yaml.in/yaml/v3 v3.0.4 // indirect |
| 29 | golang.org/x/net v0.57.0 // indirect | |
| 29 | 30 | golang.org/x/sys v0.47.0 // indirect |
| 31 | golang.org/x/text v0.41.0 // indirect | |
| 30 | 32 | modernc.org/libc v1.74.4 // indirect |
| 31 | 33 | modernc.org/mathutil v1.7.1 // indirect |
| 32 | 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= | ||
| 44 | 44 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= |
| 45 | 45 | golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= |
| 46 | 46 | golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= |
| 47 | golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= | |
| 48 | golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= | |
| 49 | golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= | |
| 50 | golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= | |
| 47 | golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= | |
| 48 | golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= | |
| 49 | golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= | |
| 50 | golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= | |
| 51 | golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= | |
| 52 | golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= | |
| 51 | 53 | golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= |
| 52 | 54 | golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| 53 | 55 | golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= |
| 54 | 56 | golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= |
| 55 | golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= | |
| 56 | golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= | |
| 57 | golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= | |
| 58 | golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= | |
| 59 | golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= | |
| 60 | golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= | |
| 57 | 61 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= |
| 58 | 62 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
| 59 | 63 | modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= |
internal/config/config.go +30 −1
| @@ -7,6 +7,7 @@ import ( | ||
| 7 | 7 | "net" |
| 8 | 8 | "os" |
| 9 | 9 | "strconv" |
| 10 | "strings" | |
| 10 | 11 | |
| 11 | 12 | "github.com/BurntSushi/toml" |
| 12 | 13 | ) |
| @@ -38,6 +39,12 @@ type HTTP struct { | ||
| 38 | 39 | TLS string `toml:"tls"` // acme | files | off |
| 39 | 40 | CertFile string `toml:"cert_file"` |
| 40 | 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 | |
| 43 | 50 | type GitDaemon struct { |
| @@ -73,7 +80,7 @@ func Default() Config { | ||
| 73 | 80 | return Config{ |
| 74 | 81 | Server: Server{Root: "/var/lib/gitbay"}, |
| 75 | 82 | SSH: SSH{Mode: "embedded", Port: 22}, |
| 76 | HTTP: HTTP{Addr: ":443", TLS: "acme"}, | |
| 83 | HTTP: HTTP{Addr: ":443", TLS: "acme", ACMEHTTPAddr: ":80"}, | |
| 77 | 84 | Web: Web{Mode: "view_only"}, |
| 78 | 85 | Registration: Registration{ |
| 79 | 86 | Mode: "closed", |
| @@ -133,6 +140,15 @@ func (c Config) Validate() error { | ||
| 133 | 140 | if c.HTTP.TLS == "files" && (c.HTTP.CertFile == "" || c.HTTP.KeyFile == "") { |
| 134 | 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 | 152 | if err := oneOf("web.mode", c.Web.Mode, "view_only", "accounts"); err != nil { |
| 137 | 153 | errs = append(errs, err) |
| 138 | 154 | } |
| @@ -165,6 +181,19 @@ func (c Config) Validate() error { | ||
| 165 | 181 | return errors.Join(errs...) |
| 166 | 182 | } |
| 167 | 183 | |
| 184 | // SiteHost returns the bare hostname from site_url (no scheme, port, path). | |
| 185 | func (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 | 197 | // CheckHost performs environment probes that only make sense on the target |
| 169 | 198 | // machine: port availability for the embedded listener and root existence. |
| 170 | 199 | func (c Config) CheckHost() error { |
internal/config/config_test.go +4
| @@ -115,6 +115,10 @@ func TestValidCombinations(t *testing.T) { | ||
| 115 | 115 | "closed registration, no smtp at all", |
| 116 | 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 | 123 | for _, tc := range cases { |
| 120 | 124 | t.Run(tc.name, func(t *testing.T) { |