cmd/gitbayd/main.go

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