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