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}