cmd/gitbayd/main.go
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}