cmd/gitbayd/main.go

24c0aad9bf75f925497b0f6de2c8e831e67eaac9
gitbay/cmd/gitbayd/main.go history · blame · raw

312 lines · 7981 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	"fmt"
  7	"log/slog"
  8	"net"
  9	"net/http"
 10	"os"
 11	"path/filepath"
 12	"strconv"
 13
 14	"github.com/spf13/cobra"
 15	"golang.org/x/crypto/ssh"
 16
 17	"gitbay.org/gitbay/internal/config"
 18	"gitbay.org/gitbay/internal/control"
 19	"gitbay.org/gitbay/internal/gitd"
 20	"gitbay.org/gitbay/internal/hookd"
 21	"gitbay.org/gitbay/internal/httpd"
 22	"gitbay.org/gitbay/internal/policy"
 23	"gitbay.org/gitbay/internal/sshd"
 24	"gitbay.org/gitbay/internal/store"
 25)
 26
 27func openStore(cfg config.Config) (*store.Store, error) {
 28	s, err := store.Open(filepath.Join(cfg.Server.Root, "gitbay.db"))
 29	if err != nil {
 30		return nil, err
 31	}
 32	if err := s.MigrateUp(); err != nil {
 33		s.Close()
 34		return nil, err
 35	}
 36	return s, nil
 37}
 38
 39var configPath string
 40
 41func main() {
 42	root := &cobra.Command{
 43		Use:           "gitbayd",
 44		Short:         "gitbay server daemon",
 45		SilenceUsage:  true,
 46		SilenceErrors: true,
 47	}
 48	root.PersistentFlags().StringVar(&configPath, "config", "/etc/gitbay/config.toml", "path to config file")
 49
 50	root.AddCommand(
 51		checkConfigCmd(),
 52		serveCmd(),
 53		migrateCmd(),
 54		adminCmd(),
 55		hookCmd(),
 56		authorizedKeysCmd(),
 57		shellCmd(),
 58	)
 59
 60	if err := root.Execute(); err != nil {
 61		fmt.Fprintln(os.Stderr, "gitbayd:", err)
 62		os.Exit(1)
 63	}
 64}
 65
 66func checkConfigCmd() *cobra.Command {
 67	var noHost bool
 68	cmd := &cobra.Command{
 69		Use:   "check-config",
 70		Short: "validate the configuration and exit",
 71		RunE: func(cmd *cobra.Command, args []string) error {
 72			cfg, err := config.Load(configPath)
 73			if err != nil {
 74				return err
 75			}
 76			if !noHost {
 77				if err := cfg.CheckHost(); err != nil {
 78					return err
 79				}
 80			}
 81			fmt.Println("config ok")
 82			return nil
 83		},
 84	}
 85	cmd.Flags().BoolVar(&noHost, "no-host-checks", false, "skip host environment probes (port binding, paths)")
 86	return cmd
 87}
 88
 89func serveCmd() *cobra.Command {
 90	return &cobra.Command{
 91		Use:   "serve",
 92		Short: "run the ssh, http, and git listeners",
 93		RunE: func(cmd *cobra.Command, args []string) error {
 94			cfg, err := config.Load(configPath)
 95			if err != nil {
 96				return err
 97			}
 98			st, err := openStore(cfg)
 99			if err != nil {
100				return err
101			}
102			defer st.Close()
103
104			// Regenerate hook scripts so a moved binary self-heals, then
105			// start the hook policy socket.
106			self, err := os.Executable()
107			if err != nil {
108				return err
109			}
110			if err := hookd.WriteHookScripts(control.HooksDir(cfg.Server.Root), self); err != nil {
111				return err
112			}
113			stopHookd, err := hookd.Serve(cfg, st)
114			if err != nil {
115				return err
116			}
117			defer stopHookd()
118
119			errCh := make(chan error, 3)
120			if cfg.SSH.Mode == "embedded" {
121				srv, err := sshd.New(cfg, st)
122				if err != nil {
123					return err
124				}
125				ln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.SSH.Port)))
126				if err != nil {
127					return err
128				}
129				slog.Info("ssh listening", "addr", ln.Addr())
130				go func() { errCh <- srv.Serve(ln) }()
131			} else {
132				// system mode: the host sshd owns the SSH port and invokes
133				// this binary via AuthorizedKeysCommand + forced command.
134				slog.Info("ssh handled by host sshd (ssh.mode = system)")
135			}
136
137
138			web := httpd.New(cfg, st)
139			hs := &http.Server{Addr: cfg.HTTP.Addr, Handler: web.Handler()}
140			go func() {
141				slog.Info("http listening", "addr", cfg.HTTP.Addr, "tls", cfg.HTTP.TLS)
142				switch cfg.HTTP.TLS {
143				case "off":
144					errCh <- hs.ListenAndServe()
145				case "files":
146					errCh <- hs.ListenAndServeTLS(cfg.HTTP.CertFile, cfg.HTTP.KeyFile)
147				default:
148					errCh <- fmt.Errorf("http.tls = %q not implemented yet; use \"files\" or \"off\"", cfg.HTTP.TLS)
149				}
150			}()
151
152			if cfg.GitDaemon.Enabled {
153				gln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.GitDaemon.Port)))
154				if err != nil {
155					return err
156				}
157				slog.Info("git-daemon listening", "addr", gln.Addr())
158				go func() { errCh <- gitd.New(cfg, st).Serve(gln) }()
159			}
160
161			return <-errCh
162		},
163	}
164}
165
166func migrateCmd() *cobra.Command {
167	var to int
168	cmd := &cobra.Command{
169		Use:   "migrate",
170		Short: "apply schema migrations",
171		RunE: func(cmd *cobra.Command, args []string) error {
172			cfg, err := config.Load(configPath)
173			if err != nil {
174				return err
175			}
176			s, err := store.Open(cfg.Server.Root + "/gitbay.db")
177			if err != nil {
178				return err
179			}
180			defer s.Close()
181			if err := s.MigrateTo(to); err != nil {
182				return err
183			}
184			v, err := s.Version()
185			if err != nil {
186				return err
187			}
188			fmt.Println("schema version", v)
189			return nil
190		},
191	}
192	cmd.Flags().IntVar(&to, "to", -1, "target schema version (-1 = latest)")
193	return cmd
194}
195
196func adminCmd() *cobra.Command {
197	admin := &cobra.Command{
198		Use:   "admin",
199		Short: "host-local administration",
200	}
201	notImplemented := func(use, short string) *cobra.Command {
202		return &cobra.Command{
203			Use:   use,
204			Short: short,
205			RunE: func(cmd *cobra.Command, args []string) error {
206				return fmt.Errorf("not implemented")
207			},
208		}
209	}
210	userCmd := &cobra.Command{Use: "user", Short: "manage users"}
211	userCmd.AddCommand(adminUserCreateCmd())
212	emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"}
213	emailCmd.AddCommand(adminEmailVerifyCmd())
214	admin.AddCommand(
215		userCmd,
216		emailCmd,
217		notImplemented("invite", "issue registration invites"),
218		notImplemented("backup", "consistent backup: repos first, then database"),
219		notImplemented("gc", "run git gc across repositories"),
220		notImplemented("stats", "instance statistics"),
221	)
222	return admin
223}
224
225func adminUserCreateCmd() *cobra.Command {
226	var keyPath, email string
227	var verified, isAdmin bool
228	cmd := &cobra.Command{
229		Use:   "create <username>",
230		Short: "create a user (host-local bootstrap; the only path in closed mode)",
231		Args:  cobra.ExactArgs(1),
232		RunE: func(cmd *cobra.Command, args []string) error {
233			username := args[0]
234			if err := policy.ValidateOwnerName(username); err != nil {
235				return err
236			}
237			cfg, err := config.Load(configPath)
238			if err != nil {
239				return err
240			}
241			st, err := openStore(cfg)
242			if err != nil {
243				return err
244			}
245			defer st.Close()
246
247			uid, err := st.CreateUser(username, isAdmin)
248			if err != nil {
249				return err
250			}
251			if email != "" {
252				verifiedBy := ""
253				if verified {
254					verifiedBy = "admin"
255				}
256				if err := st.AddEmail(uid, email, verifiedBy, true); err != nil {
257					return err
258				}
259			}
260			if keyPath != "" {
261				raw, err := os.ReadFile(keyPath)
262				if err != nil {
263					return err
264				}
265				pub, _, _, _, err := ssh.ParseAuthorizedKey(raw)
266				if err != nil {
267					return fmt.Errorf("%s: not a public key in authorized_keys format: %w", keyPath, err)
268				}
269				fp := ssh.FingerprintSHA256(pub)
270				if err := st.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full"); err != nil {
271					return err
272				}
273				fmt.Println("key", fp)
274			}
275			fmt.Println("created user", username)
276			return nil
277		},
278	}
279	cmd.Flags().StringVar(&keyPath, "key", "", "path to an SSH public key to register")
280	cmd.Flags().StringVar(&email, "email", "", "primary email address")
281	cmd.Flags().BoolVar(&verified, "verified", false, "mark the email verified (admin assertion)")
282	cmd.Flags().BoolVar(&isAdmin, "admin", false, "grant instance admin")
283	return cmd
284}
285
286func adminEmailVerifyCmd() *cobra.Command {
287	return &cobra.Command{
288		Use:   "verify <username> <address>",
289		Short: "mark an email verified by admin assertion",
290		Args:  cobra.ExactArgs(2),
291		RunE: func(cmd *cobra.Command, args []string) error {
292			cfg, err := config.Load(configPath)
293			if err != nil {
294				return err
295			}
296			st, err := openStore(cfg)
297			if err != nil {
298				return err
299			}
300			defer st.Close()
301			u, err := st.UserByUsername(args[0])
302			if err != nil {
303				return fmt.Errorf("user %s: %w", args[0], err)
304			}
305			if err := st.VerifyEmail(u.ID, args[1], "admin"); err != nil {
306				return fmt.Errorf("no address %s on user %s", args[1], args[0])
307			}
308			fmt.Println("verified", args[1])
309			return nil
310		},
311	}
312}