cmd/gitbayd/main.go

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