cmd/gitbayd/main.go

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