cmd/gitbayd/main.go

f89140d141a2e5f88ef64660dfc516ebd0af5b48
gitbay/cmd/gitbayd/main.go history · blame · raw

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