internal/config/config.go

648 lines · 23340 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	"math"
 11	"net"
 12	"os"
 13	"path/filepath"
 14	"strconv"
 15	"strings"
 16	"time"
 17
 18	"filippo.io/age"
 19	"github.com/BurntSushi/toml"
 20)
 21
 22// DefaultWriteRate is the per-account write budget when the config leaves
 23// write_rate at zero: generous for a person at a terminal, and a bound on
 24// what one account can enqueue — every write also queues notification mail
 25// and webhook deliveries.
 26const DefaultWriteRate = 60
 27
 28// Pack generation defaults for a four-core host: a full clone of a large
 29// repository runs git at about 1.5 cores (Performance wiki page).
 30const (
 31	DefaultPackConcurrency  = 3
 32	DefaultPackPerPrincipal = 2
 33	DefaultPackQueue        = 32
 34	DefaultPackQueueWait    = time.Minute
 35)
 36
 37type Config struct {
 38	Server       Server       `toml:"server"`
 39	SSH          SSH          `toml:"ssh"`
 40	HTTP         HTTP         `toml:"http"`
 41	GitDaemon    GitDaemon    `toml:"git_daemon"`
 42	Web          Web          `toml:"web"`
 43	Registration Registration `toml:"registration"`
 44	API          API          `toml:"api"`
 45	Webhooks     Webhooks     `toml:"webhooks"`
 46	Pages        Pages        `toml:"pages"`
 47	LFS          LFS          `toml:"lfs"`
 48	Limits       Limits       `toml:"limits"`
 49	Mail         Mail         `toml:"mail"`
 50	Mirrors      Mirrors      `toml:"mirrors"`
 51	Deps         Deps         `toml:"deps"`
 52	Retention    Retention    `toml:"retention"`
 53	Push         Push         `toml:"push"`
 54	Backup       Backup       `toml:"backup"`
 55	// GoImport maps vanity Go module paths to repositories, e.g.
 56	// "gitbay.org/gitbay" = "krz/gitbay". Requests carrying ?go-get=1
 57	// under a mapped path get a go-import meta tag.
 58	GoImport map[string]string `toml:"go_import"`
 59}
 60
 61type Server struct {
 62	Root    string `toml:"root"`
 63	SiteURL string `toml:"site_url"`
 64
 65	// SourceRepo names the repository this instance develops itself in, as
 66	// "owner/name". When set, startup warns if the running build's commit is
 67	// not on that repository's default branch. Empty disables the check, which
 68	// is right for any instance that does not host its own source.
 69	SourceRepo string `toml:"source_repo"`
 70
 71	// SecretKeyFile holds the keys that seal the secret columns of the
 72	// database (internal/seal). It lives outside Root, so neither a
 73	// backup archive nor a snapshot of Root carries it.
 74	SecretKeyFile string `toml:"secret_key_file"`
 75}
 76
 77type SSH struct {
 78	Mode     string   `toml:"mode"` // embedded | system
 79	Port     int      `toml:"port"`
 80	HostKeys []string `toml:"host_keys"`
 81}
 82
 83type HTTP struct {
 84	Addr     string `toml:"addr"`
 85	TLS      string `toml:"tls"` // acme | files | off
 86	CertFile string `toml:"cert_file"`
 87	KeyFile  string `toml:"key_file"`
 88	// ACME (Let's Encrypt by default). Certificates are cached under
 89	// server.root/acme. acme_http_addr serves HTTP-01 challenges and
 90	// redirects to HTTPS; "off" disables it (TLS-ALPN-01 on the HTTPS
 91	// port still works).
 92	ACMEEmail    string `toml:"acme_email"`
 93	ACMEHTTPAddr string `toml:"acme_http_addr"`
 94	// TrustedProxies are the addresses or CIDRs of reverse proxies in front
 95	// of this process. A request from one of them is attributed to the
 96	// last X-Forwarded-For hop that is not itself a trusted proxy; from
 97	// anyone else the peer address is the client and the header is
 98	// ignored. Empty means no proxy, which is how gitbayd is deployed by
 99	// default: it terminates TLS itself.
100	TrustedProxies []string `toml:"trusted_proxies,omitempty"`
101}
102
103type GitDaemon struct {
104	Enabled bool `toml:"enabled"`
105	Port    int  `toml:"port"`
106}
107
108type Web struct {
109	Mode         string `toml:"mode"` // view_only | accounts
110	PasswordAuth bool   `toml:"password_auth"`
111	// Title is the instance's display name in the header and page titles.
112	// Empty falls back to the site host.
113	Title string `toml:"title"`
114	// PrivacyNotice is operator-provided text shown on /privacy under the
115	// fixed project-level statement. Plain text; blank paragraphs split.
116	PrivacyNotice string `toml:"privacy_notice"`
117}
118
119type Registration struct {
120	Mode string `toml:"mode"` // closed | invite | open
121	// PendingExpiry is how long a self-registered account may stay
122	// unverified before it is removed, as a duration ("168h"). Empty
123	// keeps such accounts forever.
124	PendingExpiry string `toml:"pending_expiry"`
125	// NotifyAdmin mails the instance's admins when an account becomes
126	// active: an invite redeemed, or an open-mode signup that verified
127	// its address. The unverified row an open signup creates is not
128	// reported — anyone can post the form, so mailing on that would
129	// aim a flood at the admins (#234).
130	NotifyAdmin bool `toml:"notify_admin"`
131}
132
133// PendingExpiryDuration parses PendingExpiry; zero means never.
134func (r Registration) PendingExpiryDuration() time.Duration {
135	d, _ := time.ParseDuration(r.PendingExpiry)
136	return d
137}
138
139// Retention is how long the append-only tables keep a row. Each is a
140// duration string ("2160h"); empty or zero keeps forever, which is what
141// an instance that has never configured this gets. Expired sessions and
142// tokens are swept regardless: they are dead weight the moment they
143// expire and no setting makes them worth keeping.
144type Retention struct {
145	Audit             string `toml:"audit"`
146	Events            string `toml:"events"`
147	WebhookDeliveries string `toml:"webhook_deliveries"`
148	// Mail is the outbound queue: rows already sent or given up on.
149	Mail string `toml:"mail"`
150	// Push is the outbound device queue: rows already sent or given up on.
151	Push string `toml:"push"`
152}
153
154// Durations parses the five, mapping each to zero when unset or bad.
155func (r Retention) Durations() (audit, events, deliveries, mail, push time.Duration) {
156	parse := func(s string) time.Duration {
157		d, err := time.ParseDuration(s)
158		if err != nil || d < 0 {
159			return 0
160		}
161		return d
162	}
163	return parse(r.Audit), parse(r.Events), parse(r.WebhookDeliveries), parse(r.Mail), parse(r.Push)
164}
165
166// LFS stores large-file objects content-addressed under Root (default
167// <server.root>/lfs). MaxObjectBytes caps a single object; 0 means the
168// 512MB default.
169type LFS struct {
170	Root           string `toml:"root"`
171	MaxObjectBytes int64  `toml:"max_object_bytes"`
172}
173
174// Pages serves each public repo's `pages` branch as a static site on
175// <owner>.<domain> — a separate origin, so page-authored scripts never run
176// on the forge's own host. Empty domain disables the feature.
177type Pages struct {
178	Domain string `toml:"domain"`
179}
180
181// API controls the HTTPS/JSON control-plane API (bearer tokens minted over
182// SSH). Off by default: an instance that never enables it has no
183// credential-bearing HTTP surface at all.
184type API struct {
185	Enabled bool `toml:"enabled"`
186}
187
188// Webhooks controls outbound delivery. AllowLocal permits endpoints on
189// loopback/private addresses (off by default: SSRF).
190type Webhooks struct {
191	AllowLocal bool `toml:"allow_local"`
192}
193
194type Mirrors struct {
195	PullIntervalMinutes int `toml:"pull_interval_minutes"`
196}
197
198// Deps configures the dependency-update sweep. It runs only for repos that
199// have opted in with `repo deps enable`, because checking a private repo
200// tells a public registry what it depends on.
201type Deps struct {
202	CheckIntervalHours int `toml:"check_interval_hours"`
203}
204
205type Limits struct {
206	MaxPackBytes    int64 `toml:"max_pack_bytes"`
207	MaxBlobBytes    int64 `toml:"max_blob_bytes"`
208	MaxAssetBytes   int64 `toml:"max_asset_bytes"`   // per release asset
209	MaxSnippetBytes int64 `toml:"max_snippet_bytes"` // per snippet file
210	// MaxSnippetsPerUser caps snippets an account may own. 0 means
211	// unlimited, like MaxReposPerUser.
212	MaxSnippetsPerUser int `toml:"max_snippets_per_user"`
213	CloneTimeoutSec    int `toml:"clone_timeout"`
214	SSHAuthRate        int `toml:"ssh_auth_rate"`
215	// APIRate is sustained JSON-API requests per minute per caller; writes
216	// draw on a tenth of it. 0 uses the default.
217	APIRate int `toml:"api_rate"`
218	// WriteRate is sustained mutating commands per minute per account,
219	// counted in the dispatcher so every surface shares one budget. 0 uses
220	// the default; a negative value turns the limit off.
221	WriteRate int `toml:"write_rate"`
222	// Per-account quotas on what a user owns directly (organizations are
223	// not capped). 0 means unlimited; admin user limits overrides per
224	// account.
225	MaxReposPerUser int   `toml:"max_repos_per_user"`
226	MaxBytesPerUser int64 `toml:"max_bytes_per_user"`
227	// PackConcurrency caps git pack generation (upload-pack and
228	// upload-archive) running at once across SSH, smart HTTP and git://.
229	// PackPerPrincipal caps it per account, or per client address on the
230	// anonymous transports. PackQueue is how many may wait for a slot,
231	// for at most PackQueueWait ("60s"). For the three counts 0 takes the
232	// default and a negative value turns that bound off.
233	PackConcurrency  int    `toml:"pack_concurrency"`
234	PackPerPrincipal int    `toml:"pack_per_principal"`
235	PackQueue        int    `toml:"pack_queue"`
236	PackQueueWait    string `toml:"pack_queue_wait"`
237}
238
239// PackLimits resolves the pack_* settings for packlimit.New. A zero
240// max or per is no bound; an unbounded queue is math.MaxInt, since
241// packlimit reads a zero queue as no queue at all.
242func (l Limits) PackLimits() (max, per, queue int, wait time.Duration) {
243	pick := func(v, def int) int {
244		switch {
245		case v == 0:
246			return def
247		case v < 0:
248			return 0
249		}
250		return v
251	}
252	queue = pick(l.PackQueue, DefaultPackQueue)
253	if l.PackQueue < 0 {
254		queue = math.MaxInt
255	}
256	wait = DefaultPackQueueWait
257	if d, err := time.ParseDuration(l.PackQueueWait); err == nil && d > 0 {
258		wait = d
259	}
260	return pick(l.PackConcurrency, DefaultPackConcurrency),
261		pick(l.PackPerPrincipal, DefaultPackPerPrincipal), queue, wait
262}
263
264type Mail struct {
265	SMTPHost string `toml:"smtp_host"` // host:port (port defaults to 587, 465 with tls = "implicit")
266	From     string `toml:"from"`
267	SMTPUser string `toml:"smtp_user,omitempty"`
268	SMTPPass string `toml:"smtp_pass,omitempty"`
269	// RequireTLS fails delivery when the relay does not offer STARTTLS,
270	// instead of sending in clear. Unset, it is on for any relay but
271	// localhost or a loopback address (TLSRequired).
272	RequireTLS *bool `toml:"require_tls,omitempty"`
273	// TLS is "starttls" (the default, also when empty) or "implicit":
274	// TLS from the first byte, as relays on port 465 expect.
275	TLS string `toml:"tls,omitempty"`
276}
277
278// TLSRequired reports whether mail must not go to the relay in clear.
279func (m Mail) TLSRequired() bool {
280	if m.RequireTLS != nil {
281		return *m.RequireTLS
282	}
283	host := m.SMTPHost
284	if h, _, err := net.SplitHostPort(host); err == nil {
285		host = h
286	}
287	host = strings.Trim(host, "[]")
288	if host == "localhost" {
289		return false
290	}
291	ip := net.ParseIP(host)
292	return ip == nil || !ip.IsLoopback()
293}
294
295// Push is APNs delivery to registered Apple devices. A key belongs to a
296// bundle ID, so an instance pushes to the app built under the topic named
297// here and no other; a self-hoster points this at their own key and their
298// own build.
299type Push struct {
300	Enabled bool   `toml:"enabled"`
301	KeyFile string `toml:"key_file"`
302	KeyID   string `toml:"key_id"`
303	TeamID  string `toml:"team_id"`
304	Topic   string `toml:"topic"` // the app's bundle identifier
305	// Environment is a name rather than a URL so a typo cannot aim the
306	// key at a host that is not Apple's.
307	Environment string `toml:"environment"` // production | sandbox
308}
309
310// Host is the APNs endpoint for the configured environment.
311// GITBAY_APNS_HOST overrides it for tests, as GITBAY_SWEEP_TICK does for
312// the retention sweep.
313func (p Push) Host() string {
314	if h := os.Getenv("GITBAY_APNS_HOST"); h != "" {
315		return h
316	}
317	if p.Environment == "sandbox" {
318		return "api.sandbox.push.apple.com"
319	}
320	return "api.push.apple.com"
321}
322
323// LoadAPNSKey reads Apple's .p8 provider key: a PEM-wrapped PKCS#8
324// P-256 private key. Read at startup and validated there, so a
325// misconfigured [push] refuses to start rather than filling a queue
326// nobody is watching.
327func LoadAPNSKey(path string) (*ecdsa.PrivateKey, error) {
328	data, err := os.ReadFile(path)
329	if err != nil {
330		return nil, err
331	}
332	block, _ := pem.Decode(data)
333	if block == nil {
334		return nil, errors.New("not PEM")
335	}
336	any, err := x509.ParsePKCS8PrivateKey(block.Bytes)
337	if err != nil {
338		return nil, err
339	}
340	key, ok := any.(*ecdsa.PrivateKey)
341	if !ok {
342		return nil, errors.New("not an EC private key")
343	}
344	return key, nil
345}
346
347// Backup configures gitbayd admin backup.
348type Backup struct {
349	// AgeRecipients, when set, encrypts every archive to these age
350	// public keys (age1...). The matching identities stay off the host,
351	// so the host writes archives it cannot read.
352	AgeRecipients []string `toml:"age_recipients"`
353}
354
355// Recipients parses AgeRecipients.
356func (b Backup) Recipients() ([]age.Recipient, error) {
357	var rs []age.Recipient
358	for _, s := range b.AgeRecipients {
359		r, err := age.ParseX25519Recipient(s)
360		if err != nil {
361			return nil, fmt.Errorf("backup.age_recipients: %q: %w", s, err)
362		}
363		rs = append(rs, r)
364	}
365	return rs, nil
366}
367
368// Default returns the configuration used when a key is absent from the file.
369func Default() Config {
370	return Config{
371		Server: Server{Root: "/var/lib/gitbay", SecretKeyFile: "/etc/gitbay/secret.key"},
372		SSH:    SSH{Mode: "embedded", Port: 22},
373		HTTP:   HTTP{Addr: ":443", TLS: "acme", ACMEHTTPAddr: ":80"},
374		Web:    Web{Mode: "view_only"},
375		Registration: Registration{
376			Mode: "closed",
377		},
378		GitDaemon: GitDaemon{Port: 9418},
379		Mirrors:   Mirrors{PullIntervalMinutes: 15},
380		Deps:      Deps{CheckIntervalHours: 24},
381		Limits: Limits{
382			MaxPackBytes:    2 << 30, // 2 GiB
383			MaxBlobBytes:    100 << 20,
384			MaxAssetBytes:   512 << 20,
385			MaxSnippetBytes: 1 << 20,
386			CloneTimeoutSec: 3600,
387			SSHAuthRate:     10,
388			APIRate:         120,
389		},
390	}
391}
392
393// Load reads path, applies defaults, and validates. It does not probe the
394// host (see CheckHost) so it is safe in tests and on non-target machines.
395func Load(path string) (Config, error) {
396	cfg := Default()
397	md, err := toml.DecodeFile(path, &cfg)
398	if err != nil {
399		return cfg, err
400	}
401	if u := md.Undecoded(); len(u) > 0 {
402		return cfg, fmt.Errorf("unknown config key %q", u[0].String())
403	}
404	return cfg, cfg.Validate()
405}
406
407// Within reports whether path is dir or below it. Both are compared as
408// cleaned absolute paths (a relative path resolves against the working
409// directory, same as every other path in this config), with symlinks
410// resolved where the path exists on disk, so a path that reaches into dir
411// through a symlink, or through "..", is still reported as inside.
412func Within(dir, path string) bool {
413	dir, path = resolvePath(dir), resolvePath(path)
414	rel, err := filepath.Rel(dir, path)
415	return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
416}
417
418// resolvePath returns path as a cleaned absolute path, resolving symlinks in
419// it. The secret key file commonly does not exist yet (it is created by
420// `gitbayd admin secrets init`), and on this platform /var itself is a
421// symlink, so a whole-path resolution is tried first and, failing that, each
422// ancestor directory in turn, walking up to the nearest one that exists and
423// reattaching the missing suffix — a symlinked ancestor still resolves even
424// though the leaf, or several levels above it, does not exist.
425func resolvePath(path string) string {
426	abs, err := filepath.Abs(path)
427	if err != nil {
428		return filepath.Clean(path)
429	}
430	dir := abs
431	var suffix []string
432	for {
433		if resolved, err := filepath.EvalSymlinks(dir); err == nil {
434			for i := len(suffix) - 1; i >= 0; i-- {
435				resolved = filepath.Join(resolved, suffix[i])
436			}
437			return resolved
438		}
439		parent := filepath.Dir(dir)
440		if parent == dir {
441			return abs
442		}
443		suffix = append(suffix, filepath.Base(dir))
444		dir = parent
445	}
446}
447
448func oneOf(field, val string, allowed ...string) error {
449	for _, a := range allowed {
450		if val == a {
451			return nil
452		}
453	}
454	return fmt.Errorf("%s must be one of %v, got %q", field, allowed, val)
455}
456
457// Validate applies the static contradiction checks from the plan.
458func (c Config) Validate() error {
459	var errs []error
460
461	if c.Server.Root == "" {
462		errs = append(errs, errors.New("server.root is required"))
463	}
464	if d := c.Pages.Domain; d != "" {
465		if d == c.SiteHost() {
466			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"))
467		}
468		if strings.HasSuffix(c.SiteHost(), "."+d) {
469			errs = append(errs, errors.New("pages.domain must not be a parent of the site host"))
470		}
471	}
472	if c.Server.SiteURL == "" {
473		errs = append(errs, errors.New("server.site_url is required"))
474	}
475	switch {
476	case c.Server.SecretKeyFile == "":
477		errs = append(errs, errors.New("server.secret_key_file is required"))
478	case Within(c.Server.Root, c.Server.SecretKeyFile):
479		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))
480	}
481	if err := oneOf("ssh.mode", c.SSH.Mode, "embedded", "system"); err != nil {
482		errs = append(errs, err)
483	}
484	if c.Registration.PendingExpiry != "" {
485		if d, err := time.ParseDuration(c.Registration.PendingExpiry); err != nil || d <= 0 {
486			errs = append(errs, fmt.Errorf("registration.pending_expiry %q must be a positive duration such as 168h", c.Registration.PendingExpiry))
487		}
488	}
489	if c.Limits.MaxReposPerUser < 0 || c.Limits.MaxBytesPerUser < 0 || c.Limits.MaxSnippetsPerUser < 0 {
490		errs = append(errs, errors.New("limits.max_repos_per_user, max_bytes_per_user and max_snippets_per_user must not be negative"))
491	}
492	if w := c.Limits.PackQueueWait; w != "" {
493		if d, err := time.ParseDuration(w); err != nil || d <= 0 {
494			errs = append(errs, fmt.Errorf("limits.pack_queue_wait %q must be a positive duration such as 60s", w))
495		}
496	}
497	if c.Push.Enabled {
498		for _, f := range []struct{ name, val string }{
499			{"push.key_file", c.Push.KeyFile},
500			{"push.key_id", c.Push.KeyID},
501			{"push.team_id", c.Push.TeamID},
502			{"push.topic", c.Push.Topic},
503		} {
504			if f.val == "" {
505				errs = append(errs, fmt.Errorf("%s is required when push.enabled", f.name))
506			}
507		}
508		if err := oneOf("push.environment", c.Push.Environment, "production", "sandbox"); err != nil {
509			errs = append(errs, err)
510		}
511		if c.Push.KeyFile != "" {
512			if _, err := LoadAPNSKey(c.Push.KeyFile); err != nil {
513				errs = append(errs, fmt.Errorf("push.key_file: %w", err))
514			}
515		}
516	}
517	if c.SSH.Port < 1 || c.SSH.Port > 65535 {
518		errs = append(errs, fmt.Errorf("ssh.port %d out of range", c.SSH.Port))
519	}
520	if err := oneOf("http.tls", c.HTTP.TLS, "acme", "files", "off"); err != nil {
521		errs = append(errs, err)
522	}
523	if _, err := c.HTTP.TrustedProxyNets(); err != nil {
524		errs = append(errs, err)
525	}
526	if c.HTTP.TLS == "files" && (c.HTTP.CertFile == "" || c.HTTP.KeyFile == "") {
527		errs = append(errs, errors.New("http.tls = \"files\" requires cert_file and key_file"))
528	}
529	if c.HTTP.TLS == "acme" {
530		host := c.SiteHost()
531		switch {
532		case !strings.HasPrefix(c.Server.SiteURL, "https://"):
533			errs = append(errs, errors.New("http.tls = \"acme\" requires an https:// site_url: certificates are issued for that host"))
534		case host == "" || host == "localhost" || net.ParseIP(host) != nil:
535			errs = append(errs, fmt.Errorf("http.tls = \"acme\" cannot issue a certificate for %q: use a public DNS name in site_url", host))
536		}
537	}
538	if err := oneOf("web.mode", c.Web.Mode, "view_only", "accounts"); err != nil {
539		errs = append(errs, err)
540	}
541	if err := oneOf("registration.mode", c.Registration.Mode, "closed", "invite", "open"); err != nil {
542		errs = append(errs, err)
543	}
544
545	for module, repo := range c.GoImport {
546		host, _, ok := strings.Cut(module, "/")
547		if !ok || host == "" || !strings.Contains(host, ".") {
548			errs = append(errs, fmt.Errorf("go_import key %q must be host/path (e.g. gitbay.org/gitbay)", module))
549		}
550		if parts := strings.Split(repo, "/"); len(parts) != 2 || parts[0] == "" || parts[1] == "" {
551			errs = append(errs, fmt.Errorf("go_import value %q must be owner/name", repo))
552		}
553	}
554
555	if _, err := c.Backup.Recipients(); err != nil {
556		errs = append(errs, err)
557	}
558
559	// Contradictions.
560	if c.Mail.SMTPHost != "" && c.Mail.From == "" {
561		errs = append(errs, errors.New("[mail] from is required when smtp_host is set"))
562	}
563	if t := c.Mail.TLS; t != "" && t != "starttls" && t != "implicit" {
564		errs = append(errs, fmt.Errorf("mail.tls must be starttls or implicit, got %q", t))
565	}
566	if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" {
567		errs = append(errs, fmt.Errorf(
568			"registration.mode = %q requires [mail] smtp_host: email verification cannot run without SMTP",
569			c.Registration.Mode))
570	}
571	if c.Registration.NotifyAdmin && c.Mail.SMTPHost == "" {
572		errs = append(errs, errors.New(
573			"registration.notify_admin = true requires [mail] smtp_host: there is nowhere to send the notice"))
574	}
575	if c.SSH.Mode == "system" && c.Registration.Mode != "closed" {
576		errs = append(errs, fmt.Errorf(
577			"ssh.mode = \"system\" requires registration.mode = \"closed\": host sshd rejects unknown keys before the dispatcher runs, so registration by unknown key is impossible"))
578	}
579	if c.Web.PasswordAuth && c.Web.Mode == "view_only" {
580		errs = append(errs, errors.New(
581			"web.password_auth = true is meaningless with web.mode = \"view_only\": no login route exists"))
582	}
583	if c.Web.PasswordAuth && c.Web.Mode == "accounts" {
584		errs = append(errs, errors.New(
585			"web.password_auth is not implemented yet; browser sessions are minted over SSH (gitbay web login)"))
586	}
587
588	return errors.Join(errs...)
589}
590
591// SiteHost returns the bare hostname from site_url (no scheme, port, path).
592func (c Config) SiteHost() string {
593	h := strings.TrimPrefix(strings.TrimPrefix(c.Server.SiteURL, "https://"), "http://")
594	h = strings.TrimSuffix(h, "/")
595	if i := strings.IndexByte(h, '/'); i >= 0 {
596		h = h[:i]
597	}
598	if host, _, err := net.SplitHostPort(h); err == nil {
599		return host
600	}
601	return h
602}
603
604// CheckHost performs environment probes that only make sense on the target
605// machine: port availability for the embedded listener and root existence.
606func (c Config) CheckHost() error {
607	var errs []error
608
609	if st, err := os.Stat(c.Server.Root); err != nil {
610		errs = append(errs, fmt.Errorf("server.root: %w", err))
611	} else if !st.IsDir() {
612		errs = append(errs, fmt.Errorf("server.root %q is not a directory", c.Server.Root))
613	}
614
615	if c.SSH.Mode == "embedded" {
616		addr := net.JoinHostPort("", strconv.Itoa(c.SSH.Port))
617		ln, err := net.Listen("tcp", addr)
618		if err != nil {
619			errs = append(errs, fmt.Errorf("ssh.port %d is not bindable (already in use by another daemon?): %w", c.SSH.Port, err))
620		} else {
621			ln.Close()
622		}
623	}
624
625	return errors.Join(errs...)
626}
627
628// TrustedProxyNets parses http.trusted_proxies; a bare address is a /32
629// or /128.
630func (h HTTP) TrustedProxyNets() ([]*net.IPNet, error) {
631	var nets []*net.IPNet
632	for _, p := range h.TrustedProxies {
633		if _, n, err := net.ParseCIDR(p); err == nil {
634			nets = append(nets, n)
635			continue
636		}
637		ip := net.ParseIP(p)
638		if ip == nil {
639			return nil, fmt.Errorf("http.trusted_proxies: %q is not an address or CIDR", p)
640		}
641		bits := 32
642		if ip.To4() == nil {
643			bits = 128
644		}
645		nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
646	}
647	return nets, nil
648}