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