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