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}