internal/config/config.go

857 lines · 32180 bytes

  1// Package config loads and validates the gitbayd server configuration.
  2package config
  3
  4import (
  5	"crypto/ecdsa"
  6	"crypto/x509"
  7	"encoding/pem"
  8	"errors"
  9	"fmt"
 10	"io"
 11	"math"
 12	"net"
 13	"os"
 14	"path/filepath"
 15	"strconv"
 16	"strings"
 17	"time"
 18
 19	"filippo.io/age"
 20	"github.com/BurntSushi/toml"
 21)
 22
 23// DefaultWriteRate is the per-account write budget when the config leaves
 24// write_rate at zero: generous for a person at a terminal, and a bound on
 25// what one account can enqueue — every write also queues notification mail
 26// and webhook deliveries.
 27const DefaultWriteRate = 60
 28
 29// Pack generation defaults for a four-core host: a full clone of a large
 30// repository runs git at about 1.5 cores (Performance wiki page).
 31const (
 32	DefaultPackConcurrency  = 3
 33	DefaultPackPerPrincipal = 2
 34	DefaultPackQueue        = 32
 35	DefaultPackQueueWait    = time.Minute
 36)
 37
 38// Push defaults: receive-pack indexes what it is sent, a core each for a
 39// large push, and has its own budget so clones and pushes cannot starve
 40// each other.
 41const (
 42	DefaultPushConcurrency  = 2
 43	DefaultPushPerPrincipal = 1
 44	DefaultPushQueue        = 16
 45	DefaultPushQueueWait    = time.Minute
 46	// A push is killed when nothing has moved either way for
 47	// DefaultPushIdle, or when its pre-receive has not started
 48	// DefaultPushReceiveTimeout after it took its slot.
 49	DefaultPushIdle           = time.Minute
 50	DefaultPushReceiveTimeout = 15 * time.Minute
 51)
 52
 53type Config struct {
 54	Server       Server       `toml:"server"`
 55	SSH          SSH          `toml:"ssh"`
 56	HTTP         HTTP         `toml:"http"`
 57	GitDaemon    GitDaemon    `toml:"git_daemon"`
 58	Web          Web          `toml:"web"`
 59	Registration Registration `toml:"registration"`
 60	API          API          `toml:"api"`
 61	Webhooks     Webhooks     `toml:"webhooks"`
 62	Pages        Pages        `toml:"pages"`
 63	LFS          LFS          `toml:"lfs"`
 64	Limits       Limits       `toml:"limits"`
 65	Mail         Mail         `toml:"mail"`
 66	Mirrors      Mirrors      `toml:"mirrors"`
 67	Deps         Deps         `toml:"deps"`
 68	Retention    Retention    `toml:"retention"`
 69	Push         Push         `toml:"push"`
 70	Backup       Backup       `toml:"backup"`
 71	// GoImport maps vanity Go module paths to repositories, e.g.
 72	// "gitbay.org/gitbay" = "krz/gitbay". Requests carrying ?go-get=1
 73	// under a mapped path get a go-import meta tag.
 74	GoImport map[string]string `toml:"go_import"`
 75}
 76
 77type Server struct {
 78	Root    string `toml:"root"`
 79	SiteURL string `toml:"site_url"`
 80
 81	// SourceRepo names the repository this instance develops itself in, as
 82	// "owner/name". When set, startup warns if the running build's commit is
 83	// not on that repository's default branch. Empty disables the check, which
 84	// is right for any instance that does not host its own source.
 85	SourceRepo string `toml:"source_repo"`
 86
 87	// SecretKeyFile holds the keys that seal the secret columns of the
 88	// database (internal/seal). It lives outside Root, so neither a
 89	// backup archive nor a snapshot of Root carries it.
 90	SecretKeyFile string `toml:"secret_key_file"`
 91}
 92
 93type SSH struct {
 94	Mode     string   `toml:"mode"` // embedded | system
 95	Port     int      `toml:"port"`
 96	HostKeys []string `toml:"host_keys"`
 97}
 98
 99type HTTP struct {
100	Addr     string `toml:"addr"`
101	TLS      string `toml:"tls"` // acme | files | off
102	CertFile string `toml:"cert_file"`
103	KeyFile  string `toml:"key_file"`
104	// ACME (Let's Encrypt by default). Certificates are cached under
105	// server.root/acme. acme_http_addr serves HTTP-01 challenges and
106	// redirects to HTTPS; "off" disables it (TLS-ALPN-01 on the HTTPS
107	// port still works).
108	ACMEEmail    string `toml:"acme_email"`
109	ACMEHTTPAddr string `toml:"acme_http_addr"`
110	// TrustedProxies are the addresses or CIDRs of reverse proxies in front
111	// of this process. A request from one of them is attributed to the
112	// last X-Forwarded-For hop that is not itself a trusted proxy; from
113	// anyone else the peer address is the client and the header is
114	// ignored. Empty means no proxy, which is how gitbayd is deployed by
115	// default: it terminates TLS itself.
116	TrustedProxies []string `toml:"trusted_proxies,omitempty"`
117}
118
119type GitDaemon struct {
120	Enabled bool `toml:"enabled"`
121	Port    int  `toml:"port"`
122}
123
124type Web struct {
125	Mode         string `toml:"mode"` // view_only | accounts
126	PasswordAuth bool   `toml:"password_auth"`
127	// Title is the instance's display name in the header and page titles.
128	// Empty falls back to the site host.
129	Title string `toml:"title"`
130	// PrivacyNotice is operator-provided text shown on /privacy under the
131	// fixed project-level statement. Plain text; blank paragraphs split.
132	PrivacyNotice string `toml:"privacy_notice"`
133	// AbuseURL and TermsURL, when set, are linked from every page's
134	// footer: where to report abuse, and the instance's terms of use.
135	AbuseURL string `toml:"abuse_url"`
136	TermsURL string `toml:"terms_url"`
137	// AppleAppIDs are the iOS apps (TEAMID.bundle.id) that may open this
138	// instance's links, served in /.well-known/apple-app-site-association.
139	// Empty leaves the route unregistered.
140	AppleAppIDs []string `toml:"apple_app_ids"`
141}
142
143type Registration struct {
144	Mode string `toml:"mode"` // closed | invite | open
145	// PendingExpiry is how long a self-registered account may stay
146	// unverified before it is removed, as a duration ("168h"). Empty
147	// keeps such accounts forever.
148	PendingExpiry string `toml:"pending_expiry"`
149	// NotifyAdmin mails the instance's admins when an account becomes
150	// active: an invite redeemed, or an open-mode signup that verified
151	// its address. The unverified row an open signup creates is not
152	// reported — anyone can post the form, so mailing on that would
153	// aim a flood at the admins (#234).
154	NotifyAdmin bool `toml:"notify_admin"`
155}
156
157// PendingExpiryDuration parses PendingExpiry; zero means never.
158func (r Registration) PendingExpiryDuration() time.Duration {
159	d, _ := time.ParseDuration(r.PendingExpiry)
160	return d
161}
162
163// Retention is how long the append-only tables keep a row. Each is a
164// duration string ("2160h"); empty or zero keeps forever, which is what
165// an instance that has never configured this gets. Expired sessions and
166// tokens are swept regardless: they are dead weight the moment they
167// expire and no setting makes them worth keeping.
168type Retention struct {
169	Audit             string `toml:"audit"`
170	Events            string `toml:"events"`
171	WebhookDeliveries string `toml:"webhook_deliveries"`
172	// Mail is the outbound queue: rows already sent or given up on.
173	Mail string `toml:"mail"`
174	// Push is the outbound device queue: rows already sent or given up on.
175	Push string `toml:"push"`
176}
177
178// Durations parses the five, mapping each to zero when unset or bad.
179func (r Retention) Durations() (audit, events, deliveries, mail, push time.Duration) {
180	parse := func(s string) time.Duration {
181		d, err := time.ParseDuration(s)
182		if err != nil || d < 0 {
183			return 0
184		}
185		return d
186	}
187	return parse(r.Audit), parse(r.Events), parse(r.WebhookDeliveries), parse(r.Mail), parse(r.Push)
188}
189
190// LFS stores large-file objects content-addressed under Root (default
191// <server.root>/lfs). MaxObjectBytes caps a single object; 0 means the
192// 512MB default.
193type LFS struct {
194	Root           string `toml:"root"`
195	MaxObjectBytes int64  `toml:"max_object_bytes"`
196}
197
198// Pages serves each public repo's `pages` branch as a static site on
199// <owner>.<domain> — a separate origin, so page-authored scripts never run
200// on the forge's own host. Empty domain disables the feature.
201type Pages struct {
202	Domain string `toml:"domain"`
203}
204
205// API controls the HTTPS/JSON control-plane API (bearer tokens minted over
206// SSH). Off by default: an instance that never enables it has no
207// credential-bearing HTTP surface at all.
208type API struct {
209	Enabled bool `toml:"enabled"`
210}
211
212// Webhooks controls outbound delivery. AllowLocal permits endpoints on
213// loopback/private addresses (off by default: SSRF).
214type Webhooks struct {
215	AllowLocal bool `toml:"allow_local"`
216}
217
218type Mirrors struct {
219	PullIntervalMinutes int `toml:"pull_interval_minutes"`
220}
221
222// Deps configures the dependency-update sweep. It runs only for repos that
223// have opted in with `repo deps enable`, because checking a private repo
224// tells a public registry what it depends on.
225type Deps struct {
226	CheckIntervalHours int `toml:"check_interval_hours"`
227}
228
229type Limits struct {
230	MaxPackBytes    int64 `toml:"max_pack_bytes"`
231	MaxBlobBytes    int64 `toml:"max_blob_bytes"`
232	MaxAssetBytes   int64 `toml:"max_asset_bytes"`   // per release asset
233	MaxSnippetBytes int64 `toml:"max_snippet_bytes"` // per snippet file
234	// MaxSnippetsPerUser caps snippets an account may own. 0 means
235	// unlimited, like MaxReposPerUser.
236	MaxSnippetsPerUser int `toml:"max_snippets_per_user"`
237	CloneTimeoutSec    int `toml:"clone_timeout"`
238	SSHAuthRate        int `toml:"ssh_auth_rate"`
239	// APIRate is sustained JSON-API requests per minute per caller; writes
240	// draw on a tenth of it. 0 uses the default.
241	APIRate int `toml:"api_rate"`
242	// WriteRate is sustained mutating commands per minute per account,
243	// counted in the dispatcher so every surface shares one budget. 0 uses
244	// the default; a negative value turns the limit off.
245	WriteRate int `toml:"write_rate"`
246	// Quotas on what a user or an org owns directly, and on the orgs an
247	// account creates. 0 means unlimited; admin user limits and admin org
248	// limits override per owner.
249	MaxReposPerUser int   `toml:"max_repos_per_user"`
250	MaxBytesPerUser int64 `toml:"max_bytes_per_user"`
251	MaxOrgsPerUser  int   `toml:"max_orgs_per_user"`
252	MaxReposPerOrg  int   `toml:"max_repos_per_org"`
253	MaxBytesPerOrg  int64 `toml:"max_bytes_per_org"`
254	// PackConcurrency caps git pack generation (upload-pack and
255	// upload-archive) running at once across SSH, smart HTTP and git://.
256	// PackPerPrincipal caps it per account, or per client address on the
257	// anonymous transports. PackQueue is how many may wait for a slot,
258	// for at most PackQueueWait ("60s"). For the three counts 0 takes the
259	// default and a negative value turns that bound off.
260	PackConcurrency  int    `toml:"pack_concurrency"`
261	PackPerPrincipal int    `toml:"pack_per_principal"`
262	PackQueue        int    `toml:"pack_queue"`
263	PackQueueWait    string `toml:"pack_queue_wait"`
264	// PushConcurrency caps receive-pack running at once over SSH, with
265	// its hooks; PushPerPrincipal caps it per account or deploy key.
266	// PushQueue and PushQueueWait bound the wait for a slot. The counts
267	// read like the pack_* ones: 0 takes the default, negative is off.
268	PushConcurrency  int    `toml:"push_concurrency"`
269	PushPerPrincipal int    `toml:"push_per_principal"`
270	PushQueue        int    `toml:"push_queue"`
271	PushQueueWait    string `toml:"push_queue_wait"`
272	// PushIdle ("60s") kills a push when no byte has been read from the
273	// client and none written to it for that long, counted from when
274	// the pack begins or pre-receive starts; receive-pack sends a
275	// keepalive while it indexes and runs hooks. PushReceiveTimeout
276	// ("15m") kills a push whose pre-receive has not started that long
277	// after it took its slot, bounding how long a pack may take to
278	// arrive. Both apply only where the push limit is in force.
279	PushIdle           string `toml:"push_idle"`
280	PushReceiveTimeout string `toml:"push_receive_timeout"`
281}
282
283// PackLimits resolves the pack_* settings for packlimit.New. A zero
284// max or per is no bound; an unbounded queue is math.MaxInt, since
285// packlimit reads a zero queue as no queue at all.
286func (l Limits) PackLimits() (max, per, queue int, wait time.Duration) {
287	return resolveLimits(l.PackConcurrency, l.PackPerPrincipal, l.PackQueue, l.PackQueueWait,
288		DefaultPackConcurrency, DefaultPackPerPrincipal, DefaultPackQueue, DefaultPackQueueWait)
289}
290
291// PushLimits resolves the push_* settings the way PackLimits does the
292// pack_* ones.
293func (l Limits) PushLimits() (max, per, queue int, wait time.Duration) {
294	return resolveLimits(l.PushConcurrency, l.PushPerPrincipal, l.PushQueue, l.PushQueueWait,
295		DefaultPushConcurrency, DefaultPushPerPrincipal, DefaultPushQueue, DefaultPushQueueWait)
296}
297
298// PushTimeouts resolves push_idle and push_receive_timeout.
299func (l Limits) PushTimeouts() (idle, receive time.Duration) {
300	idle, receive = DefaultPushIdle, DefaultPushReceiveTimeout
301	if d, err := time.ParseDuration(l.PushIdle); err == nil && d > 0 {
302		idle = d
303	}
304	if d, err := time.ParseDuration(l.PushReceiveTimeout); err == nil && d > 0 {
305		receive = d
306	}
307	return idle, receive
308}
309
310func resolveLimits(maxV, perV, queueV int, waitV string, maxDef, perDef, queueDef int, waitDef time.Duration) (max, per, queue int, wait time.Duration) {
311	pick := func(v, def int) int {
312		switch {
313		case v == 0:
314			return def
315		case v < 0:
316			return 0
317		}
318		return v
319	}
320	queue = pick(queueV, queueDef)
321	if queueV < 0 {
322		queue = math.MaxInt
323	}
324	wait = waitDef
325	if d, err := time.ParseDuration(waitV); err == nil && d > 0 {
326		wait = d
327	}
328	return pick(maxV, maxDef), pick(perV, perDef), queue, wait
329}
330
331type Mail struct {
332	SMTPHost string `toml:"smtp_host"` // host:port (port defaults to 587, 465 with tls = "implicit")
333	From     string `toml:"from"`
334	SMTPUser string `toml:"smtp_user,omitempty"`
335	SMTPPass string `toml:"smtp_pass,omitempty"`
336	// RequireTLS fails delivery when the relay does not offer STARTTLS,
337	// instead of sending in clear. Unset, it is on for any relay but
338	// localhost or a loopback address (TLSRequired).
339	RequireTLS *bool `toml:"require_tls,omitempty"`
340	// TLS is "starttls" (the default, also when empty) or "implicit":
341	// TLS from the first byte, as relays on port 465 expect.
342	TLS string `toml:"tls,omitempty"`
343	// Inbound polls a mailbox for replies to notification mail (#295).
344	Inbound MailInbound `toml:"inbound"`
345}
346
347// MailInbound is the IMAP mailbox replies to notification mail arrive
348// in. Off unless enabled. The connection is always encrypted; there is
349// no setting for plaintext.
350type MailInbound struct {
351	Enabled bool `toml:"enabled"`
352	// IMAPHost is host:port; the port defaults to 993 with tls =
353	// "implicit" (the default) and 143 with tls = "starttls".
354	IMAPHost string `toml:"imap_host"`
355	TLS      string `toml:"tls"`
356	User     string `toml:"user"`
357	// PasswordFile holds the mailbox password, read at each connection.
358	// Never inline in this file.
359	PasswordFile string `toml:"password_file"`
360	Mailbox      string `toml:"mailbox"`       // default INBOX
361	PollInterval string `toml:"poll_interval"` // default 1m
362	// ReplyAddress is the address a notification's Reply-To is built
363	// from: reply@example.org becomes reply+<token>@example.org, so the
364	// mailbox must receive plus-addressed mail for it (or a catch-all).
365	ReplyAddress string `toml:"reply_address"`
366	// TrustedAuthservID is the authserv-id the mail host writes in its
367	// Authentication-Results header. When set, a reply must carry DMARC
368	// pass, or an aligned DKIM pass, in the topmost such header. Only
369	// safe when the mail host removes incoming headers claiming its id.
370	TrustedAuthservID string `toml:"trusted_authserv_id"`
371	// RequireDKIM makes a reply need a DKIM signature, verified by
372	// gitbayd, that covers From and whose d= is in relaxed alignment
373	// with the From domain. With TrustedAuthservID also set, either
374	// passing is enough.
375	RequireDKIM bool `toml:"require_dkim"`
376}
377
378// Authenticated reports whether a reply's From is checked at all.
379func (m MailInbound) Authenticated() bool {
380	return m.TrustedAuthservID != "" || m.RequireDKIM
381}
382
383// DefaultInboundPoll is the poll interval when poll_interval is unset.
384const DefaultInboundPoll = time.Minute
385
386// Poll is the configured poll interval.
387func (m MailInbound) Poll() time.Duration {
388	if d, err := time.ParseDuration(m.PollInterval); err == nil && d > 0 {
389		return d
390	}
391	return DefaultInboundPoll
392}
393
394// Addr is IMAPHost with the default port filled in.
395func (m MailInbound) Addr() string {
396	if _, _, err := net.SplitHostPort(m.IMAPHost); err == nil {
397		return m.IMAPHost
398	}
399	if m.TLS == "starttls" {
400		return net.JoinHostPort(m.IMAPHost, "143")
401	}
402	return net.JoinHostPort(m.IMAPHost, "993")
403}
404
405// MailboxName is Mailbox, INBOX when unset.
406func (m MailInbound) MailboxName() string {
407	if m.Mailbox == "" {
408		return "INBOX"
409	}
410	return m.Mailbox
411}
412
413// Password reads PasswordFile: its first line, which must be all it
414// holds. The file must be readable by its owner alone.
415func (m MailInbound) Password() (string, error) {
416	f, err := os.Open(m.PasswordFile)
417	if err != nil {
418		return "", fmt.Errorf("mail.inbound.password_file: %w", err)
419	}
420	defer f.Close()
421	fi, err := f.Stat()
422	if err != nil {
423		return "", fmt.Errorf("mail.inbound.password_file: %w", err)
424	}
425	if perm := fi.Mode().Perm(); perm&0o077 != 0 {
426		return "", fmt.Errorf("mail.inbound.password_file %s is mode %04o; it must be readable by its owner alone (0600)", m.PasswordFile, perm)
427	}
428	raw, err := io.ReadAll(io.LimitReader(f, 4097))
429	if err != nil {
430		return "", fmt.Errorf("mail.inbound.password_file: %w", err)
431	}
432	pass := strings.TrimRight(string(raw), "\r\n")
433	if pass == "" || len(raw) > 4096 || strings.ContainsAny(pass, "\r\n") {
434		return "", fmt.Errorf("mail.inbound.password_file %s must hold the password on one line", m.PasswordFile)
435	}
436	return pass, nil
437}
438
439func (m MailInbound) validate() []error {
440	if !m.Enabled {
441		return nil
442	}
443	var errs []error
444	for _, f := range []struct{ name, val string }{
445		{"mail.inbound.imap_host", m.IMAPHost},
446		{"mail.inbound.user", m.User},
447		{"mail.inbound.password_file", m.PasswordFile},
448		{"mail.inbound.reply_address", m.ReplyAddress},
449	} {
450		if f.val == "" {
451			errs = append(errs, fmt.Errorf("%s is required when mail.inbound.enabled", f.name))
452		}
453	}
454	if t := m.TLS; t != "" && t != "implicit" && t != "starttls" {
455		errs = append(errs, fmt.Errorf("mail.inbound.tls must be implicit or starttls, got %q: IMAP in clear is not supported", t))
456	}
457	if m.PollInterval != "" {
458		if d, err := time.ParseDuration(m.PollInterval); err != nil || d < 10*time.Second {
459			errs = append(errs, fmt.Errorf("mail.inbound.poll_interval %q must be a duration of at least 10s", m.PollInterval))
460		}
461	}
462	if id := m.TrustedAuthservID; id != "" && strings.ContainsAny(id, " \t;()\"\r\n") {
463		errs = append(errs, fmt.Errorf("mail.inbound.trusted_authserv_id %q must be a bare host name such as mx.google.com", id))
464	}
465	if a := m.ReplyAddress; a != "" {
466		local, domain, ok := strings.Cut(a, "@")
467		if !ok || local == "" || domain == "" || strings.ContainsAny(a, "+ <>\"\r\n") || strings.Contains(domain, "@") {
468			errs = append(errs, fmt.Errorf("mail.inbound.reply_address %q must be a bare address such as reply@example.org, with no + in it", a))
469		}
470	}
471	return errs
472}
473
474// TLSRequired reports whether mail must not go to the relay in clear.
475func (m Mail) TLSRequired() bool {
476	if m.RequireTLS != nil {
477		return *m.RequireTLS
478	}
479	host := m.SMTPHost
480	if h, _, err := net.SplitHostPort(host); err == nil {
481		host = h
482	}
483	host = strings.Trim(host, "[]")
484	if host == "localhost" {
485		return false
486	}
487	ip := net.ParseIP(host)
488	return ip == nil || !ip.IsLoopback()
489}
490
491// Push is APNs delivery to registered Apple devices. A key belongs to a
492// bundle ID, so an instance pushes to the app built under the topic named
493// here and no other; a self-hoster points this at their own key and their
494// own build.
495type Push struct {
496	Enabled bool   `toml:"enabled"`
497	KeyFile string `toml:"key_file"`
498	KeyID   string `toml:"key_id"`
499	TeamID  string `toml:"team_id"`
500	Topic   string `toml:"topic"` // the app's bundle identifier
501	// Environment is a name rather than a URL so a typo cannot aim the
502	// key at a host that is not Apple's.
503	Environment string `toml:"environment"` // production | sandbox
504}
505
506// Host is the APNs endpoint for the configured environment.
507// GITBAY_APNS_HOST overrides it for tests, as GITBAY_SWEEP_TICK does for
508// the retention sweep.
509func (p Push) Host() string {
510	if h := os.Getenv("GITBAY_APNS_HOST"); h != "" {
511		return h
512	}
513	if p.Environment == "sandbox" {
514		return "api.sandbox.push.apple.com"
515	}
516	return "api.push.apple.com"
517}
518
519// LoadAPNSKey reads Apple's .p8 provider key: a PEM-wrapped PKCS#8
520// P-256 private key. Read at startup and validated there, so a
521// misconfigured [push] refuses to start rather than filling a queue
522// nobody is watching.
523func LoadAPNSKey(path string) (*ecdsa.PrivateKey, error) {
524	data, err := os.ReadFile(path)
525	if err != nil {
526		return nil, err
527	}
528	block, _ := pem.Decode(data)
529	if block == nil {
530		return nil, errors.New("not PEM")
531	}
532	any, err := x509.ParsePKCS8PrivateKey(block.Bytes)
533	if err != nil {
534		return nil, err
535	}
536	key, ok := any.(*ecdsa.PrivateKey)
537	if !ok {
538		return nil, errors.New("not an EC private key")
539	}
540	return key, nil
541}
542
543// Backup configures gitbayd admin backup.
544type Backup struct {
545	// AgeRecipients, when set, encrypts every archive to these age
546	// public keys (age1...). The matching identities stay off the host,
547	// so the host writes archives it cannot read.
548	AgeRecipients []string `toml:"age_recipients"`
549}
550
551// Recipients parses AgeRecipients.
552func (b Backup) Recipients() ([]age.Recipient, error) {
553	var rs []age.Recipient
554	for _, s := range b.AgeRecipients {
555		r, err := age.ParseX25519Recipient(s)
556		if err != nil {
557			return nil, fmt.Errorf("backup.age_recipients: %q: %w", s, err)
558		}
559		rs = append(rs, r)
560	}
561	return rs, nil
562}
563
564// Default returns the configuration used when a key is absent from the file.
565func Default() Config {
566	return Config{
567		Server: Server{Root: "/var/lib/gitbay", SecretKeyFile: "/etc/gitbay/secret.key"},
568		SSH:    SSH{Mode: "embedded", Port: 22},
569		HTTP:   HTTP{Addr: ":443", TLS: "acme", ACMEHTTPAddr: ":80"},
570		Web:    Web{Mode: "view_only"},
571		Registration: Registration{
572			Mode: "closed",
573		},
574		GitDaemon: GitDaemon{Port: 9418},
575		Mirrors:   Mirrors{PullIntervalMinutes: 15},
576		Deps:      Deps{CheckIntervalHours: 24},
577		Limits: Limits{
578			MaxPackBytes:    2 << 30, // 2 GiB
579			MaxBlobBytes:    100 << 20,
580			MaxAssetBytes:   512 << 20,
581			MaxSnippetBytes: 1 << 20,
582			CloneTimeoutSec: 3600,
583			SSHAuthRate:     10,
584			APIRate:         120,
585		},
586	}
587}
588
589// Load reads path, applies defaults, and validates. It does not probe the
590// host (see CheckHost) so it is safe in tests and on non-target machines.
591func Load(path string) (Config, error) {
592	cfg := Default()
593	md, err := toml.DecodeFile(path, &cfg)
594	if err != nil {
595		return cfg, err
596	}
597	if u := md.Undecoded(); len(u) > 0 {
598		return cfg, fmt.Errorf("unknown config key %q", u[0].String())
599	}
600	return cfg, cfg.Validate()
601}
602
603// Within reports whether path is dir or below it. Both are compared as
604// cleaned absolute paths (a relative path resolves against the working
605// directory, same as every other path in this config), with symlinks
606// resolved where the path exists on disk, so a path that reaches into dir
607// through a symlink, or through "..", is still reported as inside.
608func Within(dir, path string) bool {
609	dir, path = resolvePath(dir), resolvePath(path)
610	rel, err := filepath.Rel(dir, path)
611	return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
612}
613
614// resolvePath returns path as a cleaned absolute path, resolving symlinks in
615// it. The secret key file commonly does not exist yet (it is created by
616// `gitbayd admin secrets init`), and on this platform /var itself is a
617// symlink, so a whole-path resolution is tried first and, failing that, each
618// ancestor directory in turn, walking up to the nearest one that exists and
619// reattaching the missing suffix — a symlinked ancestor still resolves even
620// though the leaf, or several levels above it, does not exist.
621func resolvePath(path string) string {
622	abs, err := filepath.Abs(path)
623	if err != nil {
624		return filepath.Clean(path)
625	}
626	dir := abs
627	var suffix []string
628	for {
629		if resolved, err := filepath.EvalSymlinks(dir); err == nil {
630			for i := len(suffix) - 1; i >= 0; i-- {
631				resolved = filepath.Join(resolved, suffix[i])
632			}
633			return resolved
634		}
635		parent := filepath.Dir(dir)
636		if parent == dir {
637			return abs
638		}
639		suffix = append(suffix, filepath.Base(dir))
640		dir = parent
641	}
642}
643
644func oneOf(field, val string, allowed ...string) error {
645	for _, a := range allowed {
646		if val == a {
647			return nil
648		}
649	}
650	return fmt.Errorf("%s must be one of %v, got %q", field, allowed, val)
651}
652
653// Validate applies the static contradiction checks from the plan.
654func (c Config) Validate() error {
655	var errs []error
656
657	if c.Server.Root == "" {
658		errs = append(errs, errors.New("server.root is required"))
659	}
660	if d := c.Pages.Domain; d != "" {
661		if d == c.SiteHost() {
662			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"))
663		}
664		if strings.HasSuffix(c.SiteHost(), "."+d) {
665			errs = append(errs, errors.New("pages.domain must not be a parent of the site host"))
666		}
667	}
668	if c.Server.SiteURL == "" {
669		errs = append(errs, errors.New("server.site_url is required"))
670	}
671	switch {
672	case c.Server.SecretKeyFile == "":
673		errs = append(errs, errors.New("server.secret_key_file is required"))
674	case Within(c.Server.Root, c.Server.SecretKeyFile):
675		errs = append(errs, fmt.Errorf("server.secret_key_file %q is inside server.root: backups of the root would carry the key beside the values it seals", c.Server.SecretKeyFile))
676	}
677	if err := oneOf("ssh.mode", c.SSH.Mode, "embedded", "system"); err != nil {
678		errs = append(errs, err)
679	}
680	if c.Registration.PendingExpiry != "" {
681		if d, err := time.ParseDuration(c.Registration.PendingExpiry); err != nil || d <= 0 {
682			errs = append(errs, fmt.Errorf("registration.pending_expiry %q must be a positive duration such as 168h", c.Registration.PendingExpiry))
683		}
684	}
685	if c.Limits.MaxReposPerUser < 0 || c.Limits.MaxBytesPerUser < 0 || c.Limits.MaxSnippetsPerUser < 0 ||
686		c.Limits.MaxOrgsPerUser < 0 || c.Limits.MaxReposPerOrg < 0 || c.Limits.MaxBytesPerOrg < 0 {
687		errs = append(errs, errors.New("limits.max_repos_per_user, max_bytes_per_user, max_snippets_per_user, max_orgs_per_user, max_repos_per_org and max_bytes_per_org must not be negative"))
688	}
689	for _, w := range []struct{ name, val string }{
690		{"pack_queue_wait", c.Limits.PackQueueWait},
691		{"push_queue_wait", c.Limits.PushQueueWait},
692		{"push_idle", c.Limits.PushIdle},
693		{"push_receive_timeout", c.Limits.PushReceiveTimeout},
694	} {
695		if w.val == "" {
696			continue
697		}
698		if d, err := time.ParseDuration(w.val); err != nil || d <= 0 {
699			errs = append(errs, fmt.Errorf("limits.%s %q must be a positive duration such as 60s", w.name, w.val))
700		}
701	}
702	if c.Push.Enabled {
703		for _, f := range []struct{ name, val string }{
704			{"push.key_file", c.Push.KeyFile},
705			{"push.key_id", c.Push.KeyID},
706			{"push.team_id", c.Push.TeamID},
707			{"push.topic", c.Push.Topic},
708		} {
709			if f.val == "" {
710				errs = append(errs, fmt.Errorf("%s is required when push.enabled", f.name))
711			}
712		}
713		if err := oneOf("push.environment", c.Push.Environment, "production", "sandbox"); err != nil {
714			errs = append(errs, err)
715		}
716		if c.Push.KeyFile != "" {
717			if _, err := LoadAPNSKey(c.Push.KeyFile); err != nil {
718				errs = append(errs, fmt.Errorf("push.key_file: %w", err))
719			}
720		}
721	}
722	if c.SSH.Port < 1 || c.SSH.Port > 65535 {
723		errs = append(errs, fmt.Errorf("ssh.port %d out of range", c.SSH.Port))
724	}
725	if err := oneOf("http.tls", c.HTTP.TLS, "acme", "files", "off"); err != nil {
726		errs = append(errs, err)
727	}
728	if _, err := c.HTTP.TrustedProxyNets(); err != nil {
729		errs = append(errs, err)
730	}
731	if c.HTTP.TLS == "files" && (c.HTTP.CertFile == "" || c.HTTP.KeyFile == "") {
732		errs = append(errs, errors.New("http.tls = \"files\" requires cert_file and key_file"))
733	}
734	if c.HTTP.TLS == "acme" {
735		host := c.SiteHost()
736		switch {
737		case !strings.HasPrefix(c.Server.SiteURL, "https://"):
738			errs = append(errs, errors.New("http.tls = \"acme\" requires an https:// site_url: certificates are issued for that host"))
739		case host == "" || host == "localhost" || net.ParseIP(host) != nil:
740			errs = append(errs, fmt.Errorf("http.tls = \"acme\" cannot issue a certificate for %q: use a public DNS name in site_url", host))
741		}
742	}
743	if err := oneOf("web.mode", c.Web.Mode, "view_only", "accounts"); err != nil {
744		errs = append(errs, err)
745	}
746	if err := oneOf("registration.mode", c.Registration.Mode, "closed", "invite", "open"); err != nil {
747		errs = append(errs, err)
748	}
749
750	for module, repo := range c.GoImport {
751		host, _, ok := strings.Cut(module, "/")
752		if !ok || host == "" || !strings.Contains(host, ".") {
753			errs = append(errs, fmt.Errorf("go_import key %q must be host/path (e.g. gitbay.org/gitbay)", module))
754		}
755		if parts := strings.Split(repo, "/"); len(parts) != 2 || parts[0] == "" || parts[1] == "" {
756			errs = append(errs, fmt.Errorf("go_import value %q must be owner/name", repo))
757		}
758	}
759
760	if _, err := c.Backup.Recipients(); err != nil {
761		errs = append(errs, err)
762	}
763
764	// Contradictions.
765	if c.Mail.SMTPHost != "" && c.Mail.From == "" {
766		errs = append(errs, errors.New("[mail] from is required when smtp_host is set"))
767	}
768	if t := c.Mail.TLS; t != "" && t != "starttls" && t != "implicit" {
769		errs = append(errs, fmt.Errorf("mail.tls must be starttls or implicit, got %q", t))
770	}
771	errs = append(errs, c.Mail.Inbound.validate()...)
772	if c.Mail.Inbound.Enabled && c.Mail.SMTPHost == "" {
773		errs = append(errs, errors.New("mail.inbound.enabled requires [mail] smtp_host: replies answer notification mail, which is not sent without SMTP"))
774	}
775	if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" {
776		errs = append(errs, fmt.Errorf(
777			"registration.mode = %q requires [mail] smtp_host: email verification cannot run without SMTP",
778			c.Registration.Mode))
779	}
780	if c.Registration.NotifyAdmin && c.Mail.SMTPHost == "" {
781		errs = append(errs, errors.New(
782			"registration.notify_admin = true requires [mail] smtp_host: there is nowhere to send the notice"))
783	}
784	if c.SSH.Mode == "system" && c.Registration.Mode != "closed" {
785		errs = append(errs, fmt.Errorf(
786			"ssh.mode = \"system\" requires registration.mode = \"closed\": host sshd rejects unknown keys before the dispatcher runs, so registration by unknown key is impossible"))
787	}
788	if c.Web.PasswordAuth && c.Web.Mode == "view_only" {
789		errs = append(errs, errors.New(
790			"web.password_auth = true is meaningless with web.mode = \"view_only\": no login route exists"))
791	}
792	if c.Web.PasswordAuth && c.Web.Mode == "accounts" {
793		errs = append(errs, errors.New(
794			"web.password_auth is not implemented yet; browser sessions are minted over SSH (gitbay web login)"))
795	}
796
797	return errors.Join(errs...)
798}
799
800// SiteHost returns the bare hostname from site_url (no scheme, port, path).
801func (c Config) SiteHost() string {
802	h := strings.TrimPrefix(strings.TrimPrefix(c.Server.SiteURL, "https://"), "http://")
803	h = strings.TrimSuffix(h, "/")
804	if i := strings.IndexByte(h, '/'); i >= 0 {
805		h = h[:i]
806	}
807	if host, _, err := net.SplitHostPort(h); err == nil {
808		return host
809	}
810	return h
811}
812
813// CheckHost performs environment probes that only make sense on the target
814// machine: port availability for the embedded listener and root existence.
815func (c Config) CheckHost() error {
816	var errs []error
817
818	if st, err := os.Stat(c.Server.Root); err != nil {
819		errs = append(errs, fmt.Errorf("server.root: %w", err))
820	} else if !st.IsDir() {
821		errs = append(errs, fmt.Errorf("server.root %q is not a directory", c.Server.Root))
822	}
823
824	if c.SSH.Mode == "embedded" {
825		addr := net.JoinHostPort("", strconv.Itoa(c.SSH.Port))
826		ln, err := net.Listen("tcp", addr)
827		if err != nil {
828			errs = append(errs, fmt.Errorf("ssh.port %d is not bindable (already in use by another daemon?): %w", c.SSH.Port, err))
829		} else {
830			ln.Close()
831		}
832	}
833
834	return errors.Join(errs...)
835}
836
837// TrustedProxyNets parses http.trusted_proxies; a bare address is a /32
838// or /128.
839func (h HTTP) TrustedProxyNets() ([]*net.IPNet, error) {
840	var nets []*net.IPNet
841	for _, p := range h.TrustedProxies {
842		if _, n, err := net.ParseCIDR(p); err == nil {
843			nets = append(nets, n)
844			continue
845		}
846		ip := net.ParseIP(p)
847		if ip == nil {
848			return nil, fmt.Errorf("http.trusted_proxies: %q is not an address or CIDR", p)
849		}
850		bits := 32
851		if ip.To4() == nil {
852			bits = 128
853		}
854		nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
855	}
856	return nets, nil
857}