cmd/gitbayd/main.go

471 lines · 13583 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	"log/slog"
  9	"net"
 10	"net/http"
 11	"os"
 12	"path/filepath"
 13	"strconv"
 14	"strings"
 15	"time"
 16
 17	"github.com/spf13/cobra"
 18	"golang.org/x/crypto/acme/autocert"
 19	"golang.org/x/crypto/ssh"
 20
 21	"gitbay.org/gitbay/internal/buildinfo"
 22	"gitbay.org/gitbay/internal/ci"
 23	"gitbay.org/gitbay/internal/config"
 24	"gitbay.org/gitbay/internal/control"
 25	"gitbay.org/gitbay/internal/deps"
 26	"gitbay.org/gitbay/internal/gitd"
 27	"gitbay.org/gitbay/internal/hookd"
 28	"gitbay.org/gitbay/internal/httpd"
 29	"gitbay.org/gitbay/internal/mail"
 30	"gitbay.org/gitbay/internal/mirror"
 31	"gitbay.org/gitbay/internal/notify"
 32	"gitbay.org/gitbay/internal/policy"
 33	"gitbay.org/gitbay/internal/sshd"
 34	"gitbay.org/gitbay/internal/store"
 35	"gitbay.org/gitbay/internal/webhook"
 36)
 37
 38func openStore(cfg config.Config) (*store.Store, error) {
 39	s, err := store.Open(filepath.Join(cfg.Server.Root, "gitbay.db"))
 40	if err != nil {
 41		return nil, err
 42	}
 43	// Say so when the schema moves. A restart migrates in silence otherwise,
 44	// which makes an unexpected schema version hard to attribute to the deploy
 45	// that caused it.
 46	before, err := s.Version()
 47	if err != nil {
 48		s.Close()
 49		return nil, err
 50	}
 51	if err := s.MigrateUp(); err != nil {
 52		s.Close()
 53		return nil, err
 54	}
 55	after, err := s.Version()
 56	if err != nil {
 57		s.Close()
 58		return nil, err
 59	}
 60	if after != before {
 61		slog.Info("schema migrated", "from", before, "to", after)
 62	}
 63	return s, nil
 64}
 65
 66var configPath string
 67
 68func main() {
 69	root := &cobra.Command{
 70		Use:           "gitbayd",
 71		Short:         "gitbay server daemon",
 72		SilenceUsage:  true,
 73		SilenceErrors: true,
 74	}
 75	root.PersistentFlags().StringVar(&configPath, "config", "/etc/gitbay/config.toml", "path to config file")
 76
 77	root.AddCommand(
 78		checkConfigCmd(),
 79		serveCmd(),
 80		migrateCmd(),
 81		adminCmd(),
 82		hookCmd(),
 83		authorizedKeysCmd(),
 84		shellCmd(),
 85		versionCmd(),
 86	)
 87
 88	if err := root.Execute(); err != nil {
 89		fmt.Fprintln(os.Stderr, "gitbayd:", err)
 90		os.Exit(1)
 91	}
 92}
 93
 94func checkConfigCmd() *cobra.Command {
 95	var noHost bool
 96	cmd := &cobra.Command{
 97		Use:   "check-config",
 98		Short: "validate the configuration and exit",
 99		RunE: func(cmd *cobra.Command, args []string) error {
100			cfg, err := config.Load(configPath)
101			if err != nil {
102				return err
103			}
104			if !noHost {
105				if err := cfg.CheckHost(); err != nil {
106					return err
107				}
108			}
109			fmt.Println("config ok")
110			return nil
111		},
112	}
113	cmd.Flags().BoolVar(&noHost, "no-host-checks", false, "skip host environment probes (port binding, paths)")
114	return cmd
115}
116
117func serveCmd() *cobra.Command {
118	return &cobra.Command{
119		Use:   "serve",
120		Short: "run the ssh, http, and git listeners",
121		RunE: func(cmd *cobra.Command, args []string) error {
122			// First line of every run: the journal then says which commit is
123			// serving, without rebuilding the binary to find out.
124			logBuild()
125			cfg, err := config.Load(configPath)
126			if err != nil {
127				return err
128			}
129			warnIfUnmerged(cfg)
130			st, err := openStore(cfg)
131			if err != nil {
132				return err
133			}
134			defer st.Close()
135
136			// Regenerate hook scripts so a moved binary self-heals, then
137			// start the hook policy socket.
138			self, err := os.Executable()
139			if err != nil {
140				return err
141			}
142			if err := hookd.WriteHookScripts(control.HooksDir(cfg.Server.Root), self); err != nil {
143				return err
144			}
145			stopHookd, err := hookd.Serve(cfg, st)
146			if err != nil {
147				return err
148			}
149			defer stopHookd()
150
151			// Outbound webhook deliveries. The retry base is overridable
152			// for tests via GITBAY_WEBHOOK_RETRY_BASE.
153			retryBase := 30 * time.Second
154			if v := os.Getenv("GITBAY_WEBHOOK_RETRY_BASE"); v != "" {
155				if d, err := time.ParseDuration(v); err == nil {
156					retryBase = d
157				}
158			}
159			whCtx, whCancel := context.WithCancel(context.Background())
160			defer whCancel()
161			go webhook.New(st, cfg.Webhooks.AllowLocal, retryBase).Run(whCtx)
162			if cfg.Mail.SMTPHost != "" {
163				go notify.New(st, cfg, retryBase).Run(whCtx)
164			}
165			go mirror.New(st, cfg).Run(whCtx)
166			go (&ci.Scheduler{St: st, SiteURL: cfg.Server.SiteURL,
167				RepoDir: func(owner, name string) string {
168					return control.RepoDir(cfg.Server.Root, owner, name)
169				}}).Run(whCtx)
170			go deps.New(st, cfg, func(owner, name string) string {
171				return control.RepoDir(cfg.Server.Root, owner, name)
172			}, buildinfo.String()).Run(whCtx)
173
174			errCh := make(chan error, 3)
175			if cfg.SSH.Mode == "embedded" {
176				srv, err := sshd.New(cfg, st)
177				if err != nil {
178					return err
179				}
180				ln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.SSH.Port)))
181				if err != nil {
182					return err
183				}
184				slog.Info("ssh listening", "addr", ln.Addr())
185				go func() { errCh <- srv.Serve(ln) }()
186			} else {
187				// system mode: the host sshd owns the SSH port and invokes
188				// this binary via AuthorizedKeysCommand + forced command.
189				slog.Info("ssh handled by host sshd (ssh.mode = system)")
190			}
191
192			web := httpd.New(cfg, st)
193			hs := &http.Server{Addr: cfg.HTTP.Addr, Handler: web.Handler()}
194			go func() {
195				slog.Info("http listening", "addr", cfg.HTTP.Addr, "tls", cfg.HTTP.TLS)
196				switch cfg.HTTP.TLS {
197				case "off":
198					errCh <- hs.ListenAndServe()
199				case "files":
200					errCh <- hs.ListenAndServeTLS(cfg.HTTP.CertFile, cfg.HTTP.KeyFile)
201				case "acme":
202					host := cfg.SiteHost()
203					stripPort := func(hp string) string {
204						if h, _, err := net.SplitHostPort(hp); err == nil {
205							return h
206						}
207						return hp
208					}
209					// Beyond the site host, allow <owner>.<pages domain>
210					// for owners that exist — certs come on demand per
211					// subdomain, no wildcard needed.
212					hostPolicy := func(ctx context.Context, h string) error {
213						if h == host {
214							return nil
215						}
216						if pd := cfg.Pages.Domain; pd != "" {
217							if h == pd {
218								return nil // apex: serves a redirect to the forge
219							}
220							if owner, ok := strings.CutSuffix(h, "."+pd); ok &&
221								!strings.Contains(owner, ".") && st.OwnerExists(owner) {
222								return nil
223							}
224						}
225						// Custom pages domains: certs only for claimed hosts.
226						if _, err := st.PageDomainRepo(h); err == nil {
227							return nil
228						}
229						return fmt.Errorf("host %q not served here", h)
230					}
231					m := &autocert.Manager{
232						Prompt:     autocert.AcceptTOS,
233						Cache:      autocert.DirCache(filepath.Join(cfg.Server.Root, "acme")),
234						HostPolicy: hostPolicy,
235						Email:      cfg.HTTP.ACMEEmail,
236					}
237					// TLS-ALPN-01 rides the HTTPS port itself. The optional
238					// plain-HTTP listener adds HTTP-01 and a redirect; losing
239					// it (port 80 taken, no privileges) is not fatal.
240					if addr := cfg.HTTP.ACMEHTTPAddr; addr != "" && addr != "off" {
241						redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
242							// Pages hosts redirect to themselves, not the
243							// forge host.
244							target := host
245							if hostPolicy(r.Context(), stripPort(r.Host)) == nil {
246								target = stripPort(r.Host)
247							}
248							http.Redirect(w, r, "https://"+target+r.URL.RequestURI(), http.StatusMovedPermanently)
249						})
250						go func() {
251							slog.Info("acme http listening", "addr", addr)
252							if err := http.ListenAndServe(addr, m.HTTPHandler(redirect)); err != nil {
253								slog.Warn("acme http listener failed; continuing with TLS-ALPN only", "err", err)
254							}
255						}()
256					}
257					hs.TLSConfig = m.TLSConfig()
258					errCh <- hs.ListenAndServeTLS("", "")
259				}
260			}()
261
262			if cfg.GitDaemon.Enabled {
263				gln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.GitDaemon.Port)))
264				if err != nil {
265					return err
266				}
267				slog.Info("git-daemon listening", "addr", gln.Addr())
268				go func() { errCh <- gitd.New(cfg, st).Serve(gln) }()
269			}
270
271			return <-errCh
272		},
273	}
274}
275
276func migrateCmd() *cobra.Command {
277	var to int
278	cmd := &cobra.Command{
279		Use:   "migrate",
280		Short: "apply schema migrations",
281		RunE: func(cmd *cobra.Command, args []string) error {
282			cfg, err := config.Load(configPath)
283			if err != nil {
284				return err
285			}
286			s, err := store.Open(cfg.Server.Root + "/gitbay.db")
287			if err != nil {
288				return err
289			}
290			defer s.Close()
291			if err := s.MigrateTo(to); err != nil {
292				return err
293			}
294			v, err := s.Version()
295			if err != nil {
296				return err
297			}
298			fmt.Println("schema version", v)
299			return nil
300		},
301	}
302	cmd.Flags().IntVar(&to, "to", -1, "target schema version (-1 = latest)")
303	return cmd
304}
305
306func adminCmd() *cobra.Command {
307	admin := &cobra.Command{
308		Use:   "admin",
309		Short: "host-local administration",
310	}
311	userCmd := &cobra.Command{Use: "user", Short: "manage users"}
312	userCmd.AddCommand(adminUserCreateCmd(), adminUserDisableCmd(), adminUserEnableCmd(), adminUserDeleteCmd())
313	emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"}
314	emailCmd.AddCommand(adminEmailVerifyCmd())
315	admin.AddCommand(
316		userCmd,
317		emailCmd,
318		adminInviteCmd(),
319		backupCmd(),
320		gcCmd(),
321		statsCmd(),
322		adminAuditCmd(),
323		adminMigrateCommitRefsCmd(),
324		adminBackfillActivityCmd(),
325	)
326	return admin
327}
328
329func adminInviteCmd() *cobra.Command {
330	var email string
331	cmd := &cobra.Command{
332		Use:   "invite",
333		Short: "issue a registration invite and email its code",
334		RunE: func(cmd *cobra.Command, args []string) error {
335			if email == "" {
336				return fmt.Errorf("--email is required")
337			}
338			cfg, err := config.Load(configPath)
339			if err != nil {
340				return err
341			}
342			st, err := openStore(cfg)
343			if err != nil {
344				return err
345			}
346			defer st.Close()
347
348			if used, err := st.EmailInUse(email); err != nil {
349				return err
350			} else if used {
351				return fmt.Errorf("%s already belongs to an account; invites are for new users", email)
352			}
353			code, hash, err := store.NewToken()
354			if err != nil {
355				return err
356			}
357			if err := st.CreateInvite(hash, email); err != nil {
358				return err
359			}
360			host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(cfg.Server.SiteURL, "https://"), "http://"), "/")
361			body := fmt.Sprintf(
362				"You have been invited to %s.\n\nCreate your account by running (with the SSH key you want to use):\n\n"+
363					"    ssh git@%s register --username <name> --invite %s\n\n"+
364					"The invite is single-use and tied to this address.\n", host, host, code)
365			if cfg.Mail.SMTPHost != "" {
366				if err := mail.Send(cfg, email, "your invite to "+host, body); err != nil {
367					return fmt.Errorf("invite stored but mail failed: %w (code: %s)", err, code)
368				}
369				st.Audit(0, "admin invite.issued", map[string]any{"email": email})
370				fmt.Printf("invite emailed to %s\n", email)
371			} else {
372				fmt.Printf("invite for %s (no SMTP configured; deliver it yourself):\n%s\n", email, code)
373			}
374			return nil
375		},
376	}
377	cmd.Flags().StringVar(&email, "email", "", "address to invite (the account's verified email)")
378	return cmd
379}
380
381func adminUserCreateCmd() *cobra.Command {
382	var keyPath, email string
383	var verified, isAdmin bool
384	cmd := &cobra.Command{
385		Use:   "create <username>",
386		Short: "create a user (host-local bootstrap; the only path in closed mode)",
387		Args:  cobra.ExactArgs(1),
388		RunE: func(cmd *cobra.Command, args []string) error {
389			username := args[0]
390			if err := policy.ValidateOwnerName(username); err != nil {
391				return err
392			}
393			cfg, err := config.Load(configPath)
394			if err != nil {
395				return err
396			}
397			st, err := openStore(cfg)
398			if err != nil {
399				return err
400			}
401			defer st.Close()
402
403			uid, err := st.CreateUser(username, isAdmin)
404			if err != nil {
405				return err
406			}
407			if email != "" {
408				verifiedBy := ""
409				if verified {
410					verifiedBy = "admin"
411				}
412				if err := st.AddEmail(uid, email, verifiedBy, true); err != nil {
413					return err
414				}
415			}
416			if keyPath != "" {
417				raw, err := os.ReadFile(keyPath)
418				if err != nil {
419					return err
420				}
421				pub, _, _, _, err := ssh.ParseAuthorizedKey(raw)
422				if err != nil {
423					return fmt.Errorf("%s: not a public key in authorized_keys format: %w", keyPath, err)
424				}
425				fp := ssh.FingerprintSHA256(pub)
426				if err := st.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full"); err != nil {
427					return err
428				}
429				fmt.Println("key", fp)
430			}
431			st.Audit(0, "admin user.created", map[string]any{"user": username})
432			fmt.Println("created user", username)
433			return nil
434		},
435	}
436	cmd.Flags().StringVar(&keyPath, "key", "", "path to an SSH public key to register")
437	cmd.Flags().StringVar(&email, "email", "", "primary email address")
438	cmd.Flags().BoolVar(&verified, "verified", false, "mark the email verified (admin assertion)")
439	cmd.Flags().BoolVar(&isAdmin, "admin", false, "grant instance admin")
440	return cmd
441}
442
443func adminEmailVerifyCmd() *cobra.Command {
444	return &cobra.Command{
445		Use:   "verify <username> <address>",
446		Short: "mark an email verified by admin assertion",
447		Args:  cobra.ExactArgs(2),
448		RunE: func(cmd *cobra.Command, args []string) error {
449			cfg, err := config.Load(configPath)
450			if err != nil {
451				return err
452			}
453			st, err := openStore(cfg)
454			if err != nil {
455				return err
456			}
457			defer st.Close()
458			u, err := st.UserByUsername(args[0])
459			if err != nil {
460				return fmt.Errorf("user %s: %w", args[0], err)
461			}
462			if err := st.VerifyEmail(u.ID, args[1], "admin"); err != nil {
463				st.Audit(0, "admin email.verify_failed", map[string]any{"user": args[0], "email": args[1]})
464				return fmt.Errorf("no address %s on user %s", args[1], args[0])
465			}
466			st.Audit(0, "admin email.verified", map[string]any{"user": args[0], "email": args[1]})
467			fmt.Println("verified", args[1])
468			return nil
469		},
470	}
471}