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}