krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
main: 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}