cmd/gitbayd/main.go

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

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