// gitbayd is the forge server daemon. The same binary also runs in hook mode // (invoked by git via core.hooksPath) and hosts the host-local admin commands. package main import ( "context" "fmt" "io" "log/slog" "net" "net/http" "os" "os/signal" "path/filepath" "strconv" "strings" "syscall" "time" "github.com/spf13/cobra" "golang.org/x/crypto/acme/autocert" "gitbay.org/gitbay/internal/buildinfo" "gitbay.org/gitbay/internal/ci" "gitbay.org/gitbay/internal/config" "gitbay.org/gitbay/internal/control" "gitbay.org/gitbay/internal/deps" "gitbay.org/gitbay/internal/gitd" "gitbay.org/gitbay/internal/hookd" "gitbay.org/gitbay/internal/httpd" "gitbay.org/gitbay/internal/mirror" "gitbay.org/gitbay/internal/notify" "gitbay.org/gitbay/internal/sshd" "gitbay.org/gitbay/internal/store" "gitbay.org/gitbay/internal/toolpath" "gitbay.org/gitbay/internal/webhook" ) func openStore(cfg config.Config) (*store.Store, error) { s, err := store.Open(filepath.Join(cfg.Server.Root, "gitbay.db")) if err != nil { return nil, err } // Say so when the schema moves. A restart migrates in silence otherwise, // which makes an unexpected schema version hard to attribute to the deploy // that caused it. before, err := s.Version() if err != nil { s.Close() return nil, err } if err := s.MigrateUp(); err != nil { s.Close() return nil, err } after, err := s.Version() if err != nil { s.Close() return nil, err } if after != before { slog.Info("schema migrated", "from", before, "to", after) } return s, nil } var configPath string func main() { root := &cobra.Command{ Use: "gitbayd", Short: "gitbay server daemon", SilenceUsage: true, SilenceErrors: true, } root.PersistentFlags().StringVar(&configPath, "config", "/etc/gitbay/config.toml", "path to config file") root.AddCommand( checkConfigCmd(), serveCmd(), migrateCmd(), adminCmd(), hookCmd(), authorizedKeysCmd(), shellCmd(), versionCmd(), ) if err := root.Execute(); err != nil { fmt.Fprintln(os.Stderr, "gitbayd:", err) os.Exit(1) } } func checkConfigCmd() *cobra.Command { var noHost bool cmd := &cobra.Command{ Use: "check-config", Short: "validate the configuration and exit", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load(configPath) if err != nil { return err } if !noHost { if err := cfg.CheckHost(); err != nil { return err } } fmt.Println("config ok") return nil }, } cmd.Flags().BoolVar(&noHost, "no-host-checks", false, "skip host environment probes (port binding, paths)") return cmd } func serveCmd() *cobra.Command { return &cobra.Command{ Use: "serve", Short: "run the ssh, http, and git listeners", RunE: func(cmd *cobra.Command, args []string) error { // First line of every run: the journal then says which commit is // serving, without rebuilding the binary to find out. logBuild() // The tools this daemon shells out to are resolved once, at // package init. Say so now rather than failing on whichever // request first needed git. if err := toolpath.Verify(); err != nil { return fmt.Errorf("required tools missing: %w", err) } cfg, err := config.Load(configPath) if err != nil { return err } warnIfUnmerged(cfg) st, err := openStore(cfg) if err != nil { return err } defer st.Close() // Regenerate hook scripts so a moved binary self-heals, then // start the hook policy socket. self, err := os.Executable() if err != nil { return err } if err := hookd.WriteHookScripts(control.HooksDir(cfg.Server.Root), self); err != nil { return err } stopHookd, err := hookd.Serve(cfg, st) if err != nil { return err } defer stopHookd() // SIGTERM is how a deploy restarts the daemon: stop accepting, // let what is in flight finish, exit 0 (#105). ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() // Outbound webhook deliveries, and the mail queue when SMTP is // configured, share one retry base. It is overridable for // tests via GITBAY_WEBHOOK_RETRY_BASE; notify.DefaultRetryBase // names the production default so nothing else has to guess it. retryBase := notify.DefaultRetryBase if v := os.Getenv("GITBAY_WEBHOOK_RETRY_BASE"); v != "" { if d, err := time.ParseDuration(v); err == nil { retryBase = d } } whCtx, whCancel := context.WithCancel(context.Background()) defer whCancel() go webhook.New(st, cfg.Webhooks.AllowLocal, retryBase).Run(whCtx) if cfg.Mail.SMTPHost != "" { go notify.New(st, cfg, retryBase).Run(whCtx) } go mirror.New(st, cfg).Run(whCtx) if d := cfg.Registration.PendingExpiryDuration(); d > 0 { go reapPending(whCtx, st, d) } go sweep(whCtx, st, cfg) go (&ci.Scheduler{St: st, SiteURL: cfg.Server.SiteURL, RepoDir: func(owner, name string) string { return control.RepoDir(cfg.Server.Root, owner, name) }}).Run(whCtx) go deps.New(st, cfg, func(owner, name string) string { return control.RepoDir(cfg.Server.Root, owner, name) }, buildinfo.String()).Run(whCtx) errCh := make(chan error, 3) var sshSrv *sshd.Server var sshLn, gitLn net.Listener if cfg.SSH.Mode == "embedded" { srv, err := sshd.New(cfg, st) if err != nil { return err } ln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.SSH.Port))) if err != nil { return err } slog.Info("ssh listening", "addr", ln.Addr()) sshSrv, sshLn = srv, ln go func() { errCh <- srv.Serve(ln) }() } else { // system mode: the host sshd owns the SSH port and invokes // this binary via AuthorizedKeysCommand + forced command. slog.Info("ssh handled by host sshd (ssh.mode = system)") } web := httpd.New(cfg, st) // Header and idle timeouts bound what an idle or slow client can // hold open. No write timeout: archives and upload-pack stream // for as long as they take (#104). hs := &http.Server{ Addr: cfg.HTTP.Addr, Handler: web.Handler(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 2 * time.Minute, MaxHeaderBytes: 64 << 10, } go func() { slog.Info("http listening", "addr", cfg.HTTP.Addr, "tls", cfg.HTTP.TLS) switch cfg.HTTP.TLS { case "off": errCh <- hs.ListenAndServe() case "files": errCh <- hs.ListenAndServeTLS(cfg.HTTP.CertFile, cfg.HTTP.KeyFile) case "acme": host := cfg.SiteHost() stripPort := func(hp string) string { if h, _, err := net.SplitHostPort(hp); err == nil { return h } return hp } // Beyond the site host, allow . // for owners that exist — certs come on demand per // subdomain, no wildcard needed. hostPolicy := func(ctx context.Context, h string) error { if h == host { return nil } if pd := cfg.Pages.Domain; pd != "" { if h == pd { return nil // apex: serves a redirect to the forge } if owner, ok := strings.CutSuffix(h, "."+pd); ok && !strings.Contains(owner, ".") && st.OwnerExists(owner) { return nil } } // Custom pages domains: certs only for claimed hosts. if _, err := st.PageDomainRepo(h); err == nil { return nil } return fmt.Errorf("host %q not served here", h) } m := &autocert.Manager{ Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(filepath.Join(cfg.Server.Root, "acme")), HostPolicy: hostPolicy, Email: cfg.HTTP.ACMEEmail, } // TLS-ALPN-01 rides the HTTPS port itself. The optional // plain-HTTP listener adds HTTP-01 and a redirect; losing // it (port 80 taken, no privileges) is not fatal. if addr := cfg.HTTP.ACMEHTTPAddr; addr != "" && addr != "off" { redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Pages hosts redirect to themselves, not the // forge host. target := host if hostPolicy(r.Context(), stripPort(r.Host)) == nil { target = stripPort(r.Host) } http.Redirect(w, r, "https://"+target+r.URL.RequestURI(), http.StatusMovedPermanently) }) go func() { slog.Info("acme http listening", "addr", addr) acmeHTTP := &http.Server{Addr: addr, Handler: m.HTTPHandler(redirect), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: time.Minute} if err := acmeHTTP.ListenAndServe(); err != nil { slog.Warn("acme http listener failed; continuing with TLS-ALPN only", "err", err) } }() } hs.TLSConfig = m.TLSConfig() errCh <- hs.ListenAndServeTLS("", "") } }() if cfg.GitDaemon.Enabled { gln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.GitDaemon.Port))) if err != nil { return err } slog.Info("git-daemon listening", "addr", gln.Addr()) gitLn = gln go func() { errCh <- gitd.New(cfg, st).Serve(gln) }() } select { case err := <-errCh: return err case <-ctx.Done(): } slog.Info("shutting down") stop() for _, ln := range []net.Listener{sshLn, gitLn} { if ln != nil { ln.Close() } } drain, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := hs.Shutdown(drain); err != nil { slog.Warn("http shutdown", "err", err) } if sshSrv != nil { if err := sshSrv.Shutdown(drain); err != nil { slog.Warn("ssh shutdown", "err", err) } } return nil }, } } func migrateCmd() *cobra.Command { var to int cmd := &cobra.Command{ Use: "migrate", Short: "apply schema migrations", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load(configPath) if err != nil { return err } s, err := store.Open(cfg.Server.Root + "/gitbay.db") if err != nil { return err } defer s.Close() if err := s.MigrateTo(to); err != nil { return err } v, err := s.Version() if err != nil { return err } fmt.Println("schema version", v) return nil }, } cmd.Flags().IntVar(&to, "to", -1, "target schema version (-1 = latest)") return cmd } func adminCmd() *cobra.Command { admin := &cobra.Command{ Use: "admin", Short: "host-local administration", } userCmd := &cobra.Command{Use: "user", Short: "manage users"} userCmd.AddCommand( hostUserCreateCmd(), hostCmd("list [--state active|pending|disabled|admin] [--limit n] [--cursor c]", "list accounts", "admin", "user", "list"), hostCmd("show ", "show an account: keys, emails, orgs, tokens, sessions", "admin", "user", "show"), hostCmd("disable ", "suspend an account: keys and sessions refused until re-enabled", "admin", "user", "disable"), hostCmd("enable ", "restore a suspended account", "admin", "user", "enable"), hostCmd("delete --yes", "delete an account that anchors nothing (keys, emails, and sessions go with it)", "admin", "user", "delete"), hostCmd("promote ", "make an account an instance admin", "admin", "user", "promote"), hostCmd("demote ", "remove instance admin from an account (never the last one)", "admin", "user", "demote"), hostCmd("limits [--repos n|default] [--bytes n|default]", "show or set repository and storage caps", "admin", "user", "limits"), ) emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"} emailCmd.AddCommand(hostCmd("verify
", "mark an email verified by admin assertion", "admin", "email", "verify")) repoCmd := &cobra.Command{Use: "repo", Short: "any repository, for moderation (audited)"} repoCmd.AddCommand( hostCmd("list [--owner o] [--visibility public|private] [--limit n] [--cursor c]", "every repository with size and last push", "admin", "repo", "list"), hostCmd("archive ", "archive a repository", "admin", "repo", "archive"), hostCmd("unarchive ", "unarchive a repository", "admin", "repo", "unarchive"), hostCmd("visibility public|private", "set a repository's visibility", "admin", "repo", "visibility"), hostCmd("delete --yes", "delete a repository", "admin", "repo", "delete"), ) configCmd := &cobra.Command{Use: "config", Short: "the configuration in effect"} configCmd.AddCommand(configShowCmd()) admin.AddCommand( userCmd, emailCmd, repoCmd, configCmd, hostCmd("invite --email
", "issue a registration invite and email its code", "admin", "invite"), hostCmd("stats [--json]", "instance statistics: counts and per-repository disk usage", "admin", "stats"), hostCmd("runners [--json]", "runner accounts: last poll, scope, the build each holds", "admin", "runners"), hostCmd("audit [--limit n] [--json]", "print the security audit log, newest first", "audit"), backupCmd(), gcCmd(), adminMigrateCommitRefsCmd(), adminMigrateProfileAboutCmd(), adminBackfillActivityCmd(), ) return admin } // hostCmd runs a registry command as the host itself: an admin context // with no account behind it, so audit rows carry no actor and the source // "host". Arguments pass through untouched; the registry owns the flags, // which is what keeps this surface and an admin's SSH session from // drifting. func hostCmd(use, short string, path ...string) *cobra.Command { return &cobra.Command{ Use: use, Short: short, DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { for _, a := range args { if a == "--help" || a == "-h" { return cmd.Help() } } return runAsHost(path, args, os.Stdin) }, } } // hostArgs pulls the root's --config out of args: with flag parsing off, // cobra hands the persistent flag through untouched. func hostArgs(args []string) []string { var rest []string for i := 0; i < len(args); i++ { switch { case args[i] == "--config" && i+1 < len(args): configPath = args[i+1] i++ case strings.HasPrefix(args[i], "--config="): configPath = strings.TrimPrefix(args[i], "--config=") default: rest = append(rest, args[i]) } } return rest } func runAsHost(path, args []string, stdin io.Reader) error { args = hostArgs(args) cfg, err := config.Load(configPath) if err != nil { return err } st, err := openStore(cfg) if err != nil { return err } c := &control.Ctx{ User: store.User{Username: "host", IsAdmin: true}, Scope: "full", Store: st, Cfg: cfg, Stdin: stdin, Stdout: os.Stdout, Stderr: os.Stderr, Source: "host", } code := control.Dispatch(c, append(append([]string{}, path...), args...)) st.Close() if code != 0 { os.Exit(code) } return nil } // hostUserCreateCmd keeps --key , which the registry command cannot // take (no file paths over SSH): the file becomes the command's stdin. func hostUserCreateCmd() *cobra.Command { return &cobra.Command{ Use: "create [--admin] [--email
[--verified]] [--key ]", Short: "create a user (host-local bootstrap; the only path in closed mode)", DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { var stdin io.Reader = os.Stdin var rest []string args = hostArgs(args) for i := 0; i < len(args); i++ { switch { case args[i] == "--help" || args[i] == "-h": return cmd.Help() case args[i] == "--key" && i+1 < len(args) && args[i+1] != "-": f, err := os.Open(args[i+1]) if err != nil { return err } defer f.Close() stdin = f rest = append(rest, "--key", "-") i++ default: rest = append(rest, args[i]) } } return runAsHost([]string{"admin", "user", "create"}, rest, stdin) }, } } // sweep prunes expired sessions and tokens, and rows past their // configured retention, hourly and once at start. GITBAY_SWEEP_TICK // shortens the interval for tests. func sweep(ctx context.Context, st *store.Store, cfg config.Config) { tick := time.Hour if v := os.Getenv("GITBAY_SWEEP_TICK"); v != "" { if d, err := time.ParseDuration(v); err == nil { tick = d } } audit, events, deliveries, mail := cfg.Retention.Durations() r := store.Retention{Audit: audit, Events: events, WebhookDeliveries: deliveries, Mail: mail} t := time.NewTicker(tick) defer t.Stop() for { swept, err := st.Sweep(r, time.Now()) if err != nil { slog.Error("sweeping", "err", err, "removed", swept.Total()) } else if n := swept.Total(); n > 0 { slog.Info("swept expired rows", "removed", n, "tables", swept) } select { case <-ctx.Done(): return case <-t.C: } } } // reapPending removes self-registered accounts still unverified after // maxAge, hourly and once at start. GITBAY_REAP_TICK shortens the // interval for tests. func reapPending(ctx context.Context, st *store.Store, maxAge time.Duration) { tick := time.Hour if v := os.Getenv("GITBAY_REAP_TICK"); v != "" { if d, err := time.ParseDuration(v); err == nil { tick = d } } t := time.NewTicker(tick) defer t.Stop() for { if removed, err := st.ReapPendingUsers(maxAge); err != nil { slog.Error("reaping pending accounts", "err", err) } else if len(removed) > 0 { slog.Info("removed unverified accounts", "users", removed) } select { case <-ctx.Done(): return case <-t.C: } } }