krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

repo-descriptions: cmd/gitbayd/main.go · raw

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