cmd/gitbayd/main.go

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