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