internal/config/config.go
413 lines · 15101 bytes
1// Package config loads and validates the gitbayd server configuration.
2package config
3
4import (
5 "errors"
6 "fmt"
7 "net"
8 "os"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/BurntSushi/toml"
14)
15
16// DefaultWriteRate is the per-account write budget when the config leaves
17// write_rate at zero: generous for a person at a terminal, and a bound on
18// what one account can enqueue — every write also queues notification mail
19// and webhook deliveries.
20const DefaultWriteRate = 60
21
22type Config struct {
23 Server Server `toml:"server"`
24 SSH SSH `toml:"ssh"`
25 HTTP HTTP `toml:"http"`
26 GitDaemon GitDaemon `toml:"git_daemon"`
27 Web Web `toml:"web"`
28 Registration Registration `toml:"registration"`
29 API API `toml:"api"`
30 Webhooks Webhooks `toml:"webhooks"`
31 Pages Pages `toml:"pages"`
32 LFS LFS `toml:"lfs"`
33 Limits Limits `toml:"limits"`
34 Mail Mail `toml:"mail"`
35 Mirrors Mirrors `toml:"mirrors"`
36 Deps Deps `toml:"deps"`
37 Retention Retention `toml:"retention"`
38 // GoImport maps vanity Go module paths to repositories, e.g.
39 // "gitbay.org/gitbay" = "krz/gitbay". Requests carrying ?go-get=1
40 // under a mapped path get a go-import meta tag.
41 GoImport map[string]string `toml:"go_import"`
42}
43
44type Server struct {
45 Root string `toml:"root"`
46 SiteURL string `toml:"site_url"`
47
48 // SourceRepo names the repository this instance develops itself in, as
49 // "owner/name". When set, startup warns if the running build's commit is
50 // not on that repository's default branch. Empty disables the check, which
51 // is right for any instance that does not host its own source.
52 SourceRepo string `toml:"source_repo"`
53}
54
55type SSH struct {
56 Mode string `toml:"mode"` // embedded | system
57 Port int `toml:"port"`
58 HostKeys []string `toml:"host_keys"`
59}
60
61type HTTP struct {
62 Addr string `toml:"addr"`
63 TLS string `toml:"tls"` // acme | files | off
64 CertFile string `toml:"cert_file"`
65 KeyFile string `toml:"key_file"`
66 // ACME (Let's Encrypt by default). Certificates are cached under
67 // server.root/acme. acme_http_addr serves HTTP-01 challenges and
68 // redirects to HTTPS; "off" disables it (TLS-ALPN-01 on the HTTPS
69 // port still works).
70 ACMEEmail string `toml:"acme_email"`
71 ACMEHTTPAddr string `toml:"acme_http_addr"`
72 // TrustedProxies are the addresses or CIDRs of reverse proxies in front
73 // of this process. A request from one of them is attributed to the
74 // last X-Forwarded-For hop that is not itself a trusted proxy; from
75 // anyone else the peer address is the client and the header is
76 // ignored. Empty means no proxy, which is how gitbayd is deployed by
77 // default: it terminates TLS itself.
78 TrustedProxies []string `toml:"trusted_proxies,omitempty"`
79}
80
81type GitDaemon struct {
82 Enabled bool `toml:"enabled"`
83 Port int `toml:"port"`
84}
85
86type Web struct {
87 Mode string `toml:"mode"` // view_only | accounts
88 PasswordAuth bool `toml:"password_auth"`
89 // Title is the instance's display name in the header and page titles.
90 // Empty falls back to the site host.
91 Title string `toml:"title"`
92 // PrivacyNotice is operator-provided text shown on /privacy under the
93 // fixed project-level statement. Plain text; blank paragraphs split.
94 PrivacyNotice string `toml:"privacy_notice"`
95}
96
97type Registration struct {
98 Mode string `toml:"mode"` // closed | invite | open
99 // PendingExpiry is how long a self-registered account may stay
100 // unverified before it is removed, as a duration ("168h"). Empty
101 // keeps such accounts forever.
102 PendingExpiry string `toml:"pending_expiry"`
103 // NotifyAdmin mails the instance's admins when an account becomes
104 // active: an invite redeemed, or an open-mode signup that verified
105 // its address. The unverified row an open signup creates is not
106 // reported — anyone can post the form, so mailing on that would
107 // aim a flood at the admins (#234).
108 NotifyAdmin bool `toml:"notify_admin"`
109}
110
111// PendingExpiryDuration parses PendingExpiry; zero means never.
112func (r Registration) PendingExpiryDuration() time.Duration {
113 d, _ := time.ParseDuration(r.PendingExpiry)
114 return d
115}
116
117// Retention is how long the append-only tables keep a row. Each is a
118// duration string ("2160h"); empty or zero keeps forever, which is what
119// an instance that has never configured this gets. Expired sessions and
120// tokens are swept regardless: they are dead weight the moment they
121// expire and no setting makes them worth keeping.
122type Retention struct {
123 Audit string `toml:"audit"`
124 Events string `toml:"events"`
125 WebhookDeliveries string `toml:"webhook_deliveries"`
126 // Mail is the outbound queue: rows already sent or given up on.
127 Mail string `toml:"mail"`
128}
129
130// Durations parses the four, mapping each to zero when unset or bad.
131func (r Retention) Durations() (audit, events, deliveries, mail time.Duration) {
132 parse := func(s string) time.Duration {
133 d, err := time.ParseDuration(s)
134 if err != nil || d < 0 {
135 return 0
136 }
137 return d
138 }
139 return parse(r.Audit), parse(r.Events), parse(r.WebhookDeliveries), parse(r.Mail)
140}
141
142// LFS stores large-file objects content-addressed under Root (default
143// <server.root>/lfs). MaxObjectBytes caps a single object; 0 means the
144// 512MB default.
145type LFS struct {
146 Root string `toml:"root"`
147 MaxObjectBytes int64 `toml:"max_object_bytes"`
148}
149
150// Pages serves each public repo's `pages` branch as a static site on
151// <owner>.<domain> — a separate origin, so page-authored scripts never run
152// on the forge's own host. Empty domain disables the feature.
153type Pages struct {
154 Domain string `toml:"domain"`
155}
156
157// API controls the HTTPS/JSON control-plane API (bearer tokens minted over
158// SSH). Off by default: an instance that never enables it has no
159// credential-bearing HTTP surface at all.
160type API struct {
161 Enabled bool `toml:"enabled"`
162}
163
164// Webhooks controls outbound delivery. AllowLocal permits endpoints on
165// loopback/private addresses (off by default: SSRF).
166type Webhooks struct {
167 AllowLocal bool `toml:"allow_local"`
168}
169
170type Mirrors struct {
171 PullIntervalMinutes int `toml:"pull_interval_minutes"`
172}
173
174// Deps configures the dependency-update sweep. It runs only for repos that
175// have opted in with `repo deps enable`, because checking a private repo
176// tells a public registry what it depends on.
177type Deps struct {
178 CheckIntervalHours int `toml:"check_interval_hours"`
179}
180
181type Limits struct {
182 MaxPackBytes int64 `toml:"max_pack_bytes"`
183 MaxBlobBytes int64 `toml:"max_blob_bytes"`
184 MaxAssetBytes int64 `toml:"max_asset_bytes"` // per release asset
185 MaxSnippetBytes int64 `toml:"max_snippet_bytes"` // per snippet file
186 // MaxSnippetsPerUser caps snippets an account may own. 0 means
187 // unlimited, like MaxReposPerUser.
188 MaxSnippetsPerUser int `toml:"max_snippets_per_user"`
189 CloneTimeoutSec int `toml:"clone_timeout"`
190 SSHAuthRate int `toml:"ssh_auth_rate"`
191 // APIRate is sustained JSON-API requests per minute per caller; writes
192 // draw on a tenth of it. 0 uses the default.
193 APIRate int `toml:"api_rate"`
194 // WriteRate is sustained mutating commands per minute per account,
195 // counted in the dispatcher so every surface shares one budget. 0 uses
196 // the default; a negative value turns the limit off.
197 WriteRate int `toml:"write_rate"`
198 // Per-account quotas on what a user owns directly (organizations are
199 // not capped). 0 means unlimited; admin user limits overrides per
200 // account.
201 MaxReposPerUser int `toml:"max_repos_per_user"`
202 MaxBytesPerUser int64 `toml:"max_bytes_per_user"`
203}
204
205type Mail struct {
206 SMTPHost string `toml:"smtp_host"` // host:port (port defaults to 587)
207 From string `toml:"from"`
208 SMTPUser string `toml:"smtp_user,omitempty"`
209 SMTPPass string `toml:"smtp_pass,omitempty"`
210}
211
212// Default returns the configuration used when a key is absent from the file.
213func Default() Config {
214 return Config{
215 Server: Server{Root: "/var/lib/gitbay"},
216 SSH: SSH{Mode: "embedded", Port: 22},
217 HTTP: HTTP{Addr: ":443", TLS: "acme", ACMEHTTPAddr: ":80"},
218 Web: Web{Mode: "view_only"},
219 Registration: Registration{
220 Mode: "closed",
221 },
222 GitDaemon: GitDaemon{Port: 9418},
223 Mirrors: Mirrors{PullIntervalMinutes: 15},
224 Deps: Deps{CheckIntervalHours: 24},
225 Limits: Limits{
226 MaxPackBytes: 2 << 30, // 2 GiB
227 MaxBlobBytes: 100 << 20,
228 MaxAssetBytes: 512 << 20,
229 MaxSnippetBytes: 1 << 20,
230 CloneTimeoutSec: 3600,
231 SSHAuthRate: 10,
232 APIRate: 120,
233 },
234 }
235}
236
237// Load reads path, applies defaults, and validates. It does not probe the
238// host (see CheckHost) so it is safe in tests and on non-target machines.
239func Load(path string) (Config, error) {
240 cfg := Default()
241 md, err := toml.DecodeFile(path, &cfg)
242 if err != nil {
243 return cfg, err
244 }
245 if u := md.Undecoded(); len(u) > 0 {
246 return cfg, fmt.Errorf("unknown config key %q", u[0].String())
247 }
248 return cfg, cfg.Validate()
249}
250
251func oneOf(field, val string, allowed ...string) error {
252 for _, a := range allowed {
253 if val == a {
254 return nil
255 }
256 }
257 return fmt.Errorf("%s must be one of %v, got %q", field, allowed, val)
258}
259
260// Validate applies the static contradiction checks from the plan.
261func (c Config) Validate() error {
262 var errs []error
263
264 if c.Server.Root == "" {
265 errs = append(errs, errors.New("server.root is required"))
266 }
267 if d := c.Pages.Domain; d != "" {
268 if d == c.SiteHost() {
269 errs = append(errs, errors.New("pages.domain must differ from the site host: pages serve repo-authored scripts, which must not run on the forge's origin"))
270 }
271 if strings.HasSuffix(c.SiteHost(), "."+d) {
272 errs = append(errs, errors.New("pages.domain must not be a parent of the site host"))
273 }
274 }
275 if c.Server.SiteURL == "" {
276 errs = append(errs, errors.New("server.site_url is required"))
277 }
278 if err := oneOf("ssh.mode", c.SSH.Mode, "embedded", "system"); err != nil {
279 errs = append(errs, err)
280 }
281 if c.Registration.PendingExpiry != "" {
282 if d, err := time.ParseDuration(c.Registration.PendingExpiry); err != nil || d <= 0 {
283 errs = append(errs, fmt.Errorf("registration.pending_expiry %q must be a positive duration such as 168h", c.Registration.PendingExpiry))
284 }
285 }
286 if c.Limits.MaxReposPerUser < 0 || c.Limits.MaxBytesPerUser < 0 || c.Limits.MaxSnippetsPerUser < 0 {
287 errs = append(errs, errors.New("limits.max_repos_per_user, max_bytes_per_user and max_snippets_per_user must not be negative"))
288 }
289 if c.SSH.Port < 1 || c.SSH.Port > 65535 {
290 errs = append(errs, fmt.Errorf("ssh.port %d out of range", c.SSH.Port))
291 }
292 if err := oneOf("http.tls", c.HTTP.TLS, "acme", "files", "off"); err != nil {
293 errs = append(errs, err)
294 }
295 if _, err := c.HTTP.TrustedProxyNets(); err != nil {
296 errs = append(errs, err)
297 }
298 if c.HTTP.TLS == "files" && (c.HTTP.CertFile == "" || c.HTTP.KeyFile == "") {
299 errs = append(errs, errors.New("http.tls = \"files\" requires cert_file and key_file"))
300 }
301 if c.HTTP.TLS == "acme" {
302 host := c.SiteHost()
303 switch {
304 case !strings.HasPrefix(c.Server.SiteURL, "https://"):
305 errs = append(errs, errors.New("http.tls = \"acme\" requires an https:// site_url: certificates are issued for that host"))
306 case host == "" || host == "localhost" || net.ParseIP(host) != nil:
307 errs = append(errs, fmt.Errorf("http.tls = \"acme\" cannot issue a certificate for %q: use a public DNS name in site_url", host))
308 }
309 }
310 if err := oneOf("web.mode", c.Web.Mode, "view_only", "accounts"); err != nil {
311 errs = append(errs, err)
312 }
313 if err := oneOf("registration.mode", c.Registration.Mode, "closed", "invite", "open"); err != nil {
314 errs = append(errs, err)
315 }
316
317 for module, repo := range c.GoImport {
318 host, _, ok := strings.Cut(module, "/")
319 if !ok || host == "" || !strings.Contains(host, ".") {
320 errs = append(errs, fmt.Errorf("go_import key %q must be host/path (e.g. gitbay.org/gitbay)", module))
321 }
322 if parts := strings.Split(repo, "/"); len(parts) != 2 || parts[0] == "" || parts[1] == "" {
323 errs = append(errs, fmt.Errorf("go_import value %q must be owner/name", repo))
324 }
325 }
326
327 // Contradictions.
328 if c.Mail.SMTPHost != "" && c.Mail.From == "" {
329 errs = append(errs, errors.New("[mail] from is required when smtp_host is set"))
330 }
331 if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" {
332 errs = append(errs, fmt.Errorf(
333 "registration.mode = %q requires [mail] smtp_host: email verification cannot run without SMTP",
334 c.Registration.Mode))
335 }
336 if c.Registration.NotifyAdmin && c.Mail.SMTPHost == "" {
337 errs = append(errs, errors.New(
338 "registration.notify_admin = true requires [mail] smtp_host: there is nowhere to send the notice"))
339 }
340 if c.SSH.Mode == "system" && c.Registration.Mode != "closed" {
341 errs = append(errs, fmt.Errorf(
342 "ssh.mode = \"system\" requires registration.mode = \"closed\": host sshd rejects unknown keys before the dispatcher runs, so registration by unknown key is impossible"))
343 }
344 if c.Web.PasswordAuth && c.Web.Mode == "view_only" {
345 errs = append(errs, errors.New(
346 "web.password_auth = true is meaningless with web.mode = \"view_only\": no login route exists"))
347 }
348 if c.Web.PasswordAuth && c.Web.Mode == "accounts" {
349 errs = append(errs, errors.New(
350 "web.password_auth is not implemented yet; browser sessions are minted over SSH (gitbay web login)"))
351 }
352
353 return errors.Join(errs...)
354}
355
356// SiteHost returns the bare hostname from site_url (no scheme, port, path).
357func (c Config) SiteHost() string {
358 h := strings.TrimPrefix(strings.TrimPrefix(c.Server.SiteURL, "https://"), "http://")
359 h = strings.TrimSuffix(h, "/")
360 if i := strings.IndexByte(h, '/'); i >= 0 {
361 h = h[:i]
362 }
363 if host, _, err := net.SplitHostPort(h); err == nil {
364 return host
365 }
366 return h
367}
368
369// CheckHost performs environment probes that only make sense on the target
370// machine: port availability for the embedded listener and root existence.
371func (c Config) CheckHost() error {
372 var errs []error
373
374 if st, err := os.Stat(c.Server.Root); err != nil {
375 errs = append(errs, fmt.Errorf("server.root: %w", err))
376 } else if !st.IsDir() {
377 errs = append(errs, fmt.Errorf("server.root %q is not a directory", c.Server.Root))
378 }
379
380 if c.SSH.Mode == "embedded" {
381 addr := net.JoinHostPort("", strconv.Itoa(c.SSH.Port))
382 ln, err := net.Listen("tcp", addr)
383 if err != nil {
384 errs = append(errs, fmt.Errorf("ssh.port %d is not bindable (already in use by another daemon?): %w", c.SSH.Port, err))
385 } else {
386 ln.Close()
387 }
388 }
389
390 return errors.Join(errs...)
391}
392
393// TrustedProxyNets parses http.trusted_proxies; a bare address is a /32
394// or /128.
395func (h HTTP) TrustedProxyNets() ([]*net.IPNet, error) {
396 var nets []*net.IPNet
397 for _, p := range h.TrustedProxies {
398 if _, n, err := net.ParseCIDR(p); err == nil {
399 nets = append(nets, n)
400 continue
401 }
402 ip := net.ParseIP(p)
403 if ip == nil {
404 return nil, fmt.Errorf("http.trusted_proxies: %q is not an address or CIDR", p)
405 }
406 bits := 32
407 if ip.To4() == nil {
408 bits = 128
409 }
410 nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
411 }
412 return nets, nil
413}