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