cmd/gitbayd/main.go

ff759b53942049b2043e132f5482b442a7265b98
gitbay/cmd/gitbayd/main.go history · blame · raw

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