cmd/gitbayd/main.go

638 lines · 20832 bytes

  1// gitbayd is the forge server daemon. The same binary also runs in hook mode
  2// (invoked by git via core.hooksPath) and hosts the host-local admin commands.
  3package main
  4
  5import (
  6	"context"
  7	"errors"
  8	"fmt"
  9	"io"
 10	"io/fs"
 11	"log/slog"
 12	"net"
 13	"net/http"
 14	"os"
 15	"os/signal"
 16	"path/filepath"
 17	"strconv"
 18	"strings"
 19	"syscall"
 20	"time"
 21
 22	"github.com/spf13/cobra"
 23	"golang.org/x/crypto/acme/autocert"
 24
 25	"gitbay.org/gitbay/internal/buildinfo"
 26	"gitbay.org/gitbay/internal/ci"
 27	"gitbay.org/gitbay/internal/config"
 28	"gitbay.org/gitbay/internal/control"
 29	"gitbay.org/gitbay/internal/deps"
 30	"gitbay.org/gitbay/internal/gitd"
 31	"gitbay.org/gitbay/internal/hookd"
 32	"gitbay.org/gitbay/internal/httpd"
 33	"gitbay.org/gitbay/internal/mailin"
 34	"gitbay.org/gitbay/internal/mirror"
 35	"gitbay.org/gitbay/internal/notify"
 36	"gitbay.org/gitbay/internal/packlimit"
 37	"gitbay.org/gitbay/internal/push"
 38	"gitbay.org/gitbay/internal/seal"
 39	"gitbay.org/gitbay/internal/sshd"
 40	"gitbay.org/gitbay/internal/store"
 41	"gitbay.org/gitbay/internal/symbols"
 42	"gitbay.org/gitbay/internal/toolpath"
 43	"gitbay.org/gitbay/internal/webhook"
 44)
 45
 46func openStore(cfg config.Config) (*store.Store, error) {
 47	// The key file seals the secret columns (#273). Without it the
 48	// database's secrets cannot be read or written, so nothing that
 49	// opens the database runs.
 50	keys, err := seal.Load(cfg.Server.SecretKeyFile)
 51	if errors.Is(err, fs.ErrNotExist) {
 52		return nil, fmt.Errorf("secret key file %s does not exist: create it with gitbayd admin secrets init, or restore it from its off-host copy (backups do not carry it)", cfg.Server.SecretKeyFile)
 53	}
 54	if err != nil {
 55		return nil, fmt.Errorf("secret key file: %w", err)
 56	}
 57	s, err := store.Open(filepath.Join(cfg.Server.Root, "gitbay.db"))
 58	if err != nil {
 59		return nil, err
 60	}
 61	s.SetKeyring(keys)
 62	// Say so when the schema moves. A restart migrates in silence otherwise,
 63	// which makes an unexpected schema version hard to attribute to the deploy
 64	// that caused it.
 65	before, err := s.Version()
 66	if err != nil {
 67		s.Close()
 68		return nil, err
 69	}
 70	if err := s.MigrateUp(); err != nil {
 71		s.Close()
 72		return nil, err
 73	}
 74	after, err := s.Version()
 75	if err != nil {
 76		s.Close()
 77		return nil, err
 78	}
 79	if after != before {
 80		slog.Info("schema migrated", "from", before, "to", after)
 81	}
 82	return s, nil
 83}
 84
 85var configPath string
 86
 87func main() {
 88	root := &cobra.Command{
 89		Use:           "gitbayd",
 90		Short:         "gitbay server daemon",
 91		SilenceUsage:  true,
 92		SilenceErrors: true,
 93	}
 94	root.PersistentFlags().StringVar(&configPath, "config", "/etc/gitbay/config.toml", "path to config file")
 95
 96	root.AddCommand(
 97		checkConfigCmd(),
 98		serveCmd(),
 99		migrateCmd(),
100		adminCmd(),
101		hookCmd(),
102		authorizedKeysCmd(),
103		shellCmd(),
104		versionCmd(),
105	)
106
107	if err := root.Execute(); err != nil {
108		fmt.Fprintln(os.Stderr, "gitbayd:", err)
109		os.Exit(1)
110	}
111}
112
113func checkConfigCmd() *cobra.Command {
114	var noHost bool
115	cmd := &cobra.Command{
116		Use:   "check-config",
117		Short: "validate the configuration and exit",
118		RunE: func(cmd *cobra.Command, args []string) error {
119			cfg, err := config.Load(configPath)
120			if err != nil {
121				return err
122			}
123			if !noHost {
124				if err := cfg.CheckHost(); err != nil {
125					return err
126				}
127			}
128			fmt.Println("config ok")
129			return nil
130		},
131	}
132	cmd.Flags().BoolVar(&noHost, "no-host-checks", false, "skip host environment probes (port binding, paths)")
133	return cmd
134}
135
136func serveCmd() *cobra.Command {
137	return &cobra.Command{
138		Use:   "serve",
139		Short: "run the ssh, http, and git listeners",
140		RunE: func(cmd *cobra.Command, args []string) error {
141			// First line of every run: the journal then says which commit is
142			// serving, without rebuilding the binary to find out.
143			logBuild()
144			// The tools this daemon shells out to are resolved once, at
145			// package init. Say so now rather than failing on whichever
146			// request first needed git.
147			if err := toolpath.Verify(); err != nil {
148				return fmt.Errorf("required tools missing: %w", err)
149			}
150			cfg, err := config.Load(configPath)
151			if err != nil {
152				return err
153			}
154			warnIfUnmerged(cfg)
155			st, err := openStore(cfg)
156			if err != nil {
157				return err
158			}
159			defer st.Close()
160			// The daemon's stderr is the service journal: a copy of each
161			// audit row outside the database the daemon can write. Rows
162			// are logged at Info, which the default handler always emits.
163			st.AuditJournal = slog.Default()
164			// Values stored before sealing existed, or under a key a
165			// rotation retired, are sealed under the current key before
166			// anything reads them. A value the key file cannot open
167			// stops the start here rather than failing each delivery.
168			if n, err := st.ResealSecrets(); err != nil {
169				return fmt.Errorf("sealing secrets under %s: %w (gitbayd admin secrets check lists every value that does not open)", cfg.Server.SecretKeyFile, err)
170			} else if n > 0 {
171				slog.Info("sealed secret values", "count", n)
172			}
173
174			// Regenerate hook scripts so a moved binary self-heals, then
175			// start the hook policy socket.
176			self, err := os.Executable()
177			if err != nil {
178				return err
179			}
180			if err := hookd.WriteHookScripts(control.HooksDir(cfg.Server.Root), self); err != nil {
181				return err
182			}
183			stopHookd, err := hookd.Serve(cfg, st)
184			if err != nil {
185				return err
186			}
187			defer stopHookd()
188
189			// SIGTERM is how a deploy restarts the daemon: stop accepting,
190			// let what is in flight finish, exit 0 (#105).
191			ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
192			defer stop()
193
194			// Outbound webhook deliveries, and the mail queue when SMTP is
195			// configured, share one retry base. It is overridable for
196			// tests via GITBAY_WEBHOOK_RETRY_BASE; notify.DefaultRetryBase
197			// names the production default so nothing else has to guess it.
198			retryBase := notify.DefaultRetryBase
199			if v := os.Getenv("GITBAY_WEBHOOK_RETRY_BASE"); v != "" {
200				if d, err := time.ParseDuration(v); err == nil {
201					retryBase = d
202				}
203			}
204			whCtx, whCancel := context.WithCancel(context.Background())
205			defer whCancel()
206			go webhook.New(st, cfg.Webhooks.AllowLocal, retryBase).Run(whCtx)
207			if cfg.Mail.SMTPHost != "" {
208				go notify.New(st, cfg, retryBase).Run(whCtx)
209			}
210			if in := cfg.Mail.Inbound; in.Enabled {
211				// An unreadable password file is a misconfiguration:
212				// refuse to start rather than poll and fail every tick.
213				if _, err := in.Password(); err != nil {
214					return err
215				}
216				if in.TrustedAuthservID == "" {
217					slog.Warn("mail reply: [mail.inbound] trusted_authserv_id is unset, so a reply's From is not checked against the mail host's DMARC and DKIM results; set it on any instance reachable from the internet")
218				}
219				go (&mailin.Poller{P: &mailin.Processor{St: st, Cfg: cfg}, In: in}).Run(whCtx)
220			}
221			if cfg.Push.Enabled {
222				p, err := push.New(st, cfg.Push, cfg.Server.SiteURL, retryBase)
223				if err != nil {
224					// Config validation already parsed the key, so this
225					// is not a misconfiguration; fail loudly rather than
226					// running with a silent delivery route.
227					slog.Error("push: starting deliverer", "err", err)
228				} else {
229					go p.Run(whCtx)
230				}
231			}
232			go mirror.New(st, cfg).Run(whCtx)
233			if d := cfg.Registration.PendingExpiryDuration(); d > 0 {
234				go reapPending(whCtx, st, d)
235			}
236			go sweep(whCtx, st, cfg)
237			go (&ci.Scheduler{St: st, SiteURL: cfg.Server.SiteURL,
238				RepoDir: func(owner, name string) string {
239					return control.RepoDir(cfg.Server.Root, owner, name)
240				}}).Run(whCtx)
241			go deps.New(st, cfg, func(owner, name string) string {
242				return control.RepoDir(cfg.Server.Root, owner, name)
243			}, buildinfo.String()).Run(whCtx)
244			go symbols.New(st, func(owner, name string) string {
245				return control.RepoDir(cfg.Server.Root, owner, name)
246			}).Run(whCtx)
247
248			// One pack-generation budget for SSH, smart HTTP and git://.
249			// Anonymous clients ("ip:" principals) share all but one
250			// slot, so an account can always get the last.
251			packMax, packPer, packQueue, packWait := cfg.Limits.PackLimits()
252			packs := packlimit.New(packMax, packPer, packQueue, packWait)
253			if packMax > 1 {
254				packs.CapClass("ip:", packMax-1)
255			}
256
257			errCh := make(chan error, 3)
258			var sshSrv *sshd.Server
259			var sshLn, gitLn net.Listener
260			if cfg.SSH.Mode == "embedded" {
261				srv, err := sshd.New(cfg, st, packs)
262				if err != nil {
263					return err
264				}
265				ln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.SSH.Port)))
266				if err != nil {
267					return err
268				}
269				slog.Info("ssh listening", "addr", ln.Addr())
270				sshSrv, sshLn = srv, ln
271				go func() { errCh <- srv.Serve(ln) }()
272			} else {
273				// system mode: the host sshd owns the SSH port and invokes
274				// this binary via AuthorizedKeysCommand + forced command.
275				slog.Info("ssh handled by host sshd (ssh.mode = system)")
276			}
277
278			web := httpd.New(cfg, st, packs)
279			// Header and idle timeouts bound what an idle or slow client can
280			// hold open. No write timeout: archives and upload-pack stream
281			// for as long as they take (#104).
282			hs := &http.Server{
283				Addr:              cfg.HTTP.Addr,
284				Handler:           web.Handler(),
285				ReadHeaderTimeout: 10 * time.Second,
286				IdleTimeout:       2 * time.Minute,
287				MaxHeaderBytes:    64 << 10,
288			}
289			go func() {
290				slog.Info("http listening", "addr", cfg.HTTP.Addr, "tls", cfg.HTTP.TLS)
291				switch cfg.HTTP.TLS {
292				case "off":
293					errCh <- hs.ListenAndServe()
294				case "files":
295					hs.TLSConfig = serverTLS(nil)
296					errCh <- hs.ListenAndServeTLS(cfg.HTTP.CertFile, cfg.HTTP.KeyFile)
297				case "acme":
298					host := cfg.SiteHost()
299					stripPort := func(hp string) string {
300						if h, _, err := net.SplitHostPort(hp); err == nil {
301							return h
302						}
303						return hp
304					}
305					// Beyond the site host, allow <owner>.<pages domain>
306					// for owners that exist — certs come on demand per
307					// subdomain, no wildcard needed.
308					hostPolicy := func(ctx context.Context, h string) error {
309						if h == host {
310							return nil
311						}
312						if pd := cfg.Pages.Domain; pd != "" {
313							if h == pd {
314								return nil // apex: serves a redirect to the forge
315							}
316							if owner, ok := strings.CutSuffix(h, "."+pd); ok &&
317								!strings.Contains(owner, ".") && st.OwnerExists(owner) {
318								return nil
319							}
320						}
321						// Custom pages domains: certs only for claimed hosts.
322						if _, err := st.PageDomainRepo(h); err == nil {
323							return nil
324						}
325						return fmt.Errorf("host %q not served here", h)
326					}
327					m := &autocert.Manager{
328						Prompt:     autocert.AcceptTOS,
329						Cache:      autocert.DirCache(filepath.Join(cfg.Server.Root, "acme")),
330						HostPolicy: hostPolicy,
331						Email:      cfg.HTTP.ACMEEmail,
332					}
333					// TLS-ALPN-01 rides the HTTPS port itself. The optional
334					// plain-HTTP listener adds HTTP-01 and a redirect; losing
335					// it (port 80 taken, no privileges) is not fatal.
336					if addr := cfg.HTTP.ACMEHTTPAddr; addr != "" && addr != "off" {
337						redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
338							// Pages hosts redirect to themselves, not the
339							// forge host.
340							target := host
341							if hostPolicy(r.Context(), stripPort(r.Host)) == nil {
342								target = stripPort(r.Host)
343							}
344							http.Redirect(w, r, "https://"+target+r.URL.RequestURI(), http.StatusMovedPermanently)
345						})
346						go func() {
347							slog.Info("acme http listening", "addr", addr)
348							acmeHTTP := &http.Server{Addr: addr, Handler: m.HTTPHandler(redirect),
349								ReadHeaderTimeout: 10 * time.Second, IdleTimeout: time.Minute}
350							if err := acmeHTTP.ListenAndServe(); err != nil {
351								slog.Warn("acme http listener failed; continuing with TLS-ALPN only", "err", err)
352							}
353						}()
354					}
355					hs.TLSConfig = serverTLS(m.TLSConfig())
356					errCh <- hs.ListenAndServeTLS("", "")
357				}
358			}()
359
360			if cfg.GitDaemon.Enabled {
361				gln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.GitDaemon.Port)))
362				if err != nil {
363					return err
364				}
365				slog.Info("git-daemon listening", "addr", gln.Addr())
366				gitLn = gln
367				go func() { errCh <- gitd.New(cfg, st, packs).Serve(gln) }()
368			}
369
370			select {
371			case err := <-errCh:
372				return err
373			case <-ctx.Done():
374			}
375			slog.Info("shutting down")
376			stop()
377			for _, ln := range []net.Listener{sshLn, gitLn} {
378				if ln != nil {
379					ln.Close()
380				}
381			}
382			// Follows run until a build ends; end them first so the drain
383			// waits only for work that finishes.
384			web.Stop()
385			if sshSrv != nil {
386				sshSrv.Stop()
387			}
388			drain, cancel := context.WithTimeout(context.Background(), 30*time.Second)
389			defer cancel()
390			if err := hs.Shutdown(drain); err != nil {
391				slog.Warn("http shutdown", "err", err)
392			}
393			if sshSrv != nil {
394				if err := sshSrv.Shutdown(drain); err != nil {
395					slog.Warn("ssh shutdown", "err", err)
396				}
397			}
398			return nil
399		},
400	}
401}
402
403func migrateCmd() *cobra.Command {
404	var to int
405	cmd := &cobra.Command{
406		Use:   "migrate",
407		Short: "apply schema migrations",
408		RunE: func(cmd *cobra.Command, args []string) error {
409			cfg, err := config.Load(configPath)
410			if err != nil {
411				return err
412			}
413			s, err := store.Open(cfg.Server.Root + "/gitbay.db")
414			if err != nil {
415				return err
416			}
417			defer s.Close()
418			if err := s.MigrateTo(to); err != nil {
419				return err
420			}
421			v, err := s.Version()
422			if err != nil {
423				return err
424			}
425			fmt.Println("schema version", v)
426			return nil
427		},
428	}
429	cmd.Flags().IntVar(&to, "to", -1, "target schema version (-1 = latest)")
430	return cmd
431}
432
433func adminCmd() *cobra.Command {
434	admin := &cobra.Command{
435		Use:   "admin",
436		Short: "host-local administration",
437	}
438	userCmd := &cobra.Command{Use: "user", Short: "manage users"}
439	userCmd.AddCommand(
440		hostUserCreateCmd(),
441		hostCmd("list [--state active|pending|disabled|admin] [--limit n] [--cursor c]", "list accounts", "admin", "user", "list"),
442		hostCmd("show <username>", "show an account: keys, emails, orgs, tokens, sessions", "admin", "user", "show"),
443		hostCmd("disable <username>", "suspend an account: keys and sessions refused until re-enabled", "admin", "user", "disable"),
444		hostCmd("enable <username>", "restore a suspended account", "admin", "user", "enable"),
445		hostCmd("delete <username> --yes", "delete an account that anchors nothing (keys, emails, and sessions go with it)", "admin", "user", "delete"),
446		hostCmd("promote <username>", "make an account an instance admin", "admin", "user", "promote"),
447		hostCmd("demote <username>", "remove instance admin from an account (never the last one)", "admin", "user", "demote"),
448		hostCmd("limits <username> [--repos n|default] [--bytes n|default]", "show or set repository and storage caps", "admin", "user", "limits"),
449	)
450	emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"}
451	emailCmd.AddCommand(hostCmd("verify <username> <address>", "mark an email verified by admin assertion", "admin", "email", "verify"))
452	repoCmd := &cobra.Command{Use: "repo", Short: "any repository, for moderation (audited)"}
453	repoCmd.AddCommand(
454		hostCmd("list [--owner o] [--visibility public|private] [--limit n] [--cursor c]", "every repository with size and last push", "admin", "repo", "list"),
455		hostCmd("archive <owner/name>", "archive a repository", "admin", "repo", "archive"),
456		hostCmd("unarchive <owner/name>", "unarchive a repository", "admin", "repo", "unarchive"),
457		hostCmd("visibility <owner/name> public|private", "set a repository's visibility", "admin", "repo", "visibility"),
458		hostCmd("delete <owner/name> --yes", "delete a repository", "admin", "repo", "delete"),
459	)
460	configCmd := &cobra.Command{Use: "config", Short: "the configuration in effect"}
461	configCmd.AddCommand(configShowCmd())
462	auditCmd := hostCmd("audit [--limit n] [--json]", "print the security audit log, newest first", "audit")
463	auditCmd.AddCommand(auditVerifyCmd())
464	admin.AddCommand(
465		userCmd,
466		emailCmd,
467		repoCmd,
468		configCmd,
469		hostCmd("invite --email <address>", "issue a registration invite and email its code", "admin", "invite"),
470		hostCmd("stats [--json]", "instance statistics: counts and per-repository disk usage", "admin", "stats"),
471		hostCmd("runners [--json]", "runner accounts: last poll, scope, the build each holds", "admin", "runners"),
472		auditCmd,
473		backupCmd(),
474		restoreDrillCmd(),
475		secretsCmd(),
476		gcCmd(),
477		adminMigrateCommitRefsCmd(),
478		adminMigrateProfileAboutCmd(),
479		adminBackfillActivityCmd(),
480	)
481	return admin
482}
483
484// hostCmd runs a registry command as the host itself: an admin context
485// with no account behind it, so audit rows carry no actor and the source
486// "host". Arguments pass through untouched; the registry owns the flags,
487// which is what keeps this surface and an admin's SSH session from
488// drifting.
489func hostCmd(use, short string, path ...string) *cobra.Command {
490	return &cobra.Command{
491		Use:                use,
492		Short:              short,
493		DisableFlagParsing: true,
494		RunE: func(cmd *cobra.Command, args []string) error {
495			for _, a := range args {
496				if a == "--help" || a == "-h" {
497					return cmd.Help()
498				}
499			}
500			return runAsHost(path, args, os.Stdin)
501		},
502	}
503}
504
505// hostArgs pulls the root's --config out of args: with flag parsing off,
506// cobra hands the persistent flag through untouched.
507func hostArgs(args []string) []string {
508	var rest []string
509	for i := 0; i < len(args); i++ {
510		switch {
511		case args[i] == "--config" && i+1 < len(args):
512			configPath = args[i+1]
513			i++
514		case strings.HasPrefix(args[i], "--config="):
515			configPath = strings.TrimPrefix(args[i], "--config=")
516		default:
517			rest = append(rest, args[i])
518		}
519	}
520	return rest
521}
522
523func runAsHost(path, args []string, stdin io.Reader) error {
524	args = hostArgs(args)
525	cfg, err := config.Load(configPath)
526	if err != nil {
527		return err
528	}
529	st, err := openStore(cfg)
530	if err != nil {
531		return err
532	}
533	c := &control.Ctx{
534		User:   store.User{Username: "host", IsAdmin: true},
535		Scope:  "full",
536		Store:  st,
537		Cfg:    cfg,
538		Stdin:  stdin,
539		Stdout: os.Stdout,
540		Stderr: os.Stderr,
541		Source: "host",
542	}
543	code := control.Dispatch(c, append(append([]string{}, path...), args...))
544	st.Close()
545	if code != 0 {
546		os.Exit(code)
547	}
548	return nil
549}
550
551// hostUserCreateCmd keeps --key <path>, which the registry command cannot
552// take (no file paths over SSH): the file becomes the command's stdin.
553func hostUserCreateCmd() *cobra.Command {
554	return &cobra.Command{
555		Use:                "create <username> [--admin] [--email <address> [--verified]] [--key <file>]",
556		Short:              "create a user (host-local bootstrap; the only path in closed mode)",
557		DisableFlagParsing: true,
558		RunE: func(cmd *cobra.Command, args []string) error {
559			var stdin io.Reader = os.Stdin
560			var rest []string
561			args = hostArgs(args)
562			for i := 0; i < len(args); i++ {
563				switch {
564				case args[i] == "--help" || args[i] == "-h":
565					return cmd.Help()
566				case args[i] == "--key" && i+1 < len(args) && args[i+1] != "-":
567					f, err := os.Open(args[i+1])
568					if err != nil {
569						return err
570					}
571					defer f.Close()
572					stdin = f
573					rest = append(rest, "--key", "-")
574					i++
575				default:
576					rest = append(rest, args[i])
577				}
578			}
579			return runAsHost([]string{"admin", "user", "create"}, rest, stdin)
580		},
581	}
582}
583
584// sweep prunes expired sessions and tokens, and rows past their
585// configured retention, hourly and once at start. GITBAY_SWEEP_TICK
586// shortens the interval for tests.
587func sweep(ctx context.Context, st *store.Store, cfg config.Config) {
588	tick := time.Hour
589	if v := os.Getenv("GITBAY_SWEEP_TICK"); v != "" {
590		if d, err := time.ParseDuration(v); err == nil {
591			tick = d
592		}
593	}
594	audit, events, deliveries, mail, push := cfg.Retention.Durations()
595	r := store.Retention{Audit: audit, Events: events,
596		WebhookDeliveries: deliveries, Mail: mail, Push: push}
597	t := time.NewTicker(tick)
598	defer t.Stop()
599	for {
600		swept, err := st.Sweep(r, time.Now())
601		if err != nil {
602			slog.Error("sweeping", "err", err, "removed", swept.Total())
603		} else if n := swept.Total(); n > 0 {
604			slog.Info("swept expired rows", "removed", n, "tables", swept)
605		}
606		select {
607		case <-ctx.Done():
608			return
609		case <-t.C:
610		}
611	}
612}
613
614// reapPending removes self-registered accounts still unverified after
615// maxAge, hourly and once at start. GITBAY_REAP_TICK shortens the
616// interval for tests.
617func reapPending(ctx context.Context, st *store.Store, maxAge time.Duration) {
618	tick := time.Hour
619	if v := os.Getenv("GITBAY_REAP_TICK"); v != "" {
620		if d, err := time.ParseDuration(v); err == nil {
621			tick = d
622		}
623	}
624	t := time.NewTicker(tick)
625	defer t.Stop()
626	for {
627		if removed, err := st.ReapPendingUsers(maxAge); err != nil {
628			slog.Error("reaping pending accounts", "err", err)
629		} else if len(removed) > 0 {
630			slog.Info("removed unverified accounts", "users", removed)
631		}
632		select {
633		case <-ctx.Done():
634			return
635		case <-t.C:
636		}
637	}
638}