cmd/gitbayd/main.go
466 lines · 13356 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/ci"
22 "gitbay.org/gitbay/internal/config"
23 "gitbay.org/gitbay/internal/control"
24 "gitbay.org/gitbay/internal/gitd"
25 "gitbay.org/gitbay/internal/hookd"
26 "gitbay.org/gitbay/internal/httpd"
27 "gitbay.org/gitbay/internal/mail"
28 "gitbay.org/gitbay/internal/mirror"
29 "gitbay.org/gitbay/internal/notify"
30 "gitbay.org/gitbay/internal/policy"
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 go (&ci.Scheduler{St: st, SiteURL: cfg.Server.SiteURL,
165 RepoDir: func(owner, name string) string {
166 return control.RepoDir(cfg.Server.Root, owner, name)
167 }}).Run(whCtx)
168
169 errCh := make(chan error, 3)
170 if cfg.SSH.Mode == "embedded" {
171 srv, err := sshd.New(cfg, st)
172 if err != nil {
173 return err
174 }
175 ln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.SSH.Port)))
176 if err != nil {
177 return err
178 }
179 slog.Info("ssh listening", "addr", ln.Addr())
180 go func() { errCh <- srv.Serve(ln) }()
181 } else {
182 // system mode: the host sshd owns the SSH port and invokes
183 // this binary via AuthorizedKeysCommand + forced command.
184 slog.Info("ssh handled by host sshd (ssh.mode = system)")
185 }
186
187 web := httpd.New(cfg, st)
188 hs := &http.Server{Addr: cfg.HTTP.Addr, Handler: web.Handler()}
189 go func() {
190 slog.Info("http listening", "addr", cfg.HTTP.Addr, "tls", cfg.HTTP.TLS)
191 switch cfg.HTTP.TLS {
192 case "off":
193 errCh <- hs.ListenAndServe()
194 case "files":
195 errCh <- hs.ListenAndServeTLS(cfg.HTTP.CertFile, cfg.HTTP.KeyFile)
196 case "acme":
197 host := cfg.SiteHost()
198 stripPort := func(hp string) string {
199 if h, _, err := net.SplitHostPort(hp); err == nil {
200 return h
201 }
202 return hp
203 }
204 // Beyond the site host, allow <owner>.<pages domain>
205 // for owners that exist — certs come on demand per
206 // subdomain, no wildcard needed.
207 hostPolicy := func(ctx context.Context, h string) error {
208 if h == host {
209 return nil
210 }
211 if pd := cfg.Pages.Domain; pd != "" {
212 if h == pd {
213 return nil // apex: serves a redirect to the forge
214 }
215 if owner, ok := strings.CutSuffix(h, "."+pd); ok &&
216 !strings.Contains(owner, ".") && st.OwnerExists(owner) {
217 return nil
218 }
219 }
220 // Custom pages domains: certs only for claimed hosts.
221 if _, err := st.PageDomainRepo(h); err == nil {
222 return nil
223 }
224 return fmt.Errorf("host %q not served here", h)
225 }
226 m := &autocert.Manager{
227 Prompt: autocert.AcceptTOS,
228 Cache: autocert.DirCache(filepath.Join(cfg.Server.Root, "acme")),
229 HostPolicy: hostPolicy,
230 Email: cfg.HTTP.ACMEEmail,
231 }
232 // TLS-ALPN-01 rides the HTTPS port itself. The optional
233 // plain-HTTP listener adds HTTP-01 and a redirect; losing
234 // it (port 80 taken, no privileges) is not fatal.
235 if addr := cfg.HTTP.ACMEHTTPAddr; addr != "" && addr != "off" {
236 redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
237 // Pages hosts redirect to themselves, not the
238 // forge host.
239 target := host
240 if hostPolicy(r.Context(), stripPort(r.Host)) == nil {
241 target = stripPort(r.Host)
242 }
243 http.Redirect(w, r, "https://"+target+r.URL.RequestURI(), http.StatusMovedPermanently)
244 })
245 go func() {
246 slog.Info("acme http listening", "addr", addr)
247 if err := http.ListenAndServe(addr, m.HTTPHandler(redirect)); err != nil {
248 slog.Warn("acme http listener failed; continuing with TLS-ALPN only", "err", err)
249 }
250 }()
251 }
252 hs.TLSConfig = m.TLSConfig()
253 errCh <- hs.ListenAndServeTLS("", "")
254 }
255 }()
256
257 if cfg.GitDaemon.Enabled {
258 gln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.GitDaemon.Port)))
259 if err != nil {
260 return err
261 }
262 slog.Info("git-daemon listening", "addr", gln.Addr())
263 go func() { errCh <- gitd.New(cfg, st).Serve(gln) }()
264 }
265
266 return <-errCh
267 },
268 }
269}
270
271func migrateCmd() *cobra.Command {
272 var to int
273 cmd := &cobra.Command{
274 Use: "migrate",
275 Short: "apply schema migrations",
276 RunE: func(cmd *cobra.Command, args []string) error {
277 cfg, err := config.Load(configPath)
278 if err != nil {
279 return err
280 }
281 s, err := store.Open(cfg.Server.Root + "/gitbay.db")
282 if err != nil {
283 return err
284 }
285 defer s.Close()
286 if err := s.MigrateTo(to); err != nil {
287 return err
288 }
289 v, err := s.Version()
290 if err != nil {
291 return err
292 }
293 fmt.Println("schema version", v)
294 return nil
295 },
296 }
297 cmd.Flags().IntVar(&to, "to", -1, "target schema version (-1 = latest)")
298 return cmd
299}
300
301func adminCmd() *cobra.Command {
302 admin := &cobra.Command{
303 Use: "admin",
304 Short: "host-local administration",
305 }
306 userCmd := &cobra.Command{Use: "user", Short: "manage users"}
307 userCmd.AddCommand(adminUserCreateCmd(), adminUserDisableCmd(), adminUserEnableCmd(), adminUserDeleteCmd())
308 emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"}
309 emailCmd.AddCommand(adminEmailVerifyCmd())
310 admin.AddCommand(
311 userCmd,
312 emailCmd,
313 adminInviteCmd(),
314 backupCmd(),
315 gcCmd(),
316 statsCmd(),
317 adminAuditCmd(),
318 adminMigrateCommitRefsCmd(),
319 adminBackfillActivityCmd(),
320 )
321 return admin
322}
323
324func adminInviteCmd() *cobra.Command {
325 var email string
326 cmd := &cobra.Command{
327 Use: "invite",
328 Short: "issue a registration invite and email its code",
329 RunE: func(cmd *cobra.Command, args []string) error {
330 if email == "" {
331 return fmt.Errorf("--email is required")
332 }
333 cfg, err := config.Load(configPath)
334 if err != nil {
335 return err
336 }
337 st, err := openStore(cfg)
338 if err != nil {
339 return err
340 }
341 defer st.Close()
342
343 if used, err := st.EmailInUse(email); err != nil {
344 return err
345 } else if used {
346 return fmt.Errorf("%s already belongs to an account; invites are for new users", email)
347 }
348 code, hash, err := store.NewToken()
349 if err != nil {
350 return err
351 }
352 if err := st.CreateInvite(hash, email); err != nil {
353 return err
354 }
355 host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(cfg.Server.SiteURL, "https://"), "http://"), "/")
356 body := fmt.Sprintf(
357 "You have been invited to %s.\n\nCreate your account by running (with the SSH key you want to use):\n\n"+
358 " ssh git@%s register --username <name> --invite %s\n\n"+
359 "The invite is single-use and tied to this address.\n", host, host, code)
360 if cfg.Mail.SMTPHost != "" {
361 if err := mail.Send(cfg, email, "your invite to "+host, body); err != nil {
362 return fmt.Errorf("invite stored but mail failed: %w (code: %s)", err, code)
363 }
364 st.Audit(0, "admin invite.issued", map[string]any{"email": email})
365 fmt.Printf("invite emailed to %s\n", email)
366 } else {
367 fmt.Printf("invite for %s (no SMTP configured; deliver it yourself):\n%s\n", email, code)
368 }
369 return nil
370 },
371 }
372 cmd.Flags().StringVar(&email, "email", "", "address to invite (the account's verified email)")
373 return cmd
374}
375
376func adminUserCreateCmd() *cobra.Command {
377 var keyPath, email string
378 var verified, isAdmin bool
379 cmd := &cobra.Command{
380 Use: "create <username>",
381 Short: "create a user (host-local bootstrap; the only path in closed mode)",
382 Args: cobra.ExactArgs(1),
383 RunE: func(cmd *cobra.Command, args []string) error {
384 username := args[0]
385 if err := policy.ValidateOwnerName(username); err != nil {
386 return err
387 }
388 cfg, err := config.Load(configPath)
389 if err != nil {
390 return err
391 }
392 st, err := openStore(cfg)
393 if err != nil {
394 return err
395 }
396 defer st.Close()
397
398 uid, err := st.CreateUser(username, isAdmin)
399 if err != nil {
400 return err
401 }
402 if email != "" {
403 verifiedBy := ""
404 if verified {
405 verifiedBy = "admin"
406 }
407 if err := st.AddEmail(uid, email, verifiedBy, true); err != nil {
408 return err
409 }
410 }
411 if keyPath != "" {
412 raw, err := os.ReadFile(keyPath)
413 if err != nil {
414 return err
415 }
416 pub, _, _, _, err := ssh.ParseAuthorizedKey(raw)
417 if err != nil {
418 return fmt.Errorf("%s: not a public key in authorized_keys format: %w", keyPath, err)
419 }
420 fp := ssh.FingerprintSHA256(pub)
421 if err := st.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full"); err != nil {
422 return err
423 }
424 fmt.Println("key", fp)
425 }
426 st.Audit(0, "admin user.created", map[string]any{"user": username})
427 fmt.Println("created user", username)
428 return nil
429 },
430 }
431 cmd.Flags().StringVar(&keyPath, "key", "", "path to an SSH public key to register")
432 cmd.Flags().StringVar(&email, "email", "", "primary email address")
433 cmd.Flags().BoolVar(&verified, "verified", false, "mark the email verified (admin assertion)")
434 cmd.Flags().BoolVar(&isAdmin, "admin", false, "grant instance admin")
435 return cmd
436}
437
438func adminEmailVerifyCmd() *cobra.Command {
439 return &cobra.Command{
440 Use: "verify <username> <address>",
441 Short: "mark an email verified by admin assertion",
442 Args: cobra.ExactArgs(2),
443 RunE: func(cmd *cobra.Command, args []string) error {
444 cfg, err := config.Load(configPath)
445 if err != nil {
446 return err
447 }
448 st, err := openStore(cfg)
449 if err != nil {
450 return err
451 }
452 defer st.Close()
453 u, err := st.UserByUsername(args[0])
454 if err != nil {
455 return fmt.Errorf("user %s: %w", args[0], err)
456 }
457 if err := st.VerifyEmail(u.ID, args[1], "admin"); err != nil {
458 st.Audit(0, "admin email.verify_failed", map[string]any{"user": args[0], "email": args[1]})
459 return fmt.Errorf("no address %s on user %s", args[1], args[0])
460 }
461 st.Audit(0, "admin email.verified", map[string]any{"user": args[0], "email": args[1]})
462 fmt.Println("verified", args[1])
463 return nil
464 },
465 }
466}