cmd/gitbayd/main.go

477 lines · 14794 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	"io"
  9	"log/slog"
 10	"net"
 11	"net/http"
 12	"os"
 13	"path/filepath"
 14	"strconv"
 15	"strings"
 16	"time"
 17
 18	"github.com/spf13/cobra"
 19	"golang.org/x/crypto/acme/autocert"
 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/mirror"
 30	"gitbay.org/gitbay/internal/notify"
 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			if d := cfg.Registration.PendingExpiryDuration(); d > 0 {
165				go reapPending(whCtx, st, d)
166			}
167			go (&ci.Scheduler{St: st, SiteURL: cfg.Server.SiteURL,
168				RepoDir: func(owner, name string) string {
169					return control.RepoDir(cfg.Server.Root, owner, name)
170				}}).Run(whCtx)
171			go deps.New(st, cfg, func(owner, name string) string {
172				return control.RepoDir(cfg.Server.Root, owner, name)
173			}, buildinfo.String()).Run(whCtx)
174
175			errCh := make(chan error, 3)
176			if cfg.SSH.Mode == "embedded" {
177				srv, err := sshd.New(cfg, st)
178				if err != nil {
179					return err
180				}
181				ln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.SSH.Port)))
182				if err != nil {
183					return err
184				}
185				slog.Info("ssh listening", "addr", ln.Addr())
186				go func() { errCh <- srv.Serve(ln) }()
187			} else {
188				// system mode: the host sshd owns the SSH port and invokes
189				// this binary via AuthorizedKeysCommand + forced command.
190				slog.Info("ssh handled by host sshd (ssh.mode = system)")
191			}
192
193			web := httpd.New(cfg, st)
194			hs := &http.Server{Addr: cfg.HTTP.Addr, Handler: web.Handler()}
195			go func() {
196				slog.Info("http listening", "addr", cfg.HTTP.Addr, "tls", cfg.HTTP.TLS)
197				switch cfg.HTTP.TLS {
198				case "off":
199					errCh <- hs.ListenAndServe()
200				case "files":
201					errCh <- hs.ListenAndServeTLS(cfg.HTTP.CertFile, cfg.HTTP.KeyFile)
202				case "acme":
203					host := cfg.SiteHost()
204					stripPort := func(hp string) string {
205						if h, _, err := net.SplitHostPort(hp); err == nil {
206							return h
207						}
208						return hp
209					}
210					// Beyond the site host, allow <owner>.<pages domain>
211					// for owners that exist — certs come on demand per
212					// subdomain, no wildcard needed.
213					hostPolicy := func(ctx context.Context, h string) error {
214						if h == host {
215							return nil
216						}
217						if pd := cfg.Pages.Domain; pd != "" {
218							if h == pd {
219								return nil // apex: serves a redirect to the forge
220							}
221							if owner, ok := strings.CutSuffix(h, "."+pd); ok &&
222								!strings.Contains(owner, ".") && st.OwnerExists(owner) {
223								return nil
224							}
225						}
226						// Custom pages domains: certs only for claimed hosts.
227						if _, err := st.PageDomainRepo(h); err == nil {
228							return nil
229						}
230						return fmt.Errorf("host %q not served here", h)
231					}
232					m := &autocert.Manager{
233						Prompt:     autocert.AcceptTOS,
234						Cache:      autocert.DirCache(filepath.Join(cfg.Server.Root, "acme")),
235						HostPolicy: hostPolicy,
236						Email:      cfg.HTTP.ACMEEmail,
237					}
238					// TLS-ALPN-01 rides the HTTPS port itself. The optional
239					// plain-HTTP listener adds HTTP-01 and a redirect; losing
240					// it (port 80 taken, no privileges) is not fatal.
241					if addr := cfg.HTTP.ACMEHTTPAddr; addr != "" && addr != "off" {
242						redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
243							// Pages hosts redirect to themselves, not the
244							// forge host.
245							target := host
246							if hostPolicy(r.Context(), stripPort(r.Host)) == nil {
247								target = stripPort(r.Host)
248							}
249							http.Redirect(w, r, "https://"+target+r.URL.RequestURI(), http.StatusMovedPermanently)
250						})
251						go func() {
252							slog.Info("acme http listening", "addr", addr)
253							if err := http.ListenAndServe(addr, m.HTTPHandler(redirect)); err != nil {
254								slog.Warn("acme http listener failed; continuing with TLS-ALPN only", "err", err)
255							}
256						}()
257					}
258					hs.TLSConfig = m.TLSConfig()
259					errCh <- hs.ListenAndServeTLS("", "")
260				}
261			}()
262
263			if cfg.GitDaemon.Enabled {
264				gln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.GitDaemon.Port)))
265				if err != nil {
266					return err
267				}
268				slog.Info("git-daemon listening", "addr", gln.Addr())
269				go func() { errCh <- gitd.New(cfg, st).Serve(gln) }()
270			}
271
272			return <-errCh
273		},
274	}
275}
276
277func migrateCmd() *cobra.Command {
278	var to int
279	cmd := &cobra.Command{
280		Use:   "migrate",
281		Short: "apply schema migrations",
282		RunE: func(cmd *cobra.Command, args []string) error {
283			cfg, err := config.Load(configPath)
284			if err != nil {
285				return err
286			}
287			s, err := store.Open(cfg.Server.Root + "/gitbay.db")
288			if err != nil {
289				return err
290			}
291			defer s.Close()
292			if err := s.MigrateTo(to); err != nil {
293				return err
294			}
295			v, err := s.Version()
296			if err != nil {
297				return err
298			}
299			fmt.Println("schema version", v)
300			return nil
301		},
302	}
303	cmd.Flags().IntVar(&to, "to", -1, "target schema version (-1 = latest)")
304	return cmd
305}
306
307func adminCmd() *cobra.Command {
308	admin := &cobra.Command{
309		Use:   "admin",
310		Short: "host-local administration",
311	}
312	userCmd := &cobra.Command{Use: "user", Short: "manage users"}
313	userCmd.AddCommand(
314		hostUserCreateCmd(),
315		hostCmd("list [--state active|pending|disabled|admin] [--limit n] [--cursor c]", "list accounts", "admin", "user", "list"),
316		hostCmd("show <username>", "show an account: keys, emails, orgs, tokens, sessions", "admin", "user", "show"),
317		hostCmd("disable <username>", "suspend an account: keys and sessions refused until re-enabled", "admin", "user", "disable"),
318		hostCmd("enable <username>", "restore a suspended account", "admin", "user", "enable"),
319		hostCmd("delete <username> --yes", "delete an account that anchors nothing (keys, emails, and sessions go with it)", "admin", "user", "delete"),
320		hostCmd("promote <username>", "make an account an instance admin", "admin", "user", "promote"),
321		hostCmd("demote <username>", "remove instance admin from an account (never the last one)", "admin", "user", "demote"),
322		hostCmd("limits <username> [--repos n|default] [--bytes n|default]", "show or set repository and storage caps", "admin", "user", "limits"),
323	)
324	emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"}
325	emailCmd.AddCommand(hostCmd("verify <username> <address>", "mark an email verified by admin assertion", "admin", "email", "verify"))
326	repoCmd := &cobra.Command{Use: "repo", Short: "any repository, for moderation (audited)"}
327	repoCmd.AddCommand(
328		hostCmd("list [--owner o] [--visibility public|private] [--limit n] [--cursor c]", "every repository with size and last push", "admin", "repo", "list"),
329		hostCmd("archive <owner/name>", "archive a repository", "admin", "repo", "archive"),
330		hostCmd("unarchive <owner/name>", "unarchive a repository", "admin", "repo", "unarchive"),
331		hostCmd("visibility <owner/name> public|private", "set a repository's visibility", "admin", "repo", "visibility"),
332		hostCmd("delete <owner/name> --yes", "delete a repository", "admin", "repo", "delete"),
333	)
334	configCmd := &cobra.Command{Use: "config", Short: "the configuration in effect"}
335	configCmd.AddCommand(configShowCmd())
336	admin.AddCommand(
337		userCmd,
338		emailCmd,
339		repoCmd,
340		configCmd,
341		hostCmd("invite --email <address>", "issue a registration invite and email its code", "admin", "invite"),
342		hostCmd("stats [--json]", "instance statistics: counts and per-repository disk usage", "admin", "stats"),
343		hostCmd("runners [--json]", "runner accounts: last poll, scope, the build each holds", "admin", "runners"),
344		hostCmd("audit [--limit n] [--json]", "print the security audit log, newest first", "audit"),
345		backupCmd(),
346		gcCmd(),
347		adminMigrateCommitRefsCmd(),
348		adminBackfillActivityCmd(),
349	)
350	return admin
351}
352
353// hostCmd runs a registry command as the host itself: an admin context
354// with no account behind it, so audit rows carry no actor and the source
355// "host". Arguments pass through untouched; the registry owns the flags,
356// which is what keeps this surface and an admin's SSH session from
357// drifting.
358func hostCmd(use, short string, path ...string) *cobra.Command {
359	return &cobra.Command{
360		Use:                use,
361		Short:              short,
362		DisableFlagParsing: true,
363		RunE: func(cmd *cobra.Command, args []string) error {
364			for _, a := range args {
365				if a == "--help" || a == "-h" {
366					return cmd.Help()
367				}
368			}
369			return runAsHost(path, args, os.Stdin)
370		},
371	}
372}
373
374// hostArgs pulls the root's --config out of args: with flag parsing off,
375// cobra hands the persistent flag through untouched.
376func hostArgs(args []string) []string {
377	var rest []string
378	for i := 0; i < len(args); i++ {
379		switch {
380		case args[i] == "--config" && i+1 < len(args):
381			configPath = args[i+1]
382			i++
383		case strings.HasPrefix(args[i], "--config="):
384			configPath = strings.TrimPrefix(args[i], "--config=")
385		default:
386			rest = append(rest, args[i])
387		}
388	}
389	return rest
390}
391
392func runAsHost(path, args []string, stdin io.Reader) error {
393	args = hostArgs(args)
394	cfg, err := config.Load(configPath)
395	if err != nil {
396		return err
397	}
398	st, err := openStore(cfg)
399	if err != nil {
400		return err
401	}
402	c := &control.Ctx{
403		User:   store.User{Username: "host", IsAdmin: true},
404		Scope:  "full",
405		Store:  st,
406		Cfg:    cfg,
407		Stdin:  stdin,
408		Stdout: os.Stdout,
409		Stderr: os.Stderr,
410		Source: "host",
411	}
412	code := control.Dispatch(c, append(append([]string{}, path...), args...))
413	st.Close()
414	if code != 0 {
415		os.Exit(code)
416	}
417	return nil
418}
419
420// hostUserCreateCmd keeps --key <path>, which the registry command cannot
421// take (no file paths over SSH): the file becomes the command's stdin.
422func hostUserCreateCmd() *cobra.Command {
423	return &cobra.Command{
424		Use:                "create <username> [--admin] [--email <address> [--verified]] [--key <file>]",
425		Short:              "create a user (host-local bootstrap; the only path in closed mode)",
426		DisableFlagParsing: true,
427		RunE: func(cmd *cobra.Command, args []string) error {
428			var stdin io.Reader = os.Stdin
429			var rest []string
430			args = hostArgs(args)
431			for i := 0; i < len(args); i++ {
432				switch {
433				case args[i] == "--help" || args[i] == "-h":
434					return cmd.Help()
435				case args[i] == "--key" && i+1 < len(args) && args[i+1] != "-":
436					f, err := os.Open(args[i+1])
437					if err != nil {
438						return err
439					}
440					defer f.Close()
441					stdin = f
442					rest = append(rest, "--key", "-")
443					i++
444				default:
445					rest = append(rest, args[i])
446				}
447			}
448			return runAsHost([]string{"admin", "user", "create"}, rest, stdin)
449		},
450	}
451}
452
453// reapPending removes self-registered accounts still unverified after
454// maxAge, hourly and once at start. GITBAY_REAP_TICK shortens the
455// interval for tests.
456func reapPending(ctx context.Context, st *store.Store, maxAge time.Duration) {
457	tick := time.Hour
458	if v := os.Getenv("GITBAY_REAP_TICK"); v != "" {
459		if d, err := time.ParseDuration(v); err == nil {
460			tick = d
461		}
462	}
463	t := time.NewTicker(tick)
464	defer t.Stop()
465	for {
466		if removed, err := st.ReapPendingUsers(maxAge); err != nil {
467			slog.Error("reaping pending accounts", "err", err)
468		} else if len(removed) > 0 {
469			slog.Info("removed unverified accounts", "users", removed)
470		}
471		select {
472		case <-ctx.Done():
473			return
474		case <-t.C:
475		}
476	}
477}